首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >从网页到 MCP:我把「139 云盘分享下载」做成了一个可复用的连接器

从网页到 MCP:我把「139 云盘分享下载」做成了一个可复用的连接器

原创
作者头像
用户12815623
发布于 2026-10-12 21:34:39
发布于 2026-10-12 21:34:39
400
举报

一次完整的工程复盘:如何分析一个 SPA 站点的私有接口、复刻它的签名协议、解决登录鉴权,最后把整套能力封装成 MCP(Model Context Protocol)连接器,让 AI 助手可以直接调用。

一、起因:一个很具体的小需求

我手上有 139 云盘(中国移动云盘)的一个分享链接,里面是 154 个 .xls 文件,分散在两层目录里:

代码语言:javascript
复制
地址表/
├── 香洲、横琴/   (87 个 .xls)
└── 金湾、斗门/   (67 个 .xls)

手动下载的体验很差:网页版一次只能点一个文件,而且点「下载」还会把分享页跳走。于是我想:能不能把这个云盘变成 AI 助手的一个工具? 以后只需说一句「帮我从这个链接把文件下载下来」。

这就是 MCP 连接器要解决的问题。

二、目标拆解

要完成这件事,需要依次解决四个问题:

阶段

问题

难点

1

找到列目录 / 取下载地址的接口

页面是 SPA,源码里没有明文接口

2

让接口认得我的请求

服务端校验自定义签名 mcloud-sign

3

让服务端认得我的身份

下载接口必须登录,匿名直接拒绝

4

把能力交给 AI

长耗时任务不能阻塞 MCP 的同步调用

下面按顺序复盘。

三、第一步:定位接口

3.1 先确认「页面里没有答案」

直接抓分享页:

代码语言:javascript
复制
curl -s -L "https://yun.139.com/shareweb/#/w/i/2xTrGM6yFun6u"

拿到的是一个几乎空的 HTML —— 典型的 SPA。注意 URL 里的 #/w/i/xxx 是 hash 路由,浏览器根本不会把它发给服务器,所以指望靠 URL 拼接口是行不通的。

3.2 从 JS bundle 里挖接口

真正的逻辑全在打包后的 JS 里。先把页面引用的 chunk 全下下来:

代码语言:javascript
复制
grep -oE 'static/js/[a-zA-Z0-9._-]+\.js' index.html | sort -u
# app.3c33c3cd.js  chunk-xxx.js  chunk-vendors.xxx.js ...

然后按业务关键词检索:

代码语言:javascript
复制
grep -ohE '"[a-zA-Z0-9/._-]*(share|Share|outLink|OutLink)[a-zA-Z0-9/._-]*"' *.js | sort -u

很快就捞到了关键路径:

代码语言:javascript
复制
/yun-share/richlifeApp/devapp/IOutLink/getOutLinkInfoV6   ← 列目录
/yun-share/richlifeApp/devapp/IOutLink/dlFromOutLinkV3    ← 取下载地址

再往上追 base URL 和请求体结构,得到完整调用:

代码语言:javascript
复制
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 递归的初始参数里,是靠试出来的:

代码语言:javascript
复制
for p in "" "root" "-1" "0"; do ... done   # 只有 "root" 有数据

3.3 递归遍历目录

拿到列表接口后,写个递归就能把整个分享树拉平:

代码语言:javascript
复制
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)。

值得一提的是,到这个阶段为止全程不需要登录 —— 列目录接口只要知道提取码就能读。真正的门槛在下载环节。

四、第二步:复刻 mcloud-sign 签名

直接调下载接口,服务端回了一个很干脆的拒绝:

代码语言:javascript
复制
{ "resultCode": "200000401", "desc": "IP鉴权失败、用户账号鉴权失败" }

在断定「必须登录」之前,先排除一种可能:是不是签名没带上? 于是回到 JS 里找签名逻辑。

4.1 拆解签名公式

前端有个请求拦截器,在发出请求前统一注入 mcloud-sign 头。把相关模块挖出来后,公式是:

代码语言:javascript
复制
sign = MD5( MD5(base64( sorted( encodeURIComponent(JSON.stringify(body)) ) )) 
          + MD5( 时间串 + ":" + 16位随机串 ) ).toUpperCase()

mcloud-sign = "{时间串},{16位随机串},{sign}"

复刻成 Python:

代码语言:javascript
复制
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",
        # ... 其余业务头
    }

