LeiSureYu/dsh-smart-download ↗★ 0

@leisureyu/dsh-smart-dl

集成aria2的多线程下载器插件 适合需要高速下载大文件,并要求落盘完整性校验的Windows/Linux用户。

套件
@leisureyu/dsh-smart-dl
相容性
待驗證
Harness 依賴範圍
>=0.1.7-rc.1 <0.1.8-0 || >=0.1.8-rc.1 <0.2.0-0 || >=0.2.0-rc.1 <0.3.0-0
Cordis 依賴範圍
>=4.0.0 <4.0.1-0 || >=4.0.1-rc.1 <5.0.0-0
版本
0.7.0
授權
MIT
最近更新
2026年9月29日

安裝

$npx -p @deepseek-ai/dsh dsh plugin --profile web add github:LeiSureYu/dsh-smart-download

dsh-smart-dl

npm version npm downloads license Publish platform Listed on dsh-plugin.org

简体中文 · English

给 DSH 装上一个内置 aria2 的下载器。 一条命令装好,不用自己配环境:大文件自动多线程加速,服务器不支持就自动回退,中途断了还能续传。

web profile 下的实时进度面板

dsh plugin --profile web add @leisureyu/dsh-smart-dl
  • 自动多线程加速 —— 下载前先探测目标服务器,支持多连接就调用内置 aria2c 并发下载,不支持则自动回退到系统 curl,两种情况都能下完。
  • 零配置 —— aria2 二进制随插件一起安装(npm optionalDependencies),无需自行下载或配置 PATH。
  • 进度看得见 —— web profile 下界面右下角有实时进度面板(文件名 / 百分比 / 速度 / 剩余时间),下载完成后自动消失。
  • 断点续传 —— 中断后用同样的 url + output 再调用一次即可续传。
  • 进度可查询 —— download_status 工具只读查询最近任务的百分比 / 速度 / ETA。

支持 Windows x64 / arm64 与 Linux x64 / arm64;请使用 0.7.0 或更新版本。

dsh-smart-dl 为 DeepSeek Harness(DSH) 注册两个工具:

  • smart_download:下载文件。先探测目标服务器是否支持多线程,支持则调用随插件分发的 aria2c 多线程加速,否则自动回退到系统自带的 curl 单线程下载,保证任何情况下都能完成下载。可选镜像加速与断点续传。
  • download_status:查询下载进度。只读地返回最近任务的状态快照(百分比、速度、ETA),可用来回答“刚才那个下载到百分之几了”。

安装

dsh plugin --profile web add @leisureyu/dsh-smart-dl

安装后无需任何额外配置:aria2 二进制通过 npm 的 optionalDependencies 机制随插件一起安装。

支持的 profile:web(上面的 --profile web 即为此插件验证过的 profile)。安装命令形如 dsh plugin --profile add ,请把 换成你实际使用的 profile 名称。

进度面板只在 web profile 生效。 其他 profile 下插件功能完全不受影响,只是没有界面面板,仍可用 download_status 工具查询进度。

