@jackwener/opencli 1.7.16 → 1.7.18
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/README.md +11 -9
- package/README.zh-CN.md +10 -8
- package/cli-manifest.json +377 -271
- package/clis/chatgpt/ask.js +1 -1
- package/clis/chatgpt/commands.test.js +2 -2
- package/clis/chatgpt/detail.js +1 -1
- package/clis/chatgpt/history.js +1 -1
- package/clis/chatgpt/image.js +38 -4
- package/clis/chatgpt/image.test.js +68 -1
- package/clis/chatgpt/new.js +1 -1
- package/clis/chatgpt/read.js +1 -1
- package/clis/chatgpt/send.js +1 -1
- package/clis/chatgpt/status.js +1 -1
- package/clis/chatgpt/utils.js +208 -16
- package/clis/chatgpt/utils.test.js +131 -2
- package/clis/claude/ask.js +1 -1
- package/clis/claude/detail.js +1 -1
- package/clis/claude/history.js +1 -1
- package/clis/claude/new.js +1 -1
- package/clis/claude/read.js +1 -1
- package/clis/claude/send.js +1 -1
- package/clis/claude/status.js +1 -1
- package/clis/deepseek/ask.js +1 -1
- package/clis/deepseek/detail.js +1 -1
- package/clis/deepseek/history.js +1 -1
- package/clis/deepseek/new.js +1 -1
- package/clis/deepseek/read.js +1 -1
- package/clis/deepseek/send.js +1 -1
- package/clis/deepseek/status.js +1 -1
- package/clis/doubao/ask.js +1 -1
- package/clis/doubao/detail.js +1 -1
- package/clis/doubao/history.js +1 -1
- package/clis/doubao/meeting-summary.js +1 -1
- package/clis/doubao/meeting-transcript.js +1 -1
- package/clis/doubao/new.js +1 -1
- package/clis/doubao/read.js +1 -1
- package/clis/doubao/send.js +1 -1
- package/clis/doubao/status.js +1 -1
- package/clis/doubao/utils.js +17 -0
- package/clis/doubao/utils.test.js +61 -0
- package/clis/gemini/ask.js +1 -1
- package/clis/gemini/deep-research-result.js +1 -1
- package/clis/gemini/deep-research.js +1 -1
- package/clis/gemini/image.js +1 -1
- package/clis/gemini/new.js +1 -1
- package/clis/grok/ask.js +1 -1
- package/clis/grok/detail.js +1 -1
- package/clis/grok/history.js +1 -1
- package/clis/grok/image.js +1 -1
- package/clis/grok/new.js +1 -1
- package/clis/grok/read.js +1 -1
- package/clis/grok/send.js +1 -1
- package/clis/grok/status.js +1 -1
- package/clis/notebooklm/current.js +1 -1
- package/clis/notebooklm/get.js +1 -1
- package/clis/notebooklm/history.js +1 -1
- package/clis/notebooklm/note-list.js +1 -1
- package/clis/notebooklm/notes-get.js +1 -1
- package/clis/notebooklm/open.js +2 -2
- package/clis/notebooklm/open.test.js +1 -1
- package/clis/notebooklm/source-fulltext.js +1 -1
- package/clis/notebooklm/source-get.js +1 -1
- package/clis/notebooklm/source-guide.js +1 -1
- package/clis/notebooklm/source-list.js +1 -1
- package/clis/notebooklm/summary.js +1 -1
- package/clis/qwen/ask.js +1 -1
- package/clis/qwen/detail.js +1 -1
- package/clis/qwen/history.js +1 -1
- package/clis/qwen/image.js +1 -1
- package/clis/qwen/new.js +1 -1
- package/clis/qwen/read.js +1 -1
- package/clis/qwen/send.js +1 -1
- package/clis/qwen/status.js +1 -1
- package/clis/reddit/comment.js +1 -1
- package/clis/reddit/frontpage.js +1 -1
- package/clis/reddit/popular.js +1 -1
- package/clis/reddit/read.js +1 -1
- package/clis/reddit/read.test.js +2 -2
- package/clis/reddit/reply.js +182 -0
- package/clis/reddit/reply.test.js +89 -0
- package/clis/reddit/save.js +1 -1
- package/clis/reddit/saved.js +1 -1
- package/clis/reddit/search.js +1 -1
- package/clis/reddit/subreddit.js +1 -1
- package/clis/reddit/subscribe.js +1 -1
- package/clis/reddit/upvote.js +1 -1
- package/clis/reddit/upvoted.js +1 -1
- package/clis/reddit/user-comments.js +1 -1
- package/clis/reddit/user-posts.js +1 -1
- package/clis/reddit/user.js +1 -1
- package/clis/rednote/comments.js +76 -0
- package/clis/rednote/download.js +59 -0
- package/clis/rednote/feed.js +95 -0
- package/clis/rednote/navigation.test.js +26 -0
- package/clis/rednote/note.js +68 -0
- package/clis/rednote/notifications.js +139 -0
- package/clis/rednote/rednote.test.js +157 -0
- package/clis/rednote/search.js +97 -0
- package/clis/rednote/user.js +55 -0
- package/clis/twitter/article.js +1 -1
- package/clis/twitter/bookmark-folder.js +1 -1
- package/clis/twitter/bookmark-folders.js +1 -1
- package/clis/twitter/bookmarks.js +1 -1
- package/clis/twitter/download.js +1 -1
- package/clis/twitter/followers.js +1 -1
- package/clis/twitter/following.js +1 -1
- package/clis/twitter/likes.js +1 -1
- package/clis/twitter/list-tweets.js +1 -1
- package/clis/twitter/lists.js +1 -1
- package/clis/twitter/notifications.js +1 -1
- package/clis/twitter/profile.js +1 -1
- package/clis/twitter/search.js +1 -1
- package/clis/twitter/thread.js +1 -1
- package/clis/twitter/timeline.js +1 -1
- package/clis/twitter/trending.js +1 -1
- package/clis/twitter/tweets.js +1 -1
- package/clis/xiaohongshu/comments.js +34 -24
- package/clis/xiaohongshu/download.js +32 -23
- package/clis/xiaohongshu/feed.js +23 -15
- package/clis/xiaohongshu/note-helpers.js +16 -6
- package/clis/xiaohongshu/note.js +26 -20
- package/clis/xiaohongshu/notifications.js +26 -19
- package/clis/xiaohongshu/search.js +37 -28
- package/clis/xiaohongshu/user-helpers.js +13 -4
- package/clis/xiaohongshu/user-helpers.test.js +20 -0
- package/clis/xiaohongshu/user.js +9 -4
- package/clis/youtube/transcript.js +28 -3
- package/clis/youtube/transcript.test.js +90 -1
- package/clis/yuanbao/ask.js +1 -1
- package/clis/yuanbao/detail.js +1 -1
- package/clis/yuanbao/history.js +1 -1
- package/clis/yuanbao/new.js +1 -1
- package/clis/yuanbao/read.js +1 -1
- package/clis/yuanbao/send.js +1 -1
- package/clis/yuanbao/status.js +1 -1
- package/dist/src/browser/bridge.d.ts +3 -1
- package/dist/src/browser/bridge.js +3 -1
- package/dist/src/browser/cdp.d.ts +3 -1
- package/dist/src/browser/daemon-client.d.ts +7 -14
- package/dist/src/browser/daemon-client.js +2 -6
- package/dist/src/browser/network-cache.d.ts +5 -5
- package/dist/src/browser/network-cache.js +8 -8
- package/dist/src/browser/network-cache.test.js +4 -4
- package/dist/src/browser/page.d.ts +8 -7
- package/dist/src/browser/page.js +23 -16
- package/dist/src/browser/page.test.js +60 -30
- package/dist/src/build-manifest.js +1 -1
- package/dist/src/cli.js +60 -162
- package/dist/src/cli.test.js +184 -198
- package/dist/src/commanderAdapter.js +2 -0
- package/dist/src/discovery.js +1 -1
- package/dist/src/doctor.d.ts +0 -4
- package/dist/src/doctor.js +14 -73
- package/dist/src/doctor.test.js +28 -97
- package/dist/src/execution.d.ts +1 -0
- package/dist/src/execution.js +20 -21
- package/dist/src/execution.test.js +27 -31
- package/dist/src/help.js +7 -1
- package/dist/src/main.js +0 -19
- package/dist/src/manifest-types.d.ts +2 -4
- package/dist/src/observation/artifact.js +1 -1
- package/dist/src/observation/artifact.test.js +3 -3
- package/dist/src/observation/events.d.ts +1 -1
- package/dist/src/observation/manager.js +1 -1
- package/dist/src/observation/manager.test.js +3 -3
- package/dist/src/registry-api.d.ts +1 -1
- package/dist/src/registry.d.ts +3 -12
- package/dist/src/registry.js +6 -10
- package/dist/src/runtime.d.ts +7 -2
- package/dist/src/runtime.js +3 -1
- package/dist/src/serialization.d.ts +1 -1
- package/dist/src/serialization.js +1 -1
- package/dist/src/types.d.ts +0 -15
- package/package.json +1 -1
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rednote search — international mirror of xiaohongshu/search.
|
|
3
|
+
*
|
|
4
|
+
* Reuses the DOM-extraction IIFE from `../xiaohongshu/search.js`; only the
|
|
5
|
+
* web host and the login-gate detection differ. See issue #1136 for the
|
|
6
|
+
* 1:1 comparison between the two frontends.
|
|
7
|
+
*/
|
|
8
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
9
|
+
import { ArgumentError, AuthRequiredError } from '@jackwener/opencli/errors';
|
|
10
|
+
import { buildSearchExtractJs, noteIdToDate } from '../xiaohongshu/search.js';
|
|
11
|
+
|
|
12
|
+
function parseLimit(raw) {
|
|
13
|
+
const parsed = Number(raw);
|
|
14
|
+
if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) {
|
|
15
|
+
throw new ArgumentError(`--limit must be an integer between 1 and 100, got ${JSON.stringify(raw)}`);
|
|
16
|
+
}
|
|
17
|
+
if (parsed < 1 || parsed > 100) {
|
|
18
|
+
throw new ArgumentError(`--limit must be between 1 and 100, got ${parsed}`);
|
|
19
|
+
}
|
|
20
|
+
return parsed;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Wait for search results or login wall using MutationObserver (max 5s).
|
|
25
|
+
*
|
|
26
|
+
* Differs from xiaohongshu by detecting a full-screen login modal instead
|
|
27
|
+
* of (and as a fallback, alongside) the inline `登录后查看搜索结果` text.
|
|
28
|
+
* The modal detector filters hidden / zero-area elements to avoid false
|
|
29
|
+
* positives on background dialogs.
|
|
30
|
+
*/
|
|
31
|
+
const WAIT_FOR_CONTENT_JS = `
|
|
32
|
+
new Promise((resolve) => {
|
|
33
|
+
const hasLoginModal = () => {
|
|
34
|
+
const candidates = document.querySelectorAll(
|
|
35
|
+
'[class*="login-modal"], [class*="LoginModal"], [class*="login-container"], [class*="LoginContainer"], dialog[role="dialog"]'
|
|
36
|
+
);
|
|
37
|
+
for (const el of candidates) {
|
|
38
|
+
if (!(el instanceof HTMLElement)) continue;
|
|
39
|
+
const rect = el.getBoundingClientRect();
|
|
40
|
+
if (rect.width <= 0 || rect.height <= 0) continue;
|
|
41
|
+
const style = getComputedStyle(el);
|
|
42
|
+
if (style.display === 'none' || style.visibility === 'hidden') continue;
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
};
|
|
47
|
+
const detect = () => {
|
|
48
|
+
if (document.querySelector('section.note-item')) return 'content';
|
|
49
|
+
if (/登录后查看搜索结果|请登录/.test(document.body?.innerText || '')) return 'login_wall';
|
|
50
|
+
if (hasLoginModal()) return 'login_wall';
|
|
51
|
+
return null;
|
|
52
|
+
};
|
|
53
|
+
const found = detect();
|
|
54
|
+
if (found) return resolve(found);
|
|
55
|
+
const observer = new MutationObserver(() => {
|
|
56
|
+
const result = detect();
|
|
57
|
+
if (result) { observer.disconnect(); resolve(result); }
|
|
58
|
+
});
|
|
59
|
+
observer.observe(document.body, { childList: true, subtree: true });
|
|
60
|
+
setTimeout(() => { observer.disconnect(); resolve('timeout'); }, 5000);
|
|
61
|
+
})
|
|
62
|
+
`;
|
|
63
|
+
|
|
64
|
+
cli({
|
|
65
|
+
site: 'rednote',
|
|
66
|
+
name: 'search',
|
|
67
|
+
access: 'read',
|
|
68
|
+
description: 'Search rednote notes',
|
|
69
|
+
domain: 'www.rednote.com',
|
|
70
|
+
strategy: Strategy.COOKIE,
|
|
71
|
+
navigateBefore: false,
|
|
72
|
+
args: [
|
|
73
|
+
{ name: 'query', required: true, positional: true, help: 'Search keyword' },
|
|
74
|
+
{ name: 'limit', type: 'int', default: 20, help: 'Number of results' },
|
|
75
|
+
],
|
|
76
|
+
columns: ['rank', 'title', 'author', 'likes', 'published_at', 'url', 'author_url'],
|
|
77
|
+
func: async (page, kwargs) => {
|
|
78
|
+
const limit = parseLimit(kwargs.limit ?? 20);
|
|
79
|
+
const keyword = encodeURIComponent(kwargs.query);
|
|
80
|
+
await page.goto(`https://www.rednote.com/search_result?keyword=${keyword}&source=web_search_result_notes`);
|
|
81
|
+
const waitResult = await page.evaluate(WAIT_FOR_CONTENT_JS);
|
|
82
|
+
if (waitResult === 'login_wall') {
|
|
83
|
+
throw new AuthRequiredError('www.rednote.com', 'Rednote search results are blocked behind a login wall');
|
|
84
|
+
}
|
|
85
|
+
await page.autoScroll({ times: 2 });
|
|
86
|
+
const payload = await page.evaluate(buildSearchExtractJs('www.rednote.com'));
|
|
87
|
+
const data = Array.isArray(payload) ? payload : [];
|
|
88
|
+
return data
|
|
89
|
+
.filter((item) => item.title)
|
|
90
|
+
.slice(0, limit)
|
|
91
|
+
.map((item, i) => ({
|
|
92
|
+
rank: i + 1,
|
|
93
|
+
...item,
|
|
94
|
+
published_at: noteIdToDate(item.url),
|
|
95
|
+
}));
|
|
96
|
+
},
|
|
97
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
import { ArgumentError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
3
|
+
import { USER_SNAPSHOT_JS } from '../xiaohongshu/user.js';
|
|
4
|
+
import { extractXhsUserNotes, normalizeXhsUserId } from '../xiaohongshu/user-helpers.js';
|
|
5
|
+
|
|
6
|
+
const WEB_HOST = 'www.rednote.com';
|
|
7
|
+
|
|
8
|
+
function parseLimit(raw) {
|
|
9
|
+
const parsed = Number(raw ?? 15);
|
|
10
|
+
if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) {
|
|
11
|
+
throw new ArgumentError(`--limit must be a positive integer, got ${JSON.stringify(raw)}`);
|
|
12
|
+
}
|
|
13
|
+
if (parsed < 1) {
|
|
14
|
+
throw new ArgumentError(`--limit must be a positive integer, got ${parsed}`);
|
|
15
|
+
}
|
|
16
|
+
return parsed;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const command = cli({
|
|
20
|
+
site: 'rednote',
|
|
21
|
+
name: 'user',
|
|
22
|
+
access: 'read',
|
|
23
|
+
description: 'Get public notes from a rednote user profile',
|
|
24
|
+
domain: WEB_HOST,
|
|
25
|
+
strategy: Strategy.COOKIE,
|
|
26
|
+
browser: true,
|
|
27
|
+
navigateBefore: false,
|
|
28
|
+
args: [
|
|
29
|
+
{ name: 'id', type: 'str', required: true, positional: true, help: 'User id or profile URL' },
|
|
30
|
+
{ name: 'limit', type: 'int', default: 15, help: 'Number of notes to return' },
|
|
31
|
+
],
|
|
32
|
+
columns: ['id', 'title', 'type', 'likes', 'url'],
|
|
33
|
+
func: async (page, kwargs) => {
|
|
34
|
+
const userId = normalizeXhsUserId(String(kwargs.id));
|
|
35
|
+
const limit = parseLimit(kwargs.limit);
|
|
36
|
+
await page.goto(`https://${WEB_HOST}/user/profile/${userId}`);
|
|
37
|
+
let snapshot = await page.evaluate(USER_SNAPSHOT_JS);
|
|
38
|
+
let results = extractXhsUserNotes(snapshot ?? {}, userId, WEB_HOST);
|
|
39
|
+
let previousCount = results.length;
|
|
40
|
+
for (let i = 0; results.length < limit && i < 4; i += 1) {
|
|
41
|
+
await page.autoScroll({ times: 1, delayMs: 1500 });
|
|
42
|
+
await page.wait({ time: 1 });
|
|
43
|
+
snapshot = await page.evaluate(USER_SNAPSHOT_JS);
|
|
44
|
+
const nextResults = extractXhsUserNotes(snapshot ?? {}, userId, WEB_HOST);
|
|
45
|
+
if (nextResults.length <= previousCount)
|
|
46
|
+
break;
|
|
47
|
+
results = nextResults;
|
|
48
|
+
previousCount = nextResults.length;
|
|
49
|
+
}
|
|
50
|
+
if (results.length === 0) {
|
|
51
|
+
throw new EmptyResultError('rednote/user', 'No public notes found for this rednote user.');
|
|
52
|
+
}
|
|
53
|
+
return results.slice(0, limit);
|
|
54
|
+
},
|
|
55
|
+
});
|
package/clis/twitter/article.js
CHANGED
|
@@ -11,7 +11,7 @@ cli({
|
|
|
11
11
|
domain: 'x.com',
|
|
12
12
|
strategy: Strategy.COOKIE,
|
|
13
13
|
browser: true,
|
|
14
|
-
|
|
14
|
+
siteSession: 'persistent',
|
|
15
15
|
args: [
|
|
16
16
|
{ name: 'tweet-id', type: 'string', positional: true, required: true, help: 'Tweet ID or URL containing the article' },
|
|
17
17
|
],
|
|
@@ -122,7 +122,7 @@ cli({
|
|
|
122
122
|
domain: 'x.com',
|
|
123
123
|
strategy: Strategy.COOKIE,
|
|
124
124
|
browser: true,
|
|
125
|
-
|
|
125
|
+
siteSession: 'persistent',
|
|
126
126
|
args: [
|
|
127
127
|
{ name: 'folder-id', positional: true, type: 'string', required: true, help: 'Folder id from `opencli twitter bookmark-folders`.' },
|
|
128
128
|
{ name: 'limit', type: 'int', default: 20, help: 'Maximum number of bookmarks to return (default 20).' },
|
|
@@ -105,7 +105,7 @@ cli({
|
|
|
105
105
|
domain: 'x.com',
|
|
106
106
|
strategy: Strategy.COOKIE,
|
|
107
107
|
browser: true,
|
|
108
|
-
|
|
108
|
+
siteSession: 'persistent',
|
|
109
109
|
args: [
|
|
110
110
|
{ name: 'limit', type: 'int', default: 20, help: 'Maximum number of bookmarks to return (default 20).' },
|
|
111
111
|
{ name: 'top-by-engagement', type: 'int', default: 0, help: 'When set to N>0, re-rank the bookmarks by weighted engagement (likes×1 + retweets×3 + replies×2 + bookmarks×5 + log10(views+1)×0.5) and return the top N. Default 0 keeps the API\'s native (saved-time) ordering.' },
|
package/clis/twitter/download.js
CHANGED
|
@@ -15,7 +15,7 @@ cli({
|
|
|
15
15
|
description: 'Download Twitter/X media (images and videos). Provide either <username> to scan a profile\'s media tab, or --tweet-url to download a single tweet.',
|
|
16
16
|
domain: 'x.com',
|
|
17
17
|
strategy: Strategy.COOKIE,
|
|
18
|
-
|
|
18
|
+
siteSession: 'persistent',
|
|
19
19
|
args: [
|
|
20
20
|
{ name: 'username', positional: true, help: 'Twitter username (with or without @) to scan their /media tab. Either <username> or --tweet-url is required.' },
|
|
21
21
|
{ name: 'tweet-url', help: 'Single tweet URL to download. Use this OR <username>, not both required at once.' },
|
package/clis/twitter/likes.js
CHANGED
|
@@ -142,7 +142,7 @@ cli({
|
|
|
142
142
|
domain: 'x.com',
|
|
143
143
|
strategy: Strategy.COOKIE,
|
|
144
144
|
browser: true,
|
|
145
|
-
|
|
145
|
+
siteSession: 'persistent',
|
|
146
146
|
args: [
|
|
147
147
|
{ name: 'username', type: 'string', positional: true, help: 'Twitter screen name (with or without @). Defaults to the logged-in user when omitted.' },
|
|
148
148
|
{ name: 'limit', type: 'int', default: 20, help: 'Maximum number of liked tweets to return (default 20).' },
|
|
@@ -112,7 +112,7 @@ cli({
|
|
|
112
112
|
domain: 'x.com',
|
|
113
113
|
strategy: Strategy.COOKIE,
|
|
114
114
|
browser: true,
|
|
115
|
-
|
|
115
|
+
siteSession: 'persistent',
|
|
116
116
|
args: [
|
|
117
117
|
{ name: 'listId', positional: true, type: 'string', required: true, help: 'Numeric ID of a Twitter/X list (e.g. from `opencli twitter lists`)' },
|
|
118
118
|
{ name: 'limit', type: 'int', default: 50 },
|
package/clis/twitter/lists.js
CHANGED
|
@@ -92,7 +92,7 @@ export const command = cli({
|
|
|
92
92
|
domain: 'x.com',
|
|
93
93
|
strategy: Strategy.COOKIE,
|
|
94
94
|
browser: true,
|
|
95
|
-
|
|
95
|
+
siteSession: 'persistent',
|
|
96
96
|
args: [
|
|
97
97
|
{ name: 'limit', type: 'int', default: 50, help: 'Maximum number of lists to return (default 50).' },
|
|
98
98
|
],
|
|
@@ -8,7 +8,7 @@ cli({
|
|
|
8
8
|
domain: 'x.com',
|
|
9
9
|
strategy: Strategy.INTERCEPT,
|
|
10
10
|
browser: true,
|
|
11
|
-
|
|
11
|
+
siteSession: 'persistent',
|
|
12
12
|
args: [
|
|
13
13
|
{ name: 'limit', type: 'int', default: 20, help: 'Maximum number of notifications to return (default 20).' },
|
|
14
14
|
],
|
package/clis/twitter/profile.js
CHANGED
|
@@ -11,7 +11,7 @@ cli({
|
|
|
11
11
|
domain: 'x.com',
|
|
12
12
|
strategy: Strategy.COOKIE,
|
|
13
13
|
browser: true,
|
|
14
|
-
|
|
14
|
+
siteSession: 'persistent',
|
|
15
15
|
args: [
|
|
16
16
|
{ name: 'username', type: 'string', positional: true, help: 'Twitter screen name (with or without @). Defaults to the logged-in user when omitted.' },
|
|
17
17
|
],
|
package/clis/twitter/search.js
CHANGED
|
@@ -228,7 +228,7 @@ cli({
|
|
|
228
228
|
domain: 'x.com',
|
|
229
229
|
strategy: Strategy.INTERCEPT, // Use intercept strategy
|
|
230
230
|
browser: true,
|
|
231
|
-
|
|
231
|
+
siteSession: 'persistent',
|
|
232
232
|
args: [
|
|
233
233
|
{ name: 'query', type: 'string', required: true, positional: true, help: 'Search query. Raw X operators (e.g. "exact phrase", #tag, OR, lang:en, since:YYYY-MM-DD, from:, since:) are passed through unchanged.' },
|
|
234
234
|
{ name: 'filter', type: 'string', default: 'top', choices: ['top', 'live'], help: 'Legacy alias for --product. Kept for backwards compatibility; if --product is set it wins.' },
|
package/clis/twitter/thread.js
CHANGED
|
@@ -100,7 +100,7 @@ cli({
|
|
|
100
100
|
domain: 'x.com',
|
|
101
101
|
strategy: Strategy.COOKIE,
|
|
102
102
|
browser: true,
|
|
103
|
-
|
|
103
|
+
siteSession: 'persistent',
|
|
104
104
|
args: [
|
|
105
105
|
{ name: 'tweet-id', positional: true, type: 'string', required: true, help: 'Tweet numeric ID (e.g. 1234567890) or full status URL' },
|
|
106
106
|
{ name: 'limit', type: 'int', default: 50 },
|
package/clis/twitter/timeline.js
CHANGED
package/clis/twitter/trending.js
CHANGED
package/clis/twitter/tweets.js
CHANGED
|
@@ -149,7 +149,7 @@ cli({
|
|
|
149
149
|
domain: 'x.com',
|
|
150
150
|
strategy: Strategy.COOKIE,
|
|
151
151
|
browser: true,
|
|
152
|
-
|
|
152
|
+
siteSession: 'persistent',
|
|
153
153
|
args: [
|
|
154
154
|
{ name: 'username', type: 'string', positional: true, required: true, help: 'Twitter screen name (with or without @)' },
|
|
155
155
|
{ name: 'limit', type: 'int', default: 20, help: 'Max tweets to return' },
|
|
@@ -8,34 +8,19 @@
|
|
|
8
8
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
9
9
|
import { AuthRequiredError, CliError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
10
10
|
import { parseNoteId, buildNoteUrl } from './note-helpers.js';
|
|
11
|
-
function parseCommentLimit(raw, fallback = 20) {
|
|
11
|
+
export function parseCommentLimit(raw, fallback = 20) {
|
|
12
12
|
const n = Number(raw);
|
|
13
13
|
if (!Number.isFinite(n))
|
|
14
14
|
return fallback;
|
|
15
15
|
return Math.max(1, Math.min(Math.floor(n), 50));
|
|
16
16
|
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
navigateBefore: false,
|
|
25
|
-
args: [
|
|
26
|
-
{ name: 'note-id', required: true, positional: true, help: 'Full Xiaohongshu note URL with xsec_token' },
|
|
27
|
-
{ name: 'limit', type: 'int', default: 20, help: 'Number of top-level comments (max 50)' },
|
|
28
|
-
{ name: 'with-replies', type: 'boolean', default: false, help: 'Include nested replies (楼中楼)' },
|
|
29
|
-
],
|
|
30
|
-
columns: ['rank', 'author', 'text', 'likes', 'time', 'is_reply', 'reply_to'],
|
|
31
|
-
func: async (page, kwargs) => {
|
|
32
|
-
const limit = parseCommentLimit(kwargs.limit);
|
|
33
|
-
const withReplies = Boolean(kwargs['with-replies']);
|
|
34
|
-
const raw = String(kwargs['note-id']);
|
|
35
|
-
const noteId = parseNoteId(raw);
|
|
36
|
-
await page.goto(buildNoteUrl(raw, { commandName: 'xiaohongshu comments' }));
|
|
37
|
-
await page.wait({ time: 2 + Math.random() * 3 });
|
|
38
|
-
const data = await page.evaluate(`
|
|
17
|
+
/**
|
|
18
|
+
* Host-agnostic IIFE that scrolls a note's comment list and extracts
|
|
19
|
+
* top-level comments (and optionally nested 楼中楼 replies). Exported so
|
|
20
|
+
* the rednote adapter can reuse the exact same selector chain.
|
|
21
|
+
*/
|
|
22
|
+
export function buildCommentsExtractJs(withReplies) {
|
|
23
|
+
return `
|
|
39
24
|
(async () => {
|
|
40
25
|
const wait = (ms) => new Promise(r => setTimeout(r, ms))
|
|
41
26
|
const withReplies = ${withReplies}
|
|
@@ -115,7 +100,30 @@ cli({
|
|
|
115
100
|
|
|
116
101
|
return { pageUrl: location.href, securityBlock, loginWall, results }
|
|
117
102
|
})()
|
|
118
|
-
|
|
103
|
+
`;
|
|
104
|
+
}
|
|
105
|
+
export const command = cli({
|
|
106
|
+
site: 'xiaohongshu',
|
|
107
|
+
name: 'comments',
|
|
108
|
+
access: 'read',
|
|
109
|
+
description: '获取小红书笔记评论(支持楼中楼子回复)',
|
|
110
|
+
domain: 'www.xiaohongshu.com',
|
|
111
|
+
strategy: Strategy.COOKIE,
|
|
112
|
+
navigateBefore: false,
|
|
113
|
+
args: [
|
|
114
|
+
{ name: 'note-id', required: true, positional: true, help: 'Full Xiaohongshu note URL with xsec_token' },
|
|
115
|
+
{ name: 'limit', type: 'int', default: 20, help: 'Number of top-level comments (max 50)' },
|
|
116
|
+
{ name: 'with-replies', type: 'boolean', default: false, help: 'Include nested replies (楼中楼)' },
|
|
117
|
+
],
|
|
118
|
+
columns: ['rank', 'author', 'text', 'likes', 'time', 'is_reply', 'reply_to'],
|
|
119
|
+
func: async (page, kwargs) => {
|
|
120
|
+
const limit = parseCommentLimit(kwargs.limit);
|
|
121
|
+
const withReplies = Boolean(kwargs['with-replies']);
|
|
122
|
+
const raw = String(kwargs['note-id']);
|
|
123
|
+
const noteId = parseNoteId(raw);
|
|
124
|
+
await page.goto(buildNoteUrl(raw, { commandName: 'xiaohongshu comments' }));
|
|
125
|
+
await page.wait({ time: 2 + Math.random() * 3 });
|
|
126
|
+
const data = await page.evaluate(buildCommentsExtractJs(withReplies));
|
|
119
127
|
if (!data || typeof data !== 'object') {
|
|
120
128
|
throw new EmptyResultError('xiaohongshu/comments', 'Unexpected evaluate response');
|
|
121
129
|
}
|
|
@@ -127,6 +135,8 @@ cli({
|
|
|
127
135
|
if (data.loginWall) {
|
|
128
136
|
throw new AuthRequiredError('www.xiaohongshu.com', 'Note comments require login');
|
|
129
137
|
}
|
|
138
|
+
// noteId currently unused after parsing — kept for symmetry with the note command
|
|
139
|
+
void noteId;
|
|
130
140
|
const all = data.results ?? [];
|
|
131
141
|
// When limiting, count only top-level comments; their replies are included for free
|
|
132
142
|
if (withReplies) {
|
|
@@ -11,27 +11,15 @@ import { formatCookieHeader } from '@jackwener/opencli/download';
|
|
|
11
11
|
import { downloadMedia } from '@jackwener/opencli/download/media-download';
|
|
12
12
|
import { CliError } from '@jackwener/opencli/errors';
|
|
13
13
|
import { buildNoteUrl, parseNoteId } from './note-helpers.js';
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
{ name: 'note-id', positional: true, required: true, help: 'Full Xiaohongshu note URL with xsec_token, or xhslink short link' },
|
|
24
|
-
{ name: 'output', default: './xiaohongshu-downloads', help: 'Output directory' },
|
|
25
|
-
],
|
|
26
|
-
columns: ['index', 'type', 'status', 'size'],
|
|
27
|
-
func: async (page, kwargs) => {
|
|
28
|
-
const rawInput = String(kwargs['note-id']);
|
|
29
|
-
const output = kwargs.output;
|
|
30
|
-
const noteId = parseNoteId(rawInput);
|
|
31
|
-
await page.goto(buildNoteUrl(rawInput, { allowShortLink: true, commandName: 'xiaohongshu download' }));
|
|
32
|
-
await page.wait({ time: 1 + Math.random() * 2 });
|
|
33
|
-
// Extract note info and media URLs
|
|
34
|
-
const data = await page.evaluate(`
|
|
14
|
+
/**
|
|
15
|
+
* Build the media-extraction IIFE. The note id is interpolated as a default
|
|
16
|
+
* since the IIFE may also resolve it from `location.pathname`. The CDN
|
|
17
|
+
* substring allowlist includes `rednote` so the rednote adapter can reuse
|
|
18
|
+
* this script unchanged — image / video URLs on both sites are served from
|
|
19
|
+
* the same xhscdn family per #1136.
|
|
20
|
+
*/
|
|
21
|
+
export function buildDownloadExtractJs(noteId) {
|
|
22
|
+
return `
|
|
35
23
|
(() => {
|
|
36
24
|
const bodyText = document.body?.innerText || '';
|
|
37
25
|
const result = {
|
|
@@ -79,7 +67,7 @@ cli({
|
|
|
79
67
|
for (const selector of imageSelectors) {
|
|
80
68
|
document.querySelectorAll(selector).forEach(img => {
|
|
81
69
|
let src = img.src || img.getAttribute('data-src') || '';
|
|
82
|
-
if (src && (src.includes('xhscdn') || src.includes('xiaohongshu'))) {
|
|
70
|
+
if (src && (src.includes('xhscdn') || src.includes('xiaohongshu') || src.includes('rednote'))) {
|
|
83
71
|
src = src.split('?')[0];
|
|
84
72
|
src = src.replace(/\\/imageView\\d+\\/\\d+\\/w\\/\\d+/, '');
|
|
85
73
|
imageUrls.add(src);
|
|
@@ -154,7 +142,28 @@ cli({
|
|
|
154
142
|
|
|
155
143
|
return result;
|
|
156
144
|
})()
|
|
157
|
-
|
|
145
|
+
`;
|
|
146
|
+
}
|
|
147
|
+
export const command = cli({
|
|
148
|
+
site: 'xiaohongshu',
|
|
149
|
+
name: 'download',
|
|
150
|
+
access: 'read',
|
|
151
|
+
description: '下载小红书笔记中的图片和视频',
|
|
152
|
+
domain: 'www.xiaohongshu.com',
|
|
153
|
+
strategy: Strategy.COOKIE,
|
|
154
|
+
navigateBefore: false,
|
|
155
|
+
args: [
|
|
156
|
+
{ name: 'note-id', positional: true, required: true, help: 'Full Xiaohongshu note URL with xsec_token, or xhslink short link' },
|
|
157
|
+
{ name: 'output', default: './xiaohongshu-downloads', help: 'Output directory' },
|
|
158
|
+
],
|
|
159
|
+
columns: ['index', 'type', 'status', 'size'],
|
|
160
|
+
func: async (page, kwargs) => {
|
|
161
|
+
const rawInput = String(kwargs['note-id']);
|
|
162
|
+
const output = kwargs.output;
|
|
163
|
+
const noteId = parseNoteId(rawInput);
|
|
164
|
+
await page.goto(buildNoteUrl(rawInput, { allowShortLink: true, commandName: 'xiaohongshu download' }));
|
|
165
|
+
await page.wait({ time: 1 + Math.random() * 2 });
|
|
166
|
+
const data = await page.evaluate(buildDownloadExtractJs(noteId));
|
|
158
167
|
if (data?.securityBlock) {
|
|
159
168
|
throw new CliError('SECURITY_BLOCK', 'Xiaohongshu security block: the note detail page was blocked by risk control.', /^https?:\/\//.test(rawInput)
|
|
160
169
|
? 'The page may be temporarily restricted. Try again later or from a different session.'
|
package/clis/xiaohongshu/feed.js
CHANGED
|
@@ -1,18 +1,12 @@
|
|
|
1
1
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
args: [
|
|
11
|
-
{ name: 'limit', type: 'int', default: 20, help: 'Number of items to return' },
|
|
12
|
-
],
|
|
13
|
-
columns: ['id', 'title', 'author', 'likes', 'type', 'url'],
|
|
14
|
-
pipeline: [
|
|
15
|
-
{ navigate: 'https://www.xiaohongshu.com/explore' },
|
|
2
|
+
/**
|
|
3
|
+
* Build the home-feed pipeline for the given web host. Exported so the
|
|
4
|
+
* rednote adapter can register the same pipeline against www.rednote.com
|
|
5
|
+
* without duplicating the tap/map/limit steps.
|
|
6
|
+
*/
|
|
7
|
+
export function buildFeedPipeline(webHost) {
|
|
8
|
+
return [
|
|
9
|
+
{ navigate: `https://${webHost}/explore` },
|
|
16
10
|
{ tap: {
|
|
17
11
|
store: 'feed',
|
|
18
12
|
action: 'fetchFeeds',
|
|
@@ -26,8 +20,22 @@ cli({
|
|
|
26
20
|
type: '${{ item.note_card.type }}',
|
|
27
21
|
author: '${{ item.note_card.user.nickname }}',
|
|
28
22
|
likes: '${{ item.note_card.interact_info.liked_count }}',
|
|
29
|
-
url:
|
|
23
|
+
url: `https://${webHost}/explore/\${{ item.id }}`,
|
|
30
24
|
} },
|
|
31
25
|
{ limit: '${{ args.limit | default(20) }}' },
|
|
26
|
+
];
|
|
27
|
+
}
|
|
28
|
+
export const command = cli({
|
|
29
|
+
site: 'xiaohongshu',
|
|
30
|
+
name: 'feed',
|
|
31
|
+
access: 'read',
|
|
32
|
+
description: '小红书首页推荐 Feed (via Pinia Store Action)',
|
|
33
|
+
domain: 'www.xiaohongshu.com',
|
|
34
|
+
strategy: Strategy.INTERCEPT,
|
|
35
|
+
browser: true,
|
|
36
|
+
args: [
|
|
37
|
+
{ name: 'limit', type: 'int', default: 20, help: 'Number of items to return' },
|
|
32
38
|
],
|
|
39
|
+
columns: ['id', 'title', 'author', 'likes', 'type', 'url'],
|
|
40
|
+
pipeline: buildFeedPipeline('www.xiaohongshu.com'),
|
|
33
41
|
});
|
|
@@ -14,9 +14,9 @@ function isShortLink(input) {
|
|
|
14
14
|
return /^https?:\/\/xhslink\.com\//i.test(input);
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
function
|
|
17
|
+
function isHostMatch(hostname, cookieRoot) {
|
|
18
18
|
const normalized = hostname.toLowerCase();
|
|
19
|
-
return normalized ===
|
|
19
|
+
return normalized === cookieRoot || normalized.endsWith('.' + cookieRoot);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
function isSupportedNotePath(pathname) {
|
|
@@ -30,14 +30,24 @@ function isSupportedNotePath(pathname) {
|
|
|
30
30
|
* XHS note detail pages now require a valid signed URL for reliable access.
|
|
31
31
|
* Bare note IDs no longer resolve deterministically, so callers must provide
|
|
32
32
|
* a full note URL with xsec_token or, for downloads only, an xhslink short link.
|
|
33
|
+
*
|
|
34
|
+
* `options.cookieRoot` overrides the default `xiaohongshu.com` cookie root —
|
|
35
|
+
* the rednote adapter passes `'rednote.com'` so the same validator accepts
|
|
36
|
+
* `www.rednote.com` URLs without duplicating this function.
|
|
37
|
+
* `options.signedUrlHint` overrides the default hint surfaced on rejection.
|
|
33
38
|
*/
|
|
34
39
|
export function buildNoteUrl(input, options = {}) {
|
|
35
|
-
const {
|
|
40
|
+
const {
|
|
41
|
+
allowShortLink = false,
|
|
42
|
+
commandName = 'xiaohongshu note',
|
|
43
|
+
cookieRoot = 'xiaohongshu.com',
|
|
44
|
+
signedUrlHint = XHS_SIGNED_URL_HINT,
|
|
45
|
+
} = options;
|
|
36
46
|
const trimmed = input.trim();
|
|
37
47
|
const message = `${commandName} now requires a full signed URL`;
|
|
38
48
|
const hint = allowShortLink
|
|
39
|
-
? `${
|
|
40
|
-
:
|
|
49
|
+
? `${signedUrlHint} For downloads, xhslink short links are also supported.`
|
|
50
|
+
: signedUrlHint;
|
|
41
51
|
|
|
42
52
|
if (/^https?:\/\//.test(trimmed)) {
|
|
43
53
|
if (isShortLink(trimmed)) {
|
|
@@ -48,7 +58,7 @@ export function buildNoteUrl(input, options = {}) {
|
|
|
48
58
|
try {
|
|
49
59
|
const url = new URL(trimmed);
|
|
50
60
|
const xsecToken = url.searchParams.get('xsec_token')?.trim();
|
|
51
|
-
if (
|
|
61
|
+
if (isHostMatch(url.hostname, cookieRoot) && isSupportedNotePath(url.pathname) && xsecToken) {
|
|
52
62
|
return trimmed;
|
|
53
63
|
}
|
|
54
64
|
}
|
package/clis/xiaohongshu/note.js
CHANGED
|
@@ -9,25 +9,12 @@
|
|
|
9
9
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
10
10
|
import { AuthRequiredError, CliError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
11
11
|
import { parseNoteId, buildNoteUrl } from './note-helpers.js';
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
strategy: Strategy.COOKIE,
|
|
19
|
-
navigateBefore: false,
|
|
20
|
-
args: [
|
|
21
|
-
{ name: 'note-id', required: true, positional: true, help: 'Full Xiaohongshu note URL with xsec_token' },
|
|
22
|
-
],
|
|
23
|
-
columns: ['field', 'value'],
|
|
24
|
-
func: async (page, kwargs) => {
|
|
25
|
-
const raw = String(kwargs['note-id']);
|
|
26
|
-
const noteId = parseNoteId(raw);
|
|
27
|
-
const url = buildNoteUrl(raw, { commandName: 'xiaohongshu note' });
|
|
28
|
-
await page.goto(url);
|
|
29
|
-
await page.wait({ time: 2 + Math.random() * 3 });
|
|
30
|
-
const data = await page.evaluate(`
|
|
12
|
+
/**
|
|
13
|
+
* Host-agnostic IIFE that scrapes note title / author / counts / tags from a
|
|
14
|
+
* rendered note detail page. Exported so the rednote adapter can reuse the
|
|
15
|
+
* exact same selector set without copying it.
|
|
16
|
+
*/
|
|
17
|
+
export const NOTE_EXTRACT_JS = `
|
|
31
18
|
(() => {
|
|
32
19
|
const bodyText = document.body?.innerText || ''
|
|
33
20
|
const loginWall = /登录后查看|请登录/.test(bodyText)
|
|
@@ -58,7 +45,26 @@ cli({
|
|
|
58
45
|
|
|
59
46
|
return { pageUrl: location.href, securityBlock, loginWall, notFound, title, desc, author, likes, collects, comments, tags }
|
|
60
47
|
})()
|
|
61
|
-
|
|
48
|
+
`;
|
|
49
|
+
export const command = cli({
|
|
50
|
+
site: 'xiaohongshu',
|
|
51
|
+
name: 'note',
|
|
52
|
+
access: 'read',
|
|
53
|
+
description: '获取小红书笔记正文和互动数据',
|
|
54
|
+
domain: 'www.xiaohongshu.com',
|
|
55
|
+
strategy: Strategy.COOKIE,
|
|
56
|
+
navigateBefore: false,
|
|
57
|
+
args: [
|
|
58
|
+
{ name: 'note-id', required: true, positional: true, help: 'Full Xiaohongshu note URL with xsec_token' },
|
|
59
|
+
],
|
|
60
|
+
columns: ['field', 'value'],
|
|
61
|
+
func: async (page, kwargs) => {
|
|
62
|
+
const raw = String(kwargs['note-id']);
|
|
63
|
+
const noteId = parseNoteId(raw);
|
|
64
|
+
const url = buildNoteUrl(raw, { commandName: 'xiaohongshu note' });
|
|
65
|
+
await page.goto(url);
|
|
66
|
+
await page.wait({ time: 2 + Math.random() * 3 });
|
|
67
|
+
const data = await page.evaluate(NOTE_EXTRACT_JS);
|
|
62
68
|
if (!data || typeof data !== 'object') {
|
|
63
69
|
throw new EmptyResultError('xiaohongshu/note', 'Unexpected evaluate response');
|
|
64
70
|
}
|