publishport-opencli 1.8.4-pp.1 → 1.8.4-pp.2
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/cli-manifest.json +7 -0
- package/clis/douyin/delete.js +10 -2
- package/clis/douyin/draft.js +139 -9
- package/clis/douyin/draft.test.js +12 -0
- package/clis/jike/auth.js +36 -2
- package/clis/jike/comment.js +29 -0
- package/clis/jike/create.js +32 -0
- package/clis/jike/like.js +11 -0
- package/clis/jike/repost.js +50 -0
- package/clis/twitter/delete.js +119 -50
- package/clis/twitter/delete.test.js +24 -6
- package/clis/twitter/post.js +13 -2
- package/clis/wechat-channels/auth.js +42 -28
- package/clis/wechat-channels/publish.js +72 -9
- package/clis/weibo/publish.js +61 -35
- package/clis/xianyu/publish.js +45 -5
- package/clis/xiaohongshu/delete-note.js +11 -2
- package/clis/xiaohongshu/delete-note.test.js +6 -1
- package/clis/xiaohongshu/publish.js +46 -16
- package/clis/xiaohongshu/publish.test.js +16 -2
- package/clis/zhihu/write-shared.js +13 -1
- package/clis/zhihu/write-shared.test.js +3 -0
- package/package.json +1 -1
package/cli-manifest.json
CHANGED
|
@@ -11853,6 +11853,13 @@
|
|
|
11853
11853
|
"friends",
|
|
11854
11854
|
"private"
|
|
11855
11855
|
]
|
|
11856
|
+
},
|
|
11857
|
+
{
|
|
11858
|
+
"name": "timeout",
|
|
11859
|
+
"type": "int",
|
|
11860
|
+
"default": 180,
|
|
11861
|
+
"required": false,
|
|
11862
|
+
"help": "命令超时(秒),视频上传/转码慢时可调大"
|
|
11856
11863
|
}
|
|
11857
11864
|
],
|
|
11858
11865
|
"columns": [
|
package/clis/douyin/delete.js
CHANGED
|
@@ -75,8 +75,16 @@ async function deleteViaCreatorManage(page, workId) {
|
|
|
75
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
76
|
deleteButton.click();
|
|
77
77
|
await sleep(800);
|
|
78
|
-
|
|
79
|
-
|
|
78
|
+
// The confirmation modal renders asynchronously after the delete click;
|
|
79
|
+
// a single probe can race that render and false-fail with
|
|
80
|
+
// confirm_button_not_found. Poll a bounded number of times for it.
|
|
81
|
+
let confirmButton = null;
|
|
82
|
+
for (let confirmAttempt = 0; confirmAttempt < 20; confirmAttempt += 1) {
|
|
83
|
+
confirmButton = Array.from(document.querySelectorAll('button,[role="button"]'))
|
|
84
|
+
.find((element) => ['确定', '确认', '删除'].includes(normalize(textOf(element))));
|
|
85
|
+
if (confirmButton) break;
|
|
86
|
+
await sleep(500);
|
|
87
|
+
}
|
|
80
88
|
if (!confirmButton) return { ok: false, reason: 'confirm_button_not_found', aweme_id: target.item.aweme_id, item_id: target.item.item_id };
|
|
81
89
|
confirmButton.click();
|
|
82
90
|
for (let wait = 0; wait < 20; wait += 1) {
|
package/clis/douyin/draft.js
CHANGED
|
@@ -18,6 +18,13 @@ const DRAFT_UPLOAD_URL = 'https://creator.douyin.com/creator-micro/content/uploa
|
|
|
18
18
|
const COMPOSER_WAIT_ATTEMPTS = 120;
|
|
19
19
|
const COVER_INPUT_WAIT_ATTEMPTS = 20;
|
|
20
20
|
const COVER_READY_WAIT_ATTEMPTS = 20;
|
|
21
|
+
// The composer's title input + 暂存离开 button surface while the video is still
|
|
22
|
+
// uploading/transcoding in the background. The visibility radios and a working
|
|
23
|
+
// (enabled) save button settle a beat later than that first paint, and on a slow
|
|
24
|
+
// upload that beat can exceed a 10s window — which is what made this command pass
|
|
25
|
+
// only intermittently. Give the per-field polls a generous budget so the success
|
|
26
|
+
// path no longer depends on upload speed.
|
|
27
|
+
const VIDEO_UPLOAD_WAIT_ATTEMPTS = 200; // up to 100s: wait for upload + composer hydration (exits early once ready; well under the 180s+30 ceiling)
|
|
21
28
|
/**
|
|
22
29
|
* Best-effort dismissal for coach marks and upload tips that can block clicks.
|
|
23
30
|
*/
|
|
@@ -56,11 +63,73 @@ async function waitForDraftComposer(page) {
|
|
|
56
63
|
}
|
|
57
64
|
throw new CommandExecutionError('等待抖音草稿编辑页超时', `当前页面: ${lastState.href || 'unknown'}`);
|
|
58
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* Wait until the background video upload/transcode finishes AND the composer is
|
|
68
|
+
* fully hydrated and ready to fill.
|
|
69
|
+
*
|
|
70
|
+
* Root cause of this command's intermittence: `waitForDraftComposer` returns as
|
|
71
|
+
* soon as the title input + 暂存离开 button first paint, but the rest of the
|
|
72
|
+
* composer (the caption contenteditable editor, the visibility radios) only
|
|
73
|
+
* hydrates *after* the video upload/transcode completes — which on a normal
|
|
74
|
+
* connection routinely takes longer than the per-field poll windows. That is why
|
|
75
|
+
* `caption-editor-missing` / `visibility-missing` appeared at random.
|
|
76
|
+
*
|
|
77
|
+
* The reliable readiness signal is the composer's own fields, not a progress
|
|
78
|
+
* string: wait until the caption editor exists, the requested visibility label
|
|
79
|
+
* exists, the 暂存离开 button is enabled, and no upload-in-progress copy remains.
|
|
80
|
+
* Gating fills on this makes the downstream steps deterministic instead of
|
|
81
|
+
* racing the upload. Combined with the raised `--timeout` ceiling (default 180s),
|
|
82
|
+
* this comfortably covers slow uploads.
|
|
83
|
+
*
|
|
84
|
+
* Best-effort: never throws — if a clear signal can't be read within the budget
|
|
85
|
+
* we fall through and let the (bounded, patient) field polls remain the safety
|
|
86
|
+
* net, so behavior is never worse than before.
|
|
87
|
+
*/
|
|
88
|
+
async function waitForVideoUploadComplete(page, visibilityLabel) {
|
|
89
|
+
for (let attempt = 0; attempt < VIDEO_UPLOAD_WAIT_ATTEMPTS; attempt += 1) {
|
|
90
|
+
const state = (await page.evaluate(`() => {
|
|
91
|
+
const text = document.body?.innerText || '';
|
|
92
|
+
const uploading = /上传中|上传\\s*\\d+%|视频上传中|处理中/.test(text);
|
|
93
|
+
const saveBtn = Array.from(document.querySelectorAll('button')).find(
|
|
94
|
+
(el) => (el.textContent || '').includes('暂存离开')
|
|
95
|
+
);
|
|
96
|
+
const saveEnabled = saveBtn instanceof HTMLButtonElement
|
|
97
|
+
? !saveBtn.disabled && saveBtn.getAttribute('aria-disabled') !== 'true'
|
|
98
|
+
: false;
|
|
99
|
+
const hasCaptionEditor = !!document.querySelector('[contenteditable="true"]');
|
|
100
|
+
const hasVisibility = !!Array.from(document.querySelectorAll('label')).find(
|
|
101
|
+
(el) => (el.textContent || '').includes(${JSON.stringify(visibilityLabel)})
|
|
102
|
+
);
|
|
103
|
+
return { uploading, saveEnabled, hasSaveBtn: !!saveBtn, hasCaptionEditor, hasVisibility };
|
|
104
|
+
}`));
|
|
105
|
+
// Ready once the composer's real fields exist, the save button is usable,
|
|
106
|
+
// and no upload-in-progress copy remains.
|
|
107
|
+
if (state.hasSaveBtn
|
|
108
|
+
&& state.saveEnabled
|
|
109
|
+
&& state.hasCaptionEditor
|
|
110
|
+
&& state.hasVisibility
|
|
111
|
+
&& !state.uploading) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
await page.wait({ time: 0.5 });
|
|
115
|
+
}
|
|
116
|
+
// Fall through silently; downstream bounded polls remain the safety net.
|
|
117
|
+
}
|
|
59
118
|
/**
|
|
60
119
|
* Fill title, caption and visibility controls on the live composer page.
|
|
61
120
|
*/
|
|
62
121
|
async function fillDraftComposer(page, options) {
|
|
63
|
-
|
|
122
|
+
// The composer hydrates its sub-fields (title input, caption editor,
|
|
123
|
+
// visibility labels) progressively after `waitForDraftComposer` returns, so a
|
|
124
|
+
// one-shot probe of each can race the render and false-fail. Poll each field a
|
|
125
|
+
// bounded number of times before giving up — same evaluate script, only more
|
|
126
|
+
// patient. Raised from 20 (10s) to 60 (30s): on a slow upload the visibility
|
|
127
|
+
// radios can settle well after the title input, and a 10s window made this
|
|
128
|
+
// step fail intermittently.
|
|
129
|
+
const FILL_FIELD_ATTEMPTS = 60;
|
|
130
|
+
let titleOk = false;
|
|
131
|
+
for (let attempt = 0; attempt < FILL_FIELD_ATTEMPTS; attempt += 1) {
|
|
132
|
+
titleOk = (await page.evaluate(`() => {
|
|
64
133
|
const titleInput = Array.from(document.querySelectorAll('input')).find(
|
|
65
134
|
(el) => (el.placeholder || '').includes('填写作品标题')
|
|
66
135
|
);
|
|
@@ -89,11 +158,17 @@ async function fillDraftComposer(page, options) {
|
|
|
89
158
|
}
|
|
90
159
|
return true;
|
|
91
160
|
}`));
|
|
161
|
+
if (titleOk)
|
|
162
|
+
break;
|
|
163
|
+
await page.wait({ time: 0.5 });
|
|
164
|
+
}
|
|
92
165
|
if (!titleOk) {
|
|
93
166
|
throw new CommandExecutionError('填写抖音草稿表单失败: title-input-missing');
|
|
94
167
|
}
|
|
95
168
|
if (options.caption) {
|
|
96
|
-
|
|
169
|
+
let captionOk = false;
|
|
170
|
+
for (let attempt = 0; attempt < FILL_FIELD_ATTEMPTS; attempt += 1) {
|
|
171
|
+
captionOk = (await page.evaluate(`() => {
|
|
97
172
|
const editor = document.querySelector('[contenteditable="true"]');
|
|
98
173
|
if (!(editor instanceof HTMLElement)) return false;
|
|
99
174
|
editor.focus();
|
|
@@ -103,11 +178,17 @@ async function fillDraftComposer(page, options) {
|
|
|
103
178
|
editor.dispatchEvent(new Event('input', { bubbles: true }));
|
|
104
179
|
return true;
|
|
105
180
|
}`));
|
|
181
|
+
if (captionOk)
|
|
182
|
+
break;
|
|
183
|
+
await page.wait({ time: 0.5 });
|
|
184
|
+
}
|
|
106
185
|
if (!captionOk) {
|
|
107
186
|
throw new CommandExecutionError('填写抖音草稿表单失败: caption-editor-missing');
|
|
108
187
|
}
|
|
109
188
|
}
|
|
110
|
-
|
|
189
|
+
let visibilityOk = false;
|
|
190
|
+
for (let attempt = 0; attempt < FILL_FIELD_ATTEMPTS; attempt += 1) {
|
|
191
|
+
visibilityOk = (await page.evaluate(`() => {
|
|
111
192
|
const visibility = Array.from(document.querySelectorAll('label')).find(
|
|
112
193
|
(el) => (el.textContent || '').includes(${JSON.stringify(options.visibilityLabel)})
|
|
113
194
|
);
|
|
@@ -115,6 +196,10 @@ async function fillDraftComposer(page, options) {
|
|
|
115
196
|
visibility.click();
|
|
116
197
|
return true;
|
|
117
198
|
}`));
|
|
199
|
+
if (visibilityOk)
|
|
200
|
+
break;
|
|
201
|
+
await page.wait({ time: 0.5 });
|
|
202
|
+
}
|
|
118
203
|
if (!visibilityOk) {
|
|
119
204
|
throw new CommandExecutionError('填写抖音草稿表单失败: visibility-missing');
|
|
120
205
|
}
|
|
@@ -213,7 +298,17 @@ async function waitForCoverReady(page) {
|
|
|
213
298
|
* Click the draft button on the composer page and extract the current creation id.
|
|
214
299
|
*/
|
|
215
300
|
async function clickSaveDraft(page) {
|
|
216
|
-
|
|
301
|
+
// The 暂存离开 button and the React fiber carrying `creation_id` can both
|
|
302
|
+
// settle a beat after the form is filled. A one-shot probe races that and
|
|
303
|
+
// false-fails with draft-button-missing / creation-id-missing. Poll a bounded
|
|
304
|
+
// number of times — same evaluate script — before giving up. Retrying only
|
|
305
|
+
// happens while the button is absent OR still disabled (ok===false), so the
|
|
306
|
+
// click never fires more than once and never fires on a disabled button.
|
|
307
|
+
// Raised from 20 (10s) to 60 (30s) to absorb slow uploads.
|
|
308
|
+
const SAVE_DRAFT_ATTEMPTS = 60;
|
|
309
|
+
let result = null;
|
|
310
|
+
for (let attempt = 0; attempt < SAVE_DRAFT_ATTEMPTS; attempt += 1) {
|
|
311
|
+
result = (await page.evaluate(`() => {
|
|
217
312
|
const extractCreationId = () => {
|
|
218
313
|
const titleInput = Array.from(document.querySelectorAll('input')).find(
|
|
219
314
|
(el) => (el.placeholder || '').includes('填写作品标题')
|
|
@@ -238,6 +333,12 @@ async function clickSaveDraft(page) {
|
|
|
238
333
|
if (!(btn instanceof HTMLButtonElement)) {
|
|
239
334
|
return { ok: false, reason: 'draft-button-missing' };
|
|
240
335
|
}
|
|
336
|
+
// The button paints before the upload finishes and is disabled until then.
|
|
337
|
+
// Treat a disabled button as "not ready yet" so the poll keeps waiting
|
|
338
|
+
// instead of firing a no-op click and then failing on a missing creation_id.
|
|
339
|
+
if (btn.disabled || btn.getAttribute('aria-disabled') === 'true') {
|
|
340
|
+
return { ok: false, reason: 'draft-button-disabled' };
|
|
341
|
+
}
|
|
241
342
|
const creationId = extractCreationId();
|
|
242
343
|
const propKey = Object.keys(btn).find((key) => key.startsWith('__reactProps$'));
|
|
243
344
|
const props = propKey ? btn[propKey] : null;
|
|
@@ -258,6 +359,10 @@ async function clickSaveDraft(page) {
|
|
|
258
359
|
creationId,
|
|
259
360
|
};
|
|
260
361
|
}`));
|
|
362
|
+
if (result?.ok)
|
|
363
|
+
break;
|
|
364
|
+
await page.wait({ time: 0.5 });
|
|
365
|
+
}
|
|
261
366
|
if (!result?.ok) {
|
|
262
367
|
throw new CommandExecutionError(`点击草稿按钮失败: ${result?.reason || 'unknown'}`);
|
|
263
368
|
}
|
|
@@ -270,22 +375,38 @@ async function clickSaveDraft(page) {
|
|
|
270
375
|
};
|
|
271
376
|
}
|
|
272
377
|
/**
|
|
273
|
-
* Wait until creator center
|
|
378
|
+
* Wait until creator center confirms the draft was saved.
|
|
379
|
+
*
|
|
380
|
+
* After clicking 暂存离开 Douyin can land on several equivalent success states
|
|
381
|
+
* depending on timing and A/B layout: the upload page may show the resumable
|
|
382
|
+
* `继续编辑` prompt, a `草稿保存成功` toast may flash, or the page may navigate
|
|
383
|
+
* to the content-manage / draft list. Requiring only the first (`继续编辑` on the
|
|
384
|
+
* upload URL) within a tight 20s window false-failed when any other equivalent
|
|
385
|
+
* state was reached first. We already hold a real `creation_id` extracted from
|
|
386
|
+
* the live React fiber at click time, so the save was issued — this step only
|
|
387
|
+
* confirms it landed. Accept any of the success signals and poll longer (40s).
|
|
274
388
|
*/
|
|
275
389
|
async function waitForDraftResult(page, creationId) {
|
|
276
390
|
let lastState = { href: '', bodyText: '' };
|
|
277
|
-
for (let attempt = 0; attempt <
|
|
391
|
+
for (let attempt = 0; attempt < 40; attempt += 1) {
|
|
278
392
|
lastState = (await page.evaluate(`() => ({
|
|
279
393
|
href: location.href,
|
|
280
394
|
bodyText: document.body?.innerText || ''
|
|
281
395
|
})`));
|
|
282
|
-
|
|
283
|
-
|
|
396
|
+
const href = lastState.href || '';
|
|
397
|
+
const body = lastState.bodyText || '';
|
|
398
|
+
const resumablePrompt = href.includes('/creator-micro/content/upload')
|
|
399
|
+
&& /继续编辑/.test(body);
|
|
400
|
+
const saveToast = /草稿保存成功|已保存到草稿|存草稿成功|保存成功/.test(body);
|
|
401
|
+
// Navigated away from the composer to the content-manage / draft area.
|
|
402
|
+
const navigatedToManage = /\/creator-micro\/content\/(manage|drafts|works)/.test(href)
|
|
403
|
+
|| (/创作中心/.test(body) && !href.includes('/content/upload'));
|
|
404
|
+
if (resumablePrompt || saveToast || navigatedToManage) {
|
|
284
405
|
return creationId;
|
|
285
406
|
}
|
|
286
407
|
await page.wait({ time: 1 });
|
|
287
408
|
}
|
|
288
|
-
throw new CommandExecutionError('
|
|
409
|
+
throw new CommandExecutionError('未检测到抖音草稿保存确认', `当前页面: ${lastState.href || 'unknown'}`);
|
|
289
410
|
}
|
|
290
411
|
cli({
|
|
291
412
|
site: 'douyin',
|
|
@@ -301,6 +422,14 @@ cli({
|
|
|
301
422
|
{ name: 'caption', default: '', help: '正文内容(≤1000字,支持 #话题)' },
|
|
302
423
|
{ name: 'cover', default: '', help: '封面图片路径' },
|
|
303
424
|
{ name: 'visibility', default: 'public', choices: ['public', 'friends', 'private'] },
|
|
425
|
+
// Video upload + transcode + composer hydration + form fill + save can
|
|
426
|
+
// easily exceed the global 60s browser-command ceiling on a normal
|
|
427
|
+
// connection, which made this command time out intermittently. Declaring
|
|
428
|
+
// a `timeout` arg opts this command into runtime-enforced timeouts and
|
|
429
|
+
// raises its default ceiling to 180s; callers can pass --timeout <secs>
|
|
430
|
+
// for larger videos. (See execution.js readUserTimeoutSeconds: a declared
|
|
431
|
+
// `timeout` arg's default becomes the ceiling.)
|
|
432
|
+
{ name: 'timeout', type: 'int', default: 180, help: '命令超时(秒),视频上传/转码慢时可调大' },
|
|
304
433
|
],
|
|
305
434
|
columns: ['status', 'draft_id'],
|
|
306
435
|
func: async (page, kwargs) => {
|
|
@@ -335,6 +464,7 @@ cli({
|
|
|
335
464
|
await dismissKnownModals(page);
|
|
336
465
|
await page.setFileInput([videoPath], 'input[type="file"]');
|
|
337
466
|
await waitForDraftComposer(page);
|
|
467
|
+
await waitForVideoUploadComplete(page, visibilityLabel);
|
|
338
468
|
await dismissKnownModals(page);
|
|
339
469
|
if (coverPath) {
|
|
340
470
|
const coverSelector = await prepareCustomCoverInput(page);
|
|
@@ -121,6 +121,8 @@ describe('douyin draft registration', () => {
|
|
|
121
121
|
const page = createPageMock([
|
|
122
122
|
undefined,
|
|
123
123
|
{ href: 'https://creator.douyin.com/creator-micro/content/post/video?enter_from=publish_page', ready: true, bodyText: '' },
|
|
124
|
+
// waitForVideoUploadComplete: composer fully hydrated, save button usable, upload done.
|
|
125
|
+
{ uploading: false, saveEnabled: true, hasSaveBtn: true, hasCaptionEditor: true, hasVisibility: true },
|
|
124
126
|
undefined,
|
|
125
127
|
true,
|
|
126
128
|
true,
|
|
@@ -163,6 +165,8 @@ describe('douyin draft registration', () => {
|
|
|
163
165
|
{ href: 'https://creator.douyin.com/creator-micro/content/upload', ready: false, bodyText: '上传中 42%' },
|
|
164
166
|
{ href: 'https://creator.douyin.com/creator-micro/content/upload', ready: false, bodyText: '转码中' },
|
|
165
167
|
{ href: 'https://creator.douyin.com/creator-micro/content/post/video?enter_from=publish_page', ready: true, bodyText: '' },
|
|
168
|
+
// waitForVideoUploadComplete returns on first poll once the composer is ready.
|
|
169
|
+
{ uploading: false, saveEnabled: true, hasSaveBtn: true, hasCaptionEditor: true, hasVisibility: true },
|
|
166
170
|
undefined,
|
|
167
171
|
true,
|
|
168
172
|
true,
|
|
@@ -195,6 +199,8 @@ describe('douyin draft registration', () => {
|
|
|
195
199
|
const page = createPageMock([
|
|
196
200
|
undefined,
|
|
197
201
|
{ href: 'https://creator.douyin.com/creator-micro/content/post/video?enter_from=publish_page', ready: true, bodyText: '' },
|
|
202
|
+
// waitForVideoUploadComplete: ready immediately.
|
|
203
|
+
{ uploading: false, saveEnabled: true, hasSaveBtn: true, hasCaptionEditor: true, hasVisibility: true },
|
|
198
204
|
undefined,
|
|
199
205
|
true,
|
|
200
206
|
true,
|
|
@@ -215,6 +221,8 @@ describe('douyin draft registration', () => {
|
|
|
215
221
|
const page = createPageMock([
|
|
216
222
|
undefined,
|
|
217
223
|
{ href: 'https://creator.douyin.com/creator-micro/content/post/video?enter_from=publish_page', ready: true, bodyText: '' },
|
|
224
|
+
// waitForVideoUploadComplete: ready immediately.
|
|
225
|
+
{ uploading: false, saveEnabled: true, hasSaveBtn: true, hasCaptionEditor: true, hasVisibility: true },
|
|
218
226
|
undefined,
|
|
219
227
|
1,
|
|
220
228
|
{ ok: false, reason: 'cover-input-pending' },
|
|
@@ -262,6 +270,8 @@ describe('douyin draft registration', () => {
|
|
|
262
270
|
const page = createPageMock([
|
|
263
271
|
undefined,
|
|
264
272
|
{ href: 'https://creator.douyin.com/creator-micro/content/post/video?enter_from=publish_page', ready: true, bodyText: '' },
|
|
273
|
+
// waitForVideoUploadComplete: ready immediately.
|
|
274
|
+
{ uploading: false, saveEnabled: true, hasSaveBtn: true, hasCaptionEditor: true, hasVisibility: true },
|
|
265
275
|
undefined,
|
|
266
276
|
1,
|
|
267
277
|
{ ok: true, selector: '[data-opencli-cover-input="1"]' },
|
|
@@ -301,6 +311,8 @@ describe('douyin draft registration', () => {
|
|
|
301
311
|
const page = createPageMock([
|
|
302
312
|
undefined,
|
|
303
313
|
{ href: 'https://creator.douyin.com/creator-micro/content/post/video?enter_from=publish_page', ready: true, bodyText: '' },
|
|
314
|
+
// waitForVideoUploadComplete: ready immediately.
|
|
315
|
+
{ uploading: false, saveEnabled: true, hasSaveBtn: true, hasCaptionEditor: true, hasVisibility: true },
|
|
304
316
|
undefined,
|
|
305
317
|
1,
|
|
306
318
|
{ ok: true, selector: '[data-opencli-cover-input="1"]' },
|
package/clis/jike/auth.js
CHANGED
|
@@ -26,10 +26,44 @@ const WHOAMI_PROBE = `(async () => {
|
|
|
26
26
|
async function verifyJikeIdentity(page) {
|
|
27
27
|
await page.goto('https://web.okjike.com/');
|
|
28
28
|
await page.wait(2);
|
|
29
|
-
|
|
29
|
+
// Navigation race: page.goto can resolve while the tab is still on the blank
|
|
30
|
+
// 'data:' bootstrap URL (SPA shell not yet committed), where reading
|
|
31
|
+
// localStorage throws "Storage is disabled inside 'data:' URLs". Poll until
|
|
32
|
+
// location.href settles on the okjike origin before probing the token.
|
|
33
|
+
let pageUrl = '';
|
|
34
|
+
for (let i = 0; i < 30; i++) {
|
|
35
|
+
pageUrl = await page.evaluate('() => location.href');
|
|
36
|
+
if (pageUrl.includes('okjike.com')) break;
|
|
37
|
+
await page.wait(0.5);
|
|
38
|
+
}
|
|
39
|
+
if (!pageUrl.includes('okjike.com')) {
|
|
40
|
+
await page.screenshot({ path: '/tmp/jike_whoami_nav_debug.png' });
|
|
41
|
+
throw new CommandExecutionError(`Jike whoami: navigation never settled on okjike.com (landed on ${pageUrl}). Debug screenshot: /tmp/jike_whoami_nav_debug.png`);
|
|
42
|
+
}
|
|
43
|
+
// SPA hydrate race: JK_ACCESS_TOKEN is written to localStorage during the
|
|
44
|
+
// app's auth bootstrap, which can lag behind navigation on slow boots. A
|
|
45
|
+
// one-shot probe right after a fixed wait can read null even on a logged-in
|
|
46
|
+
// profile. Poll with a bounded loop, breaking as soon as the probe resolves
|
|
47
|
+
// to a non-anonymous result; only the final probe is treated as authoritative.
|
|
48
|
+
let probe = await page.evaluate(WHOAMI_PROBE);
|
|
49
|
+
for (let i = 0; i < 30 && probe?.kind === 'auth'; i++) {
|
|
50
|
+
await page.wait(0.5);
|
|
51
|
+
probe = await page.evaluate(WHOAMI_PROBE);
|
|
52
|
+
}
|
|
30
53
|
if (probe?.kind === 'auth') throw new AuthRequiredError('web.okjike.com', probe.detail);
|
|
31
54
|
if (probe?.kind === 'http') throw new CommandExecutionError(`HTTP ${probe.httpStatus} from Jike users/profile`);
|
|
32
|
-
if (probe?.kind === 'exception')
|
|
55
|
+
if (probe?.kind === 'exception') {
|
|
56
|
+
// When the profile is anonymous, okjike redirects to /login and replaces the
|
|
57
|
+
// document with a blank "data:text/html,<html></html>" page, where reading
|
|
58
|
+
// localStorage throws "Storage is disabled inside 'data:' URLs". That is the
|
|
59
|
+
// not-logged-in signal, not a generic execution failure — surface it as such
|
|
60
|
+
// so callers get an actionable AUTH_REQUIRED instead of an opaque error.
|
|
61
|
+
const probeUrl = await page.evaluate('() => location.href');
|
|
62
|
+
if (/^data:/.test(probeUrl) || /\/login/.test(pageUrl)) {
|
|
63
|
+
throw new AuthRequiredError('web.okjike.com', 'Jike session anonymous (redirected to /login)');
|
|
64
|
+
}
|
|
65
|
+
throw new CommandExecutionError(`Jike whoami failed: ${probe.detail}`);
|
|
66
|
+
}
|
|
33
67
|
if (!probe?.ok) throw new CommandExecutionError(`Unexpected Jike probe: ${JSON.stringify(probe)}`);
|
|
34
68
|
return { user_id: probe.user_id, screen_name: probe.screen_name, username: probe.username };
|
|
35
69
|
}
|
package/clis/jike/comment.js
CHANGED
|
@@ -20,6 +20,21 @@ cli({
|
|
|
20
20
|
columns: ['status', 'message'],
|
|
21
21
|
func: async (page, kwargs) => {
|
|
22
22
|
await page.goto(`https://web.okjike.com/originalPost/${kwargs.id}`);
|
|
23
|
+
// 详情页 SPA 异步 hydrate,评论输入框一次性 querySelector 会和渲染竞速,慢渲染下
|
|
24
|
+
// 误报"未找到评论输入框"。改为有界轮询等待输入框(contenteditable 或 textarea)
|
|
25
|
+
// 出现,再走原有的一次性输入逻辑(选择器/控制流不变)。
|
|
26
|
+
for (let i = 0; i < 30; i++) {
|
|
27
|
+
const ready = await page.evaluate(`(() => {
|
|
28
|
+
const editor =
|
|
29
|
+
document.querySelector('[class*="_comment_"] [contenteditable="true"]') ||
|
|
30
|
+
document.querySelector('[contenteditable="true"]');
|
|
31
|
+
const textarea = document.querySelector('textarea');
|
|
32
|
+
return !!(editor || textarea);
|
|
33
|
+
})()`);
|
|
34
|
+
if (ready)
|
|
35
|
+
break;
|
|
36
|
+
await page.wait(0.5);
|
|
37
|
+
}
|
|
23
38
|
// 1. 找到评论输入框并填入文本
|
|
24
39
|
const inputResult = await page.evaluate(`(async () => {
|
|
25
40
|
try {
|
|
@@ -80,6 +95,20 @@ cli({
|
|
|
80
95
|
if (!inputResult.ok) {
|
|
81
96
|
return [{ status: 'failed', message: inputResult.message }];
|
|
82
97
|
}
|
|
98
|
+
// 输入后回复/发布按钮从禁用变为可用需要一点时间,一次性探测会和状态切换竞速,
|
|
99
|
+
// 误报"未找到可用的回复按钮"。改为有界轮询等待可用按钮出现,再走原有的一次性
|
|
100
|
+
// 点击逻辑(选择器/控制流不变)。
|
|
101
|
+
for (let i = 0; i < 30; i++) {
|
|
102
|
+
const ready = await page.evaluate(`(() => {
|
|
103
|
+
return Array.from(document.querySelectorAll('button')).some(btn => {
|
|
104
|
+
const text = btn.textContent?.trim() || '';
|
|
105
|
+
return (text === '回复' || text === '发布' || text === '发送' || text === '评论') && !btn.disabled;
|
|
106
|
+
});
|
|
107
|
+
})()`);
|
|
108
|
+
if (ready)
|
|
109
|
+
break;
|
|
110
|
+
await page.wait(0.5);
|
|
111
|
+
}
|
|
83
112
|
// 2. 点击"回复"或"发布"按钮
|
|
84
113
|
const submitResult = await page.evaluate(`(async () => {
|
|
85
114
|
try {
|
package/clis/jike/create.js
CHANGED
|
@@ -20,6 +20,24 @@ cli({
|
|
|
20
20
|
func: async (page, kwargs) => {
|
|
21
21
|
// 1. 导航到首页(有内联发帖框)
|
|
22
22
|
await page.goto('https://web.okjike.com');
|
|
23
|
+
// 首页是 SPA,导航后发帖框异步 hydrate。一次性 querySelector 会和骨架屏
|
|
24
|
+
// 竞速,慢渲染/慢网络下找不到输入框即误报"未找到发帖输入框"。改为有界轮询
|
|
25
|
+
// 等待发帖输入框出现,再走原有的一次性输入逻辑(选择器/控制流不变)。
|
|
26
|
+
for (let i = 0; i < 30; i++) {
|
|
27
|
+
const ready = await page.evaluate(`(() => {
|
|
28
|
+
const form = document.querySelector('[class*="_postForm_"]');
|
|
29
|
+
const editor = form
|
|
30
|
+
? form.querySelector('[contenteditable="true"]')
|
|
31
|
+
: document.querySelector('[contenteditable="true"]');
|
|
32
|
+
const textarea = form
|
|
33
|
+
? form.querySelector('textarea')
|
|
34
|
+
: document.querySelector('textarea');
|
|
35
|
+
return !!(editor || textarea);
|
|
36
|
+
})()`);
|
|
37
|
+
if (ready)
|
|
38
|
+
break;
|
|
39
|
+
await page.wait(0.5);
|
|
40
|
+
}
|
|
23
41
|
// 2. 在发帖框中输入文本
|
|
24
42
|
const textResult = await page.evaluate(`(async () => {
|
|
25
43
|
try {
|
|
@@ -72,6 +90,20 @@ cli({
|
|
|
72
90
|
if (!textResult.ok) {
|
|
73
91
|
return [{ status: 'failed', message: textResult.message }];
|
|
74
92
|
}
|
|
93
|
+
// 输入后"发送"按钮从禁用变为可用需要一点时间(React 状态更新)。一次性
|
|
94
|
+
// 探测会和这个状态切换竞速,慢机器上误报"未找到可用的发送按钮"。改为有界
|
|
95
|
+
// 轮询等待可用发送按钮出现,再走原有的一次性点击逻辑(选择器/控制流不变)。
|
|
96
|
+
for (let i = 0; i < 30; i++) {
|
|
97
|
+
const ready = await page.evaluate(`(() => {
|
|
98
|
+
return Array.from(document.querySelectorAll('button')).some(btn => {
|
|
99
|
+
const text = btn.textContent?.trim() || '';
|
|
100
|
+
return (text === '发送' || text === '发布') && !btn.disabled;
|
|
101
|
+
});
|
|
102
|
+
})()`);
|
|
103
|
+
if (ready)
|
|
104
|
+
break;
|
|
105
|
+
await page.wait(0.5);
|
|
106
|
+
}
|
|
75
107
|
// 3. 点击"发送"按钮
|
|
76
108
|
const submitResult = await page.evaluate(`(async () => {
|
|
77
109
|
try {
|
package/clis/jike/like.js
CHANGED
|
@@ -20,6 +20,17 @@ cli({
|
|
|
20
20
|
func: async (page, kwargs) => {
|
|
21
21
|
// 1. 导航到帖子详情页
|
|
22
22
|
await page.goto(`https://web.okjike.com/originalPost/${kwargs.id}`);
|
|
23
|
+
// 详情页 SPA 异步 hydrate,点赞按钮一次性 querySelector 会和渲染竞速,慢渲染下
|
|
24
|
+
// 误报"未找到点赞按钮"。改为有界轮询等待点赞按钮出现,再走原有的一次性点击
|
|
25
|
+
// 逻辑(选择器/控制流不变)。
|
|
26
|
+
for (let i = 0; i < 30; i++) {
|
|
27
|
+
const ready = await page.evaluate(`(() => {
|
|
28
|
+
return !!document.querySelector('[class*="_likeButton_"]');
|
|
29
|
+
})()`);
|
|
30
|
+
if (ready)
|
|
31
|
+
break;
|
|
32
|
+
await page.wait(0.5);
|
|
33
|
+
}
|
|
23
34
|
// 2. 找到点赞按钮并点击
|
|
24
35
|
const result = await page.evaluate(`(async () => {
|
|
25
36
|
try {
|
package/clis/jike/repost.js
CHANGED
|
@@ -21,6 +21,20 @@ cli({
|
|
|
21
21
|
columns: ['status', 'message'],
|
|
22
22
|
func: async (page, kwargs) => {
|
|
23
23
|
await page.goto(`https://web.okjike.com/originalPost/${kwargs.id}`);
|
|
24
|
+
// 详情页 SPA 异步 hydrate,操作栏一次性 querySelector 会和渲染竞速,慢渲染下
|
|
25
|
+
// 误报"未找到操作栏/转发按钮"。改为有界轮询等待操作栏的第三个可见子元素出现,
|
|
26
|
+
// 再走原有的一次性点击逻辑(选择器/控制流不变)。
|
|
27
|
+
for (let i = 0; i < 30; i++) {
|
|
28
|
+
const ready = await page.evaluate(`(() => {
|
|
29
|
+
const actions = document.querySelector('[class*="_actions_"]');
|
|
30
|
+
if (!actions) return false;
|
|
31
|
+
const children = Array.from(actions.children).filter(c => c.offsetHeight > 0);
|
|
32
|
+
return !!children[2];
|
|
33
|
+
})()`);
|
|
34
|
+
if (ready)
|
|
35
|
+
break;
|
|
36
|
+
await page.wait(0.5);
|
|
37
|
+
}
|
|
24
38
|
// 1. 点击操作栏中的转发按钮(第三个子元素)
|
|
25
39
|
const clickResult = await page.evaluate(`(async () => {
|
|
26
40
|
try {
|
|
@@ -39,6 +53,18 @@ cli({
|
|
|
39
53
|
return [{ status: 'failed', message: clickResult.message }];
|
|
40
54
|
}
|
|
41
55
|
await page.wait(1);
|
|
56
|
+
// 转发 Popover 菜单异步弹出,一次性查找"转发动态"会和动画/渲染竞速。改为有界
|
|
57
|
+
// 轮询等待菜单项出现,再走原有的一次性点击逻辑(选择器/控制流不变)。
|
|
58
|
+
for (let i = 0; i < 30; i++) {
|
|
59
|
+
const ready = await page.evaluate(`(() => {
|
|
60
|
+
return Array.from(document.querySelectorAll('button')).some(
|
|
61
|
+
b => b.textContent?.trim() === '转发动态'
|
|
62
|
+
);
|
|
63
|
+
})()`);
|
|
64
|
+
if (ready)
|
|
65
|
+
break;
|
|
66
|
+
await page.wait(0.5);
|
|
67
|
+
}
|
|
42
68
|
// 2. 在弹出菜单中点击"转发动态"
|
|
43
69
|
const menuResult = await page.evaluate(`(async () => {
|
|
44
70
|
try {
|
|
@@ -58,6 +84,17 @@ cli({
|
|
|
58
84
|
await page.wait(2);
|
|
59
85
|
// 3. 若有附言,在弹窗编辑器中填入
|
|
60
86
|
if (kwargs.text) {
|
|
87
|
+
// 转发编辑器弹窗异步渲染,一次性查找 contenteditable 会和弹窗渲染竞速,
|
|
88
|
+
// 慢渲染下误报"未找到附言输入框"。改为有界轮询等待编辑器出现,再走原有的
|
|
89
|
+
// 一次性写入逻辑(选择器/控制流不变)。
|
|
90
|
+
for (let i = 0; i < 30; i++) {
|
|
91
|
+
const ready = await page.evaluate(`(() => {
|
|
92
|
+
return !!document.querySelector('[contenteditable="true"]');
|
|
93
|
+
})()`);
|
|
94
|
+
if (ready)
|
|
95
|
+
break;
|
|
96
|
+
await page.wait(0.5);
|
|
97
|
+
}
|
|
61
98
|
const textResult = await page.evaluate(`(async () => {
|
|
62
99
|
try {
|
|
63
100
|
const textToInsert = ${JSON.stringify(kwargs.text)};
|
|
@@ -77,6 +114,19 @@ cli({
|
|
|
77
114
|
return [{ status: 'failed', message: textResult.message }];
|
|
78
115
|
}
|
|
79
116
|
}
|
|
117
|
+
// 转发弹窗的"发送/发布"按钮在内容就绪后才可用,一次性探测会和状态切换竞速。
|
|
118
|
+
// 改为有界轮询等待可用确认按钮出现,再走原有的一次性点击逻辑(选择器/控制流不变)。
|
|
119
|
+
for (let i = 0; i < 30; i++) {
|
|
120
|
+
const ready = await page.evaluate(`(() => {
|
|
121
|
+
return Array.from(document.querySelectorAll('button')).some(b => {
|
|
122
|
+
const text = b.textContent?.trim() || '';
|
|
123
|
+
return (text === '发送' || text === '发布') && !b.disabled;
|
|
124
|
+
});
|
|
125
|
+
})()`);
|
|
126
|
+
if (ready)
|
|
127
|
+
break;
|
|
128
|
+
await page.wait(0.5);
|
|
129
|
+
}
|
|
80
130
|
// 4. 点击"发送"按钮确认转发
|
|
81
131
|
const confirmResult = await page.evaluate(`(async () => {
|
|
82
132
|
try {
|