欢迎光临
我们一直在努力

把 CSDN 发文流程做成自动化:登录、网关签名、图床直传三个坑的实录

文章目录

  • 起因
  • 一、第一个坑:无头浏览器访问博客页,直接 403
  • 二、第二个坑:纯 HTTP 请求,被 521 拦在门口
  • 三、登录接口:藏在 loginv3.js 里
  • 四、bizapi 网关签名:最硬的一关
  • 五、建稿和发布,其实是同一个接口
  • 六、图片上传:旧接口 404,现行是 OBS 直传
  • 七、两个小坑:频控和删除
  • 八、完整链路与几点体会

起因

我写技术文章一直在 CSDN 发。每次流程都差不多:Markdown 写在本地的编辑器里,粘到 CSDN 编辑器,本地截图一张张手动传图床,排版再调一遍,发出去发现有错别字,又得回编辑器改。

一套流程走下来十几分钟,全是机械动作。于是我想把它做成脚本:一条命令,从 Markdown 直接到发布。

本以为是个下午的活儿,结果踩了三个硬坑,每一个都和"接口"无关,全是环境与反爬。这篇把过程和结论都写清楚,尤其是那几个在现有教程里已经彻底失效的地方。

完整链路

一、第一个坑:无头浏览器访问博客页,直接 403

最开始我用 Playwright 的无头 Chromium 去打开博客页,想模拟点击。结果页面返回 403,连内容都拿不到。

换成有头模式能打开,但一进登录页就被人机验证卡死:那种"依次点击[见,兔,水,阔]"的验证,不管点多少遍都是"检测未通过"。

原因不在点击方式,而在浏览器指纹:

  • Chromium 由 Playwright 拉起时自带 –enable-automation 标记;
  • 页面里 navigator.webdriver === true;
  • WebGL、字体、插件列表这些指纹和真实浏览器对不上。

风控一旦把你判成"非人类",人机验证就是硬拦截,不是概率问题——你点对了也不给过。

解法是换成本机真实浏览器内核,并把自动化特征关掉:

LAUNCH_ARGS = [
"–disable-blink-features=AutomationControlled",
"–disable-infobars",
"–no-first-run",
"–no-default-browser-check",
]

STEALTH_JS = """
Object.defineProperty(navigator, 'webdriver', {get: () => undefined});
Object.defineProperty(navigator, 'languages', {get: () => ['zh-CN', 'zh']});
Object.defineProperty(navigator, 'plugins', {get: () => [1, 2, 3, 4, 5]});
window.chrome = window.chrome || {runtime: {}};
"""

browser = p.chromium.launch(
channel="msedge", # 关键:用本机 Edge 内核,不是 Playwright 自带的 Chromium
headless=True,
args=LAUNCH_ARGS,
)

换完之后,无头模式也能正常打开博客页了。顺带一个好处:不用再下载几百兆的 Chromium 内核。

二、第二个坑:纯 HTTP 请求,被 521 拦在门口

既然浏览器能通,我就想干脆绕开浏览器,用 requests 直接调登录接口——快、省资源、也更好调度。

结果是 521。CSDN 前置的 WAF 会给请求算一个 bot-score,纯 HTTP 客户端没有跑过它那段 JS,分数不够就直接掐掉。

结论:登录这一步必须在浏览器上下文里发请求,让它带着已经通过风控的会话去调:

resp = page.evaluate(
"""async ([url, payload]) => {
const r = await fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload),
credentials: 'include'
});
return await r.text();
}""",
[LOGIN_API, payload],
)

同样的接口,requests 发出去是 521,页面内 fetch 发出去是 200。差别只在会话和指纹。

三、登录接口:藏在 loginv3.js 里

这段是最费时间的。CSDN 的登录页现在默认只渲染「微信登录 / 验证码登录 / APP登录」,我在独立登录页 passport.csdn.net/login 上反复找,就是找不到账号密码的输入框。

