@jackwener/opencli 1.7.22 → 1.8.0

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.
Files changed (222) hide show
  1. package/README.md +30 -148
  2. package/README.zh-CN.md +37 -211
  3. package/cli-manifest.json +6423 -4260
  4. package/clis/12306/me.js +73 -0
  5. package/clis/12306/orders.js +96 -0
  6. package/clis/12306/passengers.js +90 -0
  7. package/clis/12306/price.js +166 -0
  8. package/clis/12306/stations.js +66 -0
  9. package/clis/12306/train.js +91 -0
  10. package/clis/12306/trains.js +119 -0
  11. package/clis/12306/utils.js +272 -0
  12. package/clis/12306/utils.test.js +331 -0
  13. package/clis/36kr/article.js +6 -3
  14. package/clis/36kr/article.test.js +46 -0
  15. package/clis/apple-podcasts/commands.test.js +20 -0
  16. package/clis/apple-podcasts/search.js +2 -2
  17. package/clis/barchart/greeks.js +144 -56
  18. package/clis/barchart/greeks.test.js +138 -0
  19. package/clis/bilibili/summary.js +167 -0
  20. package/clis/bilibili/summary.test.js +210 -0
  21. package/clis/booking/booking.test.js +356 -0
  22. package/clis/booking/search.js +351 -0
  23. package/clis/chatgpt/envelope.test.js +108 -0
  24. package/clis/chatgpt/image.js +2 -2
  25. package/clis/chatgpt/image.test.js +6 -0
  26. package/clis/chatgpt/utils.js +148 -41
  27. package/clis/chatgpt/utils.test.js +92 -2
  28. package/clis/douyin/_shared/browser-fetch.js +44 -20
  29. package/clis/douyin/_shared/browser-fetch.test.js +22 -1
  30. package/clis/douyin/_shared/evaluate-result.js +16 -0
  31. package/clis/douyin/_shared/tos-upload.js +105 -69
  32. package/clis/douyin/_shared/vod-upload.js +212 -0
  33. package/clis/douyin/_shared/vod-upload.test.js +38 -0
  34. package/clis/douyin/delete.js +137 -4
  35. package/clis/douyin/delete.test.js +90 -1
  36. package/clis/douyin/publish-upload-id.test.js +170 -0
  37. package/clis/douyin/publish.js +88 -42
  38. package/clis/douyin/user-videos.js +9 -2
  39. package/clis/douyin/user-videos.test.js +43 -0
  40. package/clis/flomo/memos.js +228 -0
  41. package/clis/flomo/memos.test.js +144 -0
  42. package/clis/gitee/search.js +2 -2
  43. package/clis/gitee/search.test.js +65 -0
  44. package/clis/jike/post.js +27 -17
  45. package/clis/jike/read.test.js +86 -0
  46. package/clis/jike/topic.js +32 -19
  47. package/clis/jike/user.js +33 -20
  48. package/clis/lesswrong/comments.js +1 -1
  49. package/clis/lesswrong/curated.js +1 -1
  50. package/clis/lesswrong/frontpage.js +1 -1
  51. package/clis/lesswrong/frontpage.test.js +37 -0
  52. package/clis/lesswrong/new.js +1 -1
  53. package/clis/lesswrong/read.js +1 -1
  54. package/clis/lesswrong/sequences.js +1 -1
  55. package/clis/lesswrong/shortform.js +1 -1
  56. package/clis/lesswrong/tag.js +1 -1
  57. package/clis/lesswrong/top-month.js +1 -1
  58. package/clis/lesswrong/top-week.js +1 -1
  59. package/clis/lesswrong/top-year.js +1 -1
  60. package/clis/lesswrong/top.js +1 -1
  61. package/clis/linkedin/connect.js +401 -0
  62. package/clis/linkedin/connect.test.js +213 -0
  63. package/clis/linkedin/inbox.js +234 -0
  64. package/clis/linkedin/inbox.test.js +152 -0
  65. package/clis/linkedin/people-search.js +262 -0
  66. package/clis/linkedin/people-search.test.js +216 -0
  67. package/clis/linkedin/safe-send.js +357 -0
  68. package/clis/linkedin/safe-send.test.js +204 -0
  69. package/clis/linkedin/salesnav-inbox.js +210 -0
  70. package/clis/linkedin/salesnav-inbox.test.js +113 -0
  71. package/clis/linkedin/salesnav-message.js +360 -0
  72. package/clis/linkedin/salesnav-message.test.js +172 -0
  73. package/clis/linkedin/salesnav-search.js +186 -0
  74. package/clis/linkedin/salesnav-search.test.js +76 -0
  75. package/clis/linkedin/salesnav-thread.js +212 -0
  76. package/clis/linkedin/salesnav-thread.test.js +79 -0
  77. package/clis/linkedin/sent-invitations.js +92 -0
  78. package/clis/linkedin/sent-invitations.test.js +62 -0
  79. package/clis/linkedin/thread-snapshot.js +214 -0
  80. package/clis/linkedin/thread-snapshot.test.js +89 -0
  81. package/clis/linkedin-learning/course.js +138 -0
  82. package/clis/linkedin-learning/course.test.js +114 -0
  83. package/clis/linkedin-learning/search.js +155 -0
  84. package/clis/linkedin-learning/search.test.js +144 -0
  85. package/clis/linkedin-learning/trending.js +133 -0
  86. package/clis/linkedin-learning/trending.test.js +123 -0
  87. package/clis/powerchina/search.js +3 -3
  88. package/clis/powerchina/search.test.js +27 -1
  89. package/clis/reddit/extract-media.test.js +149 -0
  90. package/clis/reddit/frontpage.js +47 -9
  91. package/clis/reddit/frontpage.test.js +34 -0
  92. package/clis/reddit/home.js +31 -1
  93. package/clis/reddit/home.test.js +46 -3
  94. package/clis/reddit/hot.js +32 -1
  95. package/clis/reddit/hot.test.js +15 -1
  96. package/clis/reddit/popular.js +39 -1
  97. package/clis/reddit/popular.test.js +26 -0
  98. package/clis/reddit/saved.js +1 -1
  99. package/clis/reddit/search.js +38 -1
  100. package/clis/reddit/search.test.js +26 -0
  101. package/clis/reddit/subreddit.js +52 -7
  102. package/clis/reddit/subreddit.test.js +31 -0
  103. package/clis/reddit/subscribed.js +165 -0
  104. package/clis/reddit/subscribed.test.js +168 -0
  105. package/clis/reddit/upvoted.js +1 -1
  106. package/clis/suno/commands.test.js +188 -0
  107. package/clis/suno/download.js +140 -0
  108. package/clis/suno/download.test.js +151 -0
  109. package/clis/suno/generate.js +226 -0
  110. package/clis/suno/generate.test.js +243 -0
  111. package/clis/suno/list.js +79 -0
  112. package/clis/suno/status.js +62 -0
  113. package/clis/suno/utils.js +540 -0
  114. package/clis/suno/utils.test.js +223 -0
  115. package/clis/twitter/device-follow.js +193 -0
  116. package/clis/twitter/device-follow.test.js +287 -0
  117. package/clis/twitter/download.js +443 -73
  118. package/clis/twitter/download.test.js +457 -0
  119. package/clis/twitter/list-create.js +155 -0
  120. package/clis/twitter/list-create.test.js +169 -0
  121. package/clis/twitter/list-remove.js +12 -5
  122. package/clis/twitter/list-remove.test.js +74 -0
  123. package/clis/twitter/list-tweets.js +6 -2
  124. package/clis/twitter/list-tweets.test.js +41 -1
  125. package/clis/twitter/lists.js +31 -4
  126. package/clis/twitter/lists.test.js +152 -16
  127. package/clis/twitter/search.js +6 -2
  128. package/clis/twitter/search.test.js +6 -0
  129. package/clis/twitter/shared.js +144 -0
  130. package/clis/twitter/shared.test.js +429 -1
  131. package/clis/twitter/thread.js +10 -2
  132. package/clis/twitter/thread.test.js +58 -0
  133. package/clis/twitter/timeline.js +6 -2
  134. package/clis/twitter/timeline.test.js +2 -0
  135. package/clis/twitter/tweets.js +3 -2
  136. package/clis/twitter/tweets.test.js +1 -1
  137. package/clis/weibo/delete.js +172 -0
  138. package/clis/weibo/delete.test.js +94 -0
  139. package/clis/weibo/publish.js +37 -14
  140. package/clis/weibo/publish.test.js +14 -5
  141. package/clis/weibo/user-posts.js +234 -0
  142. package/clis/weibo/user-posts.test.js +92 -0
  143. package/clis/weread/search-regression.test.js +18 -11
  144. package/clis/weread/search.js +15 -7
  145. package/clis/weread-official/book.js +135 -0
  146. package/clis/weread-official/commands.test.js +385 -0
  147. package/clis/weread-official/discover.js +107 -0
  148. package/clis/weread-official/list-apis.js +95 -0
  149. package/clis/weread-official/notes.js +171 -0
  150. package/clis/weread-official/readdata.js +158 -0
  151. package/clis/weread-official/review.js +93 -0
  152. package/clis/weread-official/search.js +106 -0
  153. package/clis/weread-official/shelf.js +97 -0
  154. package/clis/weread-official/utils.js +293 -0
  155. package/clis/weread-official/utils.test.js +242 -0
  156. package/clis/wikipedia/trending.js +7 -3
  157. package/clis/wikipedia/trending.test.js +57 -0
  158. package/clis/xianyu/chat.js +24 -109
  159. package/clis/xianyu/chat.test.js +5 -0
  160. package/clis/xianyu/im.js +322 -0
  161. package/clis/xianyu/im.test.js +253 -0
  162. package/clis/xianyu/inbox.js +96 -0
  163. package/clis/xianyu/messages.js +91 -0
  164. package/clis/xianyu/reply.js +82 -0
  165. package/clis/xiaohongshu/creator-note-detail.js +2 -1
  166. package/clis/xiaohongshu/creator-note-detail.test.js +11 -0
  167. package/clis/xiaohongshu/creator-notes-summary.js +2 -1
  168. package/clis/xiaohongshu/creator-notes-summary.test.js +7 -0
  169. package/clis/xiaohongshu/creator-notes.js +2 -1
  170. package/clis/xiaohongshu/creator-notes.test.js +12 -0
  171. package/clis/xiaohongshu/creator-stats.js +2 -1
  172. package/clis/xiaohongshu/creator-stats.test.js +24 -0
  173. package/clis/xiaohongshu/delete-note.js +260 -0
  174. package/clis/xiaohongshu/delete-note.test.js +172 -0
  175. package/clis/xiaohongshu/publish.js +48 -8
  176. package/clis/xiaohongshu/publish.test.js +65 -10
  177. package/clis/xiaohongshu/user-helpers.test.js +41 -0
  178. package/clis/xiaohongshu/user.js +27 -4
  179. package/clis/xiaoyuzhou/download.js +1 -1
  180. package/clis/xiaoyuzhou/transcript.js +1 -1
  181. package/clis/youdao/note.js +258 -0
  182. package/clis/youdao/note.test.js +99 -0
  183. package/clis/youtube/transcript.js +397 -24
  184. package/clis/youtube/transcript.test.js +196 -6
  185. package/clis/zhihu/answer-comments.js +299 -0
  186. package/clis/zhihu/answer-comments.test.js +287 -0
  187. package/clis/zhihu/answer-detail.js +12 -0
  188. package/clis/zhihu/answer-detail.test.js +8 -0
  189. package/clis/zhihu/collection.js +15 -2
  190. package/clis/zhihu/collection.test.js +46 -0
  191. package/clis/zhihu/download.js +1 -1
  192. package/clis/zhihu/question.js +42 -9
  193. package/clis/zhihu/question.test.js +111 -9
  194. package/clis/zhihu/search.js +206 -43
  195. package/clis/zhihu/search.test.js +198 -0
  196. package/dist/src/browser/errors.js +4 -2
  197. package/dist/src/browser/errors.test.js +6 -0
  198. package/dist/src/browser/page.js +30 -4
  199. package/dist/src/browser/page.test.js +42 -0
  200. package/dist/src/browser/utils.d.ts +1 -1
  201. package/dist/src/cli-argv-preprocess.d.ts +26 -0
  202. package/dist/src/cli-argv-preprocess.js +138 -0
  203. package/dist/src/cli-argv-preprocess.test.js +79 -0
  204. package/dist/src/convention-audit.js +15 -8
  205. package/dist/src/convention-audit.test.js +21 -0
  206. package/dist/src/download/media-download.js +15 -2
  207. package/dist/src/download/media-download.test.d.ts +1 -0
  208. package/dist/src/download/media-download.test.js +110 -0
  209. package/dist/src/electron-apps.js +1 -1
  210. package/dist/src/electron-apps.test.js +7 -2
  211. package/dist/src/errors.d.ts +17 -0
  212. package/dist/src/errors.js +22 -0
  213. package/dist/src/external-clis.yaml +8 -0
  214. package/dist/src/main.js +14 -2
  215. package/dist/src/utils.d.ts +43 -0
  216. package/dist/src/utils.js +97 -0
  217. package/dist/src/utils.test.d.ts +1 -0
  218. package/dist/src/utils.test.js +155 -0
  219. package/package.json +8 -2
  220. package/scripts/silent-column-drop-baseline.json +0 -52
  221. package/scripts/typed-error-lint-baseline.json +28 -380
  222. package/clis/slock/_utils.js +0 -12
