DEV Community

mattleeee
mattleeee

Posted on Originally published at hkcode.dpdns.org

Publishing to Juejin Through Its Web API: The mark_content Lesson

Automating content publishing is a common task for developer-advocates and technical writers. Juejin (掘金) is a popular Chinese developer community, and like many modern web apps, it relies on an internal HTTP API for its editor. That API is undocumented and changes without notice. This post is a post-mortem of a silent regression: drafts saved with empty bodies and a publish call returning err_no=1010. The fix turned out to be a single field name, but finding it required a disciplined reverse-engineering loop that is worth writing down.

The Symptom

The publisher had been running for months. It did three things:

  1. POST /api/v1/draft/save with the article body.
  2. POST /api/v1/draft/publish to push the draft live.
  3. Log the returned article_id and URL.

Then, without any code change on our side, every run produced a draft with an empty body. The publish step returned:

{"err_no": 1010, "err_msg": "草稿内容为空"}
Enter fullscreen mode Exit fullscreen mode

Translated: "draft content is empty." The draft existed, the title was correct, but the body was blank. The API had added a requirement: the Markdown source must be sent in a field called mark_content, separate from the rendered HTML field the client had been sending.

Reverse-Engineering the Regression

The first move with any undocumented API is to stop guessing and capture what the real client does. Open Juejin in a browser, open DevTools, go to the Network tab, and filter for Fetch/XHR. Then perform the action manually — write a draft with some text and hit save.

You will see a request to something like:

POST https://api.juejin.cn/content_api/v1/article_draft/save
Enter fullscreen mode Exit fullscreen mode

Right-click the request, choose Copy as cURL (bash), and paste it into a text editor. This gives you the full request: method, URL, headers, cookies, and the JSON body. Do the same for the publish action. Two captures are enough to reconstruct the whole flow.

The cURL for the save call looks roughly like this (truncated):

curl 'https://api.juejin.cn/content_api/v1/article_draft/save' \
  -H 'content-type: application/json' \
  -H 'cookie: sessionid=...; uuid=...' \
  -H 'user-agent: Mozilla/5.0 ...' \
  --data-raw '{"title":"test","mark_content":"# hello","brief_content":"","category_id":"1","tag_ids":[]}'
Enter fullscreen mode Exit fullscreen mode

The key observation: the body contains both mark_content and, in the full capture, a html_content or similar rendered field. The old client only sent the rendered field. The new API validates mark_content and rejects the request if it is missing or empty.

Reading the Error Code

err_no=1010 is not documented. But the mapping is easy to infer once you reproduce it deliberately:

  • Save a draft with mark_content present and non-empty → publish succeeds.
  • Save a draft with mark_content omitted → publish returns 1010.
  • Save a draft with mark_content: "" → publish returns 1010.

That three-point experiment tells you the field is required and that empty string is treated as absent. It also tells you the failure is server-side validation, not an auth problem — a 401 or err_no in the 1000s with a different message would point elsewhere.

Replaying the Calls from Python

With the cURL captures in hand, the translation to requests is mechanical. The only care needed is header fidelity: content-type, user-agent, and the cookie header.

import json
import requests

BASE = "https://api.juejin.cn"
SAVE_URL = f"{BASE}/content_api/v1/article_draft/save"
PUBLISH_URL = f"{BASE}/content_api/v1/article_draft/publish"

HEADERS = {
    "content-type": "application/json",
    "user-agent": (
        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
        "AppleWebKit/537.36 (KHTML, like Gecko) "
        "Chrome/122.0.0.0 Safari/537.36"
    ),
    "origin": "https://juejin.cn",
    "referer": "https://juejin.cn/",
}


def save_draft(session: requests.Session, title: str, markdown: str,
               category_id: str, tag_ids: list[str]) -> str:
    payload = {
        "title": title,
        "mark_content": markdown,      # the field that broke everything
        "brief_content": markdown[:100],
        "category_id": category_id,
        "tag_ids": tag_ids,
    }
    resp = session.post(SAVE_URL, headers=HEADERS,
                        data=json.dumps(payload), timeout=30)
    resp.raise_for_status()
    body = resp.json()
    if body.get("err_no") != 0:
        raise RuntimeError(f"save failed: {body}")
    return body["data"]["id"]


def publish_draft(session: requests.Session, draft_id: str) -> str:
    payload = {"draft_id": draft_id, "column_ids": []}
    resp = session.post(PUBLISH_URL, headers=HEADERS,
                        data=json.dumps(payload), timeout=30)
    resp.raise_for_status()
    body = resp.json()
    if body.get("err_no") != 0:
        raise RuntimeError(f"publish failed: {body}")
    return body["data"]["article_id"]
Enter fullscreen mode Exit fullscreen mode

