@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.
- package/dist/browser/cdp.js +5 -0
- package/dist/browser/page.d.ts +3 -0
- package/dist/browser/page.js +24 -1
- package/dist/cli-manifest.json +465 -5
- package/dist/cli.js +34 -3
- package/dist/clis/bluesky/feeds.yaml +29 -0
- package/dist/clis/bluesky/followers.yaml +33 -0
- package/dist/clis/bluesky/following.yaml +33 -0
- package/dist/clis/bluesky/profile.yaml +27 -0
- package/dist/clis/bluesky/search.yaml +34 -0
- package/dist/clis/bluesky/starter-packs.yaml +34 -0
- package/dist/clis/bluesky/thread.yaml +32 -0
- package/dist/clis/bluesky/trending.yaml +27 -0
- package/dist/clis/bluesky/user.yaml +34 -0
- package/dist/clis/twitter/trending.js +29 -61
- package/dist/clis/v2ex/hot.yaml +17 -3
- package/dist/clis/xiaohongshu/publish.js +78 -42
- package/dist/clis/xiaohongshu/publish.test.js +20 -8
- package/dist/clis/xiaohongshu/search.d.ts +8 -1
- package/dist/clis/xiaohongshu/search.js +20 -1
- package/dist/clis/xiaohongshu/search.test.d.ts +1 -1
- package/dist/clis/xiaohongshu/search.test.js +32 -1
- package/dist/discovery.js +40 -28
- package/dist/doctor.d.ts +1 -2
- package/dist/doctor.js +2 -2
- package/dist/engine.test.js +42 -0
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js +2 -2
- package/dist/execution.js +45 -7
- package/dist/execution.test.d.ts +1 -0
- package/dist/execution.test.js +40 -0
- package/dist/external.js +6 -1
- package/dist/main.js +1 -0
- package/dist/plugin-scaffold.d.ts +28 -0
- package/dist/plugin-scaffold.js +142 -0
- package/dist/plugin-scaffold.test.d.ts +4 -0
- package/dist/plugin-scaffold.test.js +83 -0
- package/dist/plugin.d.ts +55 -17
- package/dist/plugin.js +706 -154
- package/dist/plugin.test.js +836 -38
- package/dist/runtime.d.ts +1 -0
- package/dist/runtime.js +1 -1
- package/dist/types.d.ts +2 -0
- package/docs/adapters/browser/bluesky.md +53 -0
- package/docs/guide/plugins.md +10 -0
- package/package.json +1 -1
- package/src/browser/cdp.ts +6 -0
- package/src/browser/page.ts +24 -1
- package/src/cli.ts +34 -3
- package/src/clis/bluesky/feeds.yaml +29 -0
- package/src/clis/bluesky/followers.yaml +33 -0
- package/src/clis/bluesky/following.yaml +33 -0
- package/src/clis/bluesky/profile.yaml +27 -0
- package/src/clis/bluesky/search.yaml +34 -0
- package/src/clis/bluesky/starter-packs.yaml +34 -0
- package/src/clis/bluesky/thread.yaml +32 -0
- package/src/clis/bluesky/trending.yaml +27 -0
- package/src/clis/bluesky/user.yaml +34 -0
- package/src/clis/twitter/trending.ts +29 -77
- package/src/clis/v2ex/hot.yaml +17 -3
- package/src/clis/xiaohongshu/publish.test.ts +22 -8
- package/src/clis/xiaohongshu/publish.ts +93 -52
- package/src/clis/xiaohongshu/search.test.ts +39 -1
- package/src/clis/xiaohongshu/search.ts +19 -1
- package/src/discovery.ts +41 -33
- package/src/doctor.ts +2 -3
- package/src/engine.test.ts +38 -0
- package/src/errors.ts +6 -2
- package/src/execution.test.ts +47 -0
- package/src/execution.ts +39 -6
- package/src/external.ts +6 -1
- package/src/main.ts +1 -0
- package/src/plugin-scaffold.test.ts +98 -0
- package/src/plugin-scaffold.ts +170 -0
- package/src/plugin.test.ts +881 -38
- package/src/plugin.ts +871 -158
- package/src/runtime.ts +2 -2
- package/src/types.ts +2 -0
- 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
|
-
//
|
|
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
|
-
//
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
53
|
+
return items;
|
|
41
54
|
})()`);
|
|
42
|
-
if (
|
|
43
|
-
|
|
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
|
},
|
package/dist/clis/v2ex/hot.yaml
CHANGED
|
@@ -3,7 +3,7 @@ name: hot
|
|
|
3
3
|
description: V2EX 热门话题
|
|
4
4
|
domain: www.v2ex.com
|
|
5
5
|
strategy: public
|
|
6
|
-
browser:
|
|
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
|
-
-
|
|
16
|
-
|
|
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
|
|
210
|
+
async function inspectPublishSurfaceState(page) {
|
|
196
211
|
return page.evaluate(`
|
|
197
212
|
() => {
|
|
198
|
-
const text = (document.body?.innerText || '').replace(
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
|
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
|
|
246
|
+
let surface = await inspectPublishSurfaceState(page);
|
|
234
247
|
for (let i = 0; i < maxAttempts; i++) {
|
|
235
|
-
if (surface.
|
|
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
|
|
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:
|
|
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
|
|
295
|
-
if (
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
|
429
|
+
const actionLabels = isDraft ? ['暂存离开', '存草稿'] : ['发布', '发布笔记'];
|
|
394
430
|
const btnClicked = await page.evaluate(`
|
|
395
|
-
(
|
|
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 ===
|
|
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(
|
|
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 "${
|
|
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
|
-
|
|
475
|
+
`${imageData.length}张图片`,
|
|
440
476
|
topics.length ? `话题: ${topics.join(' ')}` : '',
|
|
441
477
|
successMsg || finalUrl || '',
|
|
442
478
|
]
|