@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.
Files changed (217) 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
@@ -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 { httpDownload, ytdlpDownload, checkYtdlp, getTempDir, exportCookiesToNetscape, formatCookieHeader, } from '../../download/index.js';
12
- import { DownloadProgressTracker, formatBytes } from '../../download/progress.js';
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 cookies = await page.getCookies({ domain: 'x.com' });
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 tracker = new DownloadProgressTracker(uniqueMedia.length, true);
122
- const results = [];
123
- for (let i = 0; i < uniqueMedia.length; i++) {
124
- const media = uniqueMedia[i];
125
- const ext = media.type === 'image' ? 'jpg' : 'mp4';
126
- const filename = `${username || 'tweet'}_${i + 1}.${ext}`;
127
- const destPath = path.join(outputDir, filename);
128
- const progressBar = tracker.onFileStart(filename, i);
129
- try {
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 Error('Not logged into x.com (no ct0 cookie)');
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(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/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
+ });