版本要求:请使用 0.7.0 或更高。 0.7.0 新增了落盘完整性校验:curl / aria2 退出码 0 只代表它自己认为完成了,实测服务器用 chunked 只发 1MB 就断开时,随包 aria2 是退出码 0、摘要显示 OK、落盘却只有 1MB(声明 4MB),curl 同样 exit 0;现在会比对落盘字节数与远端声明长度,不一致就报错。同时把大文件并发阈值从 50MB 下调到 8MB(实测 8MB 文件 8 连接比 4 连接快近一倍)。0.6.0 修了续传的两个静默损坏:① 只要服务器支持 Range 就无条件续传,而 curl -C - 与 aria2 -c 都只看本地文件长度,实测在「远端变小」「等长但内容变了」两种场景下都是退出码 0 但文件是错的;现在续传前先比对远端长度与 ETag / Last-Modified,无法证实同源就删掉半包重下。② 目标文件已存在时 aria2 既不截断也不覆盖,而是另存为 f.1.bin,插件返回的 path 却仍指向旧内容的 f.bin;现在统一带 --allow-overwrite=true。0.5.0 修了进度轨道的三个静默失败:① 轨道一记录缺少 state 字段,而 dsh-task-progress 只认 state,导致面板永远显示「下载中」(即使 pct=100);② 没有会话上下文时把进度写进没人读的 default 目录;③ 未读取 DSH_HOME,把进度写进陈旧的 home。现在进度会按会话写入 /.dsh-progress//,无会话则不写轨道一,并新增 cancelled 状态。0.4.2 修了三个实测缺陷:① 探测不到文件大小时返回 size: undefined,会被宿主判为非法 JSON 并抛 ToolOutputError(下载其实已成功);② URL 路径穿越(..%2F..%2F..%2Fescaped.txt)会把文件写到工作目录之外;③ 缺少协议白名单,file:// 会被 curl 接受并复制本地文件。0.4.1 修正了 peerDependencies 的版本范围:此前 @deepseek-ai/cordis 写作 ^4.0.0、三个 @deepseek-ai/dsh-client-* 写作 >=0.1.7-rc.1 0.2.1另修复了一个**必然安装失败**的问题:此前peerDependencies中@deepseek-ai/dsh-tools写作^0.1.0,而该包从未发布过 0.1.x 正式版(实际可用版本均为预发布版),导致该范围解析不到任何版本,安装时报 npm error notarget No matching version found for @deepseek-ai/dsh-tools@^0.1.0`。

工作原理

下载决策流程(文字版):

调用 smart_download(url, output?, mirror?)
        │
        ▼
[0] 若传入 mirror,则把原始 URL 拼到镜像前缀后
     · 镜像前缀 + 完整原始 URL
     · 非 http(s) URL 不走镜像
     · 输出文件名仍按原始 URL 推导
        │
        ▼
[1] 探测 URL(超时 5s)
     · 先发送 HEAD 请求
     · 若 HEAD 返回 405 或缺少 Content-Length,
       则改用 GET + "Range: bytes=0-0"
        │
        ▼
[2] decide() 按文件大小与 aria2 可用性分档:
     · 不支持 Range / 探测失败 / 无法获取大小 /  curl
     · 1MB ~ 8MB   -> aria2 4 连接
     · ≥ 8MB        -> aria2 8 连接(保守上限,不开 16)
        │
   ┌────┴──────────────────────────┐
   ▼ aria2 档                       ▼ curl 档
[3a] 能定位到随包 aria2c?         [3b] curl 单线程(含进度条)
   │ 是              │ 否
   ▼                 ▼
  aria2 4/8 连接     curl 单线程(回退)
  (每秒摘要进度)
  (-c 续传)        (支持 Range 时 -C - 续传)
    │ 失败
    ▼
  降级为 curl 单线程(回退)
         │
         ▼
[4] 完整性校验:落盘字节数 vs 远端声明长度
        │ 一致            │ 不一致
        ▼                 ▼
   清除 .part.json    抛错,保留旁车
   返回 success

  返回结果 { success, path, method, size, fellback, reason?, requestedUrl, mirrored }

要点:

  • 任何探测异常(超时、网络错误、无法获取文件大小)都会被安全地判定为“不支持多线程”,从而走 curl 回退,不会让下载直接失败。
  • 并发数随文件大小动态选择(阈值 1MB / 8MB),reason 会区分“不支持 Range”“文件太小”“aria2 缺失”等情况。 阈值是 0.7.0 实测定下的,不是拍脑袋(见下方「并发阈值是实测的,不是猜的」)。
  • 下载完成后会校验落盘字节数:curl / aria2 的退出码 0 只代表“它自己认为完成了”,不代表字节数对。 少发、多发、镜像返回 200 的错误页等场景都不会有非零退出码,只有校验能拦下(详见「设计上的静默失败防护」)。
  • 返回的 requestedUrl 是实际请求的地址(启用镜像时为「镜像前缀 + 原始 URL」),mirrored 标明本次是否走了镜像。
  • 只允许 http / https:URL 来自模型读到的任意页面,属于不可信输入。其他协议(file:、ftp: 等)在下载开始前直接被拒绝,不会进入探测或下载流程。
  • 输出文件名会被净化:未传 output 时按 URL 推导文件名,推导结果只取单个路径段——URI 解码后的 /、\、.. 等一律丢弃,因此不会写到工作目录之外。

