@jackwener/opencli 1.5.0 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/dist/browser/cdp.js +5 -0
  2. package/dist/browser/page.d.ts +3 -0
  3. package/dist/browser/page.js +24 -1
  4. package/dist/cli-manifest.json +465 -5
  5. package/dist/cli.js +34 -3
  6. package/dist/clis/bluesky/feeds.yaml +29 -0
  7. package/dist/clis/bluesky/followers.yaml +33 -0
  8. package/dist/clis/bluesky/following.yaml +33 -0
  9. package/dist/clis/bluesky/profile.yaml +27 -0
  10. package/dist/clis/bluesky/search.yaml +34 -0
  11. package/dist/clis/bluesky/starter-packs.yaml +34 -0
  12. package/dist/clis/bluesky/thread.yaml +32 -0
  13. package/dist/clis/bluesky/trending.yaml +27 -0
  14. package/dist/clis/bluesky/user.yaml +34 -0
  15. package/dist/clis/twitter/trending.js +29 -61
  16. package/dist/clis/v2ex/hot.yaml +17 -3
  17. package/dist/clis/xiaohongshu/publish.js +78 -42
  18. package/dist/clis/xiaohongshu/publish.test.js +20 -8
  19. package/dist/clis/xiaohongshu/search.d.ts +8 -1
  20. package/dist/clis/xiaohongshu/search.js +20 -1
  21. package/dist/clis/xiaohongshu/search.test.d.ts +1 -1
  22. package/dist/clis/xiaohongshu/search.test.js +32 -1
  23. package/dist/discovery.js +40 -28
  24. package/dist/doctor.d.ts +1 -2
  25. package/dist/doctor.js +2 -2
  26. package/dist/engine.test.js +42 -0
  27. package/dist/errors.d.ts +1 -1
  28. package/dist/errors.js +2 -2
  29. package/dist/execution.js +45 -7
  30. package/dist/execution.test.d.ts +1 -0
  31. package/dist/execution.test.js +40 -0
  32. package/dist/external.js +6 -1
  33. package/dist/main.js +1 -0
  34. package/dist/plugin-scaffold.d.ts +28 -0
  35. package/dist/plugin-scaffold.js +142 -0
  36. package/dist/plugin-scaffold.test.d.ts +4 -0
  37. package/dist/plugin-scaffold.test.js +83 -0
  38. package/dist/plugin.d.ts +55 -17
  39. package/dist/plugin.js +706 -154
  40. package/dist/plugin.test.js +836 -38
  41. package/dist/runtime.d.ts +1 -0
  42. package/dist/runtime.js +1 -1
  43. package/dist/types.d.ts +2 -0
  44. package/docs/adapters/browser/bluesky.md +53 -0
  45. package/docs/guide/plugins.md +10 -0
  46. package/package.json +1 -1
  47. package/src/browser/cdp.ts +6 -0
  48. package/src/browser/page.ts +24 -1
  49. package/src/cli.ts +34 -3
  50. package/src/clis/bluesky/feeds.yaml +29 -0
  51. package/src/clis/bluesky/followers.yaml +33 -0
  52. package/src/clis/bluesky/following.yaml +33 -0
  53. package/src/clis/bluesky/profile.yaml +27 -0
  54. package/src/clis/bluesky/search.yaml +34 -0
  55. package/src/clis/bluesky/starter-packs.yaml +34 -0
  56. package/src/clis/bluesky/thread.yaml +32 -0
  57. package/src/clis/bluesky/trending.yaml +27 -0
  58. package/src/clis/bluesky/user.yaml +34 -0
  59. package/src/clis/twitter/trending.ts +29 -77
  60. package/src/clis/v2ex/hot.yaml +17 -3
  61. package/src/clis/xiaohongshu/publish.test.ts +22 -8
  62. package/src/clis/xiaohongshu/publish.ts +93 -52
  63. package/src/clis/xiaohongshu/search.test.ts +39 -1
  64. package/src/clis/xiaohongshu/search.ts +19 -1
  65. package/src/discovery.ts +41 -33
  66. package/src/doctor.ts +2 -3
  67. package/src/engine.test.ts +38 -0
  68. package/src/errors.ts +6 -2
  69. package/src/execution.test.ts +47 -0
  70. package/src/execution.ts +39 -6
  71. package/src/external.ts +6 -1
  72. package/src/main.ts +1 -0
  73. package/src/plugin-scaffold.test.ts +98 -0
  74. package/src/plugin-scaffold.ts +170 -0
  75. package/src/plugin.test.ts +881 -38
  76. package/src/plugin.ts +871 -158
  77. package/src/runtime.ts +2 -2
  78. package/src/types.ts +2 -0
  79. package/tests/e2e/browser-public.test.ts +1 -1
