Publish to ModelScope 研习社 (Learn) — headless
Publish a Markdown file as a rich ModelScope 研习社 article with only an access token. Works where there is no browser/Playwright/GUI.
When to use / not use
- Use when the user wants to create, edit, or publish an article on
https://modelscope.cn/learnfrom a terminal, or when their article renders as literal##/ code fences /|tables|(the markdown was stored as plain text, not converted). - Do not use for models, datasets, Studios, or MCP — that is
ms-hub. - Pasting Markdown source into the web editor does not auto-convert on ModelScope, so do not advise "just paste the .md"; always generate the rich block nodes (the script below does).
Prerequisites
- ModelScope access token, resolved in this order:
--token>$MODELSCOPE_API_KEY>--token-file>$MODELSCOPE_TOKENdefault file. Domestic tokens only work onmodelscope.cn(tokens are site-scoped). - Python 3 (stdlib only — no extra deps).
One-shot flow
# 1) Stage a rich DRAFT (Status=0, safe; prints draft id + edit URL):
python scripts/headless_publish.py --in article.md \
--title "文章标题" --desc "≤200字简介" \
--subjects "开发者工具,API" --domain 人工智能
# → draft id: <ID>
# → https://modelscope.cn/learn/edit/<ID>
# 2) Open the edit URL in a browser, eyeball the render, fix the .md, then iterate on the SAME id
# (create/update reuse one draft id — stray drafts are undeletable via token):
python scripts/headless_publish.py --in article.md --id <ID>
# 3) Publish (IRREVERSIBLE; delete is web-console only):
python scripts/headless_publish.py --in article.md --id <ID> --publish --yes
Auth: token as the m_session_id cookie
Authorization: Bearer and ?access_token= do not authorize writes to /api/v1/articles
(they return {"Code":10010101003,"Message":"user not logged in"}). The web API authenticates the
session cookie only, so send the access token as Cookie: m_session_id=<TOKEN>.
Endpoints (legacy web API, base https://modelscope.cn)
| Method | Path | Purpose |
|---|---|---|
| POST | /api/v1/articles | Create draft (returns Data.Id, Status=0) |
| PUT | /api/v1/articles | Full update — must include Id; omitted fields get cleared |
| GET | /api/v1/articles/{id} | Read back; body is at Data.Articles[0], not Data |
| PUT | /api/v1/articles/{id}/publish | Publish |
ContentDraft: why Markdown must be converted
The persisted body field is ContentDraft (DingTalk/cangjie rich-JSON). Writing
ContentMd / Content is ignored (reads back empty). The wrapper is always
["root", {}, block, block, ...]. Flattening Markdown into plain paragraph nodes stores fine
but renders the raw source (visible ##, ``` fences, |…|) — you must emit real block nodes:
headings, code, inlineCode, table (cell tag is tc), bullet lists (p + list attr), hr.
See references/api-reference.md for the exact node schemas and scripts/headless_publish.py
for a working Markdown→ContentDraft converter.
Field mapping & gotchas
- PUT uses
Descriptionfor the summary; GET returns it asDesc.Title≤ 128,Description≤ 200. Tagsis not persisted (reads back empty). Real topic tags live inSubjects(a JSON-array string).PUT /api/v1/articlesis a full replacement, not a partial patch.- Publishing via token is one-way:
DELETEis blocked ("deletion restricted to web console"), and the list/search API ignores every filter (SearchKey/Status/Owner), so you cannot enumerate or clean up your own drafts. Never fire throwaway POST probes; reuse one draft id while iterating. - Encoding: build the payload with
json.dump(..., ensure_ascii=False)in a Python file; do not push Chinese through inlinebash -c/ GBK consoles.
Safety defaults
The script creates a draft by default and only publishes with an explicit --publish --yes.
Keep it that way: publish is public + irreversible, so stage → verify render → publish.
Scan to join WeChat group