@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.
- package/README.md +30 -148
- package/README.zh-CN.md +37 -211
- package/cli-manifest.json +6423 -4260
- package/clis/12306/me.js +73 -0
- package/clis/12306/orders.js +96 -0
- package/clis/12306/passengers.js +90 -0
- package/clis/12306/price.js +166 -0
- package/clis/12306/stations.js +66 -0
- package/clis/12306/train.js +91 -0
- package/clis/12306/trains.js +119 -0
- package/clis/12306/utils.js +272 -0
- package/clis/12306/utils.test.js +331 -0
- package/clis/36kr/article.js +6 -3
- package/clis/36kr/article.test.js +46 -0
- package/clis/apple-podcasts/commands.test.js +20 -0
- package/clis/apple-podcasts/search.js +2 -2
- package/clis/barchart/greeks.js +144 -56
- package/clis/barchart/greeks.test.js +138 -0
- package/clis/bilibili/summary.js +167 -0
- package/clis/bilibili/summary.test.js +210 -0
- package/clis/booking/booking.test.js +356 -0
- package/clis/booking/search.js +351 -0
- package/clis/chatgpt/envelope.test.js +108 -0
- package/clis/chatgpt/image.js +2 -2
- package/clis/chatgpt/image.test.js +6 -0
- package/clis/chatgpt/utils.js +148 -41
- package/clis/chatgpt/utils.test.js +92 -2
- package/clis/douyin/_shared/browser-fetch.js +44 -20
- package/clis/douyin/_shared/browser-fetch.test.js +22 -1
- package/clis/douyin/_shared/evaluate-result.js +16 -0
- package/clis/douyin/_shared/tos-upload.js +105 -69
- package/clis/douyin/_shared/vod-upload.js +212 -0
- package/clis/douyin/_shared/vod-upload.test.js +38 -0
- package/clis/douyin/delete.js +137 -4
- package/clis/douyin/delete.test.js +90 -1
- package/clis/douyin/publish-upload-id.test.js +170 -0
- package/clis/douyin/publish.js +88 -42
- package/clis/douyin/user-videos.js +9 -2
- package/clis/douyin/user-videos.test.js +43 -0
- package/clis/flomo/memos.js +228 -0
- package/clis/flomo/memos.test.js +144 -0
- package/clis/gitee/search.js +2 -2
- package/clis/gitee/search.test.js +65 -0
- package/clis/jike/post.js +27 -17
- package/clis/jike/read.test.js +86 -0
- package/clis/jike/topic.js +32 -19
- package/clis/jike/user.js +33 -20
- package/clis/lesswrong/comments.js +1 -1
- package/clis/lesswrong/curated.js +1 -1
- package/clis/lesswrong/frontpage.js +1 -1
- package/clis/lesswrong/frontpage.test.js +37 -0
- package/clis/lesswrong/new.js +1 -1
- package/clis/lesswrong/read.js +1 -1
- package/clis/lesswrong/sequences.js +1 -1
- package/clis/lesswrong/shortform.js +1 -1
- package/clis/lesswrong/tag.js +1 -1
- package/clis/lesswrong/top-month.js +1 -1
- package/clis/lesswrong/top-week.js +1 -1
- package/clis/lesswrong/top-year.js +1 -1
- package/clis/lesswrong/top.js +1 -1
- package/clis/linkedin/connect.js +401 -0
- package/clis/linkedin/connect.test.js +213 -0
- package/clis/linkedin/inbox.js +234 -0
- package/clis/linkedin/inbox.test.js +152 -0
- package/clis/linkedin/people-search.js +262 -0
- package/clis/linkedin/people-search.test.js +216 -0
- package/clis/linkedin/safe-send.js +357 -0
- package/clis/linkedin/safe-send.test.js +204 -0
- package/clis/linkedin/salesnav-inbox.js +210 -0
- package/clis/linkedin/salesnav-inbox.test.js +113 -0
- package/clis/linkedin/salesnav-message.js +360 -0
- package/clis/linkedin/salesnav-message.test.js +172 -0
- package/clis/linkedin/salesnav-search.js +186 -0
- package/clis/linkedin/salesnav-search.test.js +76 -0
- package/clis/linkedin/salesnav-thread.js +212 -0
- package/clis/linkedin/salesnav-thread.test.js +79 -0
- package/clis/linkedin/sent-invitations.js +92 -0
- package/clis/linkedin/sent-invitations.test.js +62 -0
- package/clis/linkedin/thread-snapshot.js +214 -0
- package/clis/linkedin/thread-snapshot.test.js +89 -0
- package/clis/linkedin-learning/course.js +138 -0
- package/clis/linkedin-learning/course.test.js +114 -0
- package/clis/linkedin-learning/search.js +155 -0
- package/clis/linkedin-learning/search.test.js +144 -0
- package/clis/linkedin-learning/trending.js +133 -0
- package/clis/linkedin-learning/trending.test.js +123 -0
- package/clis/powerchina/search.js +3 -3
- package/clis/powerchina/search.test.js +27 -1
- package/clis/reddit/extract-media.test.js +149 -0
- package/clis/reddit/frontpage.js +47 -9
- package/clis/reddit/frontpage.test.js +34 -0
- package/clis/reddit/home.js +31 -1
- package/clis/reddit/home.test.js +46 -3
- package/clis/reddit/hot.js +32 -1
- package/clis/reddit/hot.test.js +15 -1
- package/clis/reddit/popular.js +39 -1
- package/clis/reddit/popular.test.js +26 -0
- package/clis/reddit/saved.js +1 -1
- package/clis/reddit/search.js +38 -1
- package/clis/reddit/search.test.js +26 -0
- package/clis/reddit/subreddit.js +52 -7
- package/clis/reddit/subreddit.test.js +31 -0
- package/clis/reddit/subscribed.js +165 -0
- package/clis/reddit/subscribed.test.js +168 -0
- package/clis/reddit/upvoted.js +1 -1
- package/clis/suno/commands.test.js +188 -0
- package/clis/suno/download.js +140 -0
- package/clis/suno/download.test.js +151 -0
- package/clis/suno/generate.js +226 -0
- package/clis/suno/generate.test.js +243 -0
- package/clis/suno/list.js +79 -0
- package/clis/suno/status.js +62 -0
- package/clis/suno/utils.js +540 -0
- package/clis/suno/utils.test.js +223 -0
- package/clis/twitter/device-follow.js +193 -0
- package/clis/twitter/device-follow.test.js +287 -0
- package/clis/twitter/download.js +443 -73
- package/clis/twitter/download.test.js +457 -0
- package/clis/twitter/list-create.js +155 -0
- package/clis/twitter/list-create.test.js +169 -0
- package/clis/twitter/list-remove.js +12 -5
- package/clis/twitter/list-remove.test.js +74 -0
- package/clis/twitter/list-tweets.js +6 -2
- package/clis/twitter/list-tweets.test.js +41 -1
- package/clis/twitter/lists.js +31 -4
- package/clis/twitter/lists.test.js +152 -16
- package/clis/twitter/search.js +6 -2
- package/clis/twitter/search.test.js +6 -0
- package/clis/twitter/shared.js +144 -0
- package/clis/twitter/shared.test.js +429 -1
- package/clis/twitter/thread.js +10 -2
- package/clis/twitter/thread.test.js +58 -0
- package/clis/twitter/timeline.js +6 -2
- package/clis/twitter/timeline.test.js +2 -0
- package/clis/twitter/tweets.js +3 -2
- package/clis/twitter/tweets.test.js +1 -1
- package/clis/weibo/delete.js +172 -0
- package/clis/weibo/delete.test.js +94 -0
- package/clis/weibo/publish.js +37 -14
- package/clis/weibo/publish.test.js +14 -5
- package/clis/weibo/user-posts.js +234 -0
- package/clis/weibo/user-posts.test.js +92 -0
- package/clis/weread/search-regression.test.js +18 -11
- package/clis/weread/search.js +15 -7
- package/clis/weread-official/book.js +135 -0
- package/clis/weread-official/commands.test.js +385 -0
- package/clis/weread-official/discover.js +107 -0
- package/clis/weread-official/list-apis.js +95 -0
- package/clis/weread-official/notes.js +171 -0
- package/clis/weread-official/readdata.js +158 -0
- package/clis/weread-official/review.js +93 -0
- package/clis/weread-official/search.js +106 -0
- package/clis/weread-official/shelf.js +97 -0
- package/clis/weread-official/utils.js +293 -0
- package/clis/weread-official/utils.test.js +242 -0
- package/clis/wikipedia/trending.js +7 -3
- package/clis/wikipedia/trending.test.js +57 -0
- package/clis/xianyu/chat.js +24 -109
- package/clis/xianyu/chat.test.js +5 -0
- package/clis/xianyu/im.js +322 -0
- package/clis/xianyu/im.test.js +253 -0
- package/clis/xianyu/inbox.js +96 -0
- package/clis/xianyu/messages.js +91 -0
- package/clis/xianyu/reply.js +82 -0
- package/clis/xiaohongshu/creator-note-detail.js +2 -1
- package/clis/xiaohongshu/creator-note-detail.test.js +11 -0
- package/clis/xiaohongshu/creator-notes-summary.js +2 -1
- package/clis/xiaohongshu/creator-notes-summary.test.js +7 -0
- package/clis/xiaohongshu/creator-notes.js +2 -1
- package/clis/xiaohongshu/creator-notes.test.js +12 -0
- package/clis/xiaohongshu/creator-stats.js +2 -1
- package/clis/xiaohongshu/creator-stats.test.js +24 -0
- package/clis/xiaohongshu/delete-note.js +260 -0
- package/clis/xiaohongshu/delete-note.test.js +172 -0
- package/clis/xiaohongshu/publish.js +48 -8
- package/clis/xiaohongshu/publish.test.js +65 -10
- package/clis/xiaohongshu/user-helpers.test.js +41 -0
- package/clis/xiaohongshu/user.js +27 -4
- package/clis/xiaoyuzhou/download.js +1 -1
- package/clis/xiaoyuzhou/transcript.js +1 -1
- package/clis/youdao/note.js +258 -0
- package/clis/youdao/note.test.js +99 -0
- package/clis/youtube/transcript.js +397 -24
- package/clis/youtube/transcript.test.js +196 -6
- package/clis/zhihu/answer-comments.js +299 -0
- package/clis/zhihu/answer-comments.test.js +287 -0
- package/clis/zhihu/answer-detail.js +12 -0
- package/clis/zhihu/answer-detail.test.js +8 -0
- package/clis/zhihu/collection.js +15 -2
- package/clis/zhihu/collection.test.js +46 -0
- package/clis/zhihu/download.js +1 -1
- package/clis/zhihu/question.js +42 -9
- package/clis/zhihu/question.test.js +111 -9
- package/clis/zhihu/search.js +206 -43
- package/clis/zhihu/search.test.js +198 -0
- package/dist/src/browser/errors.js +4 -2
- package/dist/src/browser/errors.test.js +6 -0
- package/dist/src/browser/page.js +30 -4
- package/dist/src/browser/page.test.js +42 -0
- package/dist/src/browser/utils.d.ts +1 -1
- package/dist/src/cli-argv-preprocess.d.ts +26 -0
- package/dist/src/cli-argv-preprocess.js +138 -0
- package/dist/src/cli-argv-preprocess.test.js +79 -0
- package/dist/src/convention-audit.js +15 -8
- package/dist/src/convention-audit.test.js +21 -0
- package/dist/src/download/media-download.js +15 -2
- package/dist/src/download/media-download.test.d.ts +1 -0
- package/dist/src/download/media-download.test.js +110 -0
- package/dist/src/electron-apps.js +1 -1
- package/dist/src/electron-apps.test.js +7 -2
- package/dist/src/errors.d.ts +17 -0
- package/dist/src/errors.js +22 -0
- package/dist/src/external-clis.yaml +8 -0
- package/dist/src/main.js +14 -2
- package/dist/src/utils.d.ts +43 -0
- package/dist/src/utils.js +97 -0
- package/dist/src/utils.test.d.ts +1 -0
- package/dist/src/utils.test.js +155 -0
- package/package.json +8 -2
- package/scripts/silent-column-drop-baseline.json +0 -52
- package/scripts/typed-error-lint-baseline.json +28 -380
- 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,
|
|
145
|
-
const initUrl = `${tosUrl}?
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
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
|
-
|
|
159
|
-
|
|
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
|
|
208
|
+
return uploadId;
|
|
164
209
|
}
|
|
165
210
|
// ── Phase 2: Upload a single part ────────────────────────────────────────────
|
|
166
|
-
async function uploadPart(tosUrl, partNumber, uploadId, data,
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
throw new CommandExecutionError(`TOS upload part ${partNumber} response missing ETag header`);
|
|
226
|
+
catch {
|
|
227
|
+
parsed = null;
|
|
189
228
|
}
|
|
190
|
-
|
|
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,
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
|
244
|
+
headers: gatewayHeaders(auth, uploadHeader, userId),
|
|
245
|
+
body,
|
|
219
246
|
});
|
|
220
|
-
|
|
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,
|
|
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
|
|
281
|
-
completedParts.push({ partNumber,
|
|
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,
|
|
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
|
+
});
|
package/clis/douyin/delete.js
CHANGED
|
@@ -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:
|
|
17
|
-
|
|
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
|
});
|