Two details matter here. First, data=json.dumps(payload) is used instead of json=payload. Both work in most cases, but the explicit form makes the serialization visible and avoids surprises if you later need to control separators or encoding. Second, the session object is reused so cookies persist across the save and publish calls — publishing a draft that was just saved in a different session will fail.

The Cookie Problem in Windows Batch Launchers

The publisher was launched from a .bat file, which is where the next bug lived. The cookie header contains sessionid=...; uuid=.... In a batch file, % is the escape character for environment variables. If any cookie value contains a %, CMD will try to expand it and mangle the string. Worse, the mangling is silent — you get a 401 or a mysterious err_no with no hint about why.

The fix is to stop passing cookies as a command-line string entirely. Store them in a file and read them at runtime.

cookies.json:

{
  "sessionid": "your-session-id-here",
  "uuid": "your-uuid-here"
}
Enter fullscreen mode Exit fullscreen mode

Loader:

import json
from pathlib import Path

def load_session(cookie_file: str = "cookies.json") -> requests.Session:
    data = json.loads(Path(cookie_file).read_text(encoding="utf-8"))
    session = requests.Session()
    for name, value in data.items():
        session.cookies.set(name, value, domain=".juejin.cn")
    return session
Enter fullscreen mode Exit fullscreen mode

Now the .bat launcher is trivial and cannot mangle anything:

@echo off
python publish.py --draft drafts\my-post.md
Enter fullscreen mode Exit fullscreen mode

If you must keep cookies in the batch file for some reason, double every % as %%. But the file approach is strictly better: it is readable, diffable, and easy to rotate when the session expires. Never commit cookies.json — add it to .gitignore. The sessionid is a full account credential.

Batch Backfill: Republishing 20 Historical Posts

Once the single-post path worked, the obvious next step was to repair the archive. Twenty posts had been published with empty bodies over the broken window. Rather than fix them by hand, a backfill script iterated over local Markdown files, matched each to its existing draft or article ID, and re-saved with mark_content populated.

The matching step is the interesting part. If you did not store the article ID at publish time, you cannot reliably match by title alone. The pragmatic approach is to store a small manifest alongside the drafts:

{
  "my-post.md": {"draft_id": "7123...", "article_id": "7124..."}
}
Enter fullscreen mode Exit fullscreen mode

The backfill script then reads the manifest, loads each Markdown file, and re-saves:

import json
from pathlib import Path

def backfill(session, drafts_dir: str, manifest_path: str,
             category_id: str, tag_ids: list[str]) -> None:
    manifest = json.loads(Path(manifest_path).read_text(encoding="utf-8"))
    ok, failed = 0, []

    for filename, entry in manifest.items():
        path = Path(drafts_dir) / filename
        if not path.exists():
            failed.append((filename, "missing file"))
            continue

        markdown = path.read_text(encoding="utf-8")
        title = markdown.splitlines()[0].lstrip("# ").strip()

        try:
            new_id = save_draft(session, title, markdown,
                                category_id, tag_ids)
            entry["draft_id"] = new_id
            ok += 1
        except Exception as exc:  # noqa: BLE001
            failed.append((filename, str(exc)))

    Path(manifest_path).write_text(
        json.dumps(manifest, ensure_ascii=False, indent=2),
        encoding="utf-8",
    )
    print(f"backfilled {ok}, failed {len(failed)}")
    for name, reason in failed:
        print(f"  {name}: {reason}")
Enter fullscreen mode Exit fullscreen mode

Two operational notes from running this against 20 posts:

  • Rate-limit yourself. Insert a time.sleep(2) between iterations. Twenty rapid-fire saves can trip a soft throttle, and the failure mode is an ambiguous error rather than a clean 429.
  • Write the manifest after every success, not at the end. If the script dies at post 14, you do not want to re-save the first 13. The version above writes once at the end for brevity; in production, flush after each item.

The run republished all 20 posts in a single pass. The only failures were two files whose titles had been edited in the CMS after the original publish, so the manifest IDs pointed at stale drafts. Those were resolved by re-creating the drafts and updating the manifest.

Lessons for Any Undocumented API

The regression was a one-field change, but the process that found it generalizes:

  1. Capture before you theorize. Copy-as-cURL from DevTools is the ground truth. The browser is the only client the server is guaranteed to support.
  2. Diff the capture against your payload. The missing field was visible the moment the two were placed side by side.
  3. Reproduce the error deliberately. Vary one field at a time until you can trigger and clear the failure on demand. That is what turns err_no=1010 from a mystery into a known precondition.
  4. Keep credentials out of shell strings. Files beat command lines, especially on Windows.
  5. Make batch operations idempotent and resumable. Persist progress as you go.

The publisher has run clean since. The mark_content field is now set in one place, and the backfill script lives in the repo as a reusable repair tool for the next time the API quietly grows a new requirement.

More notes like this ship every week on this site.

Top comments (0)