@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
package/src/discovery.ts CHANGED
@@ -14,6 +14,7 @@ import * as path from 'node:path';
14
14
  import { pathToFileURL } from 'node:url';
15
15
  import yaml from 'js-yaml';
16
16
  import { type CliCommand, type InternalCliCommand, type Arg, Strategy, registerCommand } from './registry.js';
17
+ import { getErrorMessage } from './errors.js';
17
18
  import { log } from './logger.js';
18
19
  import type { ManifestEntry } from './build-manifest.js';
19
20
 
@@ -45,10 +46,6 @@ interface YamlCliDefinition {
45
46
  navigateBefore?: boolean | string;
46
47
  }
47
48
 
48
- function getErrorMessage(error: unknown): string {
49
- return error instanceof Error ? error.message : String(error);
50
- }
51
-
52
49
  function parseStrategy(rawStrategy: string | undefined, fallback: Strategy = Strategy.COOKIE): Strategy {
53
50
  if (!rawStrategy) return fallback;
54
51
  const key = rawStrategy.toUpperCase() as keyof typeof Strategy;
@@ -142,29 +139,32 @@ async function discoverClisFromFs(dir: string): Promise<void> {
142
139
  const promises: Promise<unknown>[] = [];
143
140
  const entries = await fs.promises.readdir(dir, { withFileTypes: true });
144
141
 
145
- for (const entry of entries) {
146
- if (!entry.isDirectory()) continue;
147
- const site = entry.name;
148
- const siteDir = path.join(dir, site);
149
- const files = await fs.promises.readdir(siteDir);
150
- for (const file of files) {
151
- const filePath = path.join(siteDir, file);
152
- if (file.endsWith('.yaml') || file.endsWith('.yml')) {
153
- promises.push(registerYamlCli(filePath, site));
154
- } else if (
155
- (file.endsWith('.js') && !file.endsWith('.d.js')) ||
156
- (file.endsWith('.ts') && !file.endsWith('.d.ts') && !file.endsWith('.test.ts'))
157
- ) {
158
- if (!(await isCliModule(filePath))) continue;
159
- promises.push(
160
- import(pathToFileURL(filePath).href).catch((err) => {
161
- log.warn(`Failed to load module ${filePath}: ${getErrorMessage(err)}`);
162
- })
163
- );
142
+ const sitePromises = entries
143
+ .filter(entry => entry.isDirectory())
144
+ .map(async (entry) => {
145
+ const site = entry.name;
146
+ const siteDir = path.join(dir, site);
147
+ const files = await fs.promises.readdir(siteDir);
148
+ const filePromises: Promise<unknown>[] = [];
149
+ for (const file of files) {
150
+ const filePath = path.join(siteDir, file);
151
+ if (file.endsWith('.yaml') || file.endsWith('.yml')) {
152
+ filePromises.push(registerYamlCli(filePath, site));
153
+ } else if (
154
+ (file.endsWith('.js') && !file.endsWith('.d.js')) ||
155
+ (file.endsWith('.ts') && !file.endsWith('.d.ts') && !file.endsWith('.test.ts'))
156
+ ) {
157
+ if (!(await isCliModule(filePath))) continue;
158
+ filePromises.push(
159
+ import(pathToFileURL(filePath).href).catch((err) => {
160
+ log.warn(`Failed to load module ${filePath}: ${getErrorMessage(err)}`);
161
+ })
162
+ );
163
+ }
164
164
  }
165
- }
166
- }
167
- await Promise.all(promises);
165
+ await Promise.all(filePromises);
166
+ });
167
+ await Promise.all(sitePromises);
168
168
  }
169
169
 
170
170
  async function registerYamlCli(filePath: string, defaultSite: string): Promise<void> {
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Article download helper — shared logic for downloading articles as Markdown.
3
+ *
4
+ * Used by: zhihu/download, weixin/download, and future article adapters.
5
+ *
6
+ * Flow: ArticleData → TurndownService → image download → frontmatter → .md file
7
+ */
8
+
9
+ import * as fs from 'node:fs';
10
+ import * as path from 'node:path';
11
+ import TurndownService from 'turndown';
12
+ import { httpDownload, sanitizeFilename } from './index.js';
13
+ import { formatBytes } from './progress.js';
14
+
15
+ const IMAGE_CONCURRENCY = 5;
16
+
17
+ // ============================================================
18
+ // Types
19
+ // ============================================================
20
+
21
+ export interface ArticleData {
22
+ title: string;
23
+ author?: string;
24
+ publishTime?: string;
25
+ sourceUrl?: string;
26
+ contentHtml: string;
27
+ /** Pre-extracted code blocks to restore after Markdown conversion */
28
+ codeBlocks?: Array<{ lang: string; code: string }>;
29
+ /** Image URLs found in the article (pre-collected from DOM) */
30
+ imageUrls?: string[];
31
+ }
32
+
33
+ export interface FrontmatterLabels {
34
+ author?: string;
35
+ publishTime?: string;
36
+ sourceUrl?: string;
37
+ }
38
+
39
+ export interface ArticleDownloadOptions {
40
+ output: string;
41
+ downloadImages?: boolean;
42
+ /** Extra headers for image downloads (e.g. { Referer: '...' }) */
43
+ imageHeaders?: Record<string, string>;
44
+ maxTitleLength?: number;
45
+ /** Custom TurndownService configuration callback */
46
+ configureTurndown?: (td: TurndownService) => void;
47
+ /** Custom image extension detector (default: infer from URL extension) */
48
+ detectImageExt?: (url: string) => string;
49
+ /** Custom frontmatter labels (default: Chinese labels) */
50
+ frontmatterLabels?: FrontmatterLabels;
51
+ }
52
+
53
+ export interface ArticleDownloadResult {
54
+ title: string;
55
+ author: string;
56
+ publish_time: string;
57
+ status: string;
58
+ size: string;
59
+ }
60
+
61
+ const DEFAULT_LABELS: Required<FrontmatterLabels> = {
62
+ author: '作者',
63
+ publishTime: '发布时间',
64
+ sourceUrl: '原文链接',
65
+ };
66
+
67
+ // ============================================================
68
+ // Markdown Conversion
69
+ // ============================================================
70
+
71
+ function createTurndown(configure?: (td: TurndownService) => void): TurndownService {
72
+ const td = new TurndownService({
73
+ headingStyle: 'atx',
74
+ codeBlockStyle: 'fenced',
75
+ bulletListMarker: '-',
76
+ });
77
+ td.addRule('linebreak', {
78
+ filter: 'br',
79
+ replacement: () => '\n',
80
+ });
81
+ if (configure) configure(td);
82
+ return td;
83
+ }
84
+
85
+ function convertToMarkdown(
86
+ contentHtml: string,
87
+ codeBlocks: Array<{ lang: string; code: string }>,
88
+ configure?: (td: TurndownService) => void,
89
+ ): string {
90
+ const td = createTurndown(configure);
91
+ let md = td.turndown(contentHtml);
92
+
93
+ // Restore code block placeholders
94
+ codeBlocks.forEach((block, i) => {
95
+ const placeholder = `CODEBLOCK-PLACEHOLDER-${i}`;
96
+ const fenced = `\n\`\`\`${block.lang}\n${block.code}\n\`\`\`\n`;
97
+ md = md.replace(placeholder, fenced);
98
+ });
99
+
100
+ // Clean up
101
+ md = md.replace(/\u00a0/g, ' ');
102
+ md = md.replace(/\n{4,}/g, '\n\n\n');
103
+ md = md.replace(/[ \t]+$/gm, '');
104
+
105
+ return md;
106
+ }
107
+
108
+ function replaceImageUrls(md: string, urlMap: Record<string, string>): string {
109
+ return md.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, imgUrl) => {
110
+ const local = urlMap[imgUrl];
111
+ return local ? `![${alt}](${local})` : match;
112
+ });
113
+ }
114
+
115
+ // ============================================================
116
+ // Image Downloading
117
+ // ============================================================
118
+
119
+ function defaultDetectImageExt(url: string): string {
120
+ const extMatch = url.match(/\.(\w{3,4})(?:\?|$)/);
121
+ return extMatch ? extMatch[1] : 'jpg';
122
+ }
123
+
124
+ async function downloadImages(
125
+ imgUrls: string[],
126
+ imgDir: string,
127
+ headers?: Record<string, string>,
128
+ detectExt?: (url: string) => string,
129
+ ): Promise<Record<string, string>> {
130
+ const urlMap: Record<string, string> = {};
131
+ if (imgUrls.length === 0) return urlMap;
132
+
133
+ const detect = detectExt || defaultDetectImageExt;
134
+
135
+ // Deduplicate image URLs
136
+ const seen = new Set<string>();
137
+ const uniqueUrls = imgUrls.filter(url => {
138
+ if (seen.has(url)) return false;
139
+ seen.add(url);
140
+ return true;
141
+ });
142
+
143
+ for (let i = 0; i < uniqueUrls.length; i += IMAGE_CONCURRENCY) {
144
+ const batch = uniqueUrls.slice(i, i + IMAGE_CONCURRENCY);
145
+ const results = await Promise.all(
146
+ batch.map(async (rawUrl, j) => {
147
+ const index = i + j + 1;
148
+ let imgUrl = rawUrl;
149
+ if (imgUrl.startsWith('//')) imgUrl = `https:${imgUrl}`;
150
+
151
+ const ext = detect(imgUrl);
152
+ const filename = `img_${String(index).padStart(3, '0')}.${ext}`;
153
+ const filepath = path.join(imgDir, filename);
154
+
155
+ try {
156
+ const result = await httpDownload(imgUrl, filepath, {
157
+ headers,
158
+ timeout: 15000,
159
+ });
160
+ if (result.success) {
161
+ return { remoteUrl: rawUrl, localPath: `images/${filename}` };
162
+ }
163
+ } catch {
164
+ // Skip failed downloads
165
+ }
166
+ return null;
167
+ }),
168
+ );
169
+
170
+ for (const r of results) {
171
+ if (r) urlMap[r.remoteUrl] = r.localPath;
172
+ }
173
+ }
174
+ return urlMap;
175
+ }
176
+
177
+ // ============================================================
178
+ // Main API
179
+ // ============================================================
180
+
181
+ /**
182
+ * Download an article to Markdown with optional image localization.
183
+ *
184
+ * Handles the full pipeline:
185
+ * 1. HTML → Markdown (via TurndownService)
186
+ * 2. Code block placeholder restoration
187
+ * 3. Batch image downloading with concurrency + deduplication
188
+ * 4. Image URL replacement in Markdown
189
+ * 5. Frontmatter generation (customizable labels)
190
+ * 6. File write
191
+ */
192
+ export async function downloadArticle(
193
+ data: ArticleData,
194
+ options: ArticleDownloadOptions,
195
+ ): Promise<ArticleDownloadResult[]> {
196
+ const {
197
+ output,
198
+ downloadImages: shouldDownloadImages = true,
199
+ imageHeaders,
200
+ maxTitleLength = 80,
201
+ configureTurndown,
202
+ detectImageExt,
203
+ frontmatterLabels,
204
+ } = options;
205
+
206
+ const labels = { ...DEFAULT_LABELS, ...frontmatterLabels };
207
+
208
+ if (!data.title) {
209
+ return [{
210
+ title: 'Error',
211
+ author: '-',
212
+ publish_time: '-',
213
+ status: 'failed — no title',
214
+ size: '-',
215
+ }];
216
+ }
217
+
218
+ if (!data.contentHtml) {
219
+ return [{
220
+ title: data.title,
221
+ author: data.author || '-',
222
+ publish_time: data.publishTime || '-',
223
+ status: 'failed — no content',
224
+ size: '-',
225
+ }];
226
+ }
227
+
228
+ // Convert HTML to Markdown
229
+ let markdown = convertToMarkdown(
230
+ data.contentHtml,
231
+ data.codeBlocks || [],
232
+ configureTurndown,
233
+ );
234
+
235
+ // Prepare output directory
236
+ const safeTitle = sanitizeFilename(data.title, maxTitleLength);
237
+ const articleDir = path.join(output, safeTitle);
238
+ fs.mkdirSync(articleDir, { recursive: true });
239
+
240
+ // Download images
241
+ if (shouldDownloadImages && data.imageUrls && data.imageUrls.length > 0) {
242
+ const imagesDir = path.join(articleDir, 'images');
243
+ fs.mkdirSync(imagesDir, { recursive: true });
244
+
245
+ const urlMap = await downloadImages(data.imageUrls, imagesDir, imageHeaders, detectImageExt);
246
+ markdown = replaceImageUrls(markdown, urlMap);
247
+ }
248
+
249
+ // Build frontmatter with customizable labels
250
+ const headerLines = [`# ${data.title}`, ''];
251
+ if (data.author) headerLines.push(`> ${labels.author}: ${data.author}`);
252
+ if (data.publishTime) headerLines.push(`> ${labels.publishTime}: ${data.publishTime}`);
253
+ if (data.sourceUrl) headerLines.push(`> ${labels.sourceUrl}: ${data.sourceUrl}`);
254
+ headerLines.push('', '---', '');
255
+
256
+ const fullContent = headerLines.join('\n') + markdown;
257
+
258
+ // Write file
259
+ const filename = `${safeTitle}.md`;
260
+ const filePath = path.join(articleDir, filename);
261
+ fs.writeFileSync(filePath, fullContent, 'utf-8');
262
+
263
+ const size = Buffer.byteLength(fullContent, 'utf-8');
264
+
265
+ return [{
266
+ title: data.title,
267
+ author: data.author || '-',
268
+ publish_time: data.publishTime || '-',
269
+ status: 'success',
270
+ size: formatBytes(size),
271
+ }];
272
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Media download helper — shared logic for batch downloading images/videos.
3
+ *
4
+ * Used by: xiaohongshu/download, twitter/download, bilibili/download,
5
+ * and future media adapters.
6
+ *
7
+ * Flow: MediaItem[] → DownloadProgressTracker → httpDownload/ytdlpDownload → results
8
+ */
9
+
10
+ import * as fs from 'node:fs';
11
+ import * as path from 'node:path';
12
+ import {
13
+ httpDownload,
14
+ ytdlpDownload,
15
+ checkYtdlp,
16
+ getTempDir,
17
+ exportCookiesToNetscape,
18
+ } from './index.js';
19
+ import type { BrowserCookie } from '../types.js';
20
+ import { DownloadProgressTracker, formatBytes } from './progress.js';
21
+
22
+ // ============================================================
23
+ // Types
24
+ // ============================================================
25
+
26
+ export interface MediaItem {
27
+ type: 'image' | 'video' | 'video-tweet' | 'video-ytdlp';
28
+ url: string;
29
+ /** Optional custom filename (without directory) */
30
+ filename?: string;
31
+ }
32
+
33
+ export interface MediaDownloadOptions {
34
+ output: string;
35
+ /** Subdirectory inside output */
36
+ subdir?: string;
37
+ /** Cookie string for HTTP downloads */
38
+ cookies?: string;
39
+ /** Raw browser cookies — auto-exported to Netscape for yt-dlp, auto-cleaned up */
40
+ browserCookies?: BrowserCookie[];
41
+ /** Timeout in ms (default: 30000 for images, 60000 for videos) */
42
+ timeout?: number;
43
+ /** File name prefix (default: 'download') */
44
+ filenamePrefix?: string;
45
+ /** Extra yt-dlp args */
46
+ ytdlpExtraArgs?: string[];
47
+ /** Whether to show progress (default: true) */
48
+ verbose?: boolean;
49
+ }
50
+
51
+ export interface MediaDownloadResult {
52
+ index: number;
53
+ type: string;
54
+ status: string;
55
+ size: string;
56
+ }
57
+
58
+ // ============================================================
59
+ // Main API
60
+ // ============================================================
61
+
62
+ /**
63
+ * Batch download media files with progress tracking.
64
+ *
65
+ * Handles:
66
+ * - DownloadProgressTracker for terminal UX
67
+ * - Automatic httpDownload vs ytdlpDownload routing via MediaItem.type
68
+ * - Cookie export to Netscape format for yt-dlp (auto-cleanup)
69
+ * - Directory creation
70
+ * - Error handling with per-file results
71
+ */
72
+ export async function downloadMedia(
73
+ items: MediaItem[],
74
+ options: MediaDownloadOptions,
75
+ ): Promise<MediaDownloadResult[]> {
76
+ const {
77
+ output,
78
+ subdir,
79
+ cookies,
80
+ browserCookies,
81
+ timeout,
82
+ filenamePrefix = 'download',
83
+ ytdlpExtraArgs = [],
84
+ verbose = true,
85
+ } = options;
86
+
87
+ if (!items || items.length === 0) {
88
+ return [{ index: 0, type: '-', status: 'failed', size: 'No media found' }];
89
+ }
90
+
91
+ // Create output directory
92
+ const outputDir = subdir ? path.join(output, subdir) : output;
93
+ fs.mkdirSync(outputDir, { recursive: true });
94
+
95
+ // Pre-check yt-dlp availability (once, not per-item)
96
+ const hasYtdlp = checkYtdlp();
97
+
98
+ // Auto-export browser cookies to Netscape format for yt-dlp
99
+ let cookiesFile: string | undefined;
100
+ const needsYtdlp = items.some(m => m.type === 'video-tweet' || m.type === 'video-ytdlp');
101
+ if (needsYtdlp && browserCookies && browserCookies.length > 0) {
102
+ const tempDir = getTempDir();
103
+ fs.mkdirSync(tempDir, { recursive: true });
104
+ cookiesFile = path.join(tempDir, `media_cookies_${Date.now()}.txt`);
105
+ exportCookiesToNetscape(browserCookies, cookiesFile);
106
+ }
107
+
108
+ const tracker = new DownloadProgressTracker(items.length, verbose);
109
+ const results: MediaDownloadResult[] = [];
110
+
111
+ try {
112
+ for (let i = 0; i < items.length; i++) {
113
+ const media = items[i];
114
+ const isVideo = media.type !== 'image';
115
+ const ext = isVideo ? 'mp4' : 'jpg';
116
+ const filename = media.filename || `${filenamePrefix}_${i + 1}.${ext}`;
117
+ const destPath = path.join(outputDir, filename);
118
+
119
+ const progressBar = tracker.onFileStart(filename, i);
120
+
121
+ try {
122
+ let result: { success: boolean; size: number; error?: string };
123
+ const useYtdlp = (media.type === 'video-tweet' || media.type === 'video-ytdlp') && hasYtdlp;
124
+
125
+ if (useYtdlp) {
126
+ result = await ytdlpDownload(media.url, destPath, {
127
+ cookiesFile,
128
+ extraArgs: ytdlpExtraArgs,
129
+ onProgress: (percent) => {
130
+ if (progressBar) progressBar.update(percent, 100);
131
+ },
132
+ });
133
+ } else {
134
+ // Direct HTTP download for images and direct video URLs
135
+ const dlTimeout = timeout || (isVideo ? 60000 : 30000);
136
+ result = await httpDownload(media.url, destPath, {
137
+ cookies,
138
+ timeout: dlTimeout,
139
+ onProgress: (received, total) => {
140
+ if (progressBar) progressBar.update(received, total);
141
+ },
142
+ });
143
+ }
144
+
145
+ if (progressBar) {
146
+ progressBar.complete(result.success, result.success ? formatBytes(result.size) : undefined);
147
+ }
148
+ tracker.onFileComplete(result.success);
149
+
150
+ results.push({
151
+ index: i + 1,
152
+ type: media.type === 'video-tweet' || media.type === 'video-ytdlp' ? 'video' : media.type,
153
+ status: result.success ? 'success' : 'failed',
154
+ size: result.success ? formatBytes(result.size) : (result.error || 'unknown error'),
155
+ });
156
+ } catch (err: any) {
157
+ if (progressBar) progressBar.fail(err.message);
158
+ tracker.onFileComplete(false);
159
+
160
+ results.push({
161
+ index: i + 1,
162
+ type: media.type,
163
+ status: 'failed',
164
+ size: err.message,
165
+ });
166
+ }
167
+ }
168
+ } finally {
169
+ tracker.finish();
170
+
171
+ // Auto-cleanup exported cookies file
172
+ if (cookiesFile && fs.existsSync(cookiesFile)) {
173
+ fs.unlinkSync(cookiesFile);
174
+ }
175
+ }
176
+
177
+ return results;
178
+ }
@@ -0,0 +1,79 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ CliError,
4
+ BrowserConnectError,
5
+ AdapterLoadError,
6
+ CommandExecutionError,
7
+ ConfigError,
8
+ AuthRequiredError,
9
+ TimeoutError,
10
+ ArgumentError,
11
+ EmptyResultError,
12
+ SelectorError,
13
+ } from './errors.js';
14
+
15
+ describe('Error type hierarchy', () => {
16
+ it('all error types extend CliError', () => {
17
+ const errors = [
18
+ new BrowserConnectError('test'),
19
+ new AdapterLoadError('test'),
20
+ new CommandExecutionError('test'),
21
+ new ConfigError('test'),
22
+ new AuthRequiredError('example.com'),
23
+ new TimeoutError('test', 30),
24
+ new ArgumentError('test'),
25
+ new EmptyResultError('test/cmd'),
26
+ new SelectorError('.btn'),
27
+ ];
28
+
29
+ for (const err of errors) {
30
+ expect(err).toBeInstanceOf(CliError);
31
+ expect(err).toBeInstanceOf(Error);
32
+ }
33
+ });
34
+
35
+ it('AuthRequiredError has correct code, domain, and auto-generated hint', () => {
36
+ const err = new AuthRequiredError('bilibili.com');
37
+ expect(err.code).toBe('AUTH_REQUIRED');
38
+ expect(err.domain).toBe('bilibili.com');
39
+ expect(err.message).toBe('Not logged in to bilibili.com');
40
+ expect(err.hint).toContain('https://bilibili.com');
41
+ });
42
+
43
+ it('AuthRequiredError accepts custom message', () => {
44
+ const err = new AuthRequiredError('x.com', 'No ct0 cookie found');
45
+ expect(err.message).toBe('No ct0 cookie found');
46
+ expect(err.hint).toContain('https://x.com');
47
+ });
48
+
49
+ it('TimeoutError has correct code and hint', () => {
50
+ const err = new TimeoutError('bilibili/hot', 60);
51
+ expect(err.code).toBe('TIMEOUT');
52
+ expect(err.message).toBe('bilibili/hot timed out after 60s');
53
+ expect(err.hint).toContain('timeout');
54
+ });
55
+
56
+ it('ArgumentError has correct code', () => {
57
+ const err = new ArgumentError('Argument "limit" must be a valid number');
58
+ expect(err.code).toBe('ARGUMENT');
59
+ });
60
+
61
+ it('EmptyResultError has default hint', () => {
62
+ const err = new EmptyResultError('hackernews/top');
63
+ expect(err.code).toBe('EMPTY_RESULT');
64
+ expect(err.message).toBe('hackernews/top returned no data');
65
+ expect(err.hint).toBeTruthy();
66
+ });
67
+
68
+ it('SelectorError has default hint about page changes', () => {
69
+ const err = new SelectorError('.submit-btn');
70
+ expect(err.code).toBe('SELECTOR');
71
+ expect(err.message).toContain('.submit-btn');
72
+ expect(err.hint).toContain('report');
73
+ });
74
+
75
+ it('BrowserConnectError has correct code', () => {
76
+ const err = new BrowserConnectError('Cannot connect');
77
+ expect(err.code).toBe('BROWSER_CONNECT');
78
+ });
79
+ });