一次完整的工程复盘:如何分析一个 SPA 站点的私有接口、复刻它的签名协议、解决登录鉴权,最后把整套能力封装成 MCP(Model Context Protocol)连接器,让 AI 助手可以直接调用。
我手上有 139 云盘(中国移动云盘)的一个分享链接,里面是 154 个 .xls 文件,分散在两层目录里:
地址表/
├── 香洲、横琴/ (87 个 .xls)
└── 金湾、斗门/ (67 个 .xls)手动下载的体验很差:网页版一次只能点一个文件,而且点「下载」还会把分享页跳走。于是我想:能不能把这个云盘变成 AI 助手的一个工具? 以后只需说一句「帮我从这个链接把文件下载下来」。
这就是 MCP 连接器要解决的问题。
要完成这件事,需要依次解决四个问题:
阶段 | 问题 | 难点 |
|---|---|---|
1 | 找到列目录 / 取下载地址的接口 | 页面是 SPA,源码里没有明文接口 |
2 | 让接口认得我的请求 | 服务端校验自定义签名 mcloud-sign |
3 | 让服务端认得我的身份 | 下载接口必须登录,匿名直接拒绝 |
4 | 把能力交给 AI | 长耗时任务不能阻塞 MCP 的同步调用 |
下面按顺序复盘。
直接抓分享页:
curl -s -L "https://yun.139.com/shareweb/#/w/i/2xTrGM6yFun6u"拿到的是一个几乎空的 HTML —— 典型的 SPA。注意 URL 里的 #/w/i/xxx 是 hash 路由,浏览器根本不会把它发给服务器,所以指望靠 URL 拼接口是行不通的。
真正的逻辑全在打包后的 JS 里。先把页面引用的 chunk 全下下来:
grep -oE 'static/js/[a-zA-Z0-9._-]+\.js' index.html | sort -u
# app.3c33c3cd.js chunk-xxx.js chunk-vendors.xxx.js ...然后按业务关键词检索:
grep -ohE '"[a-zA-Z0-9/._-]*(share|Share|outLink|OutLink)[a-zA-Z0-9/._-]*"' *.js | sort -u很快就捞到了关键路径:
/yun-share/richlifeApp/devapp/IOutLink/getOutLinkInfoV6 ← 列目录
/yun-share/richlifeApp/devapp/IOutLink/dlFromOutLinkV3 ← 取下载地址再往上追 base URL 和请求体结构,得到完整调用:
curl -X POST "https://share-kd-njs.yun.139.com/yun-share/richlifeApp/devapp/IOutLink/getOutLinkInfoV6" \
-H "Content-Type: application/json" \
-H "Origin: https://yun.139.com" \
-d '{"getOutLinkInfoReq":{
"linkID":"2xTrGM6yFun6u","passwd":"f6ro",
"pCaID":"root","bNum":1,"eNum":50,"srtDr":1,"caSrt":"","coSrt":""
}}'第一个坑来了:pCaID。
这个字段是「父目录 ID」,翻页 / 进子目录时传对应目录 ID 是自然的。但列分享根目录时,它必须是字符串 "root",传空串、-1、0 都会返回空列表。这个值前端藏在 walk 递归的初始参数里,是靠试出来的:
for p in "" "root" "-1" "0"; do ... done # 只有 "root" 有数据拿到列表接口后,写个递归就能把整个分享树拉平:
def walk(link_id, passwd, cookies, pcaid="root", prefix="", page_size=200):
"""递归列出全部分享文件,返回 [(相对路径, 字节数, 云盘内部 path)]"""
out = []
b = 1
while True:
d = list_dir(link_id, passwd, cookies, pcaid, b, b + page_size - 1)
if not d:
break
# caLst = 子目录,coLst = 文件
for c in d.get("caLst") or []:
out += walk(link_id, passwd, cookies, c["caID"],
prefix + "/" + c.get("caName", ""), page_size)
for c in d.get("coLst") or []:
out.append((prefix + "/" + c.get("coName", ""),
int(c.get("coSize") or 0), c.get("path")))
got = len(d.get("coLst") or [])
if got < page_size and not d.get("nextPageCursor"):
break
b += page_size
return out跑通后确认:154 个文件,28,401,664 字节(27.1 MB)。
值得一提的是,到这个阶段为止全程不需要登录 —— 列目录接口只要知道提取码就能读。真正的门槛在下载环节。
直接调下载接口,服务端回了一个很干脆的拒绝:
{ "resultCode": "200000401", "desc": "IP鉴权失败、用户账号鉴权失败" }在断定「必须登录」之前,先排除一种可能:是不是签名没带上? 于是回到 JS 里找签名逻辑。
前端有个请求拦截器,在发出请求前统一注入 mcloud-sign 头。把相关模块挖出来后,公式是:
sign = MD5( MD5(base64( sorted( encodeURIComponent(JSON.stringify(body)) ) ))
+ MD5( 时间串 + ":" + 16位随机串 ) ).toUpperCase()
mcloud-sign = "{时间串},{16位随机串},{sign}"复刻成 Python:
def build_headers(data, cookies):
c = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
a = _rand16() # 16 位 [A-Za-z0-9] 随机串
if data:
s = urllib.parse.quote(json.dumps(data, separators=(",", ":"), ensure_ascii=False),
safe="~()*!.'-")
s = "".join(sorted(s)) # 注意:对整个编码后的串做字符级排序
r = _md5(base64.b64encode(s.encode()).decode())
else:
r = _md5(base64.b64encode(b"").decode())
sign = _md5(r + _md5(c + ":" + a)).upper()
return {
"Content-Type": "application/json;charset=UTF-8",
"mcloud-sign": f"{c},{a},{sign}",
"mcloud-client": "10702",
"mcloud-version": "7.13.3",
"mcloud-channel": "1000101",
# ... 其余业务头
}encodeURIComponent 不编码哪些字符 —— JS 的 encodeURIComponent 会保留 ~()!.'-,Python 的 quote 默认不保留。必须显式声明 safe="~()!.'-",否则签名对不上。sorted() 作用在整个编码后的字符串上,把字符重新排列。这看起来毫无道理,但前端就是这么写的,照抄即可。separators=(",", ":") 且 ensure_ascii=False。多一个空格、或者中文被转成 \uXXXX,签名立刻失效。签名复刻正确后,请求头已经能被服务端接受了 —— 但下载接口依然报 200000401。
这说明:签名只是「格式合法」,身份才是「权限合法」。 两者是独立的校验层。
既然匿名拿不到下载权限,就必须带上真实登录态。
登录方式有三条路:复刻 App 端的 RSA + AES 加密登录协议、复用用户浏览器里的 cookie、或者浏览器自动化扫码。
我选了第三条:启动一个真实的 Edge 窗口打开分享页,用户用手机 App 扫码,登录完成后从 cookie 里把登录态导出。理由是:
登录成功后,从浏览器上下文里 dump 出关键 cookie:
const cookies = await ctx.cookies();
const wanted = ['authorization', 'auth_token', 'token', 'skey', 'a_k', 'ud_id',
'ORCHES-I-ACCOUNT-ENCRYPT'];这些值进入后续请求头:
if cookies.get("skey"):
h["mcloud-skey"] = cookies["skey"]
if cookies.get("authorization"):
h["Authorization"] = cookies["authorization"]
h["x-m4c-token"] = cookies.get("auth_token", "")
h["x-m4c-account"] = cookies.get("ORCHES-I-ACCOUNT-ENCRYPT", "")坑 1:不要用页面对象判断登录状态。
最初的实现是「等页面上出现账号昵称 → 判定登录成功」。这在持久化 profile(launchPersistentContext)下会假死:
Error: Target page closed.原因是持久化 profile 启动时可能已经存在多个页面,context.pages()[0] 拿到的对象随时可能失效。改成基于 cookie 判定(authorization + auth_token + skey 三者齐备)后,彻底摆脱了对页面生命周期的依赖。
坑 2:绝对不要点分享页的「下载」按钮。
用「点下载按钮来拉起登录弹窗」看起来很自然,实际上那个按钮会触发一次真实的下载流程,把分享页整个关掉,Playwright 随即抛出 Target page closed。
最终采用的顺序是:
exact: true);二维码元素的特征也很好认:img,canvas 且 宽 > 80 && 高 > 80 && |宽-高| < 30。
带上登录态调 dlFromOutLinkV3:
def get_dl_url(link_id, passwd, cookies, fpath, account=""):
d = post("/richlifeApp/devapp/IOutLink/dlFromOutLinkV3", {
"dlFromOutLinkReqV3": {
"account": account, # 手机号,从加密 cookie 里 base64 解出来
"linkID": link_id,
"coIDLst": {"item": [fpath]},
}}, cookies)
return d返回的 data.redrUrl 是 CDN 签名直链。注意它只有 900 秒有效期 —— 所以下载策略必须是「取链接 → 立刻下载」,不能预先批量取 154 个链接再慢慢下。
到这里核心能力已经跑通了。但「能跑的脚本」和「AI 能用的工具」之间还差一层封装。
MCP 支持 stdio 和 HTTP 两种传输。这里选了 stdio + JSON-RPC 2.0:
服务器主体非常薄:
# 读一行 → 解析 JSON-RPC → 分发 → 回写一行
for line in sys.stdin:
req = json.loads(line)
if req.get("method") == "tools/list":
reply({"tools": TOOLS})
elif req.get("method") == "tools/call":
reply(dispatch(req["params"]["name"], req["params"].get("arguments") or {}))这是整个封装里最重要的一条经验。
MCP 的 tools/call 是同步的:客户端发一个请求,等一个响应。但「下载 154 个文件」要跑一分钟以上,如果直接在调用里同步执行:
解法:把长任务拆成「启动 + 轮询」两步。
download → 立刻返回 job_id(子进程独立跑,DETACHED_PROCESS)
download_status → 读 state/jobs/<job_id>.json,返回进度
download_cancel → 写 state/cancel/<job_id> 标记,子进程自行退出子进程用 DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP 启动,保证父进程退出不影响它;进度用「写临时文件 + os.replace 原子替换」的方式落盘,避免读到半个 JSON。
def spawn(args, log_name):
flags = 0x00000008 | 0x00000200 if os.name == "nt" else 0
return subprocess.Popen(args, cwd=BASE, env=env, stdin=subprocess.DEVNULL,
stdout=logf, stderr=logf, creationflags=flags)下载本身用 4 线程并发,配合 3 次重试:
def fetch(url, dest, timeout=300, tries=3):
for i in range(tries):
try:
with urllib.request.urlopen(req, timeout=timeout) as r, open(dest, "wb") as f:
while chunk := r.read(1 << 16):
f.write(chunk)
return os.path.getsize(dest)
except Exception as e:
if i < tries - 1:
time.sleep(2)工具 | 作用 | 需要登录 |
|---|---|---|
parse_link | 离线解析链接/口令,返回分享 ID、类型、提取码 | 否 |
list_files | 列出分享内全部文件(目录结构 + 大小) | 否 |
login | 弹窗显示二维码,扫码登录 | — |
login_status | 查扫码状态,二维码就绪时返回图片 | — |
login_cancel | 取消登录并关窗 | — |
check_login | 实际调一次鉴权接口,验证登录态 | 否 |
download | 后台批量下载,返回 job_id | 是 |
download_status | 查进度 / 失败清单 | 否 |
download_cancel | 取消任务 | 否 |
get_direct_url | 取单文件 CDN 直链(900 秒有效) | 是 |
工具描述写得足够「面向模型」也很重要 —— 比如明确写清「需要登录」「返回 job_id 后用 download_status 查进度」,模型才知道该按什么顺序调用。
第一版只服务一个分享,硬编码了默认提取码。要让连接器真正可复用,得把「链接解析」这一层做扎实。
形态 | 示例 |
|---|---|
整段分享口令 | 链接: https://... 提取码:f6ro 复制内容打开中国移动云盘手机APP,操作更方便哦 |
PC 版分享页 | https://yun.139.com/shareweb/#/w/i/2xTrGM6yFun6u |
带提取码参数 | .../#/w/i/2xTrGM6yFun6u?pwd=abcd |
手机版短链 | https://yun.139.com/sharewap/#/m/i?xxxxx |
群组分享 | .../#/w/group/i/xxxxx |
裸 ID | 2xTrGM6yFun6u@f6ro |
把 PC 版和手机版的 JS 都拆了一遍,结论是:手机版和 PC 版底层用的是同一套接口,只是路由前缀不同(#/w/i/<id> vs #/m/i?<id>)。所以解析层统一处理即可,业务层完全不用改。
第一版有这样的逻辑:
passwd = a.get("passcode") or "f6ro" # ← 危险当用户没传提取码时,会静默地拿一个旧分享的提取码去试。 对于不需要提取码的公开分享,这会导致莫名其妙的失败。
正确的做法是不猜:
def resolve_share(a):
url = (a.get("share_url") or "").strip()
pw = (a.get("passcode") or "").strip()
if not url:
return DEFAULT_SHARE, (pw or DEFAULT_PASSWD) # 完全没给链接才用示例
return url, pw # 给了链接就绝不用内置提取码兜底没识别到提取码时:list_files 照常工作(公开分享不校验),download 明确报错「该分享需要提取码,请补充 passcode」。
「宁可报错,不要猜」 —— 这在工具设计里是条硬规则。
把服务端的错误码映射成有语义的异常,模型和用户才能看懂:
if code in AUTH_FAIL_CODES or "鉴权失败" in desc:
raise NotLoggedIn(f"[{code}] {desc}")
if code in PASSCODE_FAIL_CODES or "提取码" in desc:
raise PasscodeError(f"[{code}] {desc}")
if code in NOT_FOUND_CODES or "外链不存在" in desc or "已取消" in desc:
raise ShareNotFound(f"[{code}] {desc}")再额外加一个 parse_link 工具:纯本地解析、不联网,让 AI 可以先确认「这个链接是什么类型、提取码有没有识别到、能不能下载」,再决定要不要动手。
把所有坑集中记一下,这些是最花时间的部分:
pCaID 列根目录必须传 "root" —— 空串 / -1 / 0 全部返回空列表。encodeURIComponent 的 safe 字符集 —— Python 侧要显式写 safe="~()*!.'-"。sorted() 是字符级排序,而且作用在 base64 之前。Target page closed,改用 cookie 判定。tools/call 是同步的,别在里面跑一分钟。最终结果:
.xls 文件全部下载成功,27.1 MB,目录结构完整保留;回头看,这件事的价值不在于「下载了 154 个文件」,而在于把一次性的脚本变成了可复用的工具。原来的流程是「打开网页 → 一个个点 → 手动整理」,现在的流程是:
「帮我从这个链接把文件下载下来:
<粘贴整段分享文案>」
这大概就是 MCP 这类协议真正的意义 —— 它把「人操作网页」变成「AI 调用能力」,而且这个能力是持久化的、可组合的、能被自然语言驱动的。
如果你也想把某个网页端的能力做成连接器,希望这篇复盘里的顺序(定位接口 → 复刻签名 → 解决鉴权 → 异步封装)和踩坑清单能帮你少走点弯路。
说明:本文所述的接口分析与自动化实现,仅用于下载用户本人账号可访问的分享内容,属于个人数据迁移场景;请勿用于批量抓取他人分享或绕过平台权限控制。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。