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 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": [
@@ -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
- const confirmButton = Array.from(document.querySelectorAll('button,[role="button"]'))
79
- .find((element) => ['确定', '确认', '删除'].includes(normalize(textOf(element))));
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) {
@@ -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
- const titleOk = (await page.evaluate(`() => {
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
- const captionOk = (await page.evaluate(`() => {
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
- const visibilityOk = (await page.evaluate(`() => {
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
- const result = (await page.evaluate(`() => {
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 shows the resumable-draft prompt after saving.
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 < 20; attempt += 1) {
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
- if (lastState.href.includes('/creator-micro/content/upload')
283
- && /继续编辑/.test(lastState.bodyText)) {
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('未检测到抖音草稿恢复提示', `当前页面: ${lastState.href || 'unknown'}`);
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
- const probe = await page.evaluate(WHOAMI_PROBE);
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') throw new CommandExecutionError(`Jike whoami failed: ${probe.detail}`);
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
  }
@@ -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 {
@@ -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 {
@@ -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 {