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