@jackwener/opencli 1.3.0 → 1.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +128 -0
- package/README.md +44 -5
- package/README.zh-CN.md +44 -5
- package/SKILL.md +317 -5
- package/TESTING.md +4 -4
- package/dist/browser/errors.d.ts +2 -1
- package/dist/browser/errors.js +9 -10
- package/dist/build-manifest.js +1 -3
- package/dist/cli-manifest.json +2573 -989
- package/dist/cli.js +42 -2
- package/dist/clis/bilibili/download.js +20 -65
- package/dist/clis/bilibili/utils.js +2 -1
- package/dist/clis/chaoxing/assignments.js +2 -1
- package/dist/clis/doubao/ask.d.ts +1 -0
- package/dist/clis/doubao/ask.js +35 -0
- package/dist/clis/doubao/common.d.ts +23 -0
- package/dist/clis/doubao/common.js +564 -0
- package/dist/clis/doubao/new.d.ts +1 -0
- package/dist/clis/doubao/new.js +20 -0
- package/dist/clis/doubao/read.d.ts +1 -0
- package/dist/clis/doubao/read.js +19 -0
- package/dist/clis/doubao/send.d.ts +1 -0
- package/dist/clis/doubao/send.js +22 -0
- package/dist/clis/doubao/status.d.ts +1 -0
- package/dist/clis/doubao/status.js +24 -0
- package/dist/clis/doubao-app/ask.d.ts +1 -0
- package/dist/clis/doubao-app/ask.js +53 -0
- package/dist/clis/doubao-app/common.d.ts +37 -0
- package/dist/clis/doubao-app/common.js +110 -0
- package/dist/clis/doubao-app/dump.d.ts +1 -0
- package/dist/clis/doubao-app/dump.js +24 -0
- package/dist/clis/doubao-app/new.d.ts +1 -0
- package/dist/clis/doubao-app/new.js +20 -0
- package/dist/clis/doubao-app/read.d.ts +1 -0
- package/dist/clis/doubao-app/read.js +18 -0
- package/dist/clis/doubao-app/screenshot.d.ts +1 -0
- package/dist/clis/doubao-app/screenshot.js +18 -0
- package/dist/clis/doubao-app/send.d.ts +1 -0
- package/dist/clis/doubao-app/send.js +27 -0
- package/dist/clis/doubao-app/status.d.ts +1 -0
- package/dist/clis/doubao-app/status.js +16 -0
- package/dist/clis/hackernews/ask.yaml +38 -0
- package/dist/clis/hackernews/best.yaml +38 -0
- package/dist/clis/hackernews/jobs.yaml +36 -0
- package/dist/clis/hackernews/new.yaml +38 -0
- package/dist/clis/hackernews/search.yaml +44 -0
- package/dist/clis/hackernews/show.yaml +38 -0
- package/dist/clis/hackernews/top.yaml +3 -1
- package/dist/clis/hackernews/user.yaml +25 -0
- package/dist/clis/twitter/download.js +13 -97
- package/dist/clis/twitter/thread.js +2 -1
- package/dist/clis/v2ex/member.yaml +29 -0
- package/dist/clis/v2ex/node.yaml +34 -0
- package/dist/clis/v2ex/nodes.yaml +31 -0
- package/dist/clis/v2ex/replies.yaml +32 -0
- package/dist/clis/v2ex/user.yaml +34 -0
- package/dist/clis/weibo/search.d.ts +1 -0
- package/dist/clis/weibo/search.js +73 -0
- package/dist/clis/weixin/download.d.ts +12 -0
- package/dist/clis/weixin/download.js +183 -0
- package/dist/clis/xiaohongshu/download.js +12 -60
- package/dist/clis/xiaohongshu/publish.d.ts +18 -0
- package/dist/clis/xiaohongshu/publish.js +352 -0
- package/dist/clis/xiaohongshu/search.js +47 -15
- package/dist/clis/xiaohongshu/search.test.d.ts +1 -0
- package/dist/clis/xiaohongshu/search.test.js +114 -0
- package/dist/clis/yollomi/background.d.ts +4 -0
- package/dist/clis/yollomi/background.js +45 -0
- package/dist/clis/yollomi/edit.d.ts +5 -0
- package/dist/clis/yollomi/edit.js +56 -0
- package/dist/clis/yollomi/face-swap.d.ts +5 -0
- package/dist/clis/yollomi/face-swap.js +43 -0
- package/dist/clis/yollomi/generate.d.ts +9 -0
- package/dist/clis/yollomi/generate.js +100 -0
- package/dist/clis/yollomi/models.d.ts +1 -0
- package/dist/clis/yollomi/models.js +33 -0
- package/dist/clis/yollomi/object-remover.d.ts +4 -0
- package/dist/clis/yollomi/object-remover.js +42 -0
- package/dist/clis/yollomi/remove-bg.d.ts +4 -0
- package/dist/clis/yollomi/remove-bg.js +38 -0
- package/dist/clis/yollomi/restore.d.ts +4 -0
- package/dist/clis/yollomi/restore.js +38 -0
- package/dist/clis/yollomi/try-on.d.ts +4 -0
- package/dist/clis/yollomi/try-on.js +46 -0
- package/dist/clis/yollomi/upload.d.ts +7 -0
- package/dist/clis/yollomi/upload.js +71 -0
- package/dist/clis/yollomi/upscale.d.ts +4 -0
- package/dist/clis/yollomi/upscale.js +53 -0
- package/dist/clis/yollomi/utils.d.ts +45 -0
- package/dist/clis/yollomi/utils.js +180 -0
- package/dist/clis/yollomi/video.d.ts +5 -0
- package/dist/clis/yollomi/video.js +56 -0
- package/dist/clis/zhihu/download.d.ts +1 -5
- package/dist/clis/zhihu/download.js +20 -126
- package/dist/clis/zhihu/download.test.js +7 -5
- package/dist/clis/zhihu/question.js +2 -1
- package/dist/commanderAdapter.js +4 -6
- package/dist/daemon.js +5 -2
- package/dist/discovery.js +10 -10
- package/dist/download/article-download.d.ts +59 -0
- package/dist/download/article-download.js +178 -0
- package/dist/download/media-download.d.ts +49 -0
- package/dist/download/media-download.js +112 -0
- package/dist/errors.d.ts +23 -2
- package/dist/errors.js +58 -2
- package/dist/errors.test.d.ts +1 -0
- package/dist/errors.test.js +59 -0
- package/dist/execution.js +9 -10
- package/dist/explore.js +4 -2
- package/dist/external.d.ts +15 -0
- package/dist/external.js +48 -2
- package/dist/external.test.d.ts +1 -0
- package/dist/external.test.js +64 -0
- package/dist/main.js +10 -0
- package/dist/plugin.d.ts +4 -0
- package/dist/plugin.js +45 -23
- package/dist/plugin.test.js +6 -1
- package/dist/record.d.ts +47 -0
- package/dist/record.js +545 -0
- package/dist/registry.d.ts +7 -2
- package/dist/registry.js +2 -6
- package/dist/runtime.d.ts +3 -1
- package/dist/runtime.js +10 -3
- package/dist/validate.js +1 -3
- package/docs/.vitepress/config.mts +1 -0
- package/docs/adapters/browser/doubao.md +35 -0
- package/docs/adapters/browser/hackernews.md +20 -4
- package/docs/adapters/browser/tiktok.md +1 -1
- package/docs/adapters/browser/v2ex.md +31 -10
- package/docs/adapters/browser/weibo.md +4 -0
- package/docs/adapters/browser/weixin.md +33 -0
- package/docs/adapters/browser/xiaohongshu.md +8 -6
- package/docs/adapters/browser/yollomi.md +69 -0
- package/docs/adapters/desktop/doubao-app.md +35 -0
- package/docs/adapters/index.md +16 -5
- package/docs/advanced/download.md +4 -0
- package/package.json +3 -1
- package/src/browser/errors.ts +17 -11
- package/src/build-manifest.ts +2 -3
- package/src/cli.ts +45 -2
- package/src/clis/bilibili/download.ts +25 -83
- package/src/clis/bilibili/utils.ts +2 -1
- package/src/clis/chaoxing/assignments.ts +2 -1
- package/src/clis/doubao/ask.ts +40 -0
- package/src/clis/doubao/common.ts +619 -0
- package/src/clis/doubao/new.ts +22 -0
- package/src/clis/doubao/read.ts +20 -0
- package/src/clis/doubao/send.ts +25 -0
- package/src/clis/doubao/status.ts +27 -0
- package/src/clis/doubao-app/ask.ts +60 -0
- package/src/clis/doubao-app/common.ts +116 -0
- package/src/clis/doubao-app/dump.ts +28 -0
- package/src/clis/doubao-app/new.ts +21 -0
- package/src/clis/doubao-app/read.ts +21 -0
- package/src/clis/doubao-app/screenshot.ts +19 -0
- package/src/clis/doubao-app/send.ts +30 -0
- package/src/clis/doubao-app/status.ts +17 -0
- package/src/clis/hackernews/ask.yaml +38 -0
- package/src/clis/hackernews/best.yaml +38 -0
- package/src/clis/hackernews/jobs.yaml +36 -0
- package/src/clis/hackernews/new.yaml +38 -0
- package/src/clis/hackernews/search.yaml +44 -0
- package/src/clis/hackernews/show.yaml +38 -0
- package/src/clis/hackernews/top.yaml +3 -1
- package/src/clis/hackernews/user.yaml +25 -0
- package/src/clis/twitter/download.ts +13 -111
- package/src/clis/twitter/thread.ts +2 -1
- package/src/clis/v2ex/member.yaml +29 -0
- package/src/clis/v2ex/node.yaml +34 -0
- package/src/clis/v2ex/nodes.yaml +31 -0
- package/src/clis/v2ex/replies.yaml +32 -0
- package/src/clis/v2ex/user.yaml +34 -0
- package/src/clis/weibo/search.ts +78 -0
- package/src/clis/weixin/download.ts +199 -0
- package/src/clis/xiaohongshu/download.ts +12 -71
- package/src/clis/xiaohongshu/publish.ts +392 -0
- package/src/clis/xiaohongshu/search.test.ts +134 -0
- package/src/clis/xiaohongshu/search.ts +49 -15
- package/src/clis/yollomi/background.ts +48 -0
- package/src/clis/yollomi/edit.ts +58 -0
- package/src/clis/yollomi/face-swap.ts +45 -0
- package/src/clis/yollomi/generate.ts +95 -0
- package/src/clis/yollomi/models.ts +38 -0
- package/src/clis/yollomi/object-remover.ts +44 -0
- package/src/clis/yollomi/remove-bg.ts +40 -0
- package/src/clis/yollomi/restore.ts +40 -0
- package/src/clis/yollomi/try-on.ts +48 -0
- package/src/clis/yollomi/upload.ts +78 -0
- package/src/clis/yollomi/upscale.ts +49 -0
- package/src/clis/yollomi/utils.ts +202 -0
- package/src/clis/yollomi/video.ts +61 -0
- package/src/clis/zhihu/download.test.ts +7 -5
- package/src/clis/zhihu/download.ts +23 -158
- package/src/clis/zhihu/question.ts +2 -1
- package/src/commanderAdapter.ts +4 -7
- package/src/daemon.ts +5 -2
- package/src/discovery.ts +26 -26
- package/src/download/article-download.ts +272 -0
- package/src/download/media-download.ts +178 -0
- package/src/errors.test.ts +79 -0
- package/src/errors.ts +92 -2
- package/src/execution.ts +14 -10
- package/src/explore.ts +4 -2
- package/src/external.test.ts +88 -0
- package/src/external.ts +56 -2
- package/src/generate.ts +2 -1
- package/src/main.ts +10 -0
- package/src/plugin.test.ts +7 -1
- package/src/plugin.ts +49 -25
- package/src/record.ts +617 -0
- package/src/registry.ts +9 -5
- package/src/runtime.ts +16 -4
- package/src/validate.ts +2 -3
- package/tests/e2e/browser-auth.test.ts +10 -1
- package/tests/e2e/browser-public.test.ts +13 -8
- package/tests/e2e/public-commands.test.ts +209 -21
- package/tests/smoke/api-health.test.ts +65 -6
- package/.github/workflows/release-please.yml +0 -25
|
@@ -4,11 +4,9 @@
|
|
|
4
4
|
* Usage:
|
|
5
5
|
* opencli xiaohongshu download --note_id abc123 --output ./xhs
|
|
6
6
|
*/
|
|
7
|
-
import * as fs from 'node:fs';
|
|
8
|
-
import * as path from 'node:path';
|
|
9
7
|
import { cli, Strategy } from '../../registry.js';
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
8
|
+
import { formatCookieHeader } from '../../download/index.js';
|
|
9
|
+
import { downloadMedia } from '../../download/media-download.js';
|
|
12
10
|
cli({
|
|
13
11
|
site: 'xiaohongshu',
|
|
14
12
|
name: 'download',
|
|
@@ -50,7 +48,7 @@ cli({
|
|
|
50
48
|
'.note-slider img',
|
|
51
49
|
'.note-image img',
|
|
52
50
|
'.image-wrapper img',
|
|
53
|
-
'#noteContainer img[src*="xhscdn"]',
|
|
51
|
+
'#noteContainer .media-container img[src*="xhscdn"]',
|
|
54
52
|
'img[src*="ci.xiaohongshu.com"]'
|
|
55
53
|
];
|
|
56
54
|
|
|
@@ -61,7 +59,6 @@ cli({
|
|
|
61
59
|
if (src && (src.includes('xhscdn') || src.includes('xiaohongshu'))) {
|
|
62
60
|
// Convert to high quality URL (remove resize parameters)
|
|
63
61
|
src = src.split('?')[0];
|
|
64
|
-
// Try to get original size
|
|
65
62
|
src = src.replace(/\\/imageView\\d+\\/\\d+\\/w\\/\\d+/, '');
|
|
66
63
|
imageUrls.add(src);
|
|
67
64
|
}
|
|
@@ -80,20 +77,14 @@ cli({
|
|
|
80
77
|
document.querySelectorAll(selector).forEach(v => {
|
|
81
78
|
const src = v.src || v.getAttribute('src') || '';
|
|
82
79
|
if (src) {
|
|
83
|
-
result.media.push({
|
|
84
|
-
type: 'video',
|
|
85
|
-
url: src
|
|
86
|
-
});
|
|
80
|
+
result.media.push({ type: 'video', url: src });
|
|
87
81
|
}
|
|
88
82
|
});
|
|
89
83
|
}
|
|
90
84
|
|
|
91
85
|
// Add images to media
|
|
92
86
|
imageUrls.forEach(url => {
|
|
93
|
-
result.media.push({
|
|
94
|
-
type: 'image',
|
|
95
|
-
url: url
|
|
96
|
-
});
|
|
87
|
+
result.media.push({ type: 'image', url: url });
|
|
97
88
|
});
|
|
98
89
|
|
|
99
90
|
return result;
|
|
@@ -104,51 +95,12 @@ cli({
|
|
|
104
95
|
}
|
|
105
96
|
// Extract cookies for authenticated downloads
|
|
106
97
|
const cookies = formatCookieHeader(await page.getCookies({ domain: 'xiaohongshu.com' }));
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const media = data.media[i];
|
|
115
|
-
const ext = media.type === 'video' ? 'mp4' : 'jpg';
|
|
116
|
-
const filename = `${noteId}_${i + 1}.${ext}`;
|
|
117
|
-
const destPath = path.join(outputDir, filename);
|
|
118
|
-
const progressBar = tracker.onFileStart(filename, i);
|
|
119
|
-
try {
|
|
120
|
-
const result = await httpDownload(media.url, destPath, {
|
|
121
|
-
cookies,
|
|
122
|
-
timeout: 60000,
|
|
123
|
-
onProgress: (received, total) => {
|
|
124
|
-
if (progressBar)
|
|
125
|
-
progressBar.update(received, total);
|
|
126
|
-
},
|
|
127
|
-
});
|
|
128
|
-
if (progressBar) {
|
|
129
|
-
progressBar.complete(result.success, result.success ? formatBytes(result.size) : undefined);
|
|
130
|
-
}
|
|
131
|
-
tracker.onFileComplete(result.success);
|
|
132
|
-
results.push({
|
|
133
|
-
index: i + 1,
|
|
134
|
-
type: media.type,
|
|
135
|
-
status: result.success ? 'success' : 'failed',
|
|
136
|
-
size: result.success ? formatBytes(result.size) : (result.error || 'unknown error'),
|
|
137
|
-
});
|
|
138
|
-
}
|
|
139
|
-
catch (err) {
|
|
140
|
-
if (progressBar)
|
|
141
|
-
progressBar.fail(err.message);
|
|
142
|
-
tracker.onFileComplete(false);
|
|
143
|
-
results.push({
|
|
144
|
-
index: i + 1,
|
|
145
|
-
type: media.type,
|
|
146
|
-
status: 'failed',
|
|
147
|
-
size: err.message,
|
|
148
|
-
});
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
tracker.finish();
|
|
152
|
-
return results;
|
|
98
|
+
return downloadMedia(data.media, {
|
|
99
|
+
output,
|
|
100
|
+
subdir: noteId,
|
|
101
|
+
cookies,
|
|
102
|
+
filenamePrefix: noteId,
|
|
103
|
+
timeout: 60000,
|
|
104
|
+
});
|
|
153
105
|
},
|
|
154
106
|
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Xiaohongshu 图文笔记 publisher — creator center UI automation.
|
|
3
|
+
*
|
|
4
|
+
* Flow:
|
|
5
|
+
* 1. Navigate to creator publish page
|
|
6
|
+
* 2. Upload images via DataTransfer injection into the file input
|
|
7
|
+
* 3. Fill title and body text
|
|
8
|
+
* 4. Add topic hashtags
|
|
9
|
+
* 5. Publish (or save as draft)
|
|
10
|
+
*
|
|
11
|
+
* Requires: logged into creator.xiaohongshu.com in Chrome.
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
* opencli xiaohongshu publish --title "标题" "正文内容" \
|
|
15
|
+
* --images /path/a.jpg,/path/b.jpg \
|
|
16
|
+
* --topics 生活,旅行
|
|
17
|
+
*/
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Xiaohongshu 图文笔记 publisher — creator center UI automation.
|
|
3
|
+
*
|
|
4
|
+
* Flow:
|
|
5
|
+
* 1. Navigate to creator publish page
|
|
6
|
+
* 2. Upload images via DataTransfer injection into the file input
|
|
7
|
+
* 3. Fill title and body text
|
|
8
|
+
* 4. Add topic hashtags
|
|
9
|
+
* 5. Publish (or save as draft)
|
|
10
|
+
*
|
|
11
|
+
* Requires: logged into creator.xiaohongshu.com in Chrome.
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
* opencli xiaohongshu publish --title "标题" "正文内容" \
|
|
15
|
+
* --images /path/a.jpg,/path/b.jpg \
|
|
16
|
+
* --topics 生活,旅行
|
|
17
|
+
*/
|
|
18
|
+
import * as fs from 'node:fs';
|
|
19
|
+
import * as path from 'node:path';
|
|
20
|
+
import { cli, Strategy } from '../../registry.js';
|
|
21
|
+
const PUBLISH_URL = 'https://creator.xiaohongshu.com/publish/publish?from=menu_left';
|
|
22
|
+
const MAX_IMAGES = 9;
|
|
23
|
+
const MAX_TITLE_LEN = 20;
|
|
24
|
+
const UPLOAD_SETTLE_MS = 3000;
|
|
25
|
+
/**
|
|
26
|
+
* Read a local image and return the name, MIME type, and base64 content.
|
|
27
|
+
* Throws if the file does not exist or the extension is unsupported.
|
|
28
|
+
*/
|
|
29
|
+
function readImageFile(filePath) {
|
|
30
|
+
const absPath = path.resolve(filePath);
|
|
31
|
+
if (!fs.existsSync(absPath))
|
|
32
|
+
throw new Error(`Image file not found: ${absPath}`);
|
|
33
|
+
const ext = path.extname(absPath).toLowerCase();
|
|
34
|
+
const mimeMap = {
|
|
35
|
+
'.jpg': 'image/jpeg',
|
|
36
|
+
'.jpeg': 'image/jpeg',
|
|
37
|
+
'.png': 'image/png',
|
|
38
|
+
'.gif': 'image/gif',
|
|
39
|
+
'.webp': 'image/webp',
|
|
40
|
+
};
|
|
41
|
+
const mimeType = mimeMap[ext];
|
|
42
|
+
if (!mimeType)
|
|
43
|
+
throw new Error(`Unsupported image format "${ext}". Supported: jpg, png, gif, webp`);
|
|
44
|
+
const base64 = fs.readFileSync(absPath).toString('base64');
|
|
45
|
+
return { name: path.basename(absPath), mimeType, base64 };
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Inject images into the page's file input using DataTransfer.
|
|
49
|
+
* Converts base64 payloads to File objects in the browser context, then dispatches
|
|
50
|
+
* a synthetic 'change' event on the input element.
|
|
51
|
+
*
|
|
52
|
+
* Returns { ok, count, error }.
|
|
53
|
+
*/
|
|
54
|
+
async function injectImages(page, images) {
|
|
55
|
+
const payload = JSON.stringify(images);
|
|
56
|
+
return page.evaluate(`
|
|
57
|
+
(async () => {
|
|
58
|
+
const images = ${payload};
|
|
59
|
+
|
|
60
|
+
// Prefer image/* file inputs; fall back to the first available input.
|
|
61
|
+
const inputs = Array.from(document.querySelectorAll('input[type="file"]'));
|
|
62
|
+
const input = inputs.find(el => {
|
|
63
|
+
const accept = el.getAttribute('accept') || '';
|
|
64
|
+
return accept.includes('image') || accept.includes('.jpg') || accept.includes('.png');
|
|
65
|
+
}) || inputs[0];
|
|
66
|
+
|
|
67
|
+
if (!input) return { ok: false, count: 0, error: 'No file input found on page' };
|
|
68
|
+
|
|
69
|
+
const dt = new DataTransfer();
|
|
70
|
+
for (const img of images) {
|
|
71
|
+
try {
|
|
72
|
+
const binary = atob(img.base64);
|
|
73
|
+
const bytes = new Uint8Array(binary.length);
|
|
74
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
75
|
+
const blob = new Blob([bytes], { type: img.mimeType });
|
|
76
|
+
dt.items.add(new File([blob], img.name, { type: img.mimeType }));
|
|
77
|
+
} catch (e) {
|
|
78
|
+
return { ok: false, count: 0, error: 'Failed to create File: ' + e.message };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
Object.defineProperty(input, 'files', { value: dt.files, writable: false });
|
|
83
|
+
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
84
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
85
|
+
|
|
86
|
+
return { ok: true, count: dt.files.length };
|
|
87
|
+
})()
|
|
88
|
+
`);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Wait until all upload progress indicators have disappeared (up to maxWaitMs).
|
|
92
|
+
*/
|
|
93
|
+
async function waitForUploads(page, maxWaitMs = 30_000) {
|
|
94
|
+
const pollMs = 2_000;
|
|
95
|
+
const maxAttempts = Math.ceil(maxWaitMs / pollMs);
|
|
96
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
97
|
+
const uploading = await page.evaluate(`
|
|
98
|
+
() => !!document.querySelector(
|
|
99
|
+
'[class*="upload"][class*="progress"], [class*="uploading"], [class*="loading"][class*="image"]'
|
|
100
|
+
)
|
|
101
|
+
`);
|
|
102
|
+
if (!uploading)
|
|
103
|
+
return;
|
|
104
|
+
await page.wait({ time: pollMs / 1_000 });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Fill a visible text input or contenteditable with the given text.
|
|
109
|
+
* Tries multiple selectors in priority order.
|
|
110
|
+
* Returns { ok, sel }.
|
|
111
|
+
*/
|
|
112
|
+
async function fillField(page, selectors, text, fieldName) {
|
|
113
|
+
const result = await page.evaluate(`
|
|
114
|
+
(function(selectors, text) {
|
|
115
|
+
for (const sel of selectors) {
|
|
116
|
+
const candidates = document.querySelectorAll(sel);
|
|
117
|
+
for (const el of candidates) {
|
|
118
|
+
if (!el || el.offsetParent === null) continue;
|
|
119
|
+
el.focus();
|
|
120
|
+
if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
|
|
121
|
+
el.value = '';
|
|
122
|
+
document.execCommand('selectAll', false);
|
|
123
|
+
document.execCommand('insertText', false, text);
|
|
124
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
125
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
126
|
+
} else {
|
|
127
|
+
// contenteditable
|
|
128
|
+
el.textContent = '';
|
|
129
|
+
document.execCommand('selectAll', false);
|
|
130
|
+
document.execCommand('insertText', false, text);
|
|
131
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
132
|
+
}
|
|
133
|
+
return { ok: true, sel };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return { ok: false };
|
|
137
|
+
})(${JSON.stringify(selectors)}, ${JSON.stringify(text)})
|
|
138
|
+
`);
|
|
139
|
+
if (!result.ok) {
|
|
140
|
+
await page.screenshot({ path: `/tmp/xhs_publish_${fieldName}_debug.png` });
|
|
141
|
+
throw new Error(`Could not find ${fieldName} input. Debug screenshot: /tmp/xhs_publish_${fieldName}_debug.png`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
cli({
|
|
145
|
+
site: 'xiaohongshu',
|
|
146
|
+
name: 'publish',
|
|
147
|
+
description: '小红书发布图文笔记 (creator center UI automation)',
|
|
148
|
+
domain: 'creator.xiaohongshu.com',
|
|
149
|
+
strategy: Strategy.COOKIE,
|
|
150
|
+
browser: true,
|
|
151
|
+
args: [
|
|
152
|
+
{ name: 'title', required: true, help: '笔记标题 (最多20字)' },
|
|
153
|
+
{ name: 'content', required: true, positional: true, help: '笔记正文' },
|
|
154
|
+
{ name: 'images', required: false, help: '图片路径,逗号分隔,最多9张 (jpg/png/gif/webp)' },
|
|
155
|
+
{ name: 'topics', required: false, help: '话题标签,逗号分隔,不含 # 号' },
|
|
156
|
+
{ name: 'draft', type: 'bool', default: false, help: '保存为草稿,不直接发布' },
|
|
157
|
+
],
|
|
158
|
+
columns: ['status', 'detail'],
|
|
159
|
+
func: async (page, kwargs) => {
|
|
160
|
+
if (!page)
|
|
161
|
+
throw new Error('Browser page required');
|
|
162
|
+
const title = String(kwargs.title ?? '').trim();
|
|
163
|
+
const content = String(kwargs.content ?? '').trim();
|
|
164
|
+
const imagePaths = kwargs.images
|
|
165
|
+
? String(kwargs.images).split(',').map((s) => s.trim()).filter(Boolean)
|
|
166
|
+
: [];
|
|
167
|
+
const topics = kwargs.topics
|
|
168
|
+
? String(kwargs.topics).split(',').map((s) => s.trim()).filter(Boolean)
|
|
169
|
+
: [];
|
|
170
|
+
const isDraft = Boolean(kwargs.draft);
|
|
171
|
+
// ── Validate inputs ────────────────────────────────────────────────────────
|
|
172
|
+
if (!title)
|
|
173
|
+
throw new Error('--title is required');
|
|
174
|
+
if (title.length > MAX_TITLE_LEN)
|
|
175
|
+
throw new Error(`Title is ${title.length} chars — must be ≤ ${MAX_TITLE_LEN}`);
|
|
176
|
+
if (!content)
|
|
177
|
+
throw new Error('Positional argument <content> is required');
|
|
178
|
+
if (imagePaths.length > MAX_IMAGES)
|
|
179
|
+
throw new Error(`Too many images: ${imagePaths.length} (max ${MAX_IMAGES})`);
|
|
180
|
+
// Read images in Node.js context before navigating (fast-fail on bad paths)
|
|
181
|
+
const imageData = imagePaths.map(readImageFile);
|
|
182
|
+
// ── Step 1: Navigate to publish page ──────────────────────────────────────
|
|
183
|
+
await page.goto(PUBLISH_URL);
|
|
184
|
+
await page.wait({ time: 3 });
|
|
185
|
+
// Verify we landed on the creator site (not redirected to login)
|
|
186
|
+
const pageUrl = await page.evaluate('() => location.href');
|
|
187
|
+
if (!pageUrl.includes('creator.xiaohongshu.com')) {
|
|
188
|
+
throw new Error('Redirected away from creator center — session may have expired. ' +
|
|
189
|
+
'Re-capture browser login via: opencli xiaohongshu creator-profile');
|
|
190
|
+
}
|
|
191
|
+
// ── Step 2: Select 图文 (image+text) note type if tabs are present ─────────
|
|
192
|
+
const tabClicked = await page.evaluate(`
|
|
193
|
+
() => {
|
|
194
|
+
const allEls = document.querySelectorAll('[class*="tab"], [class*="note-type"], [class*="type-item"]');
|
|
195
|
+
for (const el of allEls) {
|
|
196
|
+
const text = el.innerText || el.textContent || '';
|
|
197
|
+
if ((text.includes('图文') || text.includes('图片')) && el.offsetParent !== null) {
|
|
198
|
+
el.click();
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
`);
|
|
205
|
+
if (tabClicked)
|
|
206
|
+
await page.wait({ time: 1 });
|
|
207
|
+
// ── Step 3: Upload images ──────────────────────────────────────────────────
|
|
208
|
+
if (imageData.length > 0) {
|
|
209
|
+
const upload = await injectImages(page, imageData);
|
|
210
|
+
if (!upload.ok) {
|
|
211
|
+
await page.screenshot({ path: '/tmp/xhs_publish_upload_debug.png' });
|
|
212
|
+
throw new Error(`Image injection failed: ${upload.error ?? 'unknown'}. ` +
|
|
213
|
+
'Debug screenshot: /tmp/xhs_publish_upload_debug.png');
|
|
214
|
+
}
|
|
215
|
+
// Allow XHS to process and upload images to its CDN
|
|
216
|
+
await page.wait({ time: UPLOAD_SETTLE_MS / 1_000 });
|
|
217
|
+
await waitForUploads(page);
|
|
218
|
+
}
|
|
219
|
+
// ── Step 4: Fill title ─────────────────────────────────────────────────────
|
|
220
|
+
await fillField(page, [
|
|
221
|
+
'input[maxlength="20"]',
|
|
222
|
+
'input[class*="title"]',
|
|
223
|
+
'input[placeholder*="标题"]',
|
|
224
|
+
'input[placeholder*="title" i]',
|
|
225
|
+
'.title-input input',
|
|
226
|
+
'.note-title input',
|
|
227
|
+
'input[maxlength]',
|
|
228
|
+
], title, 'title');
|
|
229
|
+
await page.wait({ time: 0.5 });
|
|
230
|
+
// ── Step 5: Fill content / body ────────────────────────────────────────────
|
|
231
|
+
await fillField(page, [
|
|
232
|
+
'[contenteditable="true"][class*="content"]',
|
|
233
|
+
'[contenteditable="true"][class*="editor"]',
|
|
234
|
+
'[contenteditable="true"][placeholder*="描述"]',
|
|
235
|
+
'[contenteditable="true"][placeholder*="正文"]',
|
|
236
|
+
'[contenteditable="true"][placeholder*="内容"]',
|
|
237
|
+
'.note-content [contenteditable="true"]',
|
|
238
|
+
'.editor-content [contenteditable="true"]',
|
|
239
|
+
// Broad fallback — last resort; filter out any title contenteditable
|
|
240
|
+
'[contenteditable="true"]:not([placeholder*="标题"]):not([placeholder*="title" i])',
|
|
241
|
+
], content, 'content');
|
|
242
|
+
await page.wait({ time: 0.5 });
|
|
243
|
+
// ── Step 6: Add topic hashtags ─────────────────────────────────────────────
|
|
244
|
+
for (const topic of topics) {
|
|
245
|
+
// Click the "添加话题" button
|
|
246
|
+
const btnClicked = await page.evaluate(`
|
|
247
|
+
() => {
|
|
248
|
+
const candidates = document.querySelectorAll('*');
|
|
249
|
+
for (const el of candidates) {
|
|
250
|
+
const text = (el.innerText || el.textContent || '').trim();
|
|
251
|
+
if (
|
|
252
|
+
(text === '添加话题' || text === '# 话题' || text.startsWith('添加话题')) &&
|
|
253
|
+
el.offsetParent !== null &&
|
|
254
|
+
el.children.length === 0
|
|
255
|
+
) {
|
|
256
|
+
el.click();
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
// fallback: look for a hashtag icon button
|
|
261
|
+
const hashBtn = document.querySelector('[class*="topic"][class*="btn"], [class*="hashtag"][class*="btn"]');
|
|
262
|
+
if (hashBtn) { hashBtn.click(); return true; }
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
`);
|
|
266
|
+
if (!btnClicked)
|
|
267
|
+
continue; // Skip topic if UI not found — non-fatal
|
|
268
|
+
await page.wait({ time: 1 });
|
|
269
|
+
// Type into the topic search input
|
|
270
|
+
const typed = await page.evaluate(`
|
|
271
|
+
(topicName => {
|
|
272
|
+
const input = document.querySelector(
|
|
273
|
+
'[class*="topic"] input, [class*="hashtag"] input, input[placeholder*="搜索话题"]'
|
|
274
|
+
);
|
|
275
|
+
if (!input || input.offsetParent === null) return false;
|
|
276
|
+
input.focus();
|
|
277
|
+
document.execCommand('insertText', false, topicName);
|
|
278
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
279
|
+
return true;
|
|
280
|
+
})(${JSON.stringify(topic)})
|
|
281
|
+
`);
|
|
282
|
+
if (!typed)
|
|
283
|
+
continue;
|
|
284
|
+
await page.wait({ time: 1.5 }); // Wait for autocomplete suggestions
|
|
285
|
+
// Click the first suggestion
|
|
286
|
+
await page.evaluate(`
|
|
287
|
+
() => {
|
|
288
|
+
const item = document.querySelector(
|
|
289
|
+
'[class*="topic-item"], [class*="hashtag-item"], [class*="suggest-item"], [class*="suggestion"] li'
|
|
290
|
+
);
|
|
291
|
+
if (item) item.click();
|
|
292
|
+
}
|
|
293
|
+
`);
|
|
294
|
+
await page.wait({ time: 0.5 });
|
|
295
|
+
}
|
|
296
|
+
// ── Step 7: Publish or save draft ─────────────────────────────────────────
|
|
297
|
+
const actionLabel = isDraft ? '存草稿' : '发布';
|
|
298
|
+
const btnClicked = await page.evaluate(`
|
|
299
|
+
(label => {
|
|
300
|
+
const buttons = document.querySelectorAll('button, [role="button"]');
|
|
301
|
+
for (const btn of buttons) {
|
|
302
|
+
const text = (btn.innerText || btn.textContent || '').trim();
|
|
303
|
+
if (
|
|
304
|
+
(text === label || text.includes(label) || text === '发布笔记') &&
|
|
305
|
+
btn.offsetParent !== null &&
|
|
306
|
+
!btn.disabled
|
|
307
|
+
) {
|
|
308
|
+
btn.click();
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return false;
|
|
313
|
+
})(${JSON.stringify(actionLabel)})
|
|
314
|
+
`);
|
|
315
|
+
if (!btnClicked) {
|
|
316
|
+
await page.screenshot({ path: '/tmp/xhs_publish_submit_debug.png' });
|
|
317
|
+
throw new Error(`Could not find "${actionLabel}" button. ` +
|
|
318
|
+
'Debug screenshot: /tmp/xhs_publish_submit_debug.png');
|
|
319
|
+
}
|
|
320
|
+
// ── Step 8: Verify success ─────────────────────────────────────────────────
|
|
321
|
+
await page.wait({ time: 4 });
|
|
322
|
+
const finalUrl = await page.evaluate('() => location.href');
|
|
323
|
+
const successMsg = await page.evaluate(`
|
|
324
|
+
() => {
|
|
325
|
+
for (const el of document.querySelectorAll('*')) {
|
|
326
|
+
const text = (el.innerText || '').trim();
|
|
327
|
+
if (
|
|
328
|
+
el.children.length === 0 &&
|
|
329
|
+
(text.includes('发布成功') || text.includes('草稿已保存') || text.includes('上传成功'))
|
|
330
|
+
) return text;
|
|
331
|
+
}
|
|
332
|
+
return '';
|
|
333
|
+
}
|
|
334
|
+
`);
|
|
335
|
+
const navigatedAway = !finalUrl.includes('/publish/publish');
|
|
336
|
+
const isSuccess = successMsg.length > 0 || navigatedAway;
|
|
337
|
+
const verb = isDraft ? '草稿已保存' : '发布成功';
|
|
338
|
+
return [
|
|
339
|
+
{
|
|
340
|
+
status: isSuccess ? `✅ ${verb}` : '⚠️ 操作完成,请在浏览器中确认',
|
|
341
|
+
detail: [
|
|
342
|
+
`"${title}"`,
|
|
343
|
+
imageData.length ? `${imageData.length}张图片` : '无图',
|
|
344
|
+
topics.length ? `话题: ${topics.join(' ')}` : '',
|
|
345
|
+
successMsg || finalUrl || '',
|
|
346
|
+
]
|
|
347
|
+
.filter(Boolean)
|
|
348
|
+
.join(' · '),
|
|
349
|
+
},
|
|
350
|
+
];
|
|
351
|
+
},
|
|
352
|
+
});
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* Ref: https://github.com/jackwener/opencli/issues/10
|
|
7
7
|
*/
|
|
8
8
|
import { cli, Strategy } from '../../registry.js';
|
|
9
|
+
import { AuthRequiredError } from '../../errors.js';
|
|
9
10
|
cli({
|
|
10
11
|
site: 'xiaohongshu',
|
|
11
12
|
name: 'search',
|
|
@@ -16,41 +17,72 @@ cli({
|
|
|
16
17
|
{ name: 'query', required: true, positional: true, help: 'Search keyword' },
|
|
17
18
|
{ name: 'limit', type: 'int', default: 20, help: 'Number of results' },
|
|
18
19
|
],
|
|
19
|
-
columns: ['rank', 'title', 'author', 'likes'],
|
|
20
|
+
columns: ['rank', 'title', 'author', 'likes', 'url'],
|
|
20
21
|
func: async (page, kwargs) => {
|
|
21
22
|
const keyword = encodeURIComponent(kwargs.query);
|
|
22
23
|
await page.goto(`https://www.xiaohongshu.com/search_result?keyword=${keyword}&source=web_search_result_notes`);
|
|
23
24
|
await page.wait(3);
|
|
24
25
|
// Scroll a couple of times to load more results
|
|
25
26
|
await page.autoScroll({ times: 2 });
|
|
26
|
-
const
|
|
27
|
+
const payload = await page.evaluate(`
|
|
27
28
|
(() => {
|
|
28
|
-
const
|
|
29
|
+
const loginWall = /登录后查看搜索结果/.test(document.body.innerText || '');
|
|
30
|
+
|
|
31
|
+
const normalizeUrl = (href) => {
|
|
32
|
+
if (!href) return '';
|
|
33
|
+
if (href.startsWith('http://') || href.startsWith('https://')) return href;
|
|
34
|
+
if (href.startsWith('/')) return 'https://www.xiaohongshu.com' + href;
|
|
35
|
+
return '';
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const cleanText = (value) => (value || '').replace(/\\s+/g, ' ').trim();
|
|
39
|
+
|
|
29
40
|
const results = [];
|
|
30
|
-
|
|
41
|
+
const seen = new Set();
|
|
42
|
+
|
|
43
|
+
document.querySelectorAll('section.note-item').forEach(el => {
|
|
31
44
|
// Skip "related searches" sections
|
|
32
45
|
if (el.classList.contains('query-note-item')) return;
|
|
33
46
|
|
|
34
|
-
const titleEl = el.querySelector('.title, .note-title, a.title');
|
|
35
|
-
const nameEl = el.querySelector('.name, .author-name, .nick-name');
|
|
47
|
+
const titleEl = el.querySelector('.title, .note-title, a.title, .footer .title span');
|
|
48
|
+
const nameEl = el.querySelector('a.author .name, .name, .author-name, .nick-name, a.author');
|
|
36
49
|
const likesEl = el.querySelector('.count, .like-count, .like-wrapper .count');
|
|
37
|
-
|
|
50
|
+
// Prefer search_result link (preserves xsec_token) over generic /explore/ link
|
|
51
|
+
const detailLinkEl =
|
|
52
|
+
el.querySelector('a.cover.mask') ||
|
|
53
|
+
el.querySelector('a[href*="/search_result/"]') ||
|
|
54
|
+
el.querySelector('a[href*="/explore/"]') ||
|
|
55
|
+
el.querySelector('a[href*="/note/"]');
|
|
56
|
+
const authorLinkEl = el.querySelector('a.author, a[href*="/user/profile/"]');
|
|
57
|
+
|
|
58
|
+
const url = normalizeUrl(detailLinkEl?.getAttribute('href') || '');
|
|
59
|
+
if (!url) return;
|
|
38
60
|
|
|
39
|
-
const
|
|
40
|
-
|
|
61
|
+
const key = url;
|
|
62
|
+
if (seen.has(key)) return;
|
|
63
|
+
seen.add(key);
|
|
41
64
|
|
|
42
65
|
results.push({
|
|
43
|
-
title: (titleEl?.textContent || '')
|
|
44
|
-
author: (nameEl?.textContent || '')
|
|
45
|
-
likes: (likesEl?.textContent || '0')
|
|
46
|
-
url
|
|
66
|
+
title: cleanText(titleEl?.textContent || ''),
|
|
67
|
+
author: cleanText(nameEl?.textContent || ''),
|
|
68
|
+
likes: cleanText(likesEl?.textContent || '0'),
|
|
69
|
+
url,
|
|
70
|
+
author_url: normalizeUrl(authorLinkEl?.getAttribute('href') || ''),
|
|
47
71
|
});
|
|
48
72
|
});
|
|
49
|
-
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
loginWall,
|
|
76
|
+
results,
|
|
77
|
+
};
|
|
50
78
|
})()
|
|
51
79
|
`);
|
|
52
|
-
if (!
|
|
80
|
+
if (!payload || typeof payload !== 'object')
|
|
53
81
|
return [];
|
|
82
|
+
if (payload.loginWall) {
|
|
83
|
+
throw new AuthRequiredError('www.xiaohongshu.com', 'Xiaohongshu search results are blocked behind a login wall');
|
|
84
|
+
}
|
|
85
|
+
const data = Array.isArray(payload.results) ? payload.results : [];
|
|
54
86
|
return data
|
|
55
87
|
.filter((item) => item.title)
|
|
56
88
|
.slice(0, kwargs.limit)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import './search.js';
|