4.2 三个容易翻车的细节

  1. encodeURIComponent 不编码哪些字符 —— JS 的 encodeURIComponent 会保留 ~()!.'-,Python 的 quote 默认不保留。必须显式声明 safe="~()!.'-",否则签名对不上。
  2. 排序是「字符合级」不是「键级」 —— sorted() 作用在整个编码后的字符串上,把字符重新排列。这看起来毫无道理,但前端就是这么写的,照抄即可。
  3. JSON 序列化必须紧凑 —— separators=(",", ":") 且 ensure_ascii=False。多一个空格、或者中文被转成 \uXXXX,签名立刻失效。

签名复刻正确后,请求头已经能被服务端接受了 —— 但下载接口依然报 200000401。

这说明:签名只是「格式合法」,身份才是「权限合法」。 两者是独立的校验层。

五、第三步:登录鉴权

既然匿名拿不到下载权限,就必须带上真实登录态。

5.1 为什么选扫码登录

登录方式有三条路:复刻 App 端的 RSA + AES 加密登录协议、复用用户浏览器里的 cookie、或者浏览器自动化扫码。

我选了第三条:启动一个真实的 Edge 窗口打开分享页,用户用手机 App 扫码,登录完成后从 cookie 里把登录态导出。理由是:

  • 签名协议已经复刻了,再复刻一套登录加密收益不高、风险不小;
  • 扫码不需要用户把密码交给脚本;
  • 登录态一旦落盘,后续调用就是纯 API,不再需要浏览器。

5.2 导出登录态

登录成功后,从浏览器上下文里 dump 出关键 cookie:

代码语言:javascript
复制
const cookies = await ctx.cookies();
const wanted = ['authorization', 'auth_token', 'token', 'skey', 'a_k', 'ud_id',
                'ORCHES-I-ACCOUNT-ENCRYPT'];

这些值进入后续请求头:

代码语言:javascript
复制
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", "")

5.3 两个昂贵的坑

坑 1:不要用页面对象判断登录状态。

最初的实现是「等页面上出现账号昵称 → 判定登录成功」。这在持久化 profile(launchPersistentContext)下会假死:

代码语言:javascript
复制
Error: Target page closed.

原因是持久化 profile 启动时可能已经存在多个页面,context.pages()[0] 拿到的对象随时可能失效。改成基于 cookie 判定(authorization + auth_token + skey 三者齐备)后,彻底摆脱了对页面生命周期的依赖。

坑 2:绝对不要点分享页的「下载」按钮。

用「点下载按钮来拉起登录弹窗」看起来很自然,实际上那个按钮会触发一次真实的下载流程,把分享页整个关掉,Playwright 随即抛出 Target page closed。

最终采用的顺序是:

  1. 先等 6 秒,看二维码是否已经渲染(未登录时登录面板在 DOM 里已存在,但二维码要等接口返回才画出来);
  2. 没等到,再点文字为「登录」的元素(exact: true);
  3. 再等二维码。

二维码元素的特征也很好认:img,canvas 且 宽 > 80 && 高 > 80 && |宽-高| < 30。

5.4 拿到下载直链

带上登录态调 dlFromOutLinkV3:

代码语言:javascript
复制
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 个链接再慢慢下。

六、第四步:封装成 MCP 连接器

到这里核心能力已经跑通了。但「能跑的脚本」和「AI 能用的工具」之间还差一层封装。

6.1 为什么不用 HTTP 服务

MCP 支持 stdio 和 HTTP 两种传输。这里选了 stdio + JSON-RPC 2.0:

  • 不需要监听端口,不需要管理服务生命周期;
  • 客户端(WorkBuddy)拉起进程即可用;
  • 零依赖,只用 Python 标准库,换台机器也能跑。

服务器主体非常薄:

代码语言:javascript
复制
# 读一行 → 解析 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 {}))

6.2 关键设计:长任务不能阻塞同步调用

这是整个封装里最重要的一条经验。

MCP 的 tools/call 是同步的:客户端发一个请求,等一个响应。但「下载 154 个文件」要跑一分钟以上,如果直接在调用里同步执行:

  • 要么客户端超时断开;
  • 要么整个对话卡住,用户体验极差。

解法:把长任务拆成「启动 + 轮询」两步。

代码语言:javascript
复制
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。

代码语言:javascript
复制
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 次重试:

代码语言:javascript
复制
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)