@@ -56,6 +56,31 @@ function sha256Hex(data) {
56
56
  }
57
57
  return hash.digest('hex');
58
58
  }
59
+ const CRC32_TABLE = new Uint32Array(256).map((_, index) => {
60
+ let value = index;
61
+ for (let bit = 0; bit < 8; bit += 1) {
62
+ value = (value & 1) ? (0xEDB88320 ^ (value >>> 1)) : (value >>> 1);
63
+ }
64
+ return value >>> 0;
65
+ });
66
+ function crc32Hex(data) {
67
+ let crc = 0xffffffff;
68
+ for (const byte of data) {
69
+ crc = CRC32_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
70
+ }
71
+ return ((crc ^ 0xffffffff) >>> 0).toString(16).padStart(8, '0');
72
+ }
73
+ function gatewayBaseUrl(tosUrl) {
74
+ const parsedUrl = new URL(tosUrl);
75
+ return `https://${parsedUrl.host}/upload/v1${parsedUrl.pathname}`;
76
+ }
77
+ function gatewayHeaders(auth, uploadHeader, userId = '') {
78
+ return {
79
+ Authorization: auth,
80
+ 'X-Storage-U': encodeURIComponent(userId),
81
+ ...(uploadHeader ?? {}),
82
+ };
83
+ }
59
84
  function extractRegionFromHost(host) {
60
85
  // e.g. "tos-cn-i-alisg.volces.com" → "cn-i-alisg"
61
86
  // e.g. "tos-cn-beijing.ivolces.com" → "cn-beijing"
@@ -129,6 +154,7 @@ async function tosRequest(opts) {
129
154
  method,
130
155
  headers,
131
156
  body: fetchBody,
157
+ signal: AbortSignal.timeout(60000),
132
158
  });