为什么必须做协议白名单与文件名净化(实测结论,勿移除)

0.4.1 实测:file:///C:/Windows/win.ini 会被 curl 接受并成功复制本地文件到目标路径; http://host/..%2F..%2F..%2Fescaped.txt 会解码出 ../../../escaped.txt,未指定 output 时 文件被写到当前工作目录之外(实测确认写入成功)。两者都在 0.4.2 修复:前者由协议白名单 拦截,后者由「解码后再切分、只取最后一段 + 净化」拦截。

镜像加速

下载 GitHub Release 等境外资源较慢时,可给 smart_download 传入 mirror 前缀,插件会把原始 URL 拼到该前缀后面再下载:

smart_download(
  url: "https://github.com/owner/repo/releases/download/v1/a.zip",
  mirror: "https://gh-proxy.com/"
)
# 实际请求:https://gh-proxy.com/https://github.com/owner/repo/releases/download/v1/a.zip

细节:

  • 只做前缀拼接,不做路径改写,因此适配绝大多数「前缀 + 完整原始 URL」形式的公开镜像;
  • 前缀缺尾斜杠会自动补上,也可直接写裸域名(ghfast.top 会补成 https://ghfast.top/);
  • 非 http(s) 的 URL 不使用镜像;
  • 输出文件名始终按原始 URL 推导,不会把镜像域名带进文件名。

常见的公开镜像(任选其一,可用性随网络环境变化):

镜像前缀
https://gh-proxy.com/
https://ghfast.top/
https://ghproxy.net/

镜像是第三方服务:请求内容会经过该镜像服务器,请勿用它下载含敏感信息的文件。

断点续传

下载中断后再次调用 smart_download(同样的 url 与 output)会从断点继续,而不是从头重下:

  • aria2 路径:能证实同源时启用 -c,否则不续传并带 --allow-overwrite=true 覆盖写。
  • curl 路径:仅在探测确认服务器支持 Range 且能证实同源时才加 -C -。

为什么续传除了「支持 Range」还要多一道校验(实测结论,勿简化):

0.6.0 用真实 curl 8.13.0 与随包 aria2 1.37.0 在 127.0.0.1 上实测:远端从 400 字节变成 200 字节时,curl -C - 与 aria2 -c 都是退出码 0、落盘仍是 400 字节;远端等长但内容换了时,两者同样是退出码 0、文件保留旧内容。也就是说「支持 Range」只保证能续,不保证续的是同一个文件。 因此续传前会比对远端长度与 ETag / Last-Modified(指纹记在 .part.json 旁车里,下载成功时清除、中断时保留),无法证实同源就删掉本地半包全量重下。只比长度挡得住前两行,挡不住「等长但内容变了」,这也是必须有旁车指纹的原因。

为什么 curl 的续传是条件式的(实测结论,勿改成无条件):

curl -C - 在服务器不支持 Range 且本地已存在半截文件时,会以退出码 33 直接失败——实测:1000 字节资源、已有 400 字节半包 → exit 33,文件保持 400 字节不损坏;而同一场景不带 -C - 能正常全量重新下载成功。 支持 Range 时三种情况均实测正确:半包续传、已下载完整后重跑、文件不存在从头下。

查询下载进度:download_status

download_status 只读地返回本插件下载任务的状态快照,可用来回答“刚才那个下载到百分之几了”。

download_status()                      # 列出最近 10 个任务
download_status(limit: 3)              # 只列最近 3 个
download_status(taskId: "dl-xxx")      # 只查指定任务

返回示例:

{
  "ok": true,
  "taskDir": "/path/.dsh-progress/",
  "downloadDir": "/home/u/.dsh/downloads/tasks",
  "total": 1,
  "tasks": [
    {
      "id": "dl-mulfpr76-z47a",
      "name": "local-24MiB.bin",
      "pct": 100,
      "msg": "下载完成",
      "status": "completed",
      "spd": "8.2MB/s",
      "eta": "",
      "updatedAt": 1759000000000
    }
  ]
}

约束与设计取舍:

  • 纯只读:不写文件、不联网。读取的就是「进度上报」那一节里的两条轨道目录。
  • 容错优先:目录不存在、权限不足、文件内容损坏,都退化为「该任务不出现在结果里」,整个调用依然成功返回(只是任务列表更短),不会因为查状态而让下载失败。
  • 任务按 updatedAt 倒序,最新的排在最前;total 是未截断的真实总数,tasks 会被 limit 截断。
  • name 是输出文件名(面板与列表显示用),未知时回退为任务 ID。
  • spd / eta 在不支持或未知时返回空字符串(schema 要求 string),不会返回 null 或 undefined。

进度上报

下载过程中通过 ProgressReporter 双轨写入进度,任一轨道不可写都静默容错,不影响下载:

  • 轨道一(dsh-task-progress 格式):$DSH_PROGRESS_DIR/.jsonl,每行一条 JSON,append-only;未设置该环境变量时,缺省为 /.dsh-progress//.jsonl(会话 ID 与工作目录来自 exec.agent.session.header)。拿不到会话上下文时不写轨道一——dsh-task-progress 的读取端按 session 过滤,写进错误的目录等于没写;
  • 轨道二(本插件自有格式):$DSH_DOWNLOAD_PROGRESS_DIR/.json,缺省为 /downloads/tasks/.json(DSH_HOME 缺省为 ~/.dsh),整体覆盖写。

每条记录都带 state 字段(running / done / failed / cancelled):dsh-task-progress 的读取端只认 state,缺失时一律当作 running,因此 0.5.0 之前的进度记录即使 pct=100、msg=下载完成,面板也永远显示「下载中」。进度按整条记录去重(pct + state + msg + spd + eta 完全一致才跳过),任一项变化都会写入,因此面板能看到实时的速度与剩余时间;完成 / 失败 / 取消等终态因为 state 变化,必然穿透去重。aria2 解析 --summary-interval=1 的摘要行(含速度与 ETA),curl 解析 --progress-bar 的百分比。

写盘自 0.6.0 起是异步的:report() 只把记录放进队列,由 microtask 批量写出并保序,下载主流程不再被同步 appendFileSync 阻塞;调用方在返回前会 await reporter.awaitFlush() 等待终态落盘,因此 download_status 与面板读到的必定是终态,而不是上一次的中间状态。

界面进度面板(web profile)

在 web profile 下,插件会在界面右下角挂载一个实时进度面板:只要有下载在进行就自动出现,显示输出文件名、百分比、传输速度与剩余时间;下载完成后短暂显示「已完成」回执,然后自动消失。空闲时不占位、不显示。

进度面板

实现要点(如遇面板不显示,可据此排查):

  • 走宿主 RPC:客户端通过 connection.fetch.register 注册的 /api/smartdl.status 拉取进度快照,与 download_status 工具读的是同一份进度文件。
  • 插槽:注册到 shell.overlay(kind: 'list',order: 100),因此不会覆盖宿主自身界面;无内容时渲染为 null。
  • 只依赖 react:客户端脚本以 classic script 形式通过 window.__ModuleLoader__.load 注册,不做打包。
  • 陈旧任务自动忽略:超过 10 分钟没有更新的 running 任务不再计入面板,避免历史残留文件让面板永久卡住。

该面板仅 web profile 提供;CLI 等 profile 下插件会静默跳过客户端注册,工具与下载功能不受影响。

设计上的静默失败防护

开发过程中实测发现了三个同类型的静默失败:

  1. aria2 摘要格式变化:关闭 readout 后,GID 变为十六进制、无 SIZE: 前缀、速度字段由 SPD: 变为 DL:;
  2. curl --silent 抑制进度:即使进度走 stderr,--silent 也会把 --progress-bar 一并压掉;
  3. aria2 临近完成时省略 ETA:快完成的摘要行整体不输出 ETA 字段。

它们都不是逻辑错误,而是对外部程序真实行为的假设错了:逻辑都对、测试都绿、下载也成功,只是某个环节悄悄返回了默认值(“不支持” / curl / null / 跳过写入)。这类问题的危险在于退出码仍是 0,“不抛错”的测试永远抓不住。

0.5.0 又抓到同一类的三个:轨道一缺 state(面板永远显示「下载中」)、无会话时把进度写进没人读的目录、DSH_HOME 未生效导致写进陈旧的 home。0.6.0 抓到的是最危险的一类:续传静默损坏(curl -C - / aria2 -c 在远端变小或内容变更时退出码 0 但文件是错的)与 aria2 覆盖时把新内容写进 f.1.bin 而返回的 path 指向旧文件——两者都是「报告成功、产物错误」,只有「落盘内容是否等于远端内容」这种正向断言才能发现。它们同样不抛错、测试也曾经全绿——只有「面板/查询是否真的显示了正确状态」「文件是否真的是远端那份」这种正向断言才能发现。

0.7.0 抓到的是「工具报告成功、但字节数不对」:随包 aria2 1.37.0 在服务器用 chunked 只发 1MB 就干净断开时, 退出码是 0、摘要里写着 OK,落盘文件却只有 1MB(声明 4MB);curl 在同一场景下同样是 exit 0。 没有任何非零退出码可供判断,只有落盘后比对字节数才能发现。详见下节。

契约:本插件所有“返回默认值”的路径——

环节静默失败形态必须断言的正向信号
probeUrl探测异常 → 不支持已知支持的 URL 必须返回 supportsMultiThread: true
decide分支遗漏 → 落 curl已知大文件必须返回 method: 'aria2'
parseAria2Summary认不出 → null真实样本必须解析出 pct/spd(/eta)
parseCurlProgress认不出 → null真实样本必须解析出单调递增到 100% 的百分比
ProgressReporter目录不可写 → 跳过可写目录必须存在文件且内容递增
LineBuffer切分状态错误跨 chunk / \r / \r\n 边界必须切出正确的行
applyMirror拼错 → URL 仍合法拼接结果必须可 new URL() 解析,且以原始 URL 结尾
readDownloadStatus读不到 → 空列表目录里有合法任务文件就必须扫出并解析出字段
buildCurlArgs续传开关插错位置resume 为真时 -C - 必须存在,为假时必须不存在,且 -o 与路径紧邻
checkDownloadUrl不校验协议 → 读本地文件file:// / ftp:// 必须被拒绝,http(s) 必须被放行并返回解析后的 URL
deriveFilenameFromUrl不净化 → 路径穿越..%2F..%2F..%2Fescaped.txt 必须推导为单段 escaped.txt,且实际写入不得逃出当前目录
ProgressReporter轨道一缺 state → 面板永远「下载中」终态记录必须含 state: 'done' / 'failed' / 'cancelled',readDownloadStatus 必须据此返回 completed / failed / cancelled
resolveTaskProgressDir无会话 → 写进没人读的目录无 DSH_PROGRESS_DIR 且无会话时必须返回 null(即不写轨道一),有会话时必须是 /.dsh-progress/
resolveDshHome不读 DSH_HOME → 写进陈旧 homeDSH_HOME 必须生效,空 / 纯空白必须回落 ~/.dsh
statusFrom只认文案 → 状态判错记录带 state 时必须以 state 为准(state: 'running' + 文案「下载完成」仍是 running)
verifySize退出码 0 但字节数不对 → 当成成功字节数一致必须返回 ok;chunked 截断(aria2/curl 均 exit 0)必须被判为失败;「探测长度未知」与「服务器返回压缩编码」必须跳过而非误判
probeUrl 的 accept-encoding默认带 gzip, deflate → 拿到压缩长度服务器必须收到 identity,且必须取回未压缩长度(5000 而非 41)

所有断言都是正向的:检查“有没有真的产出”,而不是“有没有崩溃”。真实样本保存在 test/fixtures/(curl 为保留 \r 的 .bin),并对“fixture 必须含 \r”做了强制断言。

如果未来发现某条返回默认值的路径没有被正向信号断言覆盖,那是一个 bug,不是设计。把正向断言改回“只要不报错就行”,等于重新打开静默失败的后门。

此外,test/meta-test-discovery.test.ts 会枚举 test/ 下所有测试文件,并断言 test 脚本(glob)确实覆盖它们——连“CI 是否真的跑了这些测试”这一层本身也被强制验证,防止防护措施自己在 CI 里静默失效。

并发阈值是实测的,不是猜的

0.7.0 之前,1MB / 50MB 两个阈值是拍脑袋定的。实测后改了其中一个,并坐实了两个此前不知道的坑。

受控实验:本地服务器按每连接限速 2MB/s(模拟“单连接被限速、多连接能叠加”的真实场景), 参数取自插件真实的 buildAria2Args,每种组合跑 3 轮取中位数:

文件大小curl(1 连接)x=2x=4x=8x=16
2MB1.743.263.183.163.11
8MB1.653.256.2611.9912.23
32MB1.673.086.2212.2123.15
64MB1.623.296.6312.8824.42

(单位 MB/s。公网对照实验不可用:npmmirror 上单连接已打满本地带宽,方差 10.6~26.9 MB/s; aliyun 镜像对 aria2 的 UA 直接返回 403。)

由此得到三条结论:

  1. 并发确实有用,且收益接近线性 —— 8MB 以上,2 连接 ≈ 2×、4 连接 ≈ 4×。
  2. 有效并发数被文件大小封顶:2MB 文件在 x=2 就到顶(3.26),开到 4/8/16 反而略降到 3.1x, 因为分片数与连接数超过文件能承载的量之后只剩建连开销。
  3. 因此 LARGE_FILE 阈值从 50MB 下调到 8MB:8~50MB 这一整段长年被压在 4 连接上, 实测 8MB 文件 x=8(11.99)比 x=4(6.26)快将近一倍。

维持 8 连接上限不动:32/64MB 下 x=16 确实还能再快一倍(23~24 MB/s), 但出于“避免触发服务器按 IP 限并发”的保守考虑不引入。

两个差点让并发全部失效的坑

  • aria2 的 -x/-s 会被 min-split-size 悄悄废掉。aria2 默认 --min-split-size=20M, 文件小于 20MB 时它完全不分片——实测 8MB 文件在默认参数下只发出 1 个不带 Range 的 GET, -x 16 -s 16 形同虚设,吞吐与单连接相同。本插件已传 -k 1M 侥幸避开。 删掉 -k 1M 会让上面所有档位静默退化成单连接且不报任何错,因此这一行在源码里带了一份警告注释。 另外实测 --min-split-size=512K 会让 aria2 以退出码 28 直接失败,故 1M 是当前验证过的安全取值。
  • 探测必须强制 accept-encoding: identity。Node 的 fetch 默认带 gzip, deflate, 而 curl / aria2 默认不带。5000 字节的 body 在默认探测下会报 41 字节;若不修, 0.7.0 新增的大小校验会对任何支持 gzip 的服务器 100% 误报,把正确的下载判成损坏。

支持平台

平台架构是否支持二进制子包
Windowsx64✅ 支持@leisureyu/dsh-aria2-win32-x64
Windowsarm64✅ 支持@leisureyu/dsh-aria2-win32-arm64
Linuxx64✅ 支持@leisureyu/dsh-aria2-linux-x64
Linuxarm64✅ 支持@leisureyu/dsh-aria2-linux-arm64
macOSx64 / arm64❌ 暂不支持—

二进制子包通过 os / cpu 字段声明,npm / pnpm 在不匹配的平台上会自动跳过安装。

权限说明

本插件只做两件事:发起下载与写进度文件。逐项说明如下。

行为说明
网络出站请求仅接受 http / https,其他协议在下载前直接拒绝(checkDownloadUrl)。对通过校验的 URL 发起 HEAD / Range 探测(probeUrl,5s 超时),以及实际下载(aria2c 或系统 curl)。仅访问调用方传入的 URL,不访问其他地址。
写入下载文件写入 output 参数指定的路径;未指定时由 URL 推导文件名,落在当前工作目录。推导结果必定是单个路径段(/、\、.. 等被丢弃,并替换 Windows 非法字符、规避保留设备名),因此不会写到工作目录之外。父目录不存在时会自动创建(mkdir -p)。不会删除任何已有文件。启用断点续传后,同名文件会被续写而非重头覆盖;未启用 --allow-overwrite,因此不会静默丢弃已完成的同名文件。
读取进度文件download_status 只读取上述两条进度轨道目录,不写文件、不联网。
写进度文件轨道一 $DSH_PROGRESS_DIR/.jsonl,未设置该环境变量时写 `/.dsh-progress/<session.