一度以为是账号密码登录被下线了。后来发现两件事:

  • 账号密码登录其实还在,只是入口在博客页弹出的登录弹窗里(那个弹窗是个 iframe),不是独立页;
  • 更关键的是——自动化环境会被降级下发,同一个 URL,正常浏览器能看到「密码登录」这个 tab,脚本打开就只有三个。
  • 所以靠"点 tab"这条路是走不通的,你永远点不到一个不渲染的元素。正确做法是跳过 UI,直接找它背后的接口。

    把 loginv3.js 下载下来搜 doLogin,接口长这样:

    POST https://passport.csdn.net/v1/register/pc/login/doLogin
    {
    "userIdentification": "账号(手机号/邮箱/用户名都行)",
    "pwdOrVerifyCode": "密码",
    "loginType": "1",
    "webUmidToken": "",
    "uaToken": "",
    "agreedPrivacyPolicy": 1
    }

    loginType 是 1 走密码、0 走短信验证码。成功返回:

    {"isCheck":0,"code":"0","message":"success",
    "data":{"redirectUrl":"https://www.csdn.net/"},"status":true}

    服务端同时种下 UserName / UserToken / UserInfo / UserNick / AU / UN / BT / p_uid 这一组 Cookie。

    注意:判断登录是否成功,别看页面上有没有头像,看 UserToken 这个 Cookie 在不在,可靠得多。

    四、bizapi 网关签名:最硬的一关

    登录通了,接下来调发文接口,报错:

    X-Ca-Key is not exist

    bizapi.csdn.net 走的是阿里云 API 网关,所有业务接口都要求签名。签名逻辑在前端 csdn-http.js 里,算法是 HMAC-SHA256,但坑在拼接格式——差一个空行、差一个换行符,签名就是错的。

    stringToSign 是六段拼起来的:

    签名拼接

    对应到代码:

    def _sign(method, path_qs, content_type, accept):
    nonce = str(uuid.uuid4())
    string_to_sign = (
    f"{method}\\n"
    f"{accept}\\n"
    f"\\n" # Content-MD5,留空行
    f"{content_type}\\n"
    f"\\n" # 时间戳,留空行
    f"x-ca-key:{APP_KEY}\\n"
    f"x-ca-nonce:{nonce}\\n"
    f"{path_qs}"
    )
    sig = base64.b64encode(
    hmac.new(APP_SECRET.encode(), string_to_sign.encode(), hashlib.sha256).digest()
    ).decode()

    return {
    "x-ca-key": APP_KEY,
    "x-ca-nonce": nonce,
    "x-ca-signature": sig,
    "x-ca-signature-headers": "x-ca-key,x-ca-nonce",
    }

    几个必须注意的点:

    • 第三段和第五段是空的,但那个换行不能省,必须留出空行;
    • GET 请求的 Content-Type 是空字符串,POST 才是 application/json;charset=UTF-8;
    • path?sorted_query 里 query 要按 key 升序排,没有值的写成纯 key;
    • 最后一段的 path 不含域名。

    APP_KEY 和 APP_SECRET 都是前端 JS 里的公开常量,翻 csdn-http.js 就能找到。这里我不贴具体值——不是藏私,是这种值属于人家前端实现,写完这篇文章它随时可能换,你照着思路自己取最新的一份更靠谱。

    五、建稿和发布,其实是同一个接口

    网上能搜到的教程基本都在用 v3/editor/createArticle,现在全部 404。去编辑器 JS(app.chunk.*.js)里重新搜 v3/,真正在用的是:

    POST https://bizapi.csdn.net/blog-console-api/v3/mdeditor/saveArticle

    参数结构(精简版):

    {
    "id": "文章 id,新建传空串",
    "title": "标题",
    "markdowncontent": "Markdown 原文",
    "content": "转换后的 HTML",
    "readType": "public",
    "level": 0,
    "tags": "Python,后端",
    "status": 2,
    "categories": "后端",
    "type": "original",
    "cover_images": [],
    "cover_type": 0,
    "is_new": 1,
    "not_auto_saved": "1",
    "source": "pc_mdeditor",
    "pubStatus": "draft",
    "creation_statement": 0
    }

    关键在于两个字段:

    字段值含义
    status 2 / 0 / 64 草稿 / 公开 / 私密
    pubStatus draft / publish 必须和 status 一致,否则不生效

    同一个接口,改两个字段就在"存草稿"和"发布"之间切换。 所以「一键发布」根本不需要再单独找发布接口。

    还有两个细节:source 必须是 pc_mdeditor,否则部分字段会被后端忽略;返回体里的 data.id 就是 articleId,data.url 是最终文章地址。

    六、图片上传:旧接口 404,现行是 OBS 直传

    这块是我踩得最冤的。老教程给的地址是:

    POST https://imgservice.csdn.net/v1.0/api/img/optimal/upload

    现在直接 404。

    我先试了最直觉的写法——在页面里 fetch 这个地址:

    page.evaluate("async () => { const r = await fetch(url, {method:'POST', body: form}); … }")

    报 TypeError: Failed to fetch,调用栈里能看到 waf_captcha_embedded_bs:跨域 + 风控双杀。

    真正的机制,是在编辑器里手动粘贴一张图、抓包看到的:

    第一步,取签名

    POST https://bizapi.csdn.net/resource-api/v1/image/direct/upload/signature
    {"imageTemplate": "", "appName": "direct_blog", "imageSuffix": "png"}

    返回里是华为 OBS 的直传凭证:accessId、policy、signature、host、callbackUrl、callbackBody、filePath、customParam。

    第二步,拼 multipart 直传 OBS

    表单字段取值
    key data.filePath
    policy data.policy
    signature data.signature
    AccessKeyId data.accessId
    callbackUrl / callbackBody / callbackBodyType 原样照传
    x:<k> 遍历 data.customParam,键名统一加 x: 前缀
    file 图片二进制

    注意:这次不能用页面 fetch,要用 Playwright 的 page.request.post()。它属于同一个 context、共享 Cookie,但不受浏览器同源策略限制:

    resp = page.request.post(data["host"], multipart=multipart)
    url = resp.json()["data"]["imageUrl"] # https://i-blog.csdnimg.cn/direct/xxx.png

    OBS 会回调 CSDN 完成入库,返回体里的 imageUrl 就是可直接写进文章的地址。封面同理,只需把地址塞进 cover_images 并把 cover_type 设为 1。

    七、两个小坑:频控和删除

    频控:改完稿立刻调发布,会返回 400 文章操作频繁,请稍后再试。间隔 8~10 秒,配合指数退避重试就过了。第一次遇到时我以为是参数错了,排查了半小时。

    删除:接口是 POST /blog/phoenix/console/v1/article/del,但 deep 参数要看文章状态:

    • 草稿:{"articleId":"…","deep":true} → 彻底删除;
    • 已发布:返回 400 该文章不可以深度删除,只能 deep:false,也就是移进回收站(7 天后自动清理)。

    所以删除要写成"先试 deep=true,失败降级 deep=false",不然会卡在已发布的文章上。

    八、完整链路与几点体会

    跑通之后,整条流程是这样:

    pip install playwright markdown python-dotenv

    python scripts/login.py # 只验登录,不发文
    python scripts/publish.py 文章.md # 建草稿
    python scripts/publish.py 文章.md –publish # 直接发布
    python scripts/manage.py delete <id> # 删稿

    在我自己的账号上,建草稿 → 改草稿 → 正式发布 → 删除,四步全部返回 code=200,后台列表能查到、回收站里也有。

    回头看,几个体会比较实在:

    1. 遇到 404 先怀疑路径,不要怀疑参数。 前端改版后老接口直接下线,报错信息不会告诉你"这个接口没了",只会给你一个 404 或者 X-Ca-Key is not exist,容易往签名方向查半天。

    2. 自动化和风控的对抗,赢面在"像正常浏览器",不在"更快的请求"。 换真实内核这一步的收益,比我前面所有优化加起来都大。

    3. 别在页面上找那些"有时有、有时没有"的元素。 同一个 URL 给不同客户端下发不同 UI 是常规操作,找接口比找按钮稳。

    4. 抓包 > 猜文档。 图床这段如果我一直照着老文档试,能试到过年。手动在编辑器里粘一张图、看它发了什么请求,两分钟就清楚了。

    最后提醒一句:这类脚本适合拿来做自己的发布工具,别拿去做批量养号、刷量的事情。接口签名、频控、人机验证这些都是平台的风控设计,绕过去自己用是一回事,拿去搞别人是另一回事。

    代码我整理成了一个个独立脚本,签名、图床、建稿、删除都分开写,改版时哪个坏了修哪个,不至于一崩全崩。

    赞(0)
    未经允许不得转载:171主机测评 » 把 CSDN 发文流程做成自动化:登录、网关签名、图床直传三个坑的实录
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址