publishport-opencli 1.8.4-pp.1 → 1.8.4-pp.3
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 +147 -0
- package/clis/bilibili/dynamic-post.js +81 -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/article.js +118 -0
- package/clis/zhihu/pin.js +79 -0
- 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
|
@@ -3658,6 +3658,40 @@
|
|
|
3658
3658
|
"sourceFile": "bilibili/dynamic.js",
|
|
3659
3659
|
"navigateBefore": "https://www.bilibili.com"
|
|
3660
3660
|
},
|
|
3661
|
+
{
|
|
3662
|
+
"site": "bilibili",
|
|
3663
|
+
"name": "dynamic-post",
|
|
3664
|
+
"description": "发布 B站动态(纯文本,官方 API,需登录)",
|
|
3665
|
+
"access": "write",
|
|
3666
|
+
"domain": "www.bilibili.com",
|
|
3667
|
+
"strategy": "cookie",
|
|
3668
|
+
"browser": true,
|
|
3669
|
+
"args": [
|
|
3670
|
+
{
|
|
3671
|
+
"name": "text",
|
|
3672
|
+
"type": "str",
|
|
3673
|
+
"required": true,
|
|
3674
|
+
"positional": true,
|
|
3675
|
+
"help": "Dynamic text content"
|
|
3676
|
+
},
|
|
3677
|
+
{
|
|
3678
|
+
"name": "execute",
|
|
3679
|
+
"type": "boolean",
|
|
3680
|
+
"required": false,
|
|
3681
|
+
"help": "Actually publish. Without it the command refuses to write."
|
|
3682
|
+
}
|
|
3683
|
+
],
|
|
3684
|
+
"columns": [
|
|
3685
|
+
"status",
|
|
3686
|
+
"dynamic_id",
|
|
3687
|
+
"text",
|
|
3688
|
+
"url"
|
|
3689
|
+
],
|
|
3690
|
+
"type": "js",
|
|
3691
|
+
"modulePath": "bilibili/dynamic-post.js",
|
|
3692
|
+
"sourceFile": "bilibili/dynamic-post.js",
|
|
3693
|
+
"navigateBefore": "https://www.bilibili.com"
|
|
3694
|
+
},
|
|
3661
3695
|
{
|
|
3662
3696
|
"site": "bilibili",
|
|
3663
3697
|
"name": "favorite",
|
|
@@ -11853,6 +11887,13 @@
|
|
|
11853
11887
|
"friends",
|
|
11854
11888
|
"private"
|
|
11855
11889
|
]
|
|
11890
|
+
},
|
|
11891
|
+
{
|
|
11892
|
+
"name": "timeout",
|
|
11893
|
+
"type": "int",
|
|
11894
|
+
"default": 180,
|
|
11895
|
+
"required": false,
|
|
11896
|
+
"help": "命令超时(秒),视频上传/转码慢时可调大"
|
|
11856
11897
|
}
|
|
11857
11898
|
],
|
|
11858
11899
|
"columns": [
|
|
@@ -43189,6 +43230,68 @@
|
|
|
43189
43230
|
"sourceFile": "zhihu/answer-detail.js",
|
|
43190
43231
|
"navigateBefore": "https://www.zhihu.com"
|
|
43191
43232
|
},
|
|
43233
|
+
{
|
|
43234
|
+
"site": "zhihu",
|
|
43235
|
+
"name": "article",
|
|
43236
|
+
"description": "Publish a Zhihu article (文章/专栏)",
|
|
43237
|
+
"access": "write",
|
|
43238
|
+
"domain": "zhuanlan.zhihu.com",
|
|
43239
|
+
"strategy": "cookie",
|
|
43240
|
+
"browser": true,
|
|
43241
|
+
"args": [
|
|
43242
|
+
{
|
|
43243
|
+
"name": "title",
|
|
43244
|
+
"type": "str",
|
|
43245
|
+
"required": true,
|
|
43246
|
+
"positional": true,
|
|
43247
|
+
"help": "Article title"
|
|
43248
|
+
},
|
|
43249
|
+
{
|
|
43250
|
+
"name": "text",
|
|
43251
|
+
"type": "str",
|
|
43252
|
+
"required": false,
|
|
43253
|
+
"positional": true,
|
|
43254
|
+
"help": "Article body (plain text by default; pass --html for raw HTML)"
|
|
43255
|
+
},
|
|
43256
|
+
{
|
|
43257
|
+
"name": "file",
|
|
43258
|
+
"type": "str",
|
|
43259
|
+
"required": false,
|
|
43260
|
+
"help": "Article body file path (UTF-8)"
|
|
43261
|
+
},
|
|
43262
|
+
{
|
|
43263
|
+
"name": "html",
|
|
43264
|
+
"type": "boolean",
|
|
43265
|
+
"required": false,
|
|
43266
|
+
"help": "Treat body as raw HTML (default: plain text — escaped, newlines become paragraphs)"
|
|
43267
|
+
},
|
|
43268
|
+
{
|
|
43269
|
+
"name": "draft",
|
|
43270
|
+
"type": "boolean",
|
|
43271
|
+
"required": false,
|
|
43272
|
+
"help": "Save as draft only; do not publish"
|
|
43273
|
+
},
|
|
43274
|
+
{
|
|
43275
|
+
"name": "execute",
|
|
43276
|
+
"type": "boolean",
|
|
43277
|
+
"required": false,
|
|
43278
|
+
"help": "Actually create/publish. Without it the command refuses to write."
|
|
43279
|
+
}
|
|
43280
|
+
],
|
|
43281
|
+
"columns": [
|
|
43282
|
+
"status",
|
|
43283
|
+
"outcome",
|
|
43284
|
+
"message",
|
|
43285
|
+
"target_type",
|
|
43286
|
+
"target",
|
|
43287
|
+
"created_target",
|
|
43288
|
+
"created_url"
|
|
43289
|
+
],
|
|
43290
|
+
"type": "js",
|
|
43291
|
+
"modulePath": "zhihu/article.js",
|
|
43292
|
+
"sourceFile": "zhihu/article.js",
|
|
43293
|
+
"navigateBefore": "https://zhuanlan.zhihu.com"
|
|
43294
|
+
},
|
|
43192
43295
|
{
|
|
43193
43296
|
"site": "zhihu",
|
|
43194
43297
|
"name": "collection",
|
|
@@ -43608,6 +43711,50 @@
|
|
|
43608
43711
|
"siteSession": "persistent",
|
|
43609
43712
|
"defaultWindowMode": "foreground"
|
|
43610
43713
|
},
|
|
43714
|
+
{
|
|
43715
|
+
"site": "zhihu",
|
|
43716
|
+
"name": "pin",
|
|
43717
|
+
"description": "Publish a Zhihu pin (想法/short post)",
|
|
43718
|
+
"access": "write",
|
|
43719
|
+
"domain": "www.zhihu.com",
|
|
43720
|
+
"strategy": "cookie",
|
|
43721
|
+
"browser": true,
|
|
43722
|
+
"args": [
|
|
43723
|
+
{
|
|
43724
|
+
"name": "text",
|
|
43725
|
+
"type": "str",
|
|
43726
|
+
"required": false,
|
|
43727
|
+
"positional": true,
|
|
43728
|
+
"help": "Pin text content"
|
|
43729
|
+
},
|
|
43730
|
+
{
|
|
43731
|
+
"name": "file",
|
|
43732
|
+
"type": "str",
|
|
43733
|
+
"required": false,
|
|
43734
|
+
"help": "Pin text file path (UTF-8)"
|
|
43735
|
+
},
|
|
43736
|
+
{
|
|
43737
|
+
"name": "execute",
|
|
43738
|
+
"type": "boolean",
|
|
43739
|
+
"required": false,
|
|
43740
|
+
"help": "Actually publish the pin. Without it the command refuses to write."
|
|
43741
|
+
}
|
|
43742
|
+
],
|
|
43743
|
+
"columns": [
|
|
43744
|
+
"status",
|
|
43745
|
+
"outcome",
|
|
43746
|
+
"message",
|
|
43747
|
+
"target_type",
|
|
43748
|
+
"target",
|
|
43749
|
+
"created_target",
|
|
43750
|
+
"created_url",
|
|
43751
|
+
"author_identity"
|
|
43752
|
+
],
|
|
43753
|
+
"type": "js",
|
|
43754
|
+
"modulePath": "zhihu/pin.js",
|
|
43755
|
+
"sourceFile": "zhihu/pin.js",
|
|
43756
|
+
"navigateBefore": "https://www.zhihu.com"
|
|
43757
|
+
},
|
|
43611
43758
|
{
|
|
43612
43759
|
"site": "zhihu",
|
|
43613
43760
|
"name": "pins",
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bilibili dynamic-post — publish a text dynamic (动态) via the official web API
|
|
3
|
+
* (/x/dynamic/feed/create/dyn), authenticated by the logged-in cookie + bili_jct CSRF.
|
|
4
|
+
* Text-only for now; image dynamics need a separate bfs upload step (future work).
|
|
5
|
+
*/
|
|
6
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
7
|
+
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
8
|
+
|
|
9
|
+
cli({
|
|
10
|
+
site: 'bilibili',
|
|
11
|
+
name: 'dynamic-post',
|
|
12
|
+
access: 'write',
|
|
13
|
+
description: '发布 B站动态(纯文本,官方 API,需登录)',
|
|
14
|
+
domain: 'www.bilibili.com',
|
|
15
|
+
strategy: Strategy.COOKIE,
|
|
16
|
+
browser: true,
|
|
17
|
+
args: [
|
|
18
|
+
{ name: 'text', required: true, positional: true, help: 'Dynamic text content' },
|
|
19
|
+
{ name: 'execute', type: 'boolean', help: 'Actually publish. Without it the command refuses to write.' },
|
|
20
|
+
],
|
|
21
|
+
columns: ['status', 'dynamic_id', 'text', 'url'],
|
|
22
|
+
func: async (page, kwargs) => {
|
|
23
|
+
if (!page)
|
|
24
|
+
throw new CommandExecutionError('Browser session required for bilibili dynamic-post');
|
|
25
|
+
const text = String(kwargs.text ?? '').trim();
|
|
26
|
+
if (!text)
|
|
27
|
+
throw new ArgumentError('bilibili dynamic-post text cannot be empty');
|
|
28
|
+
if (!kwargs.execute)
|
|
29
|
+
throw new ArgumentError('Refusing to post: pass --execute to actually publish this dynamic');
|
|
30
|
+
|
|
31
|
+
// goto occasionally returns before the navigation lands (page still on a
|
|
32
|
+
// data: URL), which breaks the cookie/CSRF read — retry until we're on bilibili.
|
|
33
|
+
let landed = false;
|
|
34
|
+
for (let i = 0; i < 4; i++) {
|
|
35
|
+
await page.goto('https://t.bilibili.com');
|
|
36
|
+
await page.wait(2);
|
|
37
|
+
const href = await page.evaluate('location.href');
|
|
38
|
+
if (typeof href === 'string' && /^https?:\/\/[^/]*bilibili\.com/.test(href)) {
|
|
39
|
+
landed = true;
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (!landed)
|
|
44
|
+
throw new CommandExecutionError('Failed to navigate to t.bilibili.com (page did not land on a bilibili origin)');
|
|
45
|
+
|
|
46
|
+
const result = await page.evaluate(`(async () => {
|
|
47
|
+
var text = ${JSON.stringify(text)};
|
|
48
|
+
var csrf = (document.cookie.match(/bili_jct=([^;]+)/) || [])[1] || '';
|
|
49
|
+
var url = 'https://api.bilibili.com/x/dynamic/feed/create/dyn?csrf=' + encodeURIComponent(csrf);
|
|
50
|
+
var body = {
|
|
51
|
+
dyn_req: {
|
|
52
|
+
content: { contents: [{ raw_text: text, type: 1, biz_id: '' }] },
|
|
53
|
+
scene: 1,
|
|
54
|
+
meta: { app_meta: { from: 'create.dynamic.web', mobi_app: 'web' } },
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
var resp = await fetch(url, {
|
|
58
|
+
method: 'POST',
|
|
59
|
+
credentials: 'include',
|
|
60
|
+
headers: { 'Content-Type': 'application/json' },
|
|
61
|
+
body: JSON.stringify(body),
|
|
62
|
+
});
|
|
63
|
+
var t = await resp.text();
|
|
64
|
+
var data; try { data = JSON.parse(t); } catch (e) { data = null; }
|
|
65
|
+
if (!data) return { ok: false, message: 'Non-JSON response (HTTP ' + resp.status + '): ' + t.slice(0, 250) };
|
|
66
|
+
if (data.code !== 0) return { ok: false, message: (data.message || 'unknown error') + ' (' + data.code + ')' };
|
|
67
|
+
var dynId = data.data && (data.data.dyn_id_str || data.data.dyn_id || data.data.dynamic_id_str);
|
|
68
|
+
return { ok: true, dynId: String(dynId || ''), raw: t.slice(0, 250) };
|
|
69
|
+
})()`);
|
|
70
|
+
|
|
71
|
+
if (!result?.ok) {
|
|
72
|
+
throw new CommandExecutionError(`Bilibili dynamic-post failed: ${result?.message || 'unknown error'}`);
|
|
73
|
+
}
|
|
74
|
+
return [{
|
|
75
|
+
status: 'success',
|
|
76
|
+
dynamic_id: result.dynId,
|
|
77
|
+
text,
|
|
78
|
+
url: result.dynId ? `https://t.bilibili.com/${result.dynId}` : '',
|
|
79
|
+
}];
|
|
80
|
+
},
|
|
81
|
+
});
|
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 {
|