@jackwener/opencli 1.3.0 → 1.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +128 -0
- package/README.md +44 -5
- package/README.zh-CN.md +44 -5
- package/SKILL.md +317 -5
- package/TESTING.md +4 -4
- package/dist/browser/errors.d.ts +2 -1
- package/dist/browser/errors.js +9 -10
- package/dist/build-manifest.js +1 -3
- package/dist/cli-manifest.json +2573 -989
- package/dist/cli.js +42 -2
- package/dist/clis/bilibili/download.js +20 -65
- package/dist/clis/bilibili/utils.js +2 -1
- package/dist/clis/chaoxing/assignments.js +2 -1
- package/dist/clis/doubao/ask.d.ts +1 -0
- package/dist/clis/doubao/ask.js +35 -0
- package/dist/clis/doubao/common.d.ts +23 -0
- package/dist/clis/doubao/common.js +564 -0
- package/dist/clis/doubao/new.d.ts +1 -0
- package/dist/clis/doubao/new.js +20 -0
- package/dist/clis/doubao/read.d.ts +1 -0
- package/dist/clis/doubao/read.js +19 -0
- package/dist/clis/doubao/send.d.ts +1 -0
- package/dist/clis/doubao/send.js +22 -0
- package/dist/clis/doubao/status.d.ts +1 -0
- package/dist/clis/doubao/status.js +24 -0
- package/dist/clis/doubao-app/ask.d.ts +1 -0
- package/dist/clis/doubao-app/ask.js +53 -0
- package/dist/clis/doubao-app/common.d.ts +37 -0
- package/dist/clis/doubao-app/common.js +110 -0
- package/dist/clis/doubao-app/dump.d.ts +1 -0
- package/dist/clis/doubao-app/dump.js +24 -0
- package/dist/clis/doubao-app/new.d.ts +1 -0
- package/dist/clis/doubao-app/new.js +20 -0
- package/dist/clis/doubao-app/read.d.ts +1 -0
- package/dist/clis/doubao-app/read.js +18 -0
- package/dist/clis/doubao-app/screenshot.d.ts +1 -0
- package/dist/clis/doubao-app/screenshot.js +18 -0
- package/dist/clis/doubao-app/send.d.ts +1 -0
- package/dist/clis/doubao-app/send.js +27 -0
- package/dist/clis/doubao-app/status.d.ts +1 -0
- package/dist/clis/doubao-app/status.js +16 -0
- package/dist/clis/hackernews/ask.yaml +38 -0
- package/dist/clis/hackernews/best.yaml +38 -0
- package/dist/clis/hackernews/jobs.yaml +36 -0
- package/dist/clis/hackernews/new.yaml +38 -0
- package/dist/clis/hackernews/search.yaml +44 -0
- package/dist/clis/hackernews/show.yaml +38 -0
- package/dist/clis/hackernews/top.yaml +3 -1
- package/dist/clis/hackernews/user.yaml +25 -0
- package/dist/clis/twitter/download.js +13 -97
- package/dist/clis/twitter/thread.js +2 -1
- package/dist/clis/v2ex/member.yaml +29 -0
- package/dist/clis/v2ex/node.yaml +34 -0
- package/dist/clis/v2ex/nodes.yaml +31 -0
- package/dist/clis/v2ex/replies.yaml +32 -0
- package/dist/clis/v2ex/user.yaml +34 -0
- package/dist/clis/weibo/search.d.ts +1 -0
- package/dist/clis/weibo/search.js +73 -0
- package/dist/clis/weixin/download.d.ts +12 -0
- package/dist/clis/weixin/download.js +183 -0
- package/dist/clis/xiaohongshu/download.js +12 -60
- package/dist/clis/xiaohongshu/publish.d.ts +18 -0
- package/dist/clis/xiaohongshu/publish.js +352 -0
- package/dist/clis/xiaohongshu/search.js +47 -15
- package/dist/clis/xiaohongshu/search.test.d.ts +1 -0
- package/dist/clis/xiaohongshu/search.test.js +114 -0
- package/dist/clis/yollomi/background.d.ts +4 -0
- package/dist/clis/yollomi/background.js +45 -0
- package/dist/clis/yollomi/edit.d.ts +5 -0
- package/dist/clis/yollomi/edit.js +56 -0
- package/dist/clis/yollomi/face-swap.d.ts +5 -0
- package/dist/clis/yollomi/face-swap.js +43 -0
- package/dist/clis/yollomi/generate.d.ts +9 -0
- package/dist/clis/yollomi/generate.js +100 -0
- package/dist/clis/yollomi/models.d.ts +1 -0
- package/dist/clis/yollomi/models.js +33 -0
- package/dist/clis/yollomi/object-remover.d.ts +4 -0
- package/dist/clis/yollomi/object-remover.js +42 -0
- package/dist/clis/yollomi/remove-bg.d.ts +4 -0
- package/dist/clis/yollomi/remove-bg.js +38 -0
- package/dist/clis/yollomi/restore.d.ts +4 -0
- package/dist/clis/yollomi/restore.js +38 -0
- package/dist/clis/yollomi/try-on.d.ts +4 -0
- package/dist/clis/yollomi/try-on.js +46 -0
- package/dist/clis/yollomi/upload.d.ts +7 -0
- package/dist/clis/yollomi/upload.js +71 -0
- package/dist/clis/yollomi/upscale.d.ts +4 -0
- package/dist/clis/yollomi/upscale.js +53 -0
- package/dist/clis/yollomi/utils.d.ts +45 -0
- package/dist/clis/yollomi/utils.js +180 -0
- package/dist/clis/yollomi/video.d.ts +5 -0
- package/dist/clis/yollomi/video.js +56 -0
- package/dist/clis/zhihu/download.d.ts +1 -5
- package/dist/clis/zhihu/download.js +20 -126
- package/dist/clis/zhihu/download.test.js +7 -5
- package/dist/clis/zhihu/question.js +2 -1
- package/dist/commanderAdapter.js +4 -6
- package/dist/daemon.js +5 -2
- package/dist/discovery.js +10 -10
- package/dist/download/article-download.d.ts +59 -0
- package/dist/download/article-download.js +178 -0
- package/dist/download/media-download.d.ts +49 -0
- package/dist/download/media-download.js +112 -0
- package/dist/errors.d.ts +23 -2
- package/dist/errors.js +58 -2
- package/dist/errors.test.d.ts +1 -0
- package/dist/errors.test.js +59 -0
- package/dist/execution.js +9 -10
- package/dist/explore.js +4 -2
- package/dist/external.d.ts +15 -0
- package/dist/external.js +48 -2
- package/dist/external.test.d.ts +1 -0
- package/dist/external.test.js +64 -0
- package/dist/main.js +10 -0
- package/dist/plugin.d.ts +4 -0
- package/dist/plugin.js +45 -23
- package/dist/plugin.test.js +6 -1
- package/dist/record.d.ts +47 -0
- package/dist/record.js +545 -0
- package/dist/registry.d.ts +7 -2
- package/dist/registry.js +2 -6
- package/dist/runtime.d.ts +3 -1
- package/dist/runtime.js +10 -3
- package/dist/validate.js +1 -3
- package/docs/.vitepress/config.mts +1 -0
- package/docs/adapters/browser/doubao.md +35 -0
- package/docs/adapters/browser/hackernews.md +20 -4
- package/docs/adapters/browser/tiktok.md +1 -1
- package/docs/adapters/browser/v2ex.md +31 -10
- package/docs/adapters/browser/weibo.md +4 -0
- package/docs/adapters/browser/weixin.md +33 -0
- package/docs/adapters/browser/xiaohongshu.md +8 -6
- package/docs/adapters/browser/yollomi.md +69 -0
- package/docs/adapters/desktop/doubao-app.md +35 -0
- package/docs/adapters/index.md +16 -5
- package/docs/advanced/download.md +4 -0
- package/package.json +3 -1
- package/src/browser/errors.ts +17 -11
- package/src/build-manifest.ts +2 -3
- package/src/cli.ts +45 -2
- package/src/clis/bilibili/download.ts +25 -83
- package/src/clis/bilibili/utils.ts +2 -1
- package/src/clis/chaoxing/assignments.ts +2 -1
- package/src/clis/doubao/ask.ts +40 -0
- package/src/clis/doubao/common.ts +619 -0
- package/src/clis/doubao/new.ts +22 -0
- package/src/clis/doubao/read.ts +20 -0
- package/src/clis/doubao/send.ts +25 -0
- package/src/clis/doubao/status.ts +27 -0
- package/src/clis/doubao-app/ask.ts +60 -0
- package/src/clis/doubao-app/common.ts +116 -0
- package/src/clis/doubao-app/dump.ts +28 -0
- package/src/clis/doubao-app/new.ts +21 -0
- package/src/clis/doubao-app/read.ts +21 -0
- package/src/clis/doubao-app/screenshot.ts +19 -0
- package/src/clis/doubao-app/send.ts +30 -0
- package/src/clis/doubao-app/status.ts +17 -0
- package/src/clis/hackernews/ask.yaml +38 -0
- package/src/clis/hackernews/best.yaml +38 -0
- package/src/clis/hackernews/jobs.yaml +36 -0
- package/src/clis/hackernews/new.yaml +38 -0
- package/src/clis/hackernews/search.yaml +44 -0
- package/src/clis/hackernews/show.yaml +38 -0
- package/src/clis/hackernews/top.yaml +3 -1
- package/src/clis/hackernews/user.yaml +25 -0
- package/src/clis/twitter/download.ts +13 -111
- package/src/clis/twitter/thread.ts +2 -1
- package/src/clis/v2ex/member.yaml +29 -0
- package/src/clis/v2ex/node.yaml +34 -0
- package/src/clis/v2ex/nodes.yaml +31 -0
- package/src/clis/v2ex/replies.yaml +32 -0
- package/src/clis/v2ex/user.yaml +34 -0
- package/src/clis/weibo/search.ts +78 -0
- package/src/clis/weixin/download.ts +199 -0
- package/src/clis/xiaohongshu/download.ts +12 -71
- package/src/clis/xiaohongshu/publish.ts +392 -0
- package/src/clis/xiaohongshu/search.test.ts +134 -0
- package/src/clis/xiaohongshu/search.ts +49 -15
- package/src/clis/yollomi/background.ts +48 -0
- package/src/clis/yollomi/edit.ts +58 -0
- package/src/clis/yollomi/face-swap.ts +45 -0
- package/src/clis/yollomi/generate.ts +95 -0
- package/src/clis/yollomi/models.ts +38 -0
- package/src/clis/yollomi/object-remover.ts +44 -0
- package/src/clis/yollomi/remove-bg.ts +40 -0
- package/src/clis/yollomi/restore.ts +40 -0
- package/src/clis/yollomi/try-on.ts +48 -0
- package/src/clis/yollomi/upload.ts +78 -0
- package/src/clis/yollomi/upscale.ts +49 -0
- package/src/clis/yollomi/utils.ts +202 -0
- package/src/clis/yollomi/video.ts +61 -0
- package/src/clis/zhihu/download.test.ts +7 -5
- package/src/clis/zhihu/download.ts +23 -158
- package/src/clis/zhihu/question.ts +2 -1
- package/src/commanderAdapter.ts +4 -7
- package/src/daemon.ts +5 -2
- package/src/discovery.ts +26 -26
- package/src/download/article-download.ts +272 -0
- package/src/download/media-download.ts +178 -0
- package/src/errors.test.ts +79 -0
- package/src/errors.ts +92 -2
- package/src/execution.ts +14 -10
- package/src/explore.ts +4 -2
- package/src/external.test.ts +88 -0
- package/src/external.ts +56 -2
- package/src/generate.ts +2 -1
- package/src/main.ts +10 -0
- package/src/plugin.test.ts +7 -1
- package/src/plugin.ts +49 -25
- package/src/record.ts +617 -0
- package/src/registry.ts +9 -5
- package/src/runtime.ts +16 -4
- package/src/validate.ts +2 -3
- package/tests/e2e/browser-auth.test.ts +10 -1
- package/tests/e2e/browser-public.test.ts +13 -8
- package/tests/e2e/public-commands.test.ts +209 -21
- package/tests/smoke/api-health.test.ts +65 -6
- package/.github/workflows/release-please.yml +0 -25
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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(
|
|
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 ? `` : 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
|
+
});
|