133
159
  const responseBody = await res.text();
134
160
  const responseHeaders = {};
@@ -140,86 +166,95 @@ async function tosRequest(opts) {
140
166
  function nowDatetime() {
141
167
  return new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
142
168
  }
169
+ function extractUploadId(body) {
170
+ const xmlMatch = body.match(/<UploadId>([^<]+)<\/UploadId>/i);
171
+ if (xmlMatch) return xmlMatch[1];
172
+ try {
173
+ const json = JSON.parse(body);
174
+ return json?.payload?.uploadID
175
+ || json?.payload?.uploadId
176
+ || json?.payload?.UploadID
177
+ || json?.payload?.UploadId
178
+ || json?.data?.uploadid
179
+ || json?.data?.uploadID
180
+ || json?.data?.uploadId
181
+ || json?.data?.UploadID
182
+ || json?.data?.UploadId
183
+ || json?.UploadID
184
+ || json?.UploadId
185
+ || json?.uploadID
186
+ || json?.uploadId
187
+ || null;
188
+ }
189
+ catch {
190
+ return null;
191
+ }
192
+ }
143
193
  // ── Phase 1: Init multipart upload ───────────────────────────────────────────
144
- async function initMultipartUpload(tosUrl, auth, credentials) {
145
- const initUrl = `${tosUrl}?uploads`;
146
- const datetime = nowDatetime();
147
- // Use the pre-computed auth for INIT, as it comes from ApplyVideoUpload
148
- const headers = {
149
- Authorization: auth,
150
- 'x-amz-date': datetime,
151
- 'x-amz-security-token': credentials.session_token,
152
- 'content-type': 'application/octet-stream',
153
- };
154
- const res = await tosRequest({ method: 'POST', url: initUrl, headers });
194
+ async function initMultipartUpload(tosUrl, auth, uploadHeader, userId) {
195
+ const initUrl = `${gatewayBaseUrl(tosUrl)}?uploadmode=part&phase=init`;
196
+ const res = await tosRequest({
197
+ method: 'POST',
198
+ url: initUrl,
199
+ headers: gatewayHeaders(auth, uploadHeader, userId),
200
+ });
155
201
  if (res.status !== 200) {
156
- throw new CommandExecutionError(`TOS init multipart upload failed with status ${res.status}: ${res.body}`, 'Check that TOS credentials are valid and not expired.');
202
+ throw new CommandExecutionError(`TOS init multipart upload failed with status ${res.status}: ${res.body}`, 'Check that TOS upload authorization is valid and not expired.');
157
203
  }
158
- // Parse UploadId from XML: <UploadId>...</UploadId>
159
- const match = res.body.match(/<UploadId>([^<]+)<\/UploadId>/);
160
- if (!match) {
204
+ const uploadId = extractUploadId(res.body);
205
+ if (!uploadId) {
161
206
  throw new CommandExecutionError(`TOS init response missing UploadId: ${res.body}`);
162
207
  }
163
- return match[1];
208
+ return uploadId;
164
209
  }
165
210
  // ── Phase 2: Upload a single part ────────────────────────────────────────────
166
- async function uploadPart(tosUrl, partNumber, uploadId, data, credentials, region) {
167
- const parsedUrl = new URL(tosUrl);
168
- parsedUrl.searchParams.set('partNumber', String(partNumber));
169
- parsedUrl.searchParams.set('uploadId', uploadId);
170
- const url = parsedUrl.toString();
171
- const datetime = nowDatetime();
172
- const headers = computeAws4Headers({
173
- method: 'PUT',
174
- url,
175
- headers: { 'content-type': 'application/octet-stream' },
176
- body: data,
177
- credentials,
178
- service: 'tos',
179
- region,
180
- datetime,
181
- });
182
- const res = await tosRequest({ method: 'PUT', url, headers, body: data });
183
- if (res.status !== 200) {
184
- throw new CommandExecutionError(`TOS upload part ${partNumber} failed with status ${res.status}: ${res.body}`, 'Check that STS2 credentials are valid and not expired.');
211
+ async function uploadPart(tosUrl, partNumber, uploadId, data, auth, uploadHeader, userId) {
212
+ const crc32 = crc32Hex(data);
213
+ const url = `${gatewayBaseUrl(tosUrl)}?uploadid=${encodeURIComponent(uploadId)}&part_number=${partNumber}&phase=transfer`;
214
+ const headers = {
215
+ ...gatewayHeaders(auth, uploadHeader, userId),
216
+ 'Content-CRC32': crc32,
217
+ 'Content-Type': 'application/octet-stream',
218
+ 'X-Use-Init-Upload-Optimize': '1',
219
+ 'X-Use-Large-Local-Cache': '1',
220
+ };
221
+ const res = await tosRequest({ method: 'POST', url, headers, body: data });
222
+ let parsed;
223
+ try {
224
+ parsed = JSON.parse(res.body);
185
225
  }
186
- const etag = res.headers['etag'];
187
- if (!etag) {
188
- throw new CommandExecutionError(`TOS upload part ${partNumber} response missing ETag header`);
226
+ catch {
227
+ parsed = null;
189
228
  }
190
- return etag;
229
+ if (res.status !== 200 || parsed?.code !== 2000) {
230
+ throw new CommandExecutionError(`TOS upload part ${partNumber} failed with status ${res.status}: ${res.body}`, 'Check that TOS upload authorization is valid and not expired.');
231
+ }
232
+ return parsed?.data?.crc32 || crc32;
191
233
  }
192
234
  // ── Phase 3: Complete multipart upload ───────────────────────────────────────
193
- async function completeMultipartUpload(tosUrl, uploadId, parts, credentials, region) {
194
- const parsedUrl = new URL(tosUrl);
195
- parsedUrl.searchParams.set('uploadId', uploadId);
196
- const url = parsedUrl.toString();
197
- const xmlBody = '<CompleteMultipartUpload>' +
198
- parts
199
- .sort((a, b) => a.partNumber - b.partNumber)
200
- .map(p => `<Part><PartNumber>${p.partNumber}</PartNumber><ETag>${p.etag}</ETag></Part>`)
201
- .join('') +
202
- '</CompleteMultipartUpload>';
203
- const datetime = nowDatetime();
204
- const headers = computeAws4Headers({
205
- method: 'POST',
206
- url,
207
- headers: { 'content-type': 'application/xml' },
208
- body: xmlBody,
209
- credentials,
210
- service: 'tos',
211
- region,
212
- datetime,
213
- });
235
+ async function completeMultipartUpload(tosUrl, uploadId, parts, auth, uploadHeader, userId) {
236
+ const url = `${gatewayBaseUrl(tosUrl)}?uploadmode=part&phase=finish&uploadid=${encodeURIComponent(uploadId)}`;
237
+ const body = parts
238
+ .sort((a, b) => a.partNumber - b.partNumber)
239
+ .map(p => `${p.partNumber}:${p.crc32}`)
240
+ .join(',');
214
241
  const res = await tosRequest({
215
242
  method: 'POST',
216
243
  url,
217
- headers,
218
- body: xmlBody,
244
+ headers: gatewayHeaders(auth, uploadHeader, userId),
245
+ body,
219
246
  });
220
- if (res.status !== 200) {
247
+ let parsed;
248
+ try {
249
+ parsed = JSON.parse(res.body);
250
+ }
251
+ catch {
252
+ parsed = null;
253
+ }
254
+ if (res.status !== 200 || parsed?.code !== 2000) {
221
255
  throw new CommandExecutionError(`TOS complete multipart upload failed with status ${res.status}: ${res.body}`, 'Check that all parts were uploaded successfully.');
222
256
  }
257
+ return parsed?.data?.key || null;
223
258
  }
224
259
  let _readSyncOverride = null;
225
260
  /** @internal — for testing only */
@@ -237,7 +272,7 @@ export async function tosUpload(options) {
237
272
  if (fileSize === 0) {
238
273
  throw new CommandExecutionError(`Video file is empty: ${filePath}`);
239
274
  }
240
- const { tos_upload_url: tosUrl, auth } = uploadInfo;
275
+ const { tos_upload_url: tosUrl, auth, upload_header: uploadHeader, user_id: userId } = uploadInfo;
241
276
  const parsedTosUrl = new URL(tosUrl);
242
277
  const region = extractRegionFromHost(parsedTosUrl.host);
243
278
  const resumePath = getResumeFilePath(filePath);
@@ -251,7 +286,7 @@ export async function tosUpload(options) {
251
286
  }
252
287
  else {
253
288
  // Start fresh
254
- uploadId = await initMultipartUpload(tosUrl, auth, credentials);
289
+ uploadId = await initMultipartUpload(tosUrl, auth, uploadHeader, userId);
255
290
  completedParts = [];
256
291
  saveResumeState(resumePath, { uploadId, fileSize, parts: completedParts });
257
292
  }
@@ -277,8 +312,8 @@ export async function tosUpload(options) {
277
312
  if (bytesRead !== chunkSize) {
278
313
  throw new CommandExecutionError(`Short read on part ${partNumber}: expected ${chunkSize} bytes, got ${bytesRead}`);
279
314
  }
280
- const etag = await uploadPart(tosUrl, partNumber, uploadId, buffer, credentials, region);
281
- completedParts.push({ partNumber, etag });
315
+ const crc32 = await uploadPart(tosUrl, partNumber, uploadId, buffer, auth, uploadHeader, userId);
316
+ completedParts.push({ partNumber, crc32 });
282
317
  saveResumeState(resumePath, { uploadId, fileSize, parts: completedParts });
283
318
  uploadedBytes = Math.min(offset + chunkSize, fileSize);
284
319
  if (onProgress)
@@ -288,8 +323,9 @@ export async function tosUpload(options) {
288
323
  finally {
289
324
  fs.closeSync(fd);
290
325
  }
291
- await completeMultipartUpload(tosUrl, uploadId, completedParts, credentials, region);
326
+ const completedKey = await completeMultipartUpload(tosUrl, uploadId, completedParts, auth, uploadHeader, userId);
292
327
  deleteResumeState(resumePath);
328
+ return completedKey;
293
329
  }
294
330
  // ── Internal exports for testing ─────────────────────────────────────────────
295
- export { PART_SIZE, RESUME_DIR, extractRegionFromHost, getResumeFilePath, loadResumeState, saveResumeState, deleteResumeState, computeAws4Headers, };
331
+ export { PART_SIZE, RESUME_DIR, extractRegionFromHost, getResumeFilePath, loadResumeState, saveResumeState, deleteResumeState, computeAws4Headers, extractUploadId, crc32Hex, gatewayBaseUrl, gatewayHeaders, };
@@ -0,0 +1,212 @@
1
+ import * as crypto from 'node:crypto';
2
+ import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
3
+ import { unwrapEvaluateResult } from './evaluate-result.js';
4
+
5
+ const AUTH_V5_URL = 'https://creator.douyin.com/web/api/media/upload/auth/v5/';
6
+ const VOD_UPLOAD_HOST = 'https://vod.bytedanceapi.com/';
7
+ const VOD_SPACE_NAME = 'aweme';
8
+
9
+ function hmacSha256(key, data) {
10
+ return crypto.createHmac('sha256', key).update(data, 'utf8').digest();
11
+ }
12
+
13
+ function sha256Hex(data) {
14
+ const hash = crypto.createHash('sha256');
15
+ if (Buffer.isBuffer(data) || data instanceof Uint8Array) {
16
+ hash.update(data);
17
+ } else {
18
+ hash.update(data ?? '', 'utf8');
19
+ }
20
+ return hash.digest('hex');
21
+ }
22
+
23
+ function nowDatetime() {
24
+ return new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
25
+ }
26
+
27
+ function canonicalQuery(url) {
28
+ return [...url.searchParams.entries()]
29
+ .sort(([a], [b]) => a.localeCompare(b))
30
+ .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
31
+ .join('&');
32
+ }
33
+
34
+ function computeAws4Headers(url, credentials, options = {}) {
35
+ const parsedUrl = new URL(url);
36
+ const datetime = nowDatetime();
37
+ const date = datetime.slice(0, 8);
38
+ const method = options.method ?? 'GET';
39
+ const body = options.body ?? '';
40
+ const bodyHash = sha256Hex(body);
41
+ const headers = {
42
+ ...(options.headers ?? {}),
43
+ host: parsedUrl.host,
44
+ 'x-amz-content-sha256': bodyHash,
45
+ 'x-amz-date': datetime,
46
+ 'x-amz-security-token': credentials.session_token,
47
+ };
48
+ const sortedHeaderKeys = Object.keys(headers).sort((a, b) => a.localeCompare(b));
49
+ const canonicalHeaders = sortedHeaderKeys
50
+ .map((key) => `${key}:${String(headers[key]).trim()}`)
51
+ .join('\n') + '\n';
52
+ const signedHeaders = sortedHeaderKeys.join(';');
53
+ const canonicalRequest = [
54
+ method,
55
+ parsedUrl.pathname || '/',
56
+ canonicalQuery(parsedUrl),
57
+ canonicalHeaders,
58
+ signedHeaders,
59
+ bodyHash,
60
+ ].join('\n');
61
+ const service = 'vod';
62
+ const region = 'cn-north-1';
63
+ const credentialScope = `${date}/${region}/${service}/aws4_request`;
64
+ const stringToSign = [
65
+ 'AWS4-HMAC-SHA256',
66
+ datetime,
67
+ credentialScope,
68
+ sha256Hex(canonicalRequest),
69
+ ].join('\n');
70
+ const kDate = hmacSha256(`AWS4${credentials.secret_access_key}`, date);
71
+ const kRegion = hmacSha256(kDate, region);
72
+ const kService = hmacSha256(kRegion, service);
73
+ const kSigning = hmacSha256(kService, 'aws4_request');
74
+ const signature = hmacSha256(kSigning, stringToSign).toString('hex');
75
+ return {
76
+ ...headers,
77
+ Authorization: `AWS4-HMAC-SHA256 Credential=${credentials.access_key_id}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`,
78
+ };
79
+ }
80
+
81
+ function extractUserIdFromSessionToken(sessionToken) {
82
+ try {
83
+ const raw = sessionToken.startsWith('STS2') ? sessionToken.slice(4) : sessionToken;
84
+ const decoded = JSON.parse(Buffer.from(raw, 'base64').toString('utf8'));
85
+ const policy = JSON.parse(decoded.PolicyString || '{}');
86
+ const condition = policy?.Statement?.[0]?.Condition;
87
+ if (typeof condition === 'string') {
88
+ const parsedCondition = JSON.parse(condition);
89
+ return parsedCondition.UserId || '';
90
+ }
91
+ } catch {
92
+ return '';
93
+ }
94
+ return '';
95
+ }
96
+
97
+ export async function getUploadAuthV5Credentials(page) {
98
+ const result = unwrapEvaluateResult(await page.evaluate(`fetch(${JSON.stringify(AUTH_V5_URL)}, { credentials: 'include' }).then(r => r.json())`));
99
+ if (!result || Array.isArray(result) || typeof result !== 'object') {
100
+ throw new CommandExecutionError(`获取抖音上传授权失败: ${JSON.stringify(result)}`);
101
+ }
102
+ if (result.status_code !== 0) {
103
+ const message = result.status_msg ?? result.message ?? 'unknown error';
104
+ if (result.status_code === 401 || result.status_code === 403 || /login|cookie|auth|captcha|verify|forbidden|permission|登录|登陆|权限|验证|验证码/i.test(String(message))) {
105
+ throw new AuthRequiredError('creator.douyin.com', `获取抖音上传授权失败: ${message}`);
106
+ }
107
+ throw new CommandExecutionError(`获取抖音上传授权失败: ${JSON.stringify(result)}`);
108
+ }
109
+ if (!result.auth) {
110
+ throw new CommandExecutionError(`获取抖音上传授权失败: ${JSON.stringify(result)}`);
111
+ }
112
+ let auth;
113
+ try {
114
+ auth = JSON.parse(result.auth);
115
+ } catch (error) {
116
+ throw new CommandExecutionError(`解析抖音上传授权失败: ${error instanceof Error ? error.message : String(error)}`);
117
+ }
118
+ if (!auth.AccessKeyID || !auth.SecretAccessKey || !auth.SessionToken) {
119
+ throw new CommandExecutionError('抖音上传授权缺少 AccessKeyID/SecretAccessKey/SessionToken');
120
+ }
121
+ return {
122
+ access_key_id: auth.AccessKeyID,
123
+ secret_access_key: auth.SecretAccessKey,
124
+ session_token: auth.SessionToken,
125
+ user_id: extractUserIdFromSessionToken(auth.SessionToken),
126
+ expired_time: auth.ExpiredTime,
127
+ current_time: auth.CurrentTime,
128
+ };
129
+ }
130
+
131
+ export async function applyVideoUploadInner(fileSize, credentials) {
132
+ const params = new URLSearchParams({
133
+ Action: 'ApplyUploadInner',
134
+ Version: '2020-11-19',
135
+ SpaceName: VOD_SPACE_NAME,
136
+ FileType: 'video',
137
+ IsInner: '1',
138
+ FileSize: String(fileSize),
139
+ });
140
+ const url = `${VOD_UPLOAD_HOST}?${params.toString()}`;
141
+ const res = await fetch(url, { headers: computeAws4Headers(url, credentials), signal: AbortSignal.timeout(30000) });
142
+ const text = await res.text();
143
+ let payload;
144
+ try {
145
+ payload = JSON.parse(text);
146
+ } catch {
147
+ throw new CommandExecutionError(`申请抖音上传地址失败,非 JSON 响应: HTTP ${res.status} ${text.slice(0, 300)}`);
148
+ }
149
+ const error = payload?.ResponseMetadata?.Error;
150
+ if (!res.ok || error) {
151
+ throw new CommandExecutionError(`申请抖音上传地址失败: HTTP ${res.status} ${JSON.stringify(error ?? payload)}`);
152
+ }
153
+ const uploadNode = payload?.Result?.InnerUploadAddress?.UploadNodes?.[0];
154
+ const storeInfo = uploadNode?.StoreInfos?.[0];
155
+ const videoId = payload?.Result?.Vid || uploadNode?.Vid;
156
+ const sessionKey = uploadNode?.SessionKey ?? storeInfo?.SessionKey ?? payload?.Result?.SessionKey;
157
+ if (!uploadNode?.UploadHost || !storeInfo?.StoreUri || !storeInfo?.Auth || !videoId || !sessionKey) {
158
+ throw new CommandExecutionError(`申请抖音上传地址响应缺少必要字段: ${JSON.stringify(payload).slice(0, 500)}`);
159
+ }
160
+ return {
161
+ video_id: videoId,
162
+ tos_upload_url: `https://${uploadNode.UploadHost}/${storeInfo.StoreUri}`,
163
+ auth: storeInfo.Auth,
164
+ session_key: sessionKey,
165
+ upload_header: uploadNode.UploadHeader ?? {},
166
+ user_id: credentials.user_id ?? '',
167
+ };
168
+ }
169
+
170
+
171
+ export async function commitVideoUploadInner(uploadInfo, credentials) {
172
+ if (!uploadInfo?.session_key) {
173
+ throw new CommandExecutionError('抖音上传提交缺少 SessionKey');
174
+ }
175
+ const params = new URLSearchParams({
176
+ Action: 'CommitUploadInner',
177
+ Version: '2020-11-19',
178
+ SpaceName: VOD_SPACE_NAME,
179
+ });
180
+ const url = `${VOD_UPLOAD_HOST}?${params.toString()}`;
181
+ const body = JSON.stringify({ SessionKey: uploadInfo.session_key });
182
+ const headers = computeAws4Headers(url, credentials, {
183
+ method: 'POST',
184
+ body,
185
+ headers: { 'content-type': 'application/json;charset=UTF-8' },
186
+ });
187
+ const res = await fetch(url, { method: 'POST', headers, body, signal: AbortSignal.timeout(30000) });
188
+ const text = await res.text();
189
+ let payload;
190
+ try {
191
+ payload = JSON.parse(text);
192
+ } catch {
193
+ throw new CommandExecutionError(`提交抖音上传失败,非 JSON 响应: HTTP ${res.status} ${text.slice(0, 300)}`);
194
+ }
195
+ const error = payload?.ResponseMetadata?.Error;
196
+ if (!res.ok || error) {
197
+ throw new CommandExecutionError(`提交抖音上传失败: HTTP ${res.status} ${JSON.stringify(error ?? payload)}`);
198
+ }
199
+ const result = payload?.Result?.Results?.[0] ?? payload?.Result ?? {};
200
+ const videoId = result.Vid ?? result.VideoId ?? result.VideoID ?? result.vid ?? uploadInfo.video_id;
201
+ if (!videoId) {
202
+ throw new CommandExecutionError(`提交抖音上传响应缺少 video id: ${JSON.stringify(payload).slice(0, 500)}`);
203
+ }
204
+ const meta = result.Meta ?? result.VideoMeta ?? {};
205
+ return {
206
+ video_id: videoId,
207
+ poster_uri: result.PosterUri ?? result.PosterURI ?? result.SnapshotUri ?? result.SnapshotURI ?? '',
208
+ width: Number(meta.Width ?? meta.width ?? 720) || 720,
209
+ height: Number(meta.Height ?? meta.height ?? 1280) || 1280,
210
+ raw: result,
211
+ };
212
+ }
@@ -0,0 +1,38 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
3
+ import { getUploadAuthV5Credentials, applyVideoUploadInner } from './vod-upload.js';
4
+
5
+ describe('douyin vod upload helpers', () => {
6
+ it('parses creator upload auth v5 credentials', async () => {
7
+ const page = { evaluate: async () => ({ status_code: 0, auth: JSON.stringify({ AccessKeyID: 'ak', SecretAccessKey: 'sk', SessionToken: 'token', ExpiredTime: 123, CurrentTime: 100 }) }) };
8
+ await expect(getUploadAuthV5Credentials(page)).resolves.toEqual({ access_key_id: 'ak', secret_access_key: 'sk', session_token: 'token', user_id: '', expired_time: 123, current_time: 100 });
9
+ });
10
+
11
+ it('unwraps browser bridge envelopes around upload auth payloads', async () => {
12
+ const payload = { status_code: 0, auth: JSON.stringify({ AccessKeyID: 'ak', SecretAccessKey: 'sk', SessionToken: 'token' }) };
13
+ const page = { evaluate: async () => ({ session: 'site:douyin:test', data: payload }) };
14
+ await expect(getUploadAuthV5Credentials(page)).resolves.toMatchObject({ access_key_id: 'ak', secret_access_key: 'sk', session_token: 'token' });
15
+ });
16
+
17
+ it('maps upload auth permission errors to AuthRequiredError', async () => {
18
+ const page = { evaluate: async () => ({ status_code: 401, status_msg: 'login required' }) };
19
+ await expect(getUploadAuthV5Credentials(page)).rejects.toBeInstanceOf(AuthRequiredError);
20
+ });
21
+
22
+ it('maps ApplyUploadInner response to TOS upload info', async () => {
23
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, status: 200, text: async () => JSON.stringify({ ResponseMetadata: { RequestId: 'req' }, Result: { InnerUploadAddress: { UploadNodes: [{ Vid: 'video-id', SessionKey: 'session-key', UploadHost: 'tos.example.com', StoreInfos: [{ StoreUri: 'obj/key.mp4', Auth: 'space-auth' }] }] } } }) });
24
+ await expect(applyVideoUploadInner(1234, { access_key_id: 'ak', secret_access_key: 'sk', session_token: 'token' })).resolves.toEqual({ video_id: 'video-id', tos_upload_url: 'https://tos.example.com/obj/key.mp4', auth: 'space-auth', session_key: 'session-key', upload_header: {}, user_id: '' });
25
+ const [url, init] = fetchSpy.mock.calls[0];
26
+ expect(String(url)).toContain('Action=ApplyUploadInner');
27
+ expect(String(url)).toContain('Version=2020-11-19');
28
+ expect(init.headers.Authorization).toContain('AWS4-HMAC-SHA256 Credential=ak/');
29
+ expect(init.headers['x-amz-security-token']).toBe('token');
30
+ fetchSpy.mockRestore();
31
+ });
32
+
33
+ it('surfaces VOD API errors with context', async () => {
34
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, status: 200, text: async () => JSON.stringify({ ResponseMetadata: { Error: { Code: 'AccessDenied', Message: 'denied' } } }) });
35
+ await expect(applyVideoUploadInner(1234, { access_key_id: 'ak', secret_access_key: 'sk', session_token: 'token' })).rejects.toBeInstanceOf(CommandExecutionError);
36
+ fetchSpy.mockRestore();
37
+ });
38
+ });
@@ -1,19 +1,152 @@
1
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
2
3
  import { browserFetch } from './_shared/browser-fetch.js';
4
+ import { requireObjectEvaluateResult } from './_shared/evaluate-result.js';
5
+
6
+ const CREATOR_MANAGE_URL = 'https://creator.douyin.com/creator-micro/content/manage';
7
+ const WORK_LIST_URL = '/janus/douyin/creator/pc/work_list?status=0&count=20&max_cursor=0&scene=star_atlas&device_platform=android&aid=1128';
8
+
9
+ function readAwemeId(raw) {
10
+ const value = String(raw ?? '').trim();
11
+ if (!value) {
12
+ throw new ArgumentError('douyin delete aweme_id cannot be empty');
13
+ }
14
+ if (!/^\d+$/.test(value)) {
15
+ throw new ArgumentError('douyin delete aweme_id must be a numeric id');
16
+ }
17
+ return value;
18
+ }
19
+
20
+ function sleep(ms) {
21
+ return new Promise((resolve) => setTimeout(resolve, ms));
22
+ }
23
+
24
+ async function deleteViaCreatorManage(page, workId) {
25
+ await page.goto(CREATOR_MANAGE_URL);
26
+ await sleep(3000);
27
+ await sleep(3000);
28
+ const result = requireObjectEvaluateResult(await page.evaluate(`
29
+ (async () => {
30
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
31
+ const targetId = ${JSON.stringify(String(workId))};
32
+ const textOf = (node) => (node && (node.innerText || node.textContent) || '').trim();
33
+ const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
34
+
35
+ async function loadTarget() {
36
+ const res = await fetch(${JSON.stringify(WORK_LIST_URL)}, { credentials: 'include' });
37
+ const payload = await res.json();
38
+ const list = Array.isArray(payload.aweme_list) ? payload.aweme_list : [];
39
+ const matches = list
40
+ .map((entry, index) => ({ entry, index }))
41
+ .filter(({ entry }) => String(entry.aweme_id || '') === targetId || String(entry.item_id || '') === targetId);
42
+ if (matches.length === 0) {
43
+ return { ok: false, reason: 'not_found', status_code: payload.status_code, count: list.length };
44
+ }
45
+ if (matches.length !== 1) {
46
+ return { ok: false, reason: 'target_not_unique', count: matches.length };
47
+ }
48
+ const { entry: item, index } = matches[0];
49
+ const title = normalize(item.desc || item.caption || item.title || item.item_title || '');
50
+ return { ok: true, item, index, listCount: list.length, title };
51
+ }
52
+
53
+ function visibleWorkCards() {
54
+ const candidates = Array.from(document.querySelectorAll('[class*="video-card"]'))
55
+ .filter((element) => {
56
+ const text = normalize(textOf(element));
57
+ return text.includes('删除作品') && text.includes('继续编辑');
58
+ });
59
+ return candidates.filter((candidate) => !candidates.some((other) => other !== candidate && other.contains(candidate)));
60
+ }
61
+
62
+ const target = await loadTarget();
63
+ if (!target.ok) return target;
64
+
65
+ const allTab = Array.from(document.querySelectorAll('button,[role="button"],span,div'))
66
+ .find((element) => /^全部作品$/.test(normalize(textOf(element))));
67
+ allTab?.click();
68
+ await sleep(1000);
69
+ for (let attempt = 0; attempt < 20; attempt += 1) {
70
+ const cards = visibleWorkCards();
71
+ if (cards.length >= target.listCount && cards[target.index]) {
72
+ const card = cards[target.index];
73
+ const deleteButton = Array.from(card.querySelectorAll('button,[role="button"],span,div'))
74
+ .find((element) => /^删除作品$/.test(normalize(textOf(element))));
75
+ if (!deleteButton) return { ok: false, reason: 'delete_button_not_found', aweme_id: target.item.aweme_id, item_id: target.item.item_id, index: target.index, cardCount: cards.length };
76
+ deleteButton.click();
77
+ await sleep(800);
78
+ const confirmButton = Array.from(document.querySelectorAll('button,[role="button"]'))
79
+ .find((element) => ['确定', '确认', '删除'].includes(normalize(textOf(element))));
80
+ if (!confirmButton) return { ok: false, reason: 'confirm_button_not_found', aweme_id: target.item.aweme_id, item_id: target.item.item_id };
81
+ confirmButton.click();
82
+ for (let wait = 0; wait < 20; wait += 1) {
83
+ await sleep(500);
84
+ const after = await loadTarget();
85
+ if (!after.ok && after.reason === 'not_found') {
86
+ return { ok: true, aweme_id: target.item.aweme_id, item_id: target.item.item_id, title: target.title };
87
+ }
88
+ }
89
+ return { ok: false, reason: 'delete_not_confirmed', aweme_id: target.item.aweme_id, item_id: target.item.item_id };
90
+ }
91
+ await sleep(500);
92
+ }
93
+ return { ok: false, reason: 'card_not_found', aweme_id: target.item.aweme_id, item_id: target.item.item_id, index: target.index, listCount: target.listCount };
94
+ })()
95
+ `), '抖音后台管理删除响应异常');
96
+
97
+ if (!result?.ok) {
98
+ throw new CommandExecutionError(`抖音后台管理删除失败: ${JSON.stringify(result)}`);
99
+ }
100
+ return result;
101
+ }
102
+
103
+ async function findWorkListItem(page, workId) {
104
+ const data = await browserFetch(page, 'GET', `https://creator.douyin.com${WORK_LIST_URL}`, { timeoutMs: 8000 });
105
+ const list = data.data?.work_list ?? data.aweme_list ?? data.work_list ?? [];
106
+ if (!Array.isArray(list)) {
107
+ throw new CommandExecutionError('抖音作品列表响应缺少 work_list/aweme_list');
108
+ }
109
+ return list.find((entry) => String(entry.aweme_id || '') === workId || String(entry.item_id || '') === workId) || null;
110
+ }
111
+
3
112
  cli({
4
113
  site: 'douyin',
5
114
  name: 'delete',
6
115
  access: 'write',
7
- description: '删除作品',
116
+ description: '删除作品(优先使用创作者后台作品管理;找不到时回退到旧删除接口)',
8
117
  domain: 'creator.douyin.com',
9
118
  strategy: Strategy.COOKIE,
119
+ siteSession: 'persistent',
10
120
  args: [
11
- { name: 'aweme_id', required: true, positional: true, help: '作品 ID' },
121
+ { name: 'aweme_id', required: true, positional: true, help: '作品 ID / item_id' },
12
122
  ],
13
123
  columns: ['status'],
14
124
  func: async (page, kwargs) => {
125
+ const awemeId = readAwemeId(kwargs.aweme_id);
126
+ try {
127
+ const deleted = await deleteViaCreatorManage(page, awemeId);
128
+ return [{ status: `✅ 已通过后台管理删除 ${deleted.aweme_id || awemeId}` }];
129
+ } catch (fallbackError) {
130
+ const fallbackMessage = fallbackError instanceof Error ? fallbackError.message : String(fallbackError);
131
+ if (!fallbackMessage.includes('"reason":"not_found"')) {
132
+ throw fallbackError;
133
+ }
134
+ }
135
+
136
+ const before = await findWorkListItem(page, awemeId);
137
+ if (!before) {
138
+ throw new CommandExecutionError(`抖音作品 ${awemeId} 未在作品列表中找到,未执行删除`);
139
+ }
15
140
  const url = 'https://creator.douyin.com/web/api/media/aweme/delete/?aid=1128';
16
- await browserFetch(page, 'POST', url, { body: { aweme_id: kwargs.aweme_id } });
17
- return [{ status: `✅ 已删除 ${kwargs.aweme_id}` }];
141
+ await browserFetch(page, 'POST', url, { body: { aweme_id: awemeId }, timeoutMs: 8000 });
142
+ const deadline = Date.now() + 10_000;
143
+ while (Date.now() < deadline) {
144
+ await sleep(500);
145
+ const after = await findWorkListItem(page, awemeId);
146
+ if (!after) {
147
+ return [{ status: `✅ 已删除 ${awemeId}` }];
148
+ }
149
+ }
150
+ throw new CommandExecutionError(`抖音作品 ${awemeId} 删除后仍在作品列表中,删除未确认`);
18
151
  },
19
152
  });