@jackwener/opencli 1.3.1 → 1.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +128 -0
- package/README.md +44 -5
- package/README.zh-CN.md +44 -5
- package/SKILL.md +317 -5
- package/TESTING.md +4 -4
- package/dist/browser/errors.d.ts +2 -1
- package/dist/browser/errors.js +9 -10
- package/dist/build-manifest.js +1 -3
- package/dist/cli-manifest.json +2573 -989
- package/dist/cli.js +42 -2
- package/dist/clis/bilibili/download.js +20 -65
- package/dist/clis/bilibili/utils.js +2 -1
- package/dist/clis/chaoxing/assignments.js +2 -1
- package/dist/clis/doubao/ask.d.ts +1 -0
- package/dist/clis/doubao/ask.js +35 -0
- package/dist/clis/doubao/common.d.ts +23 -0
- package/dist/clis/doubao/common.js +564 -0
- package/dist/clis/doubao/new.d.ts +1 -0
- package/dist/clis/doubao/new.js +20 -0
- package/dist/clis/doubao/read.d.ts +1 -0
- package/dist/clis/doubao/read.js +19 -0
- package/dist/clis/doubao/send.d.ts +1 -0
- package/dist/clis/doubao/send.js +22 -0
- package/dist/clis/doubao/status.d.ts +1 -0
- package/dist/clis/doubao/status.js +24 -0
- package/dist/clis/doubao-app/ask.d.ts +1 -0
- package/dist/clis/doubao-app/ask.js +53 -0
- package/dist/clis/doubao-app/common.d.ts +37 -0
- package/dist/clis/doubao-app/common.js +110 -0
- package/dist/clis/doubao-app/dump.d.ts +1 -0
- package/dist/clis/doubao-app/dump.js +24 -0
- package/dist/clis/doubao-app/new.d.ts +1 -0
- package/dist/clis/doubao-app/new.js +20 -0
- package/dist/clis/doubao-app/read.d.ts +1 -0
- package/dist/clis/doubao-app/read.js +18 -0
- package/dist/clis/doubao-app/screenshot.d.ts +1 -0
- package/dist/clis/doubao-app/screenshot.js +18 -0
- package/dist/clis/doubao-app/send.d.ts +1 -0
- package/dist/clis/doubao-app/send.js +27 -0
- package/dist/clis/doubao-app/status.d.ts +1 -0
- package/dist/clis/doubao-app/status.js +16 -0
- package/dist/clis/hackernews/ask.yaml +38 -0
- package/dist/clis/hackernews/best.yaml +38 -0
- package/dist/clis/hackernews/jobs.yaml +36 -0
- package/dist/clis/hackernews/new.yaml +38 -0
- package/dist/clis/hackernews/search.yaml +44 -0
- package/dist/clis/hackernews/show.yaml +38 -0
- package/dist/clis/hackernews/top.yaml +3 -1
- package/dist/clis/hackernews/user.yaml +25 -0
- package/dist/clis/twitter/download.js +13 -97
- package/dist/clis/twitter/thread.js +2 -1
- package/dist/clis/v2ex/member.yaml +29 -0
- package/dist/clis/v2ex/node.yaml +34 -0
- package/dist/clis/v2ex/nodes.yaml +31 -0
- package/dist/clis/v2ex/replies.yaml +32 -0
- package/dist/clis/v2ex/user.yaml +34 -0
- package/dist/clis/weibo/search.d.ts +1 -0
- package/dist/clis/weibo/search.js +73 -0
- package/dist/clis/weixin/download.d.ts +12 -0
- package/dist/clis/weixin/download.js +183 -0
- package/dist/clis/xiaohongshu/download.js +12 -60
- package/dist/clis/xiaohongshu/publish.d.ts +18 -0
- package/dist/clis/xiaohongshu/publish.js +352 -0
- package/dist/clis/xiaohongshu/search.js +47 -15
- package/dist/clis/xiaohongshu/search.test.d.ts +1 -0
- package/dist/clis/xiaohongshu/search.test.js +114 -0
- package/dist/clis/yollomi/background.d.ts +4 -0
- package/dist/clis/yollomi/background.js +45 -0
- package/dist/clis/yollomi/edit.d.ts +5 -0
- package/dist/clis/yollomi/edit.js +56 -0
- package/dist/clis/yollomi/face-swap.d.ts +5 -0
- package/dist/clis/yollomi/face-swap.js +43 -0
- package/dist/clis/yollomi/generate.d.ts +9 -0
- package/dist/clis/yollomi/generate.js +100 -0
- package/dist/clis/yollomi/models.d.ts +1 -0
- package/dist/clis/yollomi/models.js +33 -0
- package/dist/clis/yollomi/object-remover.d.ts +4 -0
- package/dist/clis/yollomi/object-remover.js +42 -0
- package/dist/clis/yollomi/remove-bg.d.ts +4 -0
- package/dist/clis/yollomi/remove-bg.js +38 -0
- package/dist/clis/yollomi/restore.d.ts +4 -0
- package/dist/clis/yollomi/restore.js +38 -0
- package/dist/clis/yollomi/try-on.d.ts +4 -0
- package/dist/clis/yollomi/try-on.js +46 -0
- package/dist/clis/yollomi/upload.d.ts +7 -0
- package/dist/clis/yollomi/upload.js +71 -0
- package/dist/clis/yollomi/upscale.d.ts +4 -0
- package/dist/clis/yollomi/upscale.js +53 -0
- package/dist/clis/yollomi/utils.d.ts +45 -0
- package/dist/clis/yollomi/utils.js +180 -0
- package/dist/clis/yollomi/video.d.ts +5 -0
- package/dist/clis/yollomi/video.js +56 -0
- package/dist/clis/zhihu/download.d.ts +1 -5
- package/dist/clis/zhihu/download.js +20 -126
- package/dist/clis/zhihu/download.test.js +7 -5
- package/dist/clis/zhihu/question.js +2 -1
- package/dist/commanderAdapter.js +4 -6
- package/dist/daemon.js +5 -2
- package/dist/discovery.js +10 -10
- package/dist/download/article-download.d.ts +59 -0
- package/dist/download/article-download.js +178 -0
- package/dist/download/media-download.d.ts +49 -0
- package/dist/download/media-download.js +112 -0
- package/dist/errors.d.ts +23 -2
- package/dist/errors.js +58 -2
- package/dist/errors.test.d.ts +1 -0
- package/dist/errors.test.js +59 -0
- package/dist/execution.js +9 -10
- package/dist/explore.js +4 -2
- package/dist/external.d.ts +15 -0
- package/dist/external.js +48 -2
- package/dist/external.test.d.ts +1 -0
- package/dist/external.test.js +64 -0
- package/dist/main.js +10 -0
- package/dist/plugin.d.ts +4 -0
- package/dist/plugin.js +45 -23
- package/dist/plugin.test.js +6 -1
- package/dist/record.d.ts +47 -0
- package/dist/record.js +545 -0
- package/dist/registry.d.ts +7 -2
- package/dist/registry.js +2 -6
- package/dist/runtime.d.ts +3 -1
- package/dist/runtime.js +10 -3
- package/dist/validate.js +1 -3
- package/docs/.vitepress/config.mts +1 -0
- package/docs/adapters/browser/doubao.md +35 -0
- package/docs/adapters/browser/hackernews.md +20 -4
- package/docs/adapters/browser/tiktok.md +1 -1
- package/docs/adapters/browser/v2ex.md +31 -10
- package/docs/adapters/browser/weibo.md +4 -0
- package/docs/adapters/browser/weixin.md +33 -0
- package/docs/adapters/browser/xiaohongshu.md +8 -6
- package/docs/adapters/browser/yollomi.md +69 -0
- package/docs/adapters/desktop/doubao-app.md +35 -0
- package/docs/adapters/index.md +16 -5
- package/docs/advanced/download.md +4 -0
- package/package.json +3 -1
- package/src/browser/errors.ts +17 -11
- package/src/build-manifest.ts +2 -3
- package/src/cli.ts +45 -2
- package/src/clis/bilibili/download.ts +25 -83
- package/src/clis/bilibili/utils.ts +2 -1
- package/src/clis/chaoxing/assignments.ts +2 -1
- package/src/clis/doubao/ask.ts +40 -0
- package/src/clis/doubao/common.ts +619 -0
- package/src/clis/doubao/new.ts +22 -0
- package/src/clis/doubao/read.ts +20 -0
- package/src/clis/doubao/send.ts +25 -0
- package/src/clis/doubao/status.ts +27 -0
- package/src/clis/doubao-app/ask.ts +60 -0
- package/src/clis/doubao-app/common.ts +116 -0
- package/src/clis/doubao-app/dump.ts +28 -0
- package/src/clis/doubao-app/new.ts +21 -0
- package/src/clis/doubao-app/read.ts +21 -0
- package/src/clis/doubao-app/screenshot.ts +19 -0
- package/src/clis/doubao-app/send.ts +30 -0
- package/src/clis/doubao-app/status.ts +17 -0
- package/src/clis/hackernews/ask.yaml +38 -0
- package/src/clis/hackernews/best.yaml +38 -0
- package/src/clis/hackernews/jobs.yaml +36 -0
- package/src/clis/hackernews/new.yaml +38 -0
- package/src/clis/hackernews/search.yaml +44 -0
- package/src/clis/hackernews/show.yaml +38 -0
- package/src/clis/hackernews/top.yaml +3 -1
- package/src/clis/hackernews/user.yaml +25 -0
- package/src/clis/twitter/download.ts +13 -111
- package/src/clis/twitter/thread.ts +2 -1
- package/src/clis/v2ex/member.yaml +29 -0
- package/src/clis/v2ex/node.yaml +34 -0
- package/src/clis/v2ex/nodes.yaml +31 -0
- package/src/clis/v2ex/replies.yaml +32 -0
- package/src/clis/v2ex/user.yaml +34 -0
- package/src/clis/weibo/search.ts +78 -0
- package/src/clis/weixin/download.ts +199 -0
- package/src/clis/xiaohongshu/download.ts +12 -71
- package/src/clis/xiaohongshu/publish.ts +392 -0
- package/src/clis/xiaohongshu/search.test.ts +134 -0
- package/src/clis/xiaohongshu/search.ts +49 -15
- package/src/clis/yollomi/background.ts +48 -0
- package/src/clis/yollomi/edit.ts +58 -0
- package/src/clis/yollomi/face-swap.ts +45 -0
- package/src/clis/yollomi/generate.ts +95 -0
- package/src/clis/yollomi/models.ts +38 -0
- package/src/clis/yollomi/object-remover.ts +44 -0
- package/src/clis/yollomi/remove-bg.ts +40 -0
- package/src/clis/yollomi/restore.ts +40 -0
- package/src/clis/yollomi/try-on.ts +48 -0
- package/src/clis/yollomi/upload.ts +78 -0
- package/src/clis/yollomi/upscale.ts +49 -0
- package/src/clis/yollomi/utils.ts +202 -0
- package/src/clis/yollomi/video.ts +61 -0
- package/src/clis/zhihu/download.test.ts +7 -5
- package/src/clis/zhihu/download.ts +23 -158
- package/src/clis/zhihu/question.ts +2 -1
- package/src/commanderAdapter.ts +4 -7
- package/src/daemon.ts +5 -2
- package/src/discovery.ts +26 -26
- package/src/download/article-download.ts +272 -0
- package/src/download/media-download.ts +178 -0
- package/src/errors.test.ts +79 -0
- package/src/errors.ts +92 -2
- package/src/execution.ts +14 -10
- package/src/explore.ts +4 -2
- package/src/external.test.ts +88 -0
- package/src/external.ts +56 -2
- package/src/generate.ts +2 -1
- package/src/main.ts +10 -0
- package/src/plugin.test.ts +7 -1
- package/src/plugin.ts +49 -25
- package/src/record.ts +617 -0
- package/src/registry.ts +9 -5
- package/src/runtime.ts +16 -4
- package/src/validate.ts +2 -3
- package/tests/e2e/browser-auth.test.ts +10 -1
- package/tests/e2e/browser-public.test.ts +13 -8
- package/tests/e2e/public-commands.test.ts +209 -21
- package/tests/smoke/api-health.test.ts +65 -6
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Yollomi image upload — POST /api/upload (FormData)
|
|
3
|
+
*
|
|
4
|
+
* Uploads a local file to Yollomi's R2 storage and returns the URL.
|
|
5
|
+
* The URL can then be used as input for image-to-image, face-swap, etc.
|
|
6
|
+
*/
|
|
7
|
+
import * as fs from 'node:fs';
|
|
8
|
+
import * as path from 'node:path';
|
|
9
|
+
import chalk from 'chalk';
|
|
10
|
+
import { cli, Strategy } from '../../registry.js';
|
|
11
|
+
import { CliError } from '../../errors.js';
|
|
12
|
+
import { YOLLOMI_DOMAIN, ensureOnYollomi, fmtBytes } from './utils.js';
|
|
13
|
+
const MIME_MAP = {
|
|
14
|
+
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
15
|
+
'.png': 'image/png', '.gif': 'image/gif',
|
|
16
|
+
'.webp': 'image/webp',
|
|
17
|
+
'.mp4': 'video/mp4', '.mov': 'video/quicktime',
|
|
18
|
+
};
|
|
19
|
+
cli({
|
|
20
|
+
site: 'yollomi',
|
|
21
|
+
name: 'upload',
|
|
22
|
+
description: 'Upload an image or video to Yollomi (returns URL for other commands)',
|
|
23
|
+
domain: YOLLOMI_DOMAIN,
|
|
24
|
+
strategy: Strategy.COOKIE,
|
|
25
|
+
args: [
|
|
26
|
+
{ name: 'file', positional: true, required: true, help: 'Local file path to upload' },
|
|
27
|
+
],
|
|
28
|
+
columns: ['status', 'file', 'size', 'url'],
|
|
29
|
+
func: async (page, kwargs) => {
|
|
30
|
+
const filePath = path.resolve(kwargs.file);
|
|
31
|
+
if (!fs.existsSync(filePath))
|
|
32
|
+
throw new CliError('FILE_NOT_FOUND', `File not found: ${filePath}`, 'Provide a valid file path');
|
|
33
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
34
|
+
const mime = MIME_MAP[ext];
|
|
35
|
+
if (!mime)
|
|
36
|
+
throw new CliError('INVALID_TYPE', `Unsupported file type: ${ext}`, 'Supported: jpg, png, gif, webp, mp4, mov');
|
|
37
|
+
const data = fs.readFileSync(filePath);
|
|
38
|
+
// Note: base64 encoding inflates size ~33%. Video cap is conservative to avoid
|
|
39
|
+
// OOM when the base64 string is injected into the browser JS engine via page.evaluate().
|
|
40
|
+
const maxSize = mime.startsWith('video/') ? 20 * 1024 * 1024 : 10 * 1024 * 1024;
|
|
41
|
+
if (data.length > maxSize)
|
|
42
|
+
throw new CliError('FILE_TOO_LARGE', `File too large: ${fmtBytes(data.length)}`, `Max ${mime.startsWith('video/') ? '20MB' : '10MB'} (upload larger videos from a URL)`);
|
|
43
|
+
const b64 = data.toString('base64');
|
|
44
|
+
const fileName = path.basename(filePath);
|
|
45
|
+
process.stderr.write(chalk.dim(`Uploading ${fileName} (${fmtBytes(data.length)})...\n`));
|
|
46
|
+
await ensureOnYollomi(page);
|
|
47
|
+
const result = await page.evaluate(`
|
|
48
|
+
(async () => {
|
|
49
|
+
try {
|
|
50
|
+
const raw = atob(${JSON.stringify(b64)});
|
|
51
|
+
const arr = new Uint8Array(raw.length);
|
|
52
|
+
for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
|
|
53
|
+
const file = new File([arr], ${JSON.stringify(fileName)}, { type: ${JSON.stringify(mime)} });
|
|
54
|
+
const fd = new FormData();
|
|
55
|
+
fd.append('file', file);
|
|
56
|
+
const res = await fetch('/api/upload', { method: 'POST', body: fd, credentials: 'include' });
|
|
57
|
+
const json = await res.json();
|
|
58
|
+
return { ok: res.ok, status: res.status, data: json };
|
|
59
|
+
} catch (err) {
|
|
60
|
+
return { ok: false, status: 0, data: { error: err.message } };
|
|
61
|
+
}
|
|
62
|
+
})()
|
|
63
|
+
`);
|
|
64
|
+
if (!result?.ok) {
|
|
65
|
+
throw new CliError('UPLOAD_ERROR', result?.data?.error || 'Upload failed', 'Make sure you are logged in to yollomi.com');
|
|
66
|
+
}
|
|
67
|
+
const url = result.data.url;
|
|
68
|
+
process.stderr.write(chalk.green(`Uploaded! Use this URL as input for other commands.\n`));
|
|
69
|
+
return [{ status: 'uploaded', file: fileName, size: fmtBytes(data.length), url }];
|
|
70
|
+
},
|
|
71
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Yollomi image upscaling — POST /api/ai/image-upscaler
|
|
3
|
+
*/
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
import { cli, Strategy } from '../../registry.js';
|
|
7
|
+
import { CliError } from '../../errors.js';
|
|
8
|
+
import { YOLLOMI_DOMAIN, yollomiPost, downloadOutput, fmtBytes } from './utils.js';
|
|
9
|
+
cli({
|
|
10
|
+
site: 'yollomi',
|
|
11
|
+
name: 'upscale',
|
|
12
|
+
description: 'Upscale image resolution with AI (1 credit)',
|
|
13
|
+
domain: YOLLOMI_DOMAIN,
|
|
14
|
+
strategy: Strategy.COOKIE,
|
|
15
|
+
args: [
|
|
16
|
+
{ name: 'image', positional: true, required: true, help: 'Image URL to upscale' },
|
|
17
|
+
{ name: 'scale', default: '2', choices: ['2', '4'], help: 'Upscale factor (2 or 4)' },
|
|
18
|
+
{ name: 'output', default: './yollomi-output', help: 'Output directory' },
|
|
19
|
+
{ name: 'no-download', type: 'boolean', default: false, help: 'Only show URL' },
|
|
20
|
+
],
|
|
21
|
+
columns: ['status', 'file', 'size', 'scale', 'url'],
|
|
22
|
+
func: async (page, kwargs) => {
|
|
23
|
+
const scale = parseInt(kwargs.scale, 10);
|
|
24
|
+
process.stderr.write(chalk.dim(`Upscaling ${scale}x...\n`));
|
|
25
|
+
const data = await yollomiPost(page, '/api/ai/image-upscaler', {
|
|
26
|
+
imageUrl: kwargs.image,
|
|
27
|
+
scale,
|
|
28
|
+
face_enhance: false,
|
|
29
|
+
});
|
|
30
|
+
const url = data.image || (data.images?.[0]);
|
|
31
|
+
if (!url)
|
|
32
|
+
throw new CliError('EMPTY_RESPONSE', 'No result', 'Check the input image');
|
|
33
|
+
if (kwargs['no-download'])
|
|
34
|
+
return [{ status: 'upscaled', file: '-', size: '-', scale: `${scale}x`, url }];
|
|
35
|
+
try {
|
|
36
|
+
const urlPath = (() => { try {
|
|
37
|
+
return new URL(url).pathname;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return url;
|
|
41
|
+
} })();
|
|
42
|
+
const ext = urlPath.endsWith('.png') || urlPath.endsWith('.webp') ? urlPath.slice(urlPath.lastIndexOf('.')) : '.jpg';
|
|
43
|
+
const filename = `yollomi_upscale_${scale}x_${Date.now()}${ext}`;
|
|
44
|
+
const { path: fp, size } = await downloadOutput(url, kwargs.output, filename);
|
|
45
|
+
if (data.remainingCredits !== undefined)
|
|
46
|
+
process.stderr.write(chalk.dim(`Credits remaining: ${data.remainingCredits}\n`));
|
|
47
|
+
return [{ status: 'saved', file: path.relative('.', fp), size: fmtBytes(size), scale: `${scale}x`, url }];
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return [{ status: 'download-failed', file: '-', size: '-', scale: `${scale}x`, url }];
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Yollomi API utilities — browser cookie strategy.
|
|
3
|
+
*
|
|
4
|
+
* Uses the same per-model API routes as the Yollomi frontend:
|
|
5
|
+
* POST /api/ai/<model> — image generation (session cookie auth)
|
|
6
|
+
* POST /api/ai/video — video generation (session cookie auth)
|
|
7
|
+
*
|
|
8
|
+
* Auth: browser session cookies from NextAuth — just log in to yollomi.com in Chrome.
|
|
9
|
+
*/
|
|
10
|
+
import type { IPage } from '../../types.js';
|
|
11
|
+
export declare const YOLLOMI_DOMAIN = "yollomi.com";
|
|
12
|
+
/**
|
|
13
|
+
* Ensure the browser tab is on yollomi.com.
|
|
14
|
+
* The framework pre-nav sometimes silently fails, leaving the page on about:blank.
|
|
15
|
+
*/
|
|
16
|
+
export declare function ensureOnYollomi(page: IPage): Promise<void>;
|
|
17
|
+
/**
|
|
18
|
+
* POST to a Yollomi /api/ai/* route via the browser session.
|
|
19
|
+
* Uses relative paths (e.g. `/api/ai/flux`) — same as the frontend.
|
|
20
|
+
*/
|
|
21
|
+
export declare function yollomiPost(page: IPage, apiPath: string, body: Record<string, unknown>): Promise<any>;
|
|
22
|
+
/**
|
|
23
|
+
* Resolve an image input: local file → base64 data URL, URL → as-is.
|
|
24
|
+
*/
|
|
25
|
+
export declare function resolveImageInput(input: string): string;
|
|
26
|
+
export declare function downloadOutput(url: string, outputDir: string, filename: string): Promise<{
|
|
27
|
+
path: string;
|
|
28
|
+
size: number;
|
|
29
|
+
}>;
|
|
30
|
+
export declare function fmtBytes(bytes: number): string;
|
|
31
|
+
/** Per-model API route mapping (matches frontend model.apiEndpoint). */
|
|
32
|
+
export declare const MODEL_ROUTES: Record<string, string>;
|
|
33
|
+
/** Well-known image model IDs and their credit costs. */
|
|
34
|
+
export declare const IMAGE_MODELS: Record<string, {
|
|
35
|
+
credits: number;
|
|
36
|
+
description: string;
|
|
37
|
+
}>;
|
|
38
|
+
export declare const VIDEO_MODELS: Record<string, {
|
|
39
|
+
credits: number;
|
|
40
|
+
description: string;
|
|
41
|
+
}>;
|
|
42
|
+
export declare const TOOL_MODELS: Record<string, {
|
|
43
|
+
credits: number;
|
|
44
|
+
description: string;
|
|
45
|
+
}>;
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Yollomi API utilities — browser cookie strategy.
|
|
3
|
+
*
|
|
4
|
+
* Uses the same per-model API routes as the Yollomi frontend:
|
|
5
|
+
* POST /api/ai/<model> — image generation (session cookie auth)
|
|
6
|
+
* POST /api/ai/video — video generation (session cookie auth)
|
|
7
|
+
*
|
|
8
|
+
* Auth: browser session cookies from NextAuth — just log in to yollomi.com in Chrome.
|
|
9
|
+
*/
|
|
10
|
+
import * as fs from 'node:fs';
|
|
11
|
+
import * as path from 'node:path';
|
|
12
|
+
import { CliError } from '../../errors.js';
|
|
13
|
+
export const YOLLOMI_DOMAIN = 'yollomi.com';
|
|
14
|
+
/**
|
|
15
|
+
* Ensure the browser tab is on yollomi.com.
|
|
16
|
+
* The framework pre-nav sometimes silently fails, leaving the page on about:blank.
|
|
17
|
+
*/
|
|
18
|
+
export async function ensureOnYollomi(page) {
|
|
19
|
+
const currentUrl = await page.evaluate(`(() => location.href)()`);
|
|
20
|
+
if (!currentUrl || !currentUrl.includes('yollomi.com')) {
|
|
21
|
+
await page.goto('https://yollomi.com');
|
|
22
|
+
await page.wait(3);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* POST to a Yollomi /api/ai/* route via the browser session.
|
|
27
|
+
* Uses relative paths (e.g. `/api/ai/flux`) — same as the frontend.
|
|
28
|
+
*/
|
|
29
|
+
export async function yollomiPost(page, apiPath, body) {
|
|
30
|
+
const bodyJson = JSON.stringify(body);
|
|
31
|
+
await ensureOnYollomi(page);
|
|
32
|
+
const result = await page.evaluate(`
|
|
33
|
+
(async () => {
|
|
34
|
+
try {
|
|
35
|
+
const res = await fetch(${JSON.stringify(apiPath)}, {
|
|
36
|
+
method: 'POST',
|
|
37
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38
|
+
credentials: 'include',
|
|
39
|
+
body: ${JSON.stringify(bodyJson)},
|
|
40
|
+
});
|
|
41
|
+
const text = await res.text();
|
|
42
|
+
return { ok: res.ok, status: res.status, body: text };
|
|
43
|
+
} catch (err) {
|
|
44
|
+
return { ok: false, status: 0, body: err.message || 'fetch failed (on ' + location.href + ')' };
|
|
45
|
+
}
|
|
46
|
+
})()
|
|
47
|
+
`);
|
|
48
|
+
if (!result || result.status === 0) {
|
|
49
|
+
throw new CliError('FETCH_ERROR', `Network error: ${result?.body || 'Failed to fetch'}`, 'Make sure Chrome is logged in to https://yollomi.com and the Browser Bridge is running');
|
|
50
|
+
}
|
|
51
|
+
if (!result.ok) {
|
|
52
|
+
let detail = result.body;
|
|
53
|
+
try {
|
|
54
|
+
detail = JSON.parse(result.body)?.error || JSON.parse(result.body)?.message || result.body;
|
|
55
|
+
}
|
|
56
|
+
catch { }
|
|
57
|
+
throw new CliError('API_ERROR', `Yollomi API ${result.status}: ${detail}`, result.status === 401
|
|
58
|
+
? 'Not logged in — open Chrome, go to https://yollomi.com and log in'
|
|
59
|
+
: result.status === 402
|
|
60
|
+
? 'Insufficient credits — top up at https://yollomi.com/pricing'
|
|
61
|
+
: result.status === 429
|
|
62
|
+
? 'Rate limited — wait a moment and retry'
|
|
63
|
+
: 'Check the model and parameters');
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
return JSON.parse(result.body);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
throw new CliError('API_ERROR', 'Invalid JSON response', 'Try again');
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Resolve an image input: local file → base64 data URL, URL → as-is.
|
|
74
|
+
*/
|
|
75
|
+
export function resolveImageInput(input) {
|
|
76
|
+
if (input.startsWith('http://') || input.startsWith('https://') || input.startsWith('data:')) {
|
|
77
|
+
return input;
|
|
78
|
+
}
|
|
79
|
+
const resolved = path.resolve(input);
|
|
80
|
+
if (!fs.existsSync(resolved)) {
|
|
81
|
+
throw new CliError('FILE_NOT_FOUND', `File not found: ${resolved}`, 'Provide a valid file path or URL');
|
|
82
|
+
}
|
|
83
|
+
const ext = path.extname(resolved).toLowerCase();
|
|
84
|
+
const mimeMap = {
|
|
85
|
+
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
86
|
+
'.png': 'image/png', '.gif': 'image/gif',
|
|
87
|
+
'.webp': 'image/webp', '.bmp': 'image/bmp',
|
|
88
|
+
};
|
|
89
|
+
const mime = mimeMap[ext] || 'image/png';
|
|
90
|
+
const data = fs.readFileSync(resolved);
|
|
91
|
+
return `data:${mime};base64,${data.toString('base64')}`;
|
|
92
|
+
}
|
|
93
|
+
export async function downloadOutput(url, outputDir, filename) {
|
|
94
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
95
|
+
const destPath = path.join(outputDir, filename);
|
|
96
|
+
const resp = await fetch(url);
|
|
97
|
+
if (!resp.ok)
|
|
98
|
+
throw new CliError('DOWNLOAD_ERROR', `Download failed: HTTP ${resp.status}`, 'URL may have expired');
|
|
99
|
+
const buffer = Buffer.from(await resp.arrayBuffer());
|
|
100
|
+
fs.writeFileSync(destPath, buffer);
|
|
101
|
+
return { path: destPath, size: buffer.length };
|
|
102
|
+
}
|
|
103
|
+
export function fmtBytes(bytes) {
|
|
104
|
+
if (bytes === 0)
|
|
105
|
+
return '0 B';
|
|
106
|
+
const k = 1024;
|
|
107
|
+
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
108
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
109
|
+
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
|
|
110
|
+
}
|
|
111
|
+
/** Per-model API route mapping (matches frontend model.apiEndpoint). */
|
|
112
|
+
export const MODEL_ROUTES = {
|
|
113
|
+
'flux': '/api/ai/flux',
|
|
114
|
+
'flux-schnell': '/api/ai/flux-schnell',
|
|
115
|
+
'flux-2-pro': '/api/ai/flux-2-pro',
|
|
116
|
+
'flux-kontext-pro': '/api/ai/flux-kontext-pro',
|
|
117
|
+
'nano-banana': '/api/ai/nano-banana',
|
|
118
|
+
'nano-banana-pro': '/api/ai/nano-banana-pro',
|
|
119
|
+
'nano-banana-2': '/api/ai/nano-banana-2',
|
|
120
|
+
'z-image-turbo': '/api/ai/z-image-turbo',
|
|
121
|
+
'imagen-4-ultra': '/api/ai/imagen-4-ultra',
|
|
122
|
+
'imagen-4-fast': '/api/ai/imagen-4-fast',
|
|
123
|
+
'ideogram-v3-turbo': '/api/ai/ideogram-v3-turbo',
|
|
124
|
+
'stable-diffusion-3-5-large': '/api/ai/stable-diffusion-3-5-large',
|
|
125
|
+
'seedream-4-5': '/api/ai/seedream-4-5',
|
|
126
|
+
'seedream-5-lite': '/api/ai/seedream-5-lite',
|
|
127
|
+
'qwen-image-edit': '/api/ai/qwen-image-edit',
|
|
128
|
+
'qwen-image-edit-plus': '/api/ai/qwen-image-edit-plus',
|
|
129
|
+
'remove-bg': '/api/ai/remove-bg',
|
|
130
|
+
'image-upscaler': '/api/ai/image-upscaler',
|
|
131
|
+
'face-swap': '/api/ai/face-swap',
|
|
132
|
+
'virtual-try-on': '/api/ai/virtual-try-on',
|
|
133
|
+
'photo-restoration': '/api/ai/photo-restoration',
|
|
134
|
+
'ai-background-generator': '/api/ai/ai-background-generator',
|
|
135
|
+
'object-remover': '/api/ai/object-remover',
|
|
136
|
+
};
|
|
137
|
+
/** Well-known image model IDs and their credit costs. */
|
|
138
|
+
export const IMAGE_MODELS = {
|
|
139
|
+
'z-image-turbo': { credits: 1, description: 'Alibaba Qwen turbo (cheapest, 1 credit)' },
|
|
140
|
+
'flux-schnell': { credits: 2, description: 'High-speed Flux generation' },
|
|
141
|
+
'ideogram-v3-turbo': { credits: 3, description: 'Ideogram V3 Turbo' },
|
|
142
|
+
'imagen-4-fast': { credits: 3, description: 'Google Imagen 4 Fast' },
|
|
143
|
+
'seedream-4-5': { credits: 4, description: 'Seedream 4.5 (ByteDance)' },
|
|
144
|
+
'seedream-5-lite': { credits: 4, description: 'Seedream 5 Lite — 2K/3K' },
|
|
145
|
+
'flux': { credits: 4, description: 'Flux 1.1 Pro' },
|
|
146
|
+
'nano-banana': { credits: 4, description: 'Google Nano Banana' },
|
|
147
|
+
'flux-kontext-pro': { credits: 4, description: 'Flux Kontext Pro (img2img)' },
|
|
148
|
+
'imagen-4-ultra': { credits: 6, description: 'Google Imagen 4 Ultra' },
|
|
149
|
+
'nano-banana-2': { credits: 7, description: 'Google Nano Banana 2' },
|
|
150
|
+
'stable-diffusion-3-5-large': { credits: 7, description: 'Stable Diffusion 3.5 Large' },
|
|
151
|
+
'nano-banana-pro': { credits: 15, description: 'Nano Banana Pro' },
|
|
152
|
+
'flux-2-pro': { credits: 15, description: 'Flux 2 Pro (premium)' },
|
|
153
|
+
};
|
|
154
|
+
export const VIDEO_MODELS = {
|
|
155
|
+
'kling-v2-6-motion-control': { credits: 7, description: 'Kling v2.6 Motion Control' },
|
|
156
|
+
'bytedance-seedance-1-pro-fast': { credits: 8, description: 'Seedance 1.0 Pro Fast' },
|
|
157
|
+
'kling-2-1': { credits: 9, description: 'Kling 2.1' },
|
|
158
|
+
'minimax-hailuo-2-3': { credits: 9, description: 'Hailuo 2.3' },
|
|
159
|
+
'pixverse-5': { credits: 9, description: 'PixVerse 5' },
|
|
160
|
+
'wan-2-5-t2v': { credits: 9, description: 'Wan 2.5 Text-to-Video' },
|
|
161
|
+
'wan-2-5-i2v': { credits: 9, description: 'Wan 2.5 Image-to-Video' },
|
|
162
|
+
'google-veo-3-fast': { credits: 9, description: 'Google Veo 3 Fast' },
|
|
163
|
+
'google-veo-3-1-fast': { credits: 9, description: 'Google Veo 3.1 Fast' },
|
|
164
|
+
'openai-sora-2': { credits: 10, description: 'Sora 2' },
|
|
165
|
+
'google-veo-3': { credits: 10, description: 'Google Veo 3' },
|
|
166
|
+
'google-veo-3-1': { credits: 10, description: 'Google Veo 3.1' },
|
|
167
|
+
'wan-2-6-t2v': { credits: 29, description: 'Wan 2.6 T2V (premium)' },
|
|
168
|
+
'wan-2-6-i2v': { credits: 29, description: 'Wan 2.6 I2V (premium)' },
|
|
169
|
+
};
|
|
170
|
+
export const TOOL_MODELS = {
|
|
171
|
+
'remove-bg': { credits: 0, description: 'Remove background (free)' },
|
|
172
|
+
'image-upscaler': { credits: 1, description: 'Enhance image resolution' },
|
|
173
|
+
'object-remover': { credits: 3, description: 'Remove unwanted objects' },
|
|
174
|
+
'face-swap': { credits: 3, description: 'Swap faces in photos' },
|
|
175
|
+
'virtual-try-on': { credits: 3, description: 'Try clothes on photos' },
|
|
176
|
+
'qwen-image-edit': { credits: 3, description: 'Edit image with text prompt' },
|
|
177
|
+
'qwen-image-edit-plus': { credits: 3, description: 'Advanced image editing' },
|
|
178
|
+
'photo-restoration': { credits: 4, description: 'Revive old/damaged photos' },
|
|
179
|
+
'ai-background-generator': { credits: 5, description: 'Generate custom backgrounds' },
|
|
180
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Yollomi video generation — POST /api/ai/video
|
|
3
|
+
* Matches the frontend video-generator.tsx request format exactly.
|
|
4
|
+
*/
|
|
5
|
+
import * as path from 'node:path';
|
|
6
|
+
import chalk from 'chalk';
|
|
7
|
+
import { cli, Strategy } from '../../registry.js';
|
|
8
|
+
import { CliError } from '../../errors.js';
|
|
9
|
+
import { YOLLOMI_DOMAIN, yollomiPost, downloadOutput, fmtBytes } from './utils.js';
|
|
10
|
+
cli({
|
|
11
|
+
site: 'yollomi',
|
|
12
|
+
name: 'video',
|
|
13
|
+
description: 'Generate videos with AI (text-to-video or image-to-video)',
|
|
14
|
+
domain: YOLLOMI_DOMAIN,
|
|
15
|
+
strategy: Strategy.COOKIE,
|
|
16
|
+
args: [
|
|
17
|
+
{ name: 'prompt', positional: true, required: true, help: 'Text prompt describing the video' },
|
|
18
|
+
{ name: 'model', default: 'kling-2-1', help: 'Model (kling-2-1, openai-sora-2, google-veo-3-1, wan-2-5-t2v, ...)' },
|
|
19
|
+
{ name: 'image', help: 'Input image URL for image-to-video' },
|
|
20
|
+
{ name: 'ratio', default: '16:9', choices: ['1:1', '16:9', '9:16', '4:3', '3:4'], help: 'Aspect ratio' },
|
|
21
|
+
{ name: 'output', default: './yollomi-output', help: 'Output directory' },
|
|
22
|
+
{ name: 'no-download', type: 'boolean', default: false, help: 'Only show URL, skip download' },
|
|
23
|
+
],
|
|
24
|
+
columns: ['status', 'file', 'size', 'credits', 'url'],
|
|
25
|
+
func: async (page, kwargs) => {
|
|
26
|
+
const prompt = kwargs.prompt;
|
|
27
|
+
const modelId = kwargs.model;
|
|
28
|
+
const inputs = {
|
|
29
|
+
aspect_ratio: kwargs.ratio,
|
|
30
|
+
};
|
|
31
|
+
if (kwargs.image)
|
|
32
|
+
inputs.image = kwargs.image;
|
|
33
|
+
const body = { modelId, prompt, inputs };
|
|
34
|
+
process.stderr.write(chalk.dim(`Generating video with ${modelId} (may take a while)...\n`));
|
|
35
|
+
const data = await yollomiPost(page, '/api/ai/video', body);
|
|
36
|
+
const videoUrl = data.video || '';
|
|
37
|
+
if (!videoUrl)
|
|
38
|
+
throw new CliError('EMPTY_RESPONSE', 'No video returned', 'Try a different prompt or model');
|
|
39
|
+
const credits = data.remainingCredits;
|
|
40
|
+
const noDownload = kwargs['no-download'];
|
|
41
|
+
const outputDir = kwargs.output;
|
|
42
|
+
if (noDownload) {
|
|
43
|
+
return [{ status: 'generated', file: '-', size: '-', credits: credits ?? '-', url: videoUrl }];
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
const filename = `yollomi_${modelId}_${Date.now()}.mp4`;
|
|
47
|
+
const { path: fp, size } = await downloadOutput(videoUrl, outputDir, filename);
|
|
48
|
+
if (credits !== undefined)
|
|
49
|
+
process.stderr.write(chalk.dim(`Credits remaining: ${credits}\n`));
|
|
50
|
+
return [{ status: 'saved', file: path.relative('.', fp), size: fmtBytes(size), credits: credits ?? '-', url: videoUrl }];
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return [{ status: 'download-failed', file: '-', size: '-', credits: credits ?? '-', url: videoUrl }];
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
});
|
|
@@ -4,8 +4,4 @@
|
|
|
4
4
|
* Usage:
|
|
5
5
|
* opencli zhihu download --url "https://zhuanlan.zhihu.com/p/xxx" --output ./zhihu
|
|
6
6
|
*/
|
|
7
|
-
|
|
8
|
-
* Convert HTML content to Markdown.
|
|
9
|
-
* This is a simplified converter for Zhihu article content.
|
|
10
|
-
*/
|
|
11
|
-
export declare function htmlToMarkdown(html: string): string;
|
|
7
|
+
export {};
|
|
@@ -4,67 +4,8 @@
|
|
|
4
4
|
* Usage:
|
|
5
5
|
* opencli zhihu download --url "https://zhuanlan.zhihu.com/p/xxx" --output ./zhihu
|
|
6
6
|
*/
|
|
7
|
-
import * as fs from 'node:fs';
|
|
8
|
-
import * as path from 'node:path';
|
|
9
7
|
import { cli, Strategy } from '../../registry.js';
|
|
10
|
-
import {
|
|
11
|
-
import { formatBytes } from '../../download/progress.js';
|
|
12
|
-
/**
|
|
13
|
-
* Convert HTML content to Markdown.
|
|
14
|
-
* This is a simplified converter for Zhihu article content.
|
|
15
|
-
*/
|
|
16
|
-
export function htmlToMarkdown(html) {
|
|
17
|
-
let md = html;
|
|
18
|
-
// Remove script and style tags
|
|
19
|
-
md = md.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '');
|
|
20
|
-
md = md.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '');
|
|
21
|
-
// Convert headers
|
|
22
|
-
md = md.replace(/<h1[^>]*>(.*?)<\/h1>/gi, '# $1\n\n');
|
|
23
|
-
md = md.replace(/<h2[^>]*>(.*?)<\/h2>/gi, '## $1\n\n');
|
|
24
|
-
md = md.replace(/<h3[^>]*>(.*?)<\/h3>/gi, '### $1\n\n');
|
|
25
|
-
md = md.replace(/<h4[^>]*>(.*?)<\/h4>/gi, '#### $1\n\n');
|
|
26
|
-
// Convert paragraphs
|
|
27
|
-
md = md.replace(/<p[^>]*>([\s\S]*?)<\/p>/gi, '$1\n\n');
|
|
28
|
-
// Convert links
|
|
29
|
-
md = md.replace(/<a[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi, '[$2]($1)');
|
|
30
|
-
// Convert images
|
|
31
|
-
md = md.replace(/<img[^>]*src="([^"]*)"[^>]*alt="([^"]*)"[^>]*\/?>/gi, '');
|
|
32
|
-
md = md.replace(/<img[^>]*src="([^"]*)"[^>]*\/?>/gi, '');
|
|
33
|
-
// Convert lists
|
|
34
|
-
md = md.replace(/<ul[^>]*>([\s\S]*?)<\/ul>/gi, (match, content) => {
|
|
35
|
-
return content.replace(/<li[^>]*>([\s\S]*?)<\/li>/gi, '- $1\n') + '\n';
|
|
36
|
-
});
|
|
37
|
-
md = md.replace(/<ol[^>]*>([\s\S]*?)<\/ol>/gi, (match, content) => {
|
|
38
|
-
let index = 0;
|
|
39
|
-
return content.replace(/<li[^>]*>([\s\S]*?)<\/li>/gi, (_itemMatch, itemContent) => `${++index}. ${itemContent}\n`) + '\n';
|
|
40
|
-
});
|
|
41
|
-
// Convert bold and italic
|
|
42
|
-
md = md.replace(/<strong[^>]*>(.*?)<\/strong>/gi, '**$1**');
|
|
43
|
-
md = md.replace(/<b[^>]*>(.*?)<\/b>/gi, '**$1**');
|
|
44
|
-
md = md.replace(/<em[^>]*>(.*?)<\/em>/gi, '*$1*');
|
|
45
|
-
md = md.replace(/<i[^>]*>(.*?)<\/i>/gi, '*$1*');
|
|
46
|
-
// Convert code blocks
|
|
47
|
-
md = md.replace(/<pre[^>]*><code[^>]*>([\s\S]*?)<\/code><\/pre>/gi, '```\n$1\n```\n\n');
|
|
48
|
-
md = md.replace(/<code[^>]*>(.*?)<\/code>/gi, '`$1`');
|
|
49
|
-
// Convert blockquotes
|
|
50
|
-
md = md.replace(/<blockquote[^>]*>([\s\S]*?)<\/blockquote>/gi, (match, content) => {
|
|
51
|
-
return content.split('\n').map((line) => `> ${line}`).join('\n') + '\n\n';
|
|
52
|
-
});
|
|
53
|
-
// Convert line breaks
|
|
54
|
-
md = md.replace(/<br\s*\/?>/gi, '\n');
|
|
55
|
-
// Remove remaining HTML tags
|
|
56
|
-
md = md.replace(/<[^>]+>/g, '');
|
|
57
|
-
// Decode HTML entities
|
|
58
|
-
md = md.replace(/ /g, ' ');
|
|
59
|
-
md = md.replace(/</g, '<');
|
|
60
|
-
md = md.replace(/>/g, '>');
|
|
61
|
-
md = md.replace(/&/g, '&');
|
|
62
|
-
md = md.replace(/"/g, '"');
|
|
63
|
-
// Clean up extra whitespace
|
|
64
|
-
md = md.replace(/\n{3,}/g, '\n\n');
|
|
65
|
-
md = md.trim();
|
|
66
|
-
return md;
|
|
67
|
-
}
|
|
8
|
+
import { downloadArticle } from '../../download/article-download.js';
|
|
68
9
|
cli({
|
|
69
10
|
site: 'zhihu',
|
|
70
11
|
name: 'download',
|
|
@@ -72,26 +13,25 @@ cli({
|
|
|
72
13
|
domain: 'zhuanlan.zhihu.com',
|
|
73
14
|
strategy: Strategy.COOKIE,
|
|
74
15
|
args: [
|
|
75
|
-
{ name: 'url', required: true,
|
|
16
|
+
{ name: 'url', required: true, help: 'Article URL (zhuanlan.zhihu.com/p/xxx)' },
|
|
76
17
|
{ name: 'output', default: './zhihu-articles', help: 'Output directory' },
|
|
77
18
|
{ name: 'download-images', type: 'boolean', default: false, help: 'Download images locally' },
|
|
78
19
|
],
|
|
79
|
-
columns: ['title', 'author', 'status', 'size'],
|
|
20
|
+
columns: ['title', 'author', 'publish_time', 'status', 'size'],
|
|
80
21
|
func: async (page, kwargs) => {
|
|
81
22
|
const url = kwargs.url;
|
|
82
|
-
const output = kwargs.output;
|
|
83
|
-
const downloadImages = kwargs['download-images'];
|
|
84
23
|
// Navigate to article page
|
|
85
24
|
await page.goto(url);
|
|
25
|
+
await page.wait(3);
|
|
86
26
|
// Extract article content
|
|
87
27
|
const data = await page.evaluate(`
|
|
88
28
|
(() => {
|
|
89
29
|
const result = {
|
|
90
30
|
title: '',
|
|
91
31
|
author: '',
|
|
92
|
-
content: '',
|
|
93
32
|
publishTime: '',
|
|
94
|
-
|
|
33
|
+
contentHtml: '',
|
|
34
|
+
imageUrls: []
|
|
95
35
|
};
|
|
96
36
|
|
|
97
37
|
// Get title
|
|
@@ -109,13 +49,13 @@ cli({
|
|
|
109
49
|
// Get content HTML
|
|
110
50
|
const contentEl = document.querySelector('.Post-RichTextContainer, .RichText, .ArticleContent');
|
|
111
51
|
if (contentEl) {
|
|
112
|
-
result.
|
|
52
|
+
result.contentHtml = contentEl.innerHTML;
|
|
113
53
|
|
|
114
54
|
// Extract image URLs
|
|
115
55
|
contentEl.querySelectorAll('img').forEach(img => {
|
|
116
56
|
const src = img.getAttribute('data-original') || img.getAttribute('data-actualsrc') || img.src;
|
|
117
57
|
if (src && !src.includes('data:image')) {
|
|
118
|
-
result.
|
|
58
|
+
result.imageUrls.push(src);
|
|
119
59
|
}
|
|
120
60
|
});
|
|
121
61
|
}
|
|
@@ -123,63 +63,17 @@ cli({
|
|
|
123
63
|
return result;
|
|
124
64
|
})()
|
|
125
65
|
`);
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
// Create frontmatter
|
|
139
|
-
const frontmatter = [
|
|
140
|
-
'---',
|
|
141
|
-
`title: "${data.title.replace(/"/g, '\\"')}"`,
|
|
142
|
-
`author: "${data.author.replace(/"/g, '\\"')}"`,
|
|
143
|
-
`source: "${url}"`,
|
|
144
|
-
data.publishTime ? `date: "${data.publishTime}"` : '',
|
|
145
|
-
'---',
|
|
146
|
-
'',
|
|
147
|
-
].filter(Boolean).join('\n');
|
|
148
|
-
// Download images if requested
|
|
149
|
-
if (downloadImages && data.images && data.images.length > 0) {
|
|
150
|
-
const imagesDir = path.join(output, 'images');
|
|
151
|
-
fs.mkdirSync(imagesDir, { recursive: true });
|
|
152
|
-
const cookies = formatCookieHeader(await page.getCookies({ domain: 'zhihu.com' }));
|
|
153
|
-
for (let i = 0; i < data.images.length; i++) {
|
|
154
|
-
const imgUrl = data.images[i];
|
|
155
|
-
const ext = imgUrl.match(/\.(jpg|jpeg|png|gif|webp)/i)?.[1] || 'jpg';
|
|
156
|
-
const imgFilename = `img_${i + 1}.${ext}`;
|
|
157
|
-
const imgPath = path.join(imagesDir, imgFilename);
|
|
158
|
-
try {
|
|
159
|
-
await httpDownload(imgUrl, imgPath, {
|
|
160
|
-
cookies,
|
|
161
|
-
timeout: 30000,
|
|
162
|
-
});
|
|
163
|
-
// Replace image URL in markdown with local path
|
|
164
|
-
markdown = markdown.replace(new RegExp(imgUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), `./images/${imgFilename}`);
|
|
165
|
-
}
|
|
166
|
-
catch {
|
|
167
|
-
// Keep original URL if download fails
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
// Write markdown file
|
|
172
|
-
const safeTitle = sanitizeFilename(data.title, 100);
|
|
173
|
-
const filename = `${safeTitle}.md`;
|
|
174
|
-
const filePath = path.join(output, filename);
|
|
175
|
-
const fullContent = frontmatter + '\n' + markdown;
|
|
176
|
-
fs.writeFileSync(filePath, fullContent, 'utf-8');
|
|
177
|
-
const size = Buffer.byteLength(fullContent, 'utf-8');
|
|
178
|
-
return [{
|
|
179
|
-
title: data.title,
|
|
180
|
-
author: data.author,
|
|
181
|
-
status: 'success',
|
|
182
|
-
size: formatBytes(size),
|
|
183
|
-
}];
|
|
66
|
+
return downloadArticle({
|
|
67
|
+
title: data?.title || '',
|
|
68
|
+
author: data?.author,
|
|
69
|
+
publishTime: data?.publishTime,
|
|
70
|
+
sourceUrl: url,
|
|
71
|
+
contentHtml: data?.contentHtml || '',
|
|
72
|
+
imageUrls: data?.imageUrls,
|
|
73
|
+
}, {
|
|
74
|
+
output: kwargs.output,
|
|
75
|
+
downloadImages: kwargs['download-images'],
|
|
76
|
+
imageHeaders: { Referer: 'https://zhuanlan.zhihu.com/' },
|
|
77
|
+
});
|
|
184
78
|
},
|
|
185
79
|
});
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import
|
|
3
|
-
describe('
|
|
2
|
+
import TurndownService from 'turndown';
|
|
3
|
+
describe('article markdown conversion', () => {
|
|
4
4
|
it('renders ordered lists with the original list item content', () => {
|
|
5
5
|
const html = '<ol><li>First item</li><li>Second item</li></ol>';
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
expect(
|
|
6
|
+
const td = new TurndownService({ headingStyle: 'atx', bulletListMarker: '-' });
|
|
7
|
+
const md = td.turndown(html);
|
|
8
|
+
expect(md).toMatch(/1\.\s+First item/);
|
|
9
|
+
expect(md).toMatch(/2\.\s+Second item/);
|
|
10
|
+
expect(md).not.toContain('$1');
|
|
9
11
|
});
|
|
10
12
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { cli, Strategy } from '../../registry.js';
|
|
2
|
+
import { AuthRequiredError } from '../../errors.js';
|
|
2
3
|
cli({
|
|
3
4
|
site: 'zhihu',
|
|
4
5
|
name: 'question',
|
|
@@ -27,7 +28,7 @@ cli({
|
|
|
27
28
|
}
|
|
28
29
|
`);
|
|
29
30
|
if (!result || result.error)
|
|
30
|
-
throw new
|
|
31
|
+
throw new AuthRequiredError('www.zhihu.com', 'Failed to fetch question data from Zhihu');
|
|
31
32
|
const answers = (result.answers ?? []).slice(0, Number(limit)).map((a, i) => ({
|
|
32
33
|
rank: i + 1,
|
|
33
34
|
author: a.author?.name ?? 'anonymous',
|