@@ -0,0 +1,33 @@
1
+ site: bluesky
2
+ name: followers
3
+ description: List followers of a Bluesky user
4
+ domain: public.api.bsky.app
5
+ strategy: public
6
+ browser: false
7
+
8
+ args:
9
+ handle:
10
+ type: str
11
+ required: true
12
+ positional: true
13
+ description: "Bluesky handle"
14
+ limit:
15
+ type: int
16
+ default: 20
17
+ description: Number of followers
18
+
19
+ pipeline:
20
+ - fetch:
21
+ url: https://public.api.bsky.app/xrpc/app.bsky.graph.getFollowers?actor=${{ args.handle }}&limit=${{ args.limit }}
22
+
23
+ - select: followers
24
+
25
+ - map:
26
+ rank: ${{ index + 1 }}
27
+ handle: ${{ item.handle }}
28
+ name: ${{ item.displayName }}
29
+ description: ${{ item.description }}
30
+
31
+ - limit: ${{ args.limit }}
32
+
33
+ columns: [rank, handle, name, description]
@@ -0,0 +1,33 @@
1
+ site: bluesky
2
+ name: following
3
+ description: List accounts a Bluesky user is following
4
+ domain: public.api.bsky.app
5
+ strategy: public
6
+ browser: false
7
+
8
+ args:
9
+ handle:
10
+ type: str
11
+ required: true
12
+ positional: true
13
+ description: "Bluesky handle"
14
+ limit:
15
+ type: int
16
+ default: 20
17
+ description: Number of accounts
18
+
19
+ pipeline:
20
+ - fetch:
21
+ url: https://public.api.bsky.app/xrpc/app.bsky.graph.getFollows?actor=${{ args.handle }}&limit=${{ args.limit }}
22
+
23
+ - select: follows
24
+
25
+ - map:
26
+ rank: ${{ index + 1 }}
27
+ handle: ${{ item.handle }}
28
+ name: ${{ item.displayName }}
29
+ description: ${{ item.description }}
30
+
31
+ - limit: ${{ args.limit }}
32
+
33
+ columns: [rank, handle, name, description]
@@ -0,0 +1,27 @@
1
+ site: bluesky
2
+ name: profile
3
+ description: Get Bluesky user profile info
4
+ domain: public.api.bsky.app
5
+ strategy: public
6
+ browser: false
7
+
8
+ args:
9
+ handle:
10
+ type: str
11
+ required: true
12
+ positional: true
13
+ description: "Bluesky handle (e.g. bsky.app, jay.bsky.team)"
14
+
15
+ pipeline:
16
+ - fetch:
17
+ url: https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${{ args.handle }}
18
+
19
+ - map:
20
+ handle: ${{ item.handle }}
21
+ name: ${{ item.displayName }}
22
+ followers: ${{ item.followersCount }}
23
+ following: ${{ item.followsCount }}
24
+ posts: ${{ item.postsCount }}
25
+ description: ${{ item.description }}
26
+
27
+ columns: [handle, name, followers, following, posts, description]
@@ -0,0 +1,34 @@
1
+ site: bluesky
2
+ name: search
3
+ description: Search Bluesky users
4
+ domain: public.api.bsky.app
5
+ strategy: public
6
+ browser: false
7
+
8
+ args:
9
+ query:
10
+ type: str
11
+ required: true
12
+ positional: true
13
+ description: Search query
14
+ limit:
15
+ type: int
16
+ default: 10
17
+ description: Number of results
18
+
19
+ pipeline:
20
+ - fetch:
21
+ url: https://public.api.bsky.app/xrpc/app.bsky.actor.searchActors?q=${{ args.query }}&limit=${{ args.limit }}
22
+
23
+ - select: actors
24
+
25
+ - map:
26
+ rank: ${{ index + 1 }}
27
+ handle: ${{ item.handle }}
28
+ name: ${{ item.displayName }}
29
+ followers: ${{ item.followersCount }}
30
+ description: ${{ item.description }}
31
+
32
+ - limit: ${{ args.limit }}
33
+
34
+ columns: [rank, handle, name, followers, description]
@@ -0,0 +1,34 @@
1
+ site: bluesky
2
+ name: starter-packs
3
+ description: Get starter packs created by a Bluesky user
4
+ domain: public.api.bsky.app
5
+ strategy: public
6
+ browser: false
7
+
8
+ args:
9
+ handle:
10
+ type: str
11
+ required: true
12
+ positional: true
13
+ description: "Bluesky handle"
14
+ limit:
15
+ type: int
16
+ default: 10
17
+ description: Number of starter packs
18
+
19
+ pipeline:
20
+ - fetch:
21
+ url: https://public.api.bsky.app/xrpc/app.bsky.graph.getActorStarterPacks?actor=${{ args.handle }}&limit=${{ args.limit }}
22
+
23
+ - select: starterPacks
24
+
25
+ - map:
26
+ rank: ${{ index + 1 }}
27
+ name: ${{ item.record.name }}
28
+ description: ${{ item.record.description }}
29
+ members: ${{ item.listItemCount }}
30
+ joins: ${{ item.joinedAllTimeCount }}
31
+
32
+ - limit: ${{ args.limit }}
33
+
34
+ columns: [rank, name, description, members, joins]
@@ -0,0 +1,32 @@
1
+ site: bluesky
2
+ name: thread
3
+ description: Get a Bluesky post thread with replies
4
+ domain: public.api.bsky.app
5
+ strategy: public
6
+ browser: false
7
+
8
+ args:
9
+ uri:
10
+ type: str
11
+ required: true
12
+ positional: true
13
+ description: "Post AT URI (at://did:.../app.bsky.feed.post/...) or bsky.app URL"
14
+ limit:
15
+ type: int
16
+ default: 20
17
+ description: Number of replies
18
+
19
+ pipeline:
20
+ - fetch:
21
+ url: https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?uri=${{ args.uri }}&depth=2
22
+
23
+ - select: thread
24
+
25
+ - map:
26
+ author: ${{ item.post.author.handle }}
27
+ text: ${{ item.post.record.text }}
28
+ likes: ${{ item.post.likeCount }}
29
+ reposts: ${{ item.post.repostCount }}
30
+ replies_count: ${{ item.post.replyCount }}
31
+
32
+ columns: [author, text, likes, reposts, replies_count]
@@ -0,0 +1,27 @@
1
+ site: bluesky
2
+ name: trending
3
+ description: Trending topics on Bluesky
4
+ domain: public.api.bsky.app
5
+ strategy: public
6
+ browser: false
7
+
8
+ args:
9
+ limit:
10
+ type: int
11
+ default: 20
12
+ description: Number of topics
13
+
14
+ pipeline:
15
+ - fetch:
16
+ url: https://public.api.bsky.app/xrpc/app.bsky.unspecced.getTrendingTopics
17
+
18
+ - select: topics
19
+
20
+ - map:
21
+ rank: ${{ index + 1 }}
22
+ topic: ${{ item.topic }}
23
+ link: ${{ item.link }}
24
+
25
+ - limit: ${{ args.limit }}
26
+
27
+ columns: [rank, topic, link]
@@ -0,0 +1,34 @@
1
+ site: bluesky
2
+ name: user
3
+ description: Get recent posts from a Bluesky user
4
+ domain: public.api.bsky.app
5
+ strategy: public
6
+ browser: false
7
+
8
+ args:
9
+ handle:
10
+ type: str
11
+ required: true
12
+ positional: true
13
+ description: "Bluesky handle (e.g. bsky.app)"
14
+ limit:
15
+ type: int
16
+ default: 20
17
+ description: Number of posts
18
+
19
+ pipeline:
20
+ - fetch:
21
+ url: https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor=${{ args.handle }}&limit=${{ args.limit }}
22
+
23
+ - select: feed
24
+
25
+ - map:
26
+ rank: ${{ index + 1 }}
27
+ text: ${{ item.post.record.text }}
28
+ likes: ${{ item.post.likeCount }}
29
+ reposts: ${{ item.post.repostCount }}
30
+ replies: ${{ item.post.replyCount }}
31
+
32
+ - limit: ${{ args.limit }}
33
+
34
+ columns: [rank, text, likes, reposts, replies]
@@ -1,7 +1,5 @@
1
1
  import { cli, Strategy } from '../../registry.js';
