@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.
Files changed (218) hide show
  1. package/CHANGELOG.md +128 -0
  2. package/README.md +44 -5
  3. package/README.zh-CN.md +44 -5
  4. package/SKILL.md +317 -5
  5. package/TESTING.md +4 -4
  6. package/dist/browser/errors.d.ts +2 -1
  7. package/dist/browser/errors.js +9 -10
  8. package/dist/build-manifest.js +1 -3
  9. package/dist/cli-manifest.json +2573 -989
  10. package/dist/cli.js +42 -2
  11. package/dist/clis/bilibili/download.js +20 -65
  12. package/dist/clis/bilibili/utils.js +2 -1
  13. package/dist/clis/chaoxing/assignments.js +2 -1
  14. package/dist/clis/doubao/ask.d.ts +1 -0
  15. package/dist/clis/doubao/ask.js +35 -0
  16. package/dist/clis/doubao/common.d.ts +23 -0
  17. package/dist/clis/doubao/common.js +564 -0
  18. package/dist/clis/doubao/new.d.ts +1 -0
  19. package/dist/clis/doubao/new.js +20 -0
  20. package/dist/clis/doubao/read.d.ts +1 -0
  21. package/dist/clis/doubao/read.js +19 -0
  22. package/dist/clis/doubao/send.d.ts +1 -0
  23. package/dist/clis/doubao/send.js +22 -0
  24. package/dist/clis/doubao/status.d.ts +1 -0
  25. package/dist/clis/doubao/status.js +24 -0
  26. package/dist/clis/doubao-app/ask.d.ts +1 -0
  27. package/dist/clis/doubao-app/ask.js +53 -0
  28. package/dist/clis/doubao-app/common.d.ts +37 -0
  29. package/dist/clis/doubao-app/common.js +110 -0
  30. package/dist/clis/doubao-app/dump.d.ts +1 -0
  31. package/dist/clis/doubao-app/dump.js +24 -0
  32. package/dist/clis/doubao-app/new.d.ts +1 -0
  33. package/dist/clis/doubao-app/new.js +20 -0
  34. package/dist/clis/doubao-app/read.d.ts +1 -0
  35. package/dist/clis/doubao-app/read.js +18 -0
  36. package/dist/clis/doubao-app/screenshot.d.ts +1 -0
  37. package/dist/clis/doubao-app/screenshot.js +18 -0
  38. package/dist/clis/doubao-app/send.d.ts +1 -0
  39. package/dist/clis/doubao-app/send.js +27 -0
  40. package/dist/clis/doubao-app/status.d.ts +1 -0
  41. package/dist/clis/doubao-app/status.js +16 -0
  42. package/dist/clis/hackernews/ask.yaml +38 -0
  43. package/dist/clis/hackernews/best.yaml +38 -0
  44. package/dist/clis/hackernews/jobs.yaml +36 -0
  45. package/dist/clis/hackernews/new.yaml +38 -0
  46. package/dist/clis/hackernews/search.yaml +44 -0
  47. package/dist/clis/hackernews/show.yaml +38 -0
  48. package/dist/clis/hackernews/top.yaml +3 -1
  49. package/dist/clis/hackernews/user.yaml +25 -0
  50. package/dist/clis/twitter/download.js +13 -97
  51. package/dist/clis/twitter/thread.js +2 -1
  52. package/dist/clis/v2ex/member.yaml +29 -0
  53. package/dist/clis/v2ex/node.yaml +34 -0
  54. package/dist/clis/v2ex/nodes.yaml +31 -0
  55. package/dist/clis/v2ex/replies.yaml +32 -0
  56. package/dist/clis/v2ex/user.yaml +34 -0
  57. package/dist/clis/weibo/search.d.ts +1 -0
  58. package/dist/clis/weibo/search.js +73 -0
  59. package/dist/clis/weixin/download.d.ts +12 -0
  60. package/dist/clis/weixin/download.js +183 -0
  61. package/dist/clis/xiaohongshu/download.js +12 -60
  62. package/dist/clis/xiaohongshu/publish.d.ts +18 -0
  63. package/dist/clis/xiaohongshu/publish.js +352 -0
  64. package/dist/clis/xiaohongshu/search.js +47 -15
  65. package/dist/clis/xiaohongshu/search.test.d.ts +1 -0
  66. package/dist/clis/xiaohongshu/search.test.js +114 -0
  67. package/dist/clis/yollomi/background.d.ts +4 -0
  68. package/dist/clis/yollomi/background.js +45 -0
  69. package/dist/clis/yollomi/edit.d.ts +5 -0
  70. package/dist/clis/yollomi/edit.js +56 -0
  71. package/dist/clis/yollomi/face-swap.d.ts +5 -0
  72. package/dist/clis/yollomi/face-swap.js +43 -0
  73. package/dist/clis/yollomi/generate.d.ts +9 -0
  74. package/dist/clis/yollomi/generate.js +100 -0
  75. package/dist/clis/yollomi/models.d.ts +1 -0
  76. package/dist/clis/yollomi/models.js +33 -0
  77. package/dist/clis/yollomi/object-remover.d.ts +4 -0
  78. package/dist/clis/yollomi/object-remover.js +42 -0
  79. package/dist/clis/yollomi/remove-bg.d.ts +4 -0
  80. package/dist/clis/yollomi/remove-bg.js +38 -0
  81. package/dist/clis/yollomi/restore.d.ts +4 -0
  82. package/dist/clis/yollomi/restore.js +38 -0
  83. package/dist/clis/yollomi/try-on.d.ts +4 -0
  84. package/dist/clis/yollomi/try-on.js +46 -0
  85. package/dist/clis/yollomi/upload.d.ts +7 -0
  86. package/dist/clis/yollomi/upload.js +71 -0
  87. package/dist/clis/yollomi/upscale.d.ts +4 -0
  88. package/dist/clis/yollomi/upscale.js +53 -0
  89. package/dist/clis/yollomi/utils.d.ts +45 -0
  90. package/dist/clis/yollomi/utils.js +180 -0
  91. package/dist/clis/yollomi/video.d.ts +5 -0
  92. package/dist/clis/yollomi/video.js +56 -0
  93. package/dist/clis/zhihu/download.d.ts +1 -5
  94. package/dist/clis/zhihu/download.js +20 -126
  95. package/dist/clis/zhihu/download.test.js +7 -5
  96. package/dist/clis/zhihu/question.js +2 -1
  97. package/dist/commanderAdapter.js +4 -6
  98. package/dist/daemon.js +5 -2
  99. package/dist/discovery.js +10 -10
  100. package/dist/download/article-download.d.ts +59 -0
  101. package/dist/download/article-download.js +178 -0
  102. package/dist/download/media-download.d.ts +49 -0
  103. package/dist/download/media-download.js +112 -0
  104. package/dist/errors.d.ts +23 -2
  105. package/dist/errors.js +58 -2
  106. package/dist/errors.test.d.ts +1 -0
  107. package/dist/errors.test.js +59 -0
  108. package/dist/execution.js +9 -10
  109. package/dist/explore.js +4 -2
  110. package/dist/external.d.ts +15 -0
  111. package/dist/external.js +48 -2
  112. package/dist/external.test.d.ts +1 -0
  113. package/dist/external.test.js +64 -0
  114. package/dist/main.js +10 -0
  115. package/dist/plugin.d.ts +4 -0
  116. package/dist/plugin.js +45 -23
  117. package/dist/plugin.test.js +6 -1
  118. package/dist/record.d.ts +47 -0
  119. package/dist/record.js +545 -0
  120. package/dist/registry.d.ts +7 -2
  121. package/dist/registry.js +2 -6
  122. package/dist/runtime.d.ts +3 -1
  123. package/dist/runtime.js +10 -3
  124. package/dist/validate.js +1 -3
  125. package/docs/.vitepress/config.mts +1 -0
  126. package/docs/adapters/browser/doubao.md +35 -0
  127. package/docs/adapters/browser/hackernews.md +20 -4
  128. package/docs/adapters/browser/tiktok.md +1 -1
  129. package/docs/adapters/browser/v2ex.md +31 -10
  130. package/docs/adapters/browser/weibo.md +4 -0
  131. package/docs/adapters/browser/weixin.md +33 -0
  132. package/docs/adapters/browser/xiaohongshu.md +8 -6
  133. package/docs/adapters/browser/yollomi.md +69 -0
  134. package/docs/adapters/desktop/doubao-app.md +35 -0
  135. package/docs/adapters/index.md +16 -5
  136. package/docs/advanced/download.md +4 -0
  137. package/package.json +3 -1
  138. package/src/browser/errors.ts +17 -11
  139. package/src/build-manifest.ts +2 -3
  140. package/src/cli.ts +45 -2
  141. package/src/clis/bilibili/download.ts +25 -83
  142. package/src/clis/bilibili/utils.ts +2 -1
  143. package/src/clis/chaoxing/assignments.ts +2 -1
  144. package/src/clis/doubao/ask.ts +40 -0
  145. package/src/clis/doubao/common.ts +619 -0
  146. package/src/clis/doubao/new.ts +22 -0
  147. package/src/clis/doubao/read.ts +20 -0
  148. package/src/clis/doubao/send.ts +25 -0
  149. package/src/clis/doubao/status.ts +27 -0
  150. package/src/clis/doubao-app/ask.ts +60 -0
  151. package/src/clis/doubao-app/common.ts +116 -0
  152. package/src/clis/doubao-app/dump.ts +28 -0
  153. package/src/clis/doubao-app/new.ts +21 -0
  154. package/src/clis/doubao-app/read.ts +21 -0
  155. package/src/clis/doubao-app/screenshot.ts +19 -0
  156. package/src/clis/doubao-app/send.ts +30 -0
  157. package/src/clis/doubao-app/status.ts +17 -0
  158. package/src/clis/hackernews/ask.yaml +38 -0
  159. package/src/clis/hackernews/best.yaml +38 -0
  160. package/src/clis/hackernews/jobs.yaml +36 -0
  161. package/src/clis/hackernews/new.yaml +38 -0
  162. package/src/clis/hackernews/search.yaml +44 -0
  163. package/src/clis/hackernews/show.yaml +38 -0
  164. package/src/clis/hackernews/top.yaml +3 -1
  165. package/src/clis/hackernews/user.yaml +25 -0
  166. package/src/clis/twitter/download.ts +13 -111
  167. package/src/clis/twitter/thread.ts +2 -1
  168. package/src/clis/v2ex/member.yaml +29 -0
  169. package/src/clis/v2ex/node.yaml +34 -0
  170. package/src/clis/v2ex/nodes.yaml +31 -0
  171. package/src/clis/v2ex/replies.yaml +32 -0
  172. package/src/clis/v2ex/user.yaml +34 -0
  173. package/src/clis/weibo/search.ts +78 -0
  174. package/src/clis/weixin/download.ts +199 -0
  175. package/src/clis/xiaohongshu/download.ts +12 -71
  176. package/src/clis/xiaohongshu/publish.ts +392 -0
  177. package/src/clis/xiaohongshu/search.test.ts +134 -0
  178. package/src/clis/xiaohongshu/search.ts +49 -15
  179. package/src/clis/yollomi/background.ts +48 -0
  180. package/src/clis/yollomi/edit.ts +58 -0
  181. package/src/clis/yollomi/face-swap.ts +45 -0
  182. package/src/clis/yollomi/generate.ts +95 -0
  183. package/src/clis/yollomi/models.ts +38 -0
  184. package/src/clis/yollomi/object-remover.ts +44 -0
  185. package/src/clis/yollomi/remove-bg.ts +40 -0
  186. package/src/clis/yollomi/restore.ts +40 -0
  187. package/src/clis/yollomi/try-on.ts +48 -0
  188. package/src/clis/yollomi/upload.ts +78 -0
  189. package/src/clis/yollomi/upscale.ts +49 -0
  190. package/src/clis/yollomi/utils.ts +202 -0
  191. package/src/clis/yollomi/video.ts +61 -0
  192. package/src/clis/zhihu/download.test.ts +7 -5
  193. package/src/clis/zhihu/download.ts +23 -158
  194. package/src/clis/zhihu/question.ts +2 -1
  195. package/src/commanderAdapter.ts +4 -7
  196. package/src/daemon.ts +5 -2
  197. package/src/discovery.ts +26 -26
  198. package/src/download/article-download.ts +272 -0
  199. package/src/download/media-download.ts +178 -0
  200. package/src/errors.test.ts +79 -0
  201. package/src/errors.ts +92 -2
  202. package/src/execution.ts +14 -10
  203. package/src/explore.ts +4 -2
  204. package/src/external.test.ts +88 -0
  205. package/src/external.ts +56 -2
  206. package/src/generate.ts +2 -1
  207. package/src/main.ts +10 -0
  208. package/src/plugin.test.ts +7 -1
  209. package/src/plugin.ts +49 -25
  210. package/src/record.ts +617 -0
  211. package/src/registry.ts +9 -5
  212. package/src/runtime.ts +16 -4
  213. package/src/validate.ts +2 -3
  214. package/tests/e2e/browser-auth.test.ts +10 -1
  215. package/tests/e2e/browser-public.test.ts +13 -8
  216. package/tests/e2e/public-commands.test.ts +209 -21
  217. package/tests/smoke/api-health.test.ts +65 -6
  218. package/.github/workflows/release-please.yml +0 -25
@@ -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
- httpDownload,
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 cookies = await page.getCookies({ domain: 'x.com' });
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 tracker = new DownloadProgressTracker(uniqueMedia.length, true);
140
- const results: any[] = [];
141
-
142
- for (let i = 0; i < uniqueMedia.length; i++) {
143
- const media = uniqueMedia[i];
144
- const ext = media.type === 'image' ? 'jpg' : 'mp4';
145
- const filename = `${username || 'tweet'}_${i + 1}.${ext}`;
146
- const destPath = path.join(outputDir, filename);
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 Error('Not logged into x.com (no ct0 cookie)');
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(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/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
+ });