dsh-mcloud 0.0.0-stage → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,182 @@
1
+ /**
2
+ * dsh-mcloud — 传输层(纯函数,不碰鉴权也不碰 DSH)
3
+ *
4
+ * 移动云盘的上传是标准的「建任务 → 分片 PUT → complete」三段式,
5
+ * 两个独立实现(OpenList 的 Go 驱动、cloud_driver_sdk 的 Python SDK)流程一致:
6
+ *
7
+ * 1. POST {host}/file/create 带整文件 SHA256 + 全部分片信息
8
+ * → 命中秒传(exist / rapidUpload)就直接结束
9
+ * → 否则给回 fileId、uploadId 和前 100 个分片的 uploadUrl
10
+ * 2. PUT 每个 uploadUrl,body 是该分片的原始字节(按 partOffset/partSize 从文件里切)
11
+ * 3. POST {host}/file/complete
12
+ *
13
+ * 这里只负责:算哈希、切分片、把一段字节流式 PUT 上去、边传边报进度。
14
+ */
15
+
16
+ import { createReadStream, createWriteStream, mkdirSync, renameSync, rmSync } from 'node:fs'
17
+ import { createHash } from 'node:crypto'
18
+ import { dirname } from 'node:path'
19
+ import { Readable, Transform } from 'node:stream'
20
+ import { pipeline } from 'node:stream/promises'
21
+ import http from 'node:http'
22
+ import https from 'node:https'
23
+
24
+ export const MB = 1024 * 1024
25
+
26
+ /** 分片大小:跟 OpenList 一致,>30GB 才放大到 512MB */
27
+ export const PART_SIZE_DEFAULT = 100 * MB
28
+ export const PART_SIZE_LARGE = 512 * MB
29
+ /** 服务端一次最多接受/返回 100 个分片 */
30
+ export const PART_BATCH = 100
31
+
32
+ export const UPLOAD_CONTENT_TYPE = 'application/octet-stream'
33
+
34
+ export function partSizeFor(size) {
35
+ return size > 30 * 1024 * MB ? PART_SIZE_LARGE : PART_SIZE_DEFAULT
36
+ }
37
+
38
+ /** 按文件大小切出 partInfos(就是发给 /file/create 的那个数组) */
39
+ export function buildPartInfos(size, partSize) {
40
+ if (size <= 0) {
41
+ // 空文件:给一个 0 长分片,服务端只认任务本身
42
+ return [{ partNumber: 1, partSize: 0, parallelHashCtx: { partOffset: 0 } }]
43
+ }
44
+ const count = Math.ceil(size / partSize)
45
+ const list = []
46
+ for (let index = 0; index < count; index += 1) {
47
+ const offset = index * partSize
48
+ list.push({
49
+ partNumber: index + 1,
50
+ partSize: Math.min(partSize, size - offset),
51
+ parallelHashCtx: { partOffset: offset },
52
+ })
53
+ }
54
+ return list
55
+ }
56
+
57
+ /** 流式算整文件 SHA256,返回小写 hex(大文件也不会吃内存) */
58
+ export function sha256File(filePath) {
59
+ return new Promise((resolveHash, rejectHash) => {
60
+ const hash = createHash('sha256')
61
+ const source = createReadStream(filePath)
62
+ source.on('error', rejectHash)
63
+ source.on('data', (chunk) => hash.update(chunk))
64
+ source.on('end', () => resolveHash(hash.digest('hex')))
65
+ })
66
+ }
67
+
68
+ /**
69
+ * 把文件的一段 [start, end](闭区间)流式 PUT 到分片地址。
70
+ * 刻意不把分片读进内存 —— 分片可能是 512MB。
71
+ */
72
+ export function putRange(urlString, filePath, start, end, onBytes, signal) {
73
+ return new Promise((resolvePut, rejectPut) => {
74
+ let target
75
+ try {
76
+ target = new URL(urlString)
77
+ } catch {
78
+ rejectPut(new Error(`服务端给的分片地址不合法:${String(urlString).slice(0, 120)}`))
79
+ return
80
+ }
81
+ if (target.protocol !== 'http:' && target.protocol !== 'https:') {
82
+ rejectPut(new Error(`分片地址协议不支持:${target.protocol}`))
83
+ return
84
+ }
85
+
86
+ const length = end - start + 1
87
+ const transport = target.protocol === 'http:' ? http : https
88
+ const request = transport.request(
89
+ {
90
+ method: 'PUT',
91
+ hostname: target.hostname,
92
+ port: target.port || (target.protocol === 'http:' ? 80 : 443),
93
+ path: `${target.pathname}${target.search}`,
94
+ headers: {
95
+ 'content-type': UPLOAD_CONTENT_TYPE,
96
+ 'content-length': String(length),
97
+ origin: 'https://yun.139.com',
98
+ referer: 'https://yun.139.com/',
99
+ },
100
+ signal,
101
+ },
102
+ (response) => {
103
+ const chunks = []
104
+ response.on('data', (chunk) => chunks.push(chunk))
105
+ response.on('end', () => {
106
+ if (response.statusCode === 200) {
107
+ resolvePut()
108
+ return
109
+ }
110
+ const body = Buffer.concat(chunks).toString('utf8').slice(0, 300)
111
+ rejectPut(new Error(`分片上传失败 HTTP ${response.statusCode}:${body}`))
112
+ })
113
+ },
114
+ )
115
+
116
+ request.on('error', rejectPut)
117
+
118
+ const source = createReadStream(filePath, { start, end })
119
+ source.on('error', rejectPut)
120
+
121
+ const counter = new Transform({
122
+ transform(chunk, _encoding, callback) {
123
+ try {
124
+ onBytes?.(chunk.length)
125
+ } catch {
126
+ /* 进度回调不该影响传输本身 */
127
+ }
128
+ callback(null, chunk)
129
+ },
130
+ })
131
+
132
+ source.pipe(counter).pipe(request)
133
+ })
134
+ }
135
+
136
+ /**
137
+ * 把一段 web 响应体流式写进本地文件。
138
+ * 先写 `<path>.part-xxxx` 再原子改名 —— 失败(含取消)时不会留半个文件冒充成品。
139
+ */
140
+ export async function streamToFile(webBody, localPath, onBytes, signal) {
141
+ mkdirSync(dirname(localPath), { recursive: true })
142
+ const temporary = `${localPath}.part-${Math.random().toString(16).slice(2, 10)}`
143
+ const counter = new Transform({
144
+ transform(chunk, _encoding, callback) {
145
+ try {
146
+ onBytes?.(chunk.length)
147
+ } catch {
148
+ /* 进度回调不该影响传输本身 */
149
+ }
150
+ callback(null, chunk)
151
+ },
152
+ })
153
+ try {
154
+ await pipeline(Readable.fromWeb(webBody), counter, createWriteStream(temporary), { signal })
155
+ renameSync(temporary, localPath)
156
+ } catch (error) {
157
+ rmSync(temporary, { force: true })
158
+ throw error
159
+ }
160
+ return localPath
161
+ }
162
+
163
+ /**
164
+ * 把一批「服务端返回的分片地址」对应到我们自己的 partInfos 上依次传完。
165
+ * 顺序按 partNumber 排,这样进度条是单调的。
166
+ */
167
+ export async function putParts(partInfos, uploadPartInfos, filePath, onBytes, signal, onPart) {
168
+ const list = [...(uploadPartInfos ?? [])].sort(
169
+ (a, b) => Number(a?.partNumber ?? 0) - Number(b?.partNumber ?? 0),
170
+ )
171
+ for (const item of list) {
172
+ const partNumber = Number(item?.partNumber ?? 0)
173
+ const info = partInfos[partNumber - 1]
174
+ if (!info) throw new Error(`服务端返回了越界的分片号 ${partNumber}`)
175
+ if (!item?.uploadUrl) continue
176
+ const size = Number(info.partSize ?? 0)
177
+ if (size <= 0) continue
178
+ const start = Number(info.parallelHashCtx?.partOffset ?? 0)
179
+ await putRange(item.uploadUrl, filePath, start, start + size - 1, onBytes, signal)
180
+ onPart?.(partNumber)
181
+ }
182
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Mobile Cloud",
4
+ "description": "China Mobile Cloud (139): quota, directory listing, download links, plus mkdir/rename/move/delete."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "移动云盘",
4
+ "description": "中国移动云盘(139):容量查询、列目录、下载直链,以及新建/改名/移动/删除。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,64 @@
1
1
  {
2
2
  "name": "dsh-mcloud",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.1",
4
+ "description": "中国移动云盘(139)接入 DeepSeek Harness:容量查询、列目录、上传下载、转移、以及在会话页提供一个可操作的标签面板。",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "icon": "./icon.svg",
8
+ "exports": {
9
+ ".": "./lib/index.js",
10
+ "./client": "./lib/client.js",
11
+ "./locale/*.json": "./locale/*.json",
12
+ "./icon": "./icon.svg",
13
+ "./package.json": "./package.json"
14
+ },
15
+ "files": [
16
+ "lib",
17
+ "locale/*.json",
18
+ "icon.svg",
19
+ "cordis.patch.yml",
20
+ "README.md",
21
+ "CHANGELOG.md",
22
+ "CONTRIBUTING.md",
23
+ "FAQ.md",
24
+ "LICENSE"
25
+ ],
26
+ "dsh": {
27
+ "bundle": {
28
+ "patch": "./cordis.patch.yml"
29
+ },
30
+ "client": {
31
+ "platform": "web",
32
+ "immediately": true,
33
+ "inject": [
34
+ "@deepseek-ai/dsh-client-locale"
35
+ ]
36
+ }
37
+ },
38
+ "keywords": [
39
+ "dsh",
40
+ "dsh-plugin",
41
+ "deepseek-harness",
42
+ "139",
43
+ "mobile-cloud",
44
+ "china-mobile",
45
+ "yun139"
46
+ ],
47
+ "engines": {
48
+ "node": ">=22",
49
+ "dsh": ">=0.2.1-rc.5 <0.3.0-0"
50
+ },
51
+ "repository": {
52
+ "type": "git",
53
+ "url": "git+https://github.com/GIYN-S/dsh-mcloud.git"
54
+ },
55
+ "homepage": "https://github.com/GIYN-S/dsh-mcloud#readme",
56
+ "bugs": {
57
+ "url": "https://github.com/GIYN-S/dsh-mcloud/issues"
58
+ },
59
+ "publishConfig": {
60
+ "access": "public",
61
+ "registry": "https://registry.npmjs.org/"
62
+ },
63
+ "license": "MIT"
64
+ }