2
2
  import { AuthRequiredError, EmptyResultError } from '../../errors.js';
3
- // ── Twitter GraphQL constants ──────────────────────────────────────────
4
- const BEARER_TOKEN = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
5
3
  // ── CLI definition ────────────────────────────────────────────────────
6
4
  cli({
7
5
  site: 'twitter',
@@ -19,73 +17,43 @@ cli({
19
17
  // Navigate to trending page
20
18
  await page.goto('https://x.com/explore/tabs/trending');
21
19
  await page.wait(3);
22
- // Extract CSRF token to verify login
20
+ // Verify login via CSRF cookie
23
21
  const ct0 = await page.evaluate(`(() => {
24
22
  return document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('ct0='))?.split('=')[1] || null;
25
23
  })()`);
26
24
  if (!ct0)
27
25
  throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
28
- // Try legacy guide.json API first (faster than DOM scraping)
29
- let trends = [];
30
- const apiData = await page.evaluate(`(async () => {
31
- const ct0 = document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('ct0='))?.split('=')[1] || '';
32
- const r = await fetch('/i/api/2/guide.json?include_page_configuration=true', {
33
- credentials: 'include',
34
- headers: {
35
- 'x-twitter-active-user': 'yes',
36
- 'x-csrf-token': ct0,
37
- 'authorization': 'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA'
26
+ // Scrape trends from DOM (consistent with what the user sees on the page)
27
+ // DOM children: [0] rank + category, [1] topic, optional post count,
28
+ // and a caret menu button identified by [data-testid="caret"].
29
+ await page.wait(2);
30
+ const trends = await page.evaluate(`(() => {
31
+ const items = [];
32
+ const cells = document.querySelectorAll('[data-testid="trend"]');
33
+ cells.forEach((cell) => {
34
+ const text = cell.textContent || '';
35
+ if (text.includes('Promoted')) return;
36
+ const container = cell.querySelector(':scope > div');
37
+ if (!container) return;
38
+ const divs = container.children;
39
+ if (divs.length < 2) return;
40
+ const topic = divs[1].textContent.trim();
41
+ if (!topic) return;
42
+ const catText = divs[0].textContent.trim();
43
+ const category = catText.replace(/^\\d+\\s*/, '').replace(/^\\xB7\\s*/, '').trim();
44
+ // Find post count: skip rank, topic, and the caret menu button
45
+ let tweets = 'N/A';
46
+ for (let j = 2; j < divs.length; j++) {
47
+ if (divs[j].matches('[data-testid="caret"]') || divs[j].querySelector('[data-testid="caret"]')) continue;
48
+ const t = divs[j].textContent.trim();
49
+ if (t && /\\d/.test(t)) { tweets = t; break; }
38
50
  }
51
+ items.push({ rank: items.length + 1, topic, tweets, category });
39
52
  });
40
- return r.ok ? await r.json() : null;
53
+ return items;
41
54
  })()`);
42
- if (apiData) {
43
- const instructions = apiData?.timeline?.instructions || [];
44
- const entries = instructions.flatMap((inst) => inst?.addEntries?.entries || inst?.entries || []);
45
- const apiTrends = entries
46
- .filter((e) => e.content?.timelineModule)
47
- .flatMap((e) => e.content.timelineModule.items || [])
48
- .map((t) => t?.item?.content?.trend)
49
- .filter(Boolean);
50
- trends = apiTrends.map((t, i) => ({
51
- rank: i + 1,
52
- topic: t.name,
53
- tweets: t.tweetCount ? String(t.tweetCount) : 'N/A',
54
- category: t.trendMetadata?.domainContext || '',
55
- }));
56
- }
57
- // Fallback: scrape from the loaded DOM
58
- if (trends.length === 0) {
59
- await page.wait(2);
60
- const domTrends = await page.evaluate(`(() => {
61
- const items = [];
62
- const cells = document.querySelectorAll('[data-testid="trend"]');
63
- cells.forEach((cell) => {
64
- const text = cell.textContent || '';
65
- if (text.includes('Promoted')) return;
66
- const container = cell.querySelector(':scope > div');
67
- if (!container) return;
68
- const divs = container.children;
69
- // Structure: divs[0] = rank + category, divs[1] = topic name, divs[2] = extra
70
- const topicEl = divs.length >= 2 ? divs[1] : null;
71
- const topic = topicEl ? topicEl.textContent.trim() : '';
72
- const catEl = divs.length >= 1 ? divs[0] : null;
73
- const catText = catEl ? catEl.textContent.trim() : '';
74
- const category = catText.replace(/^\\d+\\s*/, '').replace(/^\\xB7\\s*/, '').trim();
75
- const extraEl = divs.length >= 3 ? divs[2] : null;
76
- const extra = extraEl ? extraEl.textContent.trim() : '';
77
- if (topic) {
78
- items.push({ rank: items.length + 1, topic, tweets: extra || 'N/A', category });
79
- }
80
- });
81
- return items;
82
- })()`);
83
- if (Array.isArray(domTrends) && domTrends.length > 0) {
84
- trends = domTrends;
85
- }
86
- }
87
- if (trends.length === 0) {
88
- throw new EmptyResultError('twitter trending', 'API may have changed or login may be required.');
55
+ if (!Array.isArray(trends) || trends.length === 0) {
56
+ throw new EmptyResultError('twitter trending', 'No trends found. The page structure may have changed.');
89
57
  }
90
58
  return trends.slice(0, limit);
91
59
  },
@@ -3,7 +3,7 @@ name: hot
3
3
  description: V2EX 热门话题
4
4
  domain: www.v2ex.com
5
5
  strategy: public
6
- browser: false
6
+ browser: true
7
7
 
8
8
  args:
9
9
  limit:
@@ -12,8 +12,22 @@ args:
12
12
  description: Number of topics
13
13
 
14
14
  pipeline:
15
- - fetch:
16
- url: https://www.v2ex.com/api/topics/hot.json
15
+ - navigate: https://www.v2ex.com/
16
+
17
+ - evaluate: |
18
+ (async () => {
19
+ const response = await fetch('/api/topics/hot.json', {
20
+ credentials: 'include',
21
+ headers: {
22
+ accept: 'application/json, text/plain, */*',
23
+ 'x-requested-with': 'XMLHttpRequest',
24
+ },
25
+ });
26
+ if (!response.ok) {
27
+ throw new Error(`V2EX hot API request failed: ${response.status}`);
28
+ }
29
+ return await response.json();
30
+ })()
17
31
 
18
32
  - map:
19
33
  rank: ${{ index + 1 }}
@@ -22,6 +22,21 @@ const PUBLISH_URL = 'https://creator.xiaohongshu.com/publish/publish?from=menu_l
22
22
  const MAX_IMAGES = 9;
23
23
  const MAX_TITLE_LEN = 20;
24
24
  const UPLOAD_SETTLE_MS = 3000;
25
+ /** Selectors for the title field, ordered by priority (new UI first). */
26
+ const TITLE_SELECTORS = [
27
+ // New creator center (2026-03) uses contenteditable for the title field.
28
+ // Placeholder observed: "填写标题会有更多赞哦"
29
+ '[contenteditable="true"][placeholder*="标题"]',
30
+ '[contenteditable="true"][placeholder*="赞"]',
31
+ '[contenteditable="true"][class*="title"]',
32
+ 'input[maxlength="20"]',
33
+ 'input[class*="title"]',
34
+ 'input[placeholder*="标题"]',
35
+ 'input[placeholder*="title" i]',
36
+ '.title-input input',
37
+ '.note-title input',
38
+ 'input[maxlength]',
39
+ ];
25
40
  /**
26
41
  * Read a local image and return the name, MIME type, and base64 content.
27
42
  * Throws if the file does not exist or the extension is unsupported.
@@ -192,10 +207,10 @@ async function selectImageTextTab(page) {
192
207
  }
193
208
  return result;
194
209
  }
195
- async function inspectPublishSurface(page) {
210
+ async function inspectPublishSurfaceState(page) {
196
211
  return page.evaluate(`
197
212
  () => {
198
- const text = (document.body?.innerText || '').replace(/\\s+/g, ' ').trim();
213
+ const text = (document.body?.innerText || '').replace(/\s+/g, ' ').trim();
199
214
  const hasTitleInput = !!Array.from(document.querySelectorAll('input, textarea')).find((el) => {
200
215
  if (!el || el.offsetParent === null) return false;
201
216
  const placeholder = (el.getAttribute('placeholder') || '').trim();
@@ -219,29 +234,51 @@ async function inspectPublishSurface(page) {
219
234
  accept.includes('.webp')
220
235
  );
221
236
  });
222
- return {
223
- hasTitleInput,
224
- hasImageInput,
225
- hasVideoSurface: text.includes('拖拽视频到此处点击上传') || text.includes('上传视频'),
226
- };
237
+ const hasVideoSurface = text.includes('拖拽视频到此处点击上传') || text.includes('上传视频');
238
+ const state = hasTitleInput ? 'editor_ready' : hasImageInput || !hasVideoSurface ? 'image_surface' : 'video_surface';
239
+ return { state, hasTitleInput, hasImageInput, hasVideoSurface };
227
240
  }
228
241
  `);
229
242
  }
230
- async function waitForImageTextSurface(page, maxWaitMs = 5_000) {
243
+ async function waitForPublishSurfaceState(page, maxWaitMs = 5_000) {
231
244
  const pollMs = 500;
232
245
  const maxAttempts = Math.max(1, Math.ceil(maxWaitMs / pollMs));
233
- let surface = await inspectPublishSurface(page);
246
+ let surface = await inspectPublishSurfaceState(page);
234
247
  for (let i = 0; i < maxAttempts; i++) {
235
- if (surface.hasTitleInput || surface.hasImageInput || !surface.hasVideoSurface) {
248
+ if (surface.state !== 'video_surface') {
236
249
  return surface;
237
250
  }
238
251
  if (i < maxAttempts - 1) {
239
252
  await page.wait({ time: pollMs / 1_000 });
240
- surface = await inspectPublishSurface(page);
253
+ surface = await inspectPublishSurfaceState(page);
241
254
  }
242
255
  }
243
256
  return surface;
244
257
  }
258
+ /**
259
+ * Poll until the title/content editing form appears on the page.
260
+ * The new creator center UI only renders the editor after images are uploaded.
261
+ */
262
+ async function waitForEditForm(page, maxWaitMs = 10_000) {
263
+ const pollMs = 1_000;
264
+ const maxAttempts = Math.ceil(maxWaitMs / pollMs);
265
+ for (let i = 0; i < maxAttempts; i++) {
266
+ const found = await page.evaluate(`
267
+ (() => {
268
+ const sels = ${JSON.stringify(TITLE_SELECTORS)};
269
+ for (const sel of sels) {
270
+ const el = document.querySelector(sel);
271
+ if (el && el.offsetParent !== null) return true;
272
+ }
273
+ return false;
274
+ })()`);
275
+ if (found)
276
+ return true;
277
+ if (i < maxAttempts - 1)
278
+ await page.wait({ time: pollMs / 1_000 });
279
+ }
280
+ return false;
281
+ }
245
282
  cli({
246
283
  site: 'xiaohongshu',
247
284
  name: 'publish',
@@ -252,7 +289,7 @@ cli({
252
289
  args: [
253
290
  { name: 'title', required: true, help: '笔记标题 (最多20字)' },
254
291
  { name: 'content', required: true, positional: true, help: '笔记正文' },
255
- { name: 'images', required: false, help: '图片路径,逗号分隔,最多9张 (jpg/png/gif/webp)' },
292
+ { name: 'images', required: true, help: '图片路径,逗号分隔,最多9张 (jpg/png/gif/webp)' },
256
293
  { name: 'topics', required: false, help: '话题标签,逗号分隔,不含 # 号' },
257
294
  { name: 'draft', type: 'bool', default: false, help: '保存为草稿,不直接发布' },
258
295
  ],
@@ -276,6 +313,8 @@ cli({
276
313
  throw new Error(`Title is ${title.length} chars — must be ≤ ${MAX_TITLE_LEN}`);
277
314
  if (!content)
278
315
  throw new Error('Positional argument <content> is required');
316
+ if (imagePaths.length === 0)
317
+ throw new Error('At least one --images path is required. The creator center now requires images before showing the editor.');
279
318
  if (imagePaths.length > MAX_IMAGES)
280
319
  throw new Error(`Too many images: ${imagePaths.length} (max ${MAX_IMAGES})`);
281
320
  // Read images in Node.js context before navigating (fast-fail on bad paths)
@@ -291,8 +330,8 @@ cli({
291
330
  }
292
331
  // ── Step 2: Select 图文 (image+text) note type if tabs are present ─────────
293
332
  const tabResult = await selectImageTextTab(page);
294
- const surface = await waitForImageTextSurface(page, tabResult?.ok ? 5_000 : 2_000);
295
- if (!surface.hasTitleInput && !surface.hasImageInput && surface.hasVideoSurface) {
333
+ const surface = await waitForPublishSurfaceState(page, tabResult?.ok ? 5_000 : 2_000);
334
+ if (surface.state === 'video_surface') {
296
335
  await page.screenshot({ path: '/tmp/xhs_publish_tab_debug.png' });
297
336
  const detail = tabResult?.ok
298
337
  ? `clicked "${tabResult.text}"`
@@ -301,27 +340,24 @@ cli({
301
340
  `Details: ${detail}. Debug screenshot: /tmp/xhs_publish_tab_debug.png`);
302
341
  }
303
342
  // ── Step 3: Upload images ──────────────────────────────────────────────────
304
- if (imageData.length > 0) {
305
- const upload = await injectImages(page, imageData);
306
- if (!upload.ok) {
307
- await page.screenshot({ path: '/tmp/xhs_publish_upload_debug.png' });
308
- throw new Error(`Image injection failed: ${upload.error ?? 'unknown'}. ` +
309
- 'Debug screenshot: /tmp/xhs_publish_upload_debug.png');
310
- }
311
- // Allow XHS to process and upload images to its CDN
312
- await page.wait({ time: UPLOAD_SETTLE_MS / 1_000 });
313
- await waitForUploads(page);
343
+ const upload = await injectImages(page, imageData);
344
+ if (!upload.ok) {
345
+ await page.screenshot({ path: '/tmp/xhs_publish_upload_debug.png' });
346
+ throw new Error(`Image injection failed: ${upload.error ?? 'unknown'}. ` +
347
+ 'Debug screenshot: /tmp/xhs_publish_upload_debug.png');
348
+ }
349
+ // Allow XHS to process and upload images to its CDN
350
+ await page.wait({ time: UPLOAD_SETTLE_MS / 1_000 });
351
+ await waitForUploads(page);
352
+ // ── Step 3b: Wait for editor form to render ───────────────────────────────
353
+ const formReady = await waitForEditForm(page);
354
+ if (!formReady) {
355
+ await page.screenshot({ path: '/tmp/xhs_publish_form_debug.png' });
356
+ throw new Error('Editing form did not appear after image upload. The page layout may have changed. ' +
357
+ 'Debug screenshot: /tmp/xhs_publish_form_debug.png');
314
358
  }
315
359
  // ── Step 4: Fill title ─────────────────────────────────────────────────────
316
- await fillField(page, [
317
- 'input[maxlength="20"]',
318
- 'input[class*="title"]',
319
- 'input[placeholder*="标题"]',
320
- 'input[placeholder*="title" i]',
321
- '.title-input input',
322
- '.note-title input',
323
- 'input[maxlength]',
324
- ], title, 'title');
360
+ await fillField(page, TITLE_SELECTORS, title, 'title');
325
361
  await page.wait({ time: 0.5 });
326
362
  // ── Step 5: Fill content / body ────────────────────────────────────────────
327
363
  await fillField(page, [
@@ -333,7 +369,7 @@ cli({
333
369
  '.note-content [contenteditable="true"]',
334
370
  '.editor-content [contenteditable="true"]',
335
371
  // Broad fallback — last resort; filter out any title contenteditable
336
- '[contenteditable="true"]:not([placeholder*="标题"]):not([placeholder*="title" i])',
372
+ '[contenteditable="true"]:not([placeholder*="标题"]):not([placeholder*="赞"]):not([placeholder*="title" i])',
337
373
  ], content, 'content');
338
374
  await page.wait({ time: 0.5 });
339
375
  // ── Step 6: Add topic hashtags ─────────────────────────────────────────────
@@ -390,14 +426,14 @@ cli({
390
426
  await page.wait({ time: 0.5 });
391
427
  }
392
428
  // ── Step 7: Publish or save draft ─────────────────────────────────────────
393
- const actionLabel = isDraft ? '存草稿' : '发布';
429
+ const actionLabels = isDraft ? ['暂存离开', '存草稿'] : ['发布', '发布笔记'];
394
430
  const btnClicked = await page.evaluate(`
395
- (label => {
431
+ (labels => {
396
432
  const buttons = document.querySelectorAll('button, [role="button"]');
397
433
  for (const btn of buttons) {
398
434
  const text = (btn.innerText || btn.textContent || '').trim();
399
435
  if (
400
- (text === label || text.includes(label) || text === '发布笔记') &&
436
+ labels.some(l => text === l || text.includes(l)) &&
401
437
  btn.offsetParent !== null &&
402
438
  !btn.disabled
403
439
  ) {
@@ -406,11 +442,11 @@ cli({
406
442
  }
407
443
  }
408
444
  return false;
409
- })(${JSON.stringify(actionLabel)})
445
+ })(${JSON.stringify(actionLabels)})
410
446
  `);
411
447
  if (!btnClicked) {
412
448
  await page.screenshot({ path: '/tmp/xhs_publish_submit_debug.png' });
413
- throw new Error(`Could not find "${actionLabel}" button. ` +
449
+ throw new Error(`Could not find "${actionLabels[0]}" button. ` +
414
450
  'Debug screenshot: /tmp/xhs_publish_submit_debug.png');
415
451
  }
416
452
  // ── Step 8: Verify success ─────────────────────────────────────────────────
@@ -422,7 +458,7 @@ cli({
422
458
  const text = (el.innerText || '').trim();
423
459
  if (
424
460
  el.children.length === 0 &&
425
- (text.includes('发布成功') || text.includes('草稿已保存') || text.includes('上传成功'))
461
+ (text.includes('发布成功') || text.includes('草稿已保存') || text.includes('暂存成功') || text.includes('上传成功'))
426
462
  ) return text;
427
463
  }
428
464
  return '';
@@ -430,13 +466,13 @@ cli({
430
466
  `);
431
467
  const navigatedAway = !finalUrl.includes('/publish/publish');
432
468
  const isSuccess = successMsg.length > 0 || navigatedAway;
433
- const verb = isDraft ? '草稿已保存' : '发布成功';
469
+ const verb = isDraft ? '暂存成功' : '发布成功';
434
470
  return [
435
471
  {
436
472
  status: isSuccess ? `✅ ${verb}` : '⚠️ 操作完成,请在浏览器中确认',
437
473
  detail: [
438
474
  `"${title}"`,
439
- imageData.length ? `${imageData.length}张图片` : '无图',
475
+ `${imageData.length}张图片`,
440
476
  topics.length ? `话题: ${topics.join(' ')}` : '',
441
477
  successMsg || finalUrl || '',
442
478
  ]