文章目录
- 起因
- 一、第一个坑:无头浏览器访问博客页,直接 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 上反复找,就是找不到账号密码的输入框。
一度以为是账号密码登录被下线了。后来发现两件事:
所以靠"点 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. 抓包 > 猜文档。 图床这段如果我一直照着老文档试,能试到过年。手动在编辑器里粘一张图、看它发了什么请求,两分钟就清楚了。
最后提醒一句:这类脚本适合拿来做自己的发布工具,别拿去做批量养号、刷量的事情。接口签名、频控、人机验证这些都是平台的风控设计,绕过去自己用是一回事,拿去搞别人是另一回事。
代码我整理成了一个个独立脚本,签名、图床、建稿、删除都分开写,改版时哪个坏了修哪个,不至于一崩全崩。