6.3 最终交付的 10 个工具

工具

作用

需要登录

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 查进度」,模型才知道该按什么顺序调用。

七、通用化:让任意分享链接都能用

第一版只服务一个分享,硬编码了默认提取码。要让连接器真正可复用,得把「链接解析」这一层做扎实。

7.1 139 的分享链接有多种形态

形态

示例

整段分享口令

链接: 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>)。所以解析层统一处理即可,业务层完全不用改。

7.2 一个必须修掉的坏设计

第一版有这样的逻辑:

代码语言:javascript
复制
passwd = a.get("passcode") or "f6ro"    # ← 危险

当用户没传提取码时,会静默地拿一个旧分享的提取码去试。 对于不需要提取码的公开分享,这会导致莫名其妙的失败。

正确的做法是不猜:

代码语言:javascript
复制
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」。

「宁可报错,不要猜」 —— 这在工具设计里是条硬规则。

7.3 错误也要分类

把服务端的错误码映射成有语义的异常,模型和用户才能看懂:

代码语言:javascript
复制
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 可以先确认「这个链接是什么类型、提取码有没有识别到、能不能下载」,再决定要不要动手。

八、踩坑清单

把所有坑集中记一下,这些是最花时间的部分:

  1. pCaID 列根目录必须传 "root" —— 空串 / -1 / 0 全部返回空列表。
  2. encodeURIComponent 的 safe 字符集 —— Python 侧要显式写 safe="~()*!.'-"。
  3. 签名里的 sorted() 是字符级排序,而且作用在 base64 之前。
  4. JSON 必须紧凑序列化,中文不能转义。
  5. 签名合法 ≠ 有权限 —— 401 可能是「格式对但没登录」,别把两者混为一谈。
  6. 不要用页面对象判断登录态 —— 持久化 profile 下会 Target page closed,改用 cookie 判定。
  7. 不要点分享页的「下载」按钮 —— 会关掉页面。
  8. 下载直链 900 秒过期 —— 必须「取链接 → 立即下载」。
  9. 长任务必须异步化 —— MCP 的 tools/call 是同步的,别在里面跑一分钟。
  10. 不要给提取码设默认值 —— 宁可报错,不要猜。

九、结果与小结

最终结果:

  • 154 个 .xls 文件全部下载成功,27.1 MB,目录结构完整保留;
  • 逐个校验 OLE2 文件头,无损坏文件;
  • 整个下载约 1 分钟跑完(4 线程并发)。

回头看,这件事的价值不在于「下载了 154 个文件」,而在于把一次性的脚本变成了可复用的工具。原来的流程是「打开网页 → 一个个点 → 手动整理」,现在的流程是:

「帮我从这个链接把文件下载下来:<粘贴整段分享文案>」

这大概就是 MCP 这类协议真正的意义 —— 它把「人操作网页」变成「AI 调用能力」,而且这个能力是持久化的、可组合的、能被自然语言驱动的。

如果你也想把某个网页端的能力做成连接器,希望这篇复盘里的顺序(定位接口 → 复刻签名 → 解决鉴权 → 异步封装)和踩坑清单能帮你少走点弯路。


说明:本文所述的接口分析与自动化实现,仅用于下载用户本人账号可访问的分享内容,属于个人数据迁移场景;请勿用于批量抓取他人分享或绕过平台权限控制。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 一、起因:一个很具体的小需求
  • 二、目标拆解
  • 三、第一步:定位接口
    • 3.1 先确认「页面里没有答案」
    • 3.2 从 JS bundle 里挖接口
    • 3.3 递归遍历目录
  • 四、第二步:复刻 mcloud-sign 签名
    • 4.1 拆解签名公式
    • 4.2 三个容易翻车的细节
  • 五、第三步:登录鉴权
    • 5.1 为什么选扫码登录
    • 5.2 导出登录态
    • 5.3 两个昂贵的坑
    • 5.4 拿到下载直链
  • 六、第四步:封装成 MCP 连接器
    • 6.1 为什么不用 HTTP 服务
    • 6.2 关键设计:长任务不能阻塞同步调用
    • 6.3 最终交付的 10 个工具
  • 七、通用化:让任意分享链接都能用
    • 7.1 139 的分享链接有多种形态
    • 7.2 一个必须修掉的坏设计
    • 7.3 错误也要分类
  • 八、踩坑清单
  • 九、结果与小结
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档