@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
|
@@ -5,11 +5,9 @@
|
|
|
5
5
|
* opencli twitter download elonmusk --limit 10 --output ./twitter
|
|
6
6
|
* opencli twitter download --tweet-url https://x.com/xxx/status/123 --output ./twitter
|
|
7
7
|
*/
|
|
8
|
-
import * as fs from 'node:fs';
|
|
9
|
-
import * as path from 'node:path';
|
|
10
8
|
import { cli, Strategy } from '../../registry.js';
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
9
|
+
import { formatCookieHeader } from '../../download/index.js';
|
|
10
|
+
import { downloadMedia } from '../../download/media-download.js';
|
|
13
11
|
cli({
|
|
14
12
|
site: 'twitter',
|
|
15
13
|
name: 'download',
|
|
@@ -87,29 +85,10 @@ cli({
|
|
|
87
85
|
})()
|
|
88
86
|
`);
|
|
89
87
|
if (!data || data.length === 0) {
|
|
90
|
-
return [{
|
|
91
|
-
index: 0,
|
|
92
|
-
type: '-',
|
|
93
|
-
status: 'failed',
|
|
94
|
-
size: 'No media found',
|
|
95
|
-
}];
|
|
88
|
+
return [{ index: 0, type: '-', status: 'failed', size: 'No media found' }];
|
|
96
89
|
}
|
|
97
90
|
// Extract cookies
|
|
98
|
-
const
|
|
99
|
-
const cookieString = formatCookieHeader(cookies);
|
|
100
|
-
// Create output directory
|
|
101
|
-
const outputDir = tweetUrl
|
|
102
|
-
? path.join(output, 'tweets')
|
|
103
|
-
: path.join(output, username || 'media');
|
|
104
|
-
fs.mkdirSync(outputDir, { recursive: true });
|
|
105
|
-
// Export cookies for yt-dlp
|
|
106
|
-
let cookiesFile;
|
|
107
|
-
if (cookies.length > 0) {
|
|
108
|
-
const tempDir = getTempDir();
|
|
109
|
-
fs.mkdirSync(tempDir, { recursive: true });
|
|
110
|
-
cookiesFile = path.join(tempDir, `twitter_cookies_${Date.now()}.txt`);
|
|
111
|
-
exportCookiesToNetscape(cookies, cookiesFile);
|
|
112
|
-
}
|
|
91
|
+
const browserCookies = await page.getCookies({ domain: 'x.com' });
|
|
113
92
|
// Deduplicate media
|
|
114
93
|
const seen = new Set();
|
|
115
94
|
const uniqueMedia = data.filter((m) => {
|
|
@@ -118,77 +97,14 @@ cli({
|
|
|
118
97
|
seen.add(m.url);
|
|
119
98
|
return true;
|
|
120
99
|
}).slice(0, limit);
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
let result;
|
|
131
|
-
if (media.type === 'video-tweet' && checkYtdlp()) {
|
|
132
|
-
// Use yt-dlp for video tweets
|
|
133
|
-
result = await ytdlpDownload(media.url, destPath, {
|
|
134
|
-
cookiesFile,
|
|
135
|
-
extraArgs: ['--merge-output-format', 'mp4'],
|
|
136
|
-
onProgress: (percent) => {
|
|
137
|
-
if (progressBar)
|
|
138
|
-
progressBar.update(percent, 100);
|
|
139
|
-
},
|
|
140
|
-
});
|
|
141
|
-
}
|
|
142
|
-
else if (media.type === 'image') {
|
|
143
|
-
// Direct HTTP download for images
|
|
144
|
-
result = await httpDownload(media.url, destPath, {
|
|
145
|
-
cookies: cookieString,
|
|
146
|
-
timeout: 30000,
|
|
147
|
-
onProgress: (received, total) => {
|
|
148
|
-
if (progressBar)
|
|
149
|
-
progressBar.update(received, total);
|
|
150
|
-
},
|
|
151
|
-
});
|
|
152
|
-
}
|
|
153
|
-
else {
|
|
154
|
-
// Direct HTTP download for direct video URLs
|
|
155
|
-
result = await httpDownload(media.url, destPath, {
|
|
156
|
-
cookies: cookieString,
|
|
157
|
-
timeout: 60000,
|
|
158
|
-
onProgress: (received, total) => {
|
|
159
|
-
if (progressBar)
|
|
160
|
-
progressBar.update(received, total);
|
|
161
|
-
},
|
|
162
|
-
});
|
|
163
|
-
}
|
|
164
|
-
if (progressBar) {
|
|
165
|
-
progressBar.complete(result.success, result.success ? formatBytes(result.size) : undefined);
|
|
166
|
-
}
|
|
167
|
-
tracker.onFileComplete(result.success);
|
|
168
|
-
results.push({
|
|
169
|
-
index: i + 1,
|
|
170
|
-
type: media.type === 'video-tweet' ? 'video' : media.type,
|
|
171
|
-
status: result.success ? 'success' : 'failed',
|
|
172
|
-
size: result.success ? formatBytes(result.size) : (result.error || 'unknown error'),
|
|
173
|
-
});
|
|
174
|
-
}
|
|
175
|
-
catch (err) {
|
|
176
|
-
if (progressBar)
|
|
177
|
-
progressBar.fail(err.message);
|
|
178
|
-
tracker.onFileComplete(false);
|
|
179
|
-
results.push({
|
|
180
|
-
index: i + 1,
|
|
181
|
-
type: media.type,
|
|
182
|
-
status: 'failed',
|
|
183
|
-
size: err.message,
|
|
184
|
-
});
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
tracker.finish();
|
|
188
|
-
// Cleanup cookies file
|
|
189
|
-
if (cookiesFile && fs.existsSync(cookiesFile)) {
|
|
190
|
-
fs.unlinkSync(cookiesFile);
|
|
191
|
-
}
|
|
192
|
-
return results;
|
|
100
|
+
const subdir = tweetUrl ? 'tweets' : (username || 'media');
|
|
101
|
+
return downloadMedia(uniqueMedia, {
|
|
102
|
+
output,
|
|
103
|
+
subdir,
|
|
104
|
+
cookies: formatCookieHeader(browserCookies),
|
|
105
|
+
browserCookies,
|
|
106
|
+
filenamePrefix: username || 'tweet',
|
|
107
|
+
ytdlpExtraArgs: ['--merge-output-format', 'mp4'],
|
|
108
|
+
});
|
|
193
109
|
},
|
|
194
110
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { cli, Strategy } from '../../registry.js';
|
|
2
|
+
import { AuthRequiredError } from '../../errors.js';
|
|
2
3
|
// ── Twitter GraphQL constants ──────────────────────────────────────────
|
|
3
4
|
const BEARER_TOKEN = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
|
|
4
5
|
const TWEET_DETAIL_QUERY_ID = 'nBS-WpgA6ZG0CyNHD517JQ';
|
|
@@ -114,7 +115,7 @@ cli({
|
|
|
114
115
|
return document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('ct0='))?.split('=')[1] || null;
|
|
115
116
|
}`);
|
|
116
117
|
if (!ct0)
|
|
117
|
-
throw new
|
|
118
|
+
throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
|
|
118
119
|
// Build auth headers in TypeScript
|
|
119
120
|
const headers = JSON.stringify({
|
|
120
121
|
'Authorization': `Bearer ${decodeURIComponent(BEARER_TOKEN)}`,
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
site: v2ex
|
|
2
|
+
name: member
|
|
3
|
+
description: V2EX 用户资料
|
|
4
|
+
domain: www.v2ex.com
|
|
5
|
+
strategy: public
|
|
6
|
+
browser: false
|
|
7
|
+
|
|
8
|
+
args:
|
|
9
|
+
username:
|
|
10
|
+
positional: true
|
|
11
|
+
type: str
|
|
12
|
+
required: true
|
|
13
|
+
description: Username
|
|
14
|
+
|
|
15
|
+
pipeline:
|
|
16
|
+
- fetch:
|
|
17
|
+
url: https://www.v2ex.com/api/members/show.json
|
|
18
|
+
params:
|
|
19
|
+
username: ${{ args.username }}
|
|
20
|
+
|
|
21
|
+
- map:
|
|
22
|
+
username: ${{ item.username }}
|
|
23
|
+
tagline: ${{ item.tagline }}
|
|
24
|
+
website: ${{ item.website }}
|
|
25
|
+
github: ${{ item.github }}
|
|
26
|
+
twitter: ${{ item.twitter }}
|
|
27
|
+
location: ${{ item.location }}
|
|
28
|
+
|
|
29
|
+
columns: [username, tagline, website, github, twitter, location]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
site: v2ex
|
|
2
|
+
name: node
|
|
3
|
+
description: V2EX 节点话题列表
|
|
4
|
+
domain: www.v2ex.com
|
|
5
|
+
strategy: public
|
|
6
|
+
browser: false
|
|
7
|
+
|
|
8
|
+
args:
|
|
9
|
+
name:
|
|
10
|
+
positional: true
|
|
11
|
+
type: str
|
|
12
|
+
required: true
|
|
13
|
+
description: Node name (e.g. python, javascript, apple)
|
|
14
|
+
limit:
|
|
15
|
+
type: int
|
|
16
|
+
default: 10
|
|
17
|
+
description: Number of topics (API returns max 20)
|
|
18
|
+
|
|
19
|
+
pipeline:
|
|
20
|
+
- fetch:
|
|
21
|
+
url: https://www.v2ex.com/api/topics/show.json
|
|
22
|
+
params:
|
|
23
|
+
node_name: ${{ args.name }}
|
|
24
|
+
|
|
25
|
+
- map:
|
|
26
|
+
rank: ${{ index + 1 }}
|
|
27
|
+
title: ${{ item.title }}
|
|
28
|
+
author: ${{ item.member.username }}
|
|
29
|
+
replies: ${{ item.replies }}
|
|
30
|
+
url: ${{ item.url }}
|
|
31
|
+
|
|
32
|
+
- limit: ${{ args.limit }}
|
|
33
|
+
|
|
34
|
+
columns: [rank, title, author, replies, url]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
site: v2ex
|
|
2
|
+
name: nodes
|
|
3
|
+
description: V2EX 所有节点列表
|
|
4
|
+
domain: www.v2ex.com
|
|
5
|
+
strategy: public
|
|
6
|
+
browser: false
|
|
7
|
+
|
|
8
|
+
args:
|
|
9
|
+
limit:
|
|
10
|
+
type: int
|
|
11
|
+
default: 30
|
|
12
|
+
description: Number of nodes
|
|
13
|
+
|
|
14
|
+
pipeline:
|
|
15
|
+
- fetch:
|
|
16
|
+
url: https://www.v2ex.com/api/nodes/all.json
|
|
17
|
+
|
|
18
|
+
- sort:
|
|
19
|
+
by: topics
|
|
20
|
+
order: desc
|
|
21
|
+
|
|
22
|
+
- map:
|
|
23
|
+
rank: ${{ index + 1 }}
|
|
24
|
+
name: ${{ item.name }}
|
|
25
|
+
title: ${{ item.title }}
|
|
26
|
+
topics: ${{ item.topics }}
|
|
27
|
+
stars: ${{ item.stars }}
|
|
28
|
+
|
|
29
|
+
- limit: ${{ args.limit }}
|
|
30
|
+
|
|
31
|
+
columns: [rank, name, title, topics, stars]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
site: v2ex
|
|
2
|
+
name: replies
|
|
3
|
+
description: V2EX 主题回复列表
|
|
4
|
+
domain: www.v2ex.com
|
|
5
|
+
strategy: public
|
|
6
|
+
browser: false
|
|
7
|
+
|
|
8
|
+
args:
|
|
9
|
+
id:
|
|
10
|
+
positional: true
|
|
11
|
+
type: str
|
|
12
|
+
required: true
|
|
13
|
+
description: Topic ID
|
|
14
|
+
limit:
|
|
15
|
+
type: int
|
|
16
|
+
default: 20
|
|
17
|
+
description: Number of replies
|
|
18
|
+
|
|
19
|
+
pipeline:
|
|
20
|
+
- fetch:
|
|
21
|
+
url: https://www.v2ex.com/api/replies/show.json
|
|
22
|
+
params:
|
|
23
|
+
topic_id: ${{ args.id }}
|
|
24
|
+
|
|
25
|
+
- map:
|
|
26
|
+
floor: ${{ index + 1 }}
|
|
27
|
+
author: ${{ item.member.username }}
|
|
28
|
+
content: ${{ item.content }}
|
|
29
|
+
|
|
30
|
+
- limit: ${{ args.limit }}
|
|
31
|
+
|
|
32
|
+
columns: [floor, author, content]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
site: v2ex
|
|
2
|
+
name: user
|
|
3
|
+
description: V2EX 用户发帖列表
|
|
4
|
+
domain: www.v2ex.com
|
|
5
|
+
strategy: public
|
|
6
|
+
browser: false
|
|
7
|
+
|
|
8
|
+
args:
|
|
9
|
+
username:
|
|
10
|
+
positional: true
|
|
11
|
+
type: str
|
|
12
|
+
required: true
|
|
13
|
+
description: Username
|
|
14
|
+
limit:
|
|
15
|
+
type: int
|
|
16
|
+
default: 10
|
|
17
|
+
description: Number of topics (API returns max 20)
|
|
18
|
+
|
|
19
|
+
pipeline:
|
|
20
|
+
- fetch:
|
|
21
|
+
url: https://www.v2ex.com/api/topics/show.json
|
|
22
|
+
params:
|
|
23
|
+
username: ${{ args.username }}
|
|
24
|
+
|
|
25
|
+
- map:
|
|
26
|
+
rank: ${{ index + 1 }}
|
|
27
|
+
title: ${{ item.title }}
|
|
28
|
+
node: ${{ item.node.title }}
|
|
29
|
+
replies: ${{ item.replies }}
|
|
30
|
+
url: ${{ item.url }}
|
|
31
|
+
|
|
32
|
+
- limit: ${{ args.limit }}
|
|
33
|
+
|
|
34
|
+
columns: [rank, title, node, replies, url]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Weibo search — browser DOM extraction from search results.
|
|
3
|
+
*/
|
|
4
|
+
import { cli, Strategy } from '../../registry.js';
|
|
5
|
+
import { CliError } from '../../errors.js';
|
|
6
|
+
cli({
|
|
7
|
+
site: 'weibo',
|
|
8
|
+
name: 'search',
|
|
9
|
+
description: '搜索微博',
|
|
10
|
+
domain: 'weibo.com',
|
|
11
|
+
browser: true,
|
|
12
|
+
strategy: Strategy.COOKIE,
|
|
13
|
+
args: [
|
|
14
|
+
{ name: 'keyword', required: true, positional: true, help: 'Search keyword' },
|
|
15
|
+
{ name: 'limit', type: 'int', default: 10, help: 'Number of results (max 50)' },
|
|
16
|
+
],
|
|
17
|
+
columns: ['rank', 'title', 'author', 'time', 'url'],
|
|
18
|
+
func: async (page, kwargs) => {
|
|
19
|
+
const limit = Math.max(1, Math.min(Number(kwargs.limit) || 10, 50));
|
|
20
|
+
const keyword = encodeURIComponent(String(kwargs.keyword ?? '').trim());
|
|
21
|
+
await page.goto(`https://s.weibo.com/weibo?q=${keyword}`);
|
|
22
|
+
await page.wait(2);
|
|
23
|
+
const data = await page.evaluate(`
|
|
24
|
+
(() => {
|
|
25
|
+
const clean = (value) => (value || '').replace(/\\s+/g, ' ').trim();
|
|
26
|
+
const absoluteUrl = (href) => {
|
|
27
|
+
if (!href) return '';
|
|
28
|
+
if (href.startsWith('http://') || href.startsWith('https://')) return href;
|
|
29
|
+
if (href.startsWith('//')) return window.location.protocol + href;
|
|
30
|
+
if (href.startsWith('/')) return window.location.origin + href;
|
|
31
|
+
return href;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const cards = Array.from(document.querySelectorAll('.card-wrap'));
|
|
35
|
+
const rows = [];
|
|
36
|
+
|
|
37
|
+
for (const card of cards) {
|
|
38
|
+
const contentEl =
|
|
39
|
+
card.querySelector('[node-type="feed_list_content_full"]') ||
|
|
40
|
+
card.querySelector('[node-type="feed_list_content"]') ||
|
|
41
|
+
card.querySelector('.txt');
|
|
42
|
+
const authorEl =
|
|
43
|
+
card.querySelector('.info .name') ||
|
|
44
|
+
card.querySelector('.name');
|
|
45
|
+
const timeEl = card.querySelector('.from a');
|
|
46
|
+
const urlEl =
|
|
47
|
+
card.querySelector('.from a[href*="/detail/"]') ||
|
|
48
|
+
card.querySelector('.from a[href*="/status/"]') ||
|
|
49
|
+
timeEl;
|
|
50
|
+
|
|
51
|
+
const title = clean(contentEl && contentEl.textContent);
|
|
52
|
+
if (!title) continue;
|
|
53
|
+
|
|
54
|
+
rows.push({
|
|
55
|
+
title,
|
|
56
|
+
author: clean(authorEl && authorEl.textContent),
|
|
57
|
+
time: clean(timeEl && timeEl.textContent),
|
|
58
|
+
url: absoluteUrl(urlEl && urlEl.getAttribute('href')),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return rows;
|
|
63
|
+
})()
|
|
64
|
+
`);
|
|
65
|
+
if (!Array.isArray(data) || data.length === 0) {
|
|
66
|
+
throw new CliError('NOT_FOUND', 'No Weibo search results found', 'Try a different keyword or ensure you are logged into weibo.com');
|
|
67
|
+
}
|
|
68
|
+
return data.slice(0, limit).map((item, index) => ({
|
|
69
|
+
rank: index + 1,
|
|
70
|
+
...item,
|
|
71
|
+
}));
|
|
72
|
+
},
|
|
73
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WeChat article download — export WeChat Official Account articles to Markdown.
|
|
3
|
+
*
|
|
4
|
+
* Ported from jackwener/wechat-article-to-markdown (JS version) to OpenCLI adapter.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* opencli weixin download --url "https://mp.weixin.qq.com/s/xxx" --output ./weixin
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Normalize a pasted WeChat article URL.
|
|
11
|
+
*/
|
|
12
|
+
export declare function normalizeWechatUrl(raw: string): string;
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WeChat article download — export WeChat Official Account articles to Markdown.
|
|
3
|
+
*
|
|
4
|
+
* Ported from jackwener/wechat-article-to-markdown (JS version) to OpenCLI adapter.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* opencli weixin download --url "https://mp.weixin.qq.com/s/xxx" --output ./weixin
|
|
8
|
+
*/
|
|
9
|
+
import { cli, Strategy } from '../../registry.js';
|
|
10
|
+
import { downloadArticle } from '../../download/article-download.js';
|
|
11
|
+
// ============================================================
|
|
12
|
+
// URL Normalization
|
|
13
|
+
// ============================================================
|
|
14
|
+
/**
|
|
15
|
+
* Normalize a pasted WeChat article URL.
|
|
16
|
+
*/
|
|
17
|
+
export function normalizeWechatUrl(raw) {
|
|
18
|
+
let s = (raw || '').trim();
|
|
19
|
+
if (!s)
|
|
20
|
+
return s;
|
|
21
|
+
// Strip wrapping quotes / angle brackets
|
|
22
|
+
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
|
23
|
+
s = s.slice(1, -1).trim();
|
|
24
|
+
}
|
|
25
|
+
if (s.startsWith('<') && s.endsWith('>')) {
|
|
26
|
+
s = s.slice(1, -1).trim();
|
|
27
|
+
}
|
|
28
|
+
// Remove backslash escapes before URL-significant characters
|
|
29
|
+
s = s.replace(/\\+([:/&?=#%])/g, '$1');
|
|
30
|
+
// Decode HTML entities
|
|
31
|
+
s = s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
32
|
+
// Allow bare hostnames
|
|
33
|
+
if (s.startsWith('mp.weixin.qq.com/') || s.startsWith('//mp.weixin.qq.com/')) {
|
|
34
|
+
s = 'https://' + s.replace(/^\/+/, '');
|
|
35
|
+
}
|
|
36
|
+
// Force https for mp.weixin.qq.com
|
|
37
|
+
try {
|
|
38
|
+
const parsed = new URL(s);
|
|
39
|
+
if (['http:', 'https:'].includes(parsed.protocol) && parsed.hostname.toLowerCase() === 'mp.weixin.qq.com') {
|
|
40
|
+
parsed.protocol = 'https:';
|
|
41
|
+
s = parsed.toString();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// Ignore parse errors
|
|
46
|
+
}
|
|
47
|
+
return s;
|
|
48
|
+
}
|
|
49
|
+
// ============================================================
|
|
50
|
+
// CLI Registration
|
|
51
|
+
// ============================================================
|
|
52
|
+
cli({
|
|
53
|
+
site: 'weixin',
|
|
54
|
+
name: 'download',
|
|
55
|
+
description: '下载微信公众号文章为 Markdown 格式',
|
|
56
|
+
domain: 'mp.weixin.qq.com',
|
|
57
|
+
strategy: Strategy.COOKIE,
|
|
58
|
+
args: [
|
|
59
|
+
{ name: 'url', required: true, help: 'WeChat article URL (mp.weixin.qq.com/s/xxx)' },
|
|
60
|
+
{ name: 'output', default: './weixin-articles', help: 'Output directory' },
|
|
61
|
+
{ name: 'download-images', type: 'boolean', default: true, help: 'Download images locally' },
|
|
62
|
+
],
|
|
63
|
+
columns: ['title', 'author', 'publish_time', 'status', 'size'],
|
|
64
|
+
func: async (page, kwargs) => {
|
|
65
|
+
const rawUrl = kwargs.url;
|
|
66
|
+
const url = normalizeWechatUrl(rawUrl);
|
|
67
|
+
if (!url.startsWith('https://mp.weixin.qq.com/')) {
|
|
68
|
+
return [{ title: 'Error', author: '-', publish_time: '-', status: 'invalid URL', size: '-' }];
|
|
69
|
+
}
|
|
70
|
+
// Navigate and wait for content to load
|
|
71
|
+
await page.goto(url);
|
|
72
|
+
await page.wait(5);
|
|
73
|
+
// Extract article data in browser context
|
|
74
|
+
const data = await page.evaluate(`
|
|
75
|
+
(() => {
|
|
76
|
+
const result = {
|
|
77
|
+
title: '',
|
|
78
|
+
author: '',
|
|
79
|
+
publishTime: '',
|
|
80
|
+
contentHtml: '',
|
|
81
|
+
codeBlocks: [],
|
|
82
|
+
imageUrls: []
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// Title: #activity-name
|
|
86
|
+
const titleEl = document.querySelector('#activity-name');
|
|
87
|
+
result.title = titleEl ? titleEl.textContent.trim() : '';
|
|
88
|
+
|
|
89
|
+
// Author (WeChat Official Account name): #js_name
|
|
90
|
+
const authorEl = document.querySelector('#js_name');
|
|
91
|
+
result.author = authorEl ? authorEl.textContent.trim() : '';
|
|
92
|
+
|
|
93
|
+
// Publish time: extract create_time from script tags
|
|
94
|
+
const htmlStr = document.documentElement.innerHTML;
|
|
95
|
+
let timeMatch = htmlStr.match(/create_time\\s*:\\s*JsDecode\\('([^']+)'\\)/);
|
|
96
|
+
if (!timeMatch) timeMatch = htmlStr.match(/create_time\\s*:\\s*'(\\d+)'/);
|
|
97
|
+
if (!timeMatch) timeMatch = htmlStr.match(/create_time\\s*[:=]\\s*["']?(\\d+)["']?/);
|
|
98
|
+
if (timeMatch) {
|
|
99
|
+
const ts = parseInt(timeMatch[1], 10);
|
|
100
|
+
if (ts > 0) {
|
|
101
|
+
const d = new Date(ts * 1000);
|
|
102
|
+
const pad = n => String(n).padStart(2, '0');
|
|
103
|
+
const utc8 = new Date(d.getTime() + 8 * 3600 * 1000);
|
|
104
|
+
result.publishTime =
|
|
105
|
+
utc8.getUTCFullYear() + '-' +
|
|
106
|
+
pad(utc8.getUTCMonth() + 1) + '-' +
|
|
107
|
+
pad(utc8.getUTCDate()) + ' ' +
|
|
108
|
+
pad(utc8.getUTCHours()) + ':' +
|
|
109
|
+
pad(utc8.getUTCMinutes()) + ':' +
|
|
110
|
+
pad(utc8.getUTCSeconds());
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Content processing
|
|
115
|
+
const contentEl = document.querySelector('#js_content');
|
|
116
|
+
if (!contentEl) return result;
|
|
117
|
+
|
|
118
|
+
// Fix lazy-loaded images: data-src -> src
|
|
119
|
+
contentEl.querySelectorAll('img').forEach(img => {
|
|
120
|
+
const dataSrc = img.getAttribute('data-src');
|
|
121
|
+
if (dataSrc) img.setAttribute('src', dataSrc);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// Extract code blocks with placeholder replacement
|
|
125
|
+
const codeBlocks = [];
|
|
126
|
+
contentEl.querySelectorAll('.code-snippet__fix').forEach(el => {
|
|
127
|
+
el.querySelectorAll('.code-snippet__line-index').forEach(li => li.remove());
|
|
128
|
+
const pre = el.querySelector('pre[data-lang]');
|
|
129
|
+
const lang = pre ? (pre.getAttribute('data-lang') || '') : '';
|
|
130
|
+
const lines = [];
|
|
131
|
+
el.querySelectorAll('code').forEach(codeTag => {
|
|
132
|
+
const text = codeTag.textContent;
|
|
133
|
+
if (/^[ce]?ounter\\(line/.test(text)) return;
|
|
134
|
+
lines.push(text);
|
|
135
|
+
});
|
|
136
|
+
if (lines.length === 0) lines.push(el.textContent);
|
|
137
|
+
const placeholder = 'CODEBLOCK-PLACEHOLDER-' + codeBlocks.length;
|
|
138
|
+
codeBlocks.push({ lang, code: lines.join('\\n') });
|
|
139
|
+
const p = document.createElement('p');
|
|
140
|
+
p.textContent = placeholder;
|
|
141
|
+
el.replaceWith(p);
|
|
142
|
+
});
|
|
143
|
+
result.codeBlocks = codeBlocks;
|
|
144
|
+
|
|
145
|
+
// Remove noise elements
|
|
146
|
+
['script', 'style', '.qr_code_pc', '.reward_area'].forEach(sel => {
|
|
147
|
+
contentEl.querySelectorAll(sel).forEach(tag => tag.remove());
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// Collect image URLs (deduplicated)
|
|
151
|
+
const seen = new Set();
|
|
152
|
+
contentEl.querySelectorAll('img[src]').forEach(img => {
|
|
153
|
+
const src = img.getAttribute('src');
|
|
154
|
+
if (src && !seen.has(src)) {
|
|
155
|
+
seen.add(src);
|
|
156
|
+
result.imageUrls.push(src);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
result.contentHtml = contentEl.innerHTML;
|
|
161
|
+
return result;
|
|
162
|
+
})()
|
|
163
|
+
`);
|
|
164
|
+
return downloadArticle({
|
|
165
|
+
title: data?.title || '',
|
|
166
|
+
author: data?.author,
|
|
167
|
+
publishTime: data?.publishTime,
|
|
168
|
+
sourceUrl: url,
|
|
169
|
+
contentHtml: data?.contentHtml || '',
|
|
170
|
+
codeBlocks: data?.codeBlocks,
|
|
171
|
+
imageUrls: data?.imageUrls,
|
|
172
|
+
}, {
|
|
173
|
+
output: kwargs.output,
|
|
174
|
+
downloadImages: kwargs['download-images'],
|
|
175
|
+
imageHeaders: { Referer: 'https://mp.weixin.qq.com/' },
|
|
176
|
+
frontmatterLabels: { author: '公众号' },
|
|
177
|
+
detectImageExt: (url) => {
|
|
178
|
+
const m = url.match(/wx_fmt=(\w+)/) || url.match(/\.(\w{3,4})(?:\?|$)/);
|
|
179
|
+
return m ? m[1] : 'png';
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
},
|
|
183
|
+
});
|