@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
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Yollomi image upscaling — POST /api/ai/image-upscaler
3
+ */
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
+
11
+ cli({
12
+ site: 'yollomi',
13
+ name: 'upscale',
14
+ description: 'Upscale image resolution with AI (1 credit)',
15
+ domain: YOLLOMI_DOMAIN,
16
+ strategy: Strategy.COOKIE,
17
+ args: [
18
+ { name: 'image', positional: true, required: true, help: 'Image URL to upscale' },
19
+ { name: 'scale', default: '2', choices: ['2', '4'], help: 'Upscale factor (2 or 4)' },
20
+ { name: 'output', default: './yollomi-output', help: 'Output directory' },
21
+ { name: 'no-download', type: 'boolean', default: false, help: 'Only show URL' },
22
+ ],
23
+ columns: ['status', 'file', 'size', 'scale', 'url'],
24
+ func: async (page, kwargs) => {
25
+ const scale = parseInt(kwargs.scale as string, 10);
26
+ process.stderr.write(chalk.dim(`Upscaling ${scale}x...\n`));
27
+ const data = await yollomiPost(page, '/api/ai/image-upscaler', {
28
+ imageUrl: kwargs.image as string,
29
+ scale,
30
+ face_enhance: false,
31
+ });
32
+
33
+ const url = data.image || (data.images?.[0]);
34
+ if (!url) throw new CliError('EMPTY_RESPONSE', 'No result', 'Check the input image');
35
+
36
+ if (kwargs['no-download']) return [{ status: 'upscaled', file: '-', size: '-', scale: `${scale}x`, url }];
37
+
38
+ try {
39
+ const urlPath = (() => { try { return new URL(url).pathname; } catch { return url; } })();
40
+ const ext = urlPath.endsWith('.png') || urlPath.endsWith('.webp') ? urlPath.slice(urlPath.lastIndexOf('.')) : '.jpg';
41
+ const filename = `yollomi_upscale_${scale}x_${Date.now()}${ext}`;
42
+ const { path: fp, size } = await downloadOutput(url, kwargs.output as string, filename);
43
+ if (data.remainingCredits !== undefined) process.stderr.write(chalk.dim(`Credits remaining: ${data.remainingCredits}\n`));
44
+ return [{ status: 'saved', file: path.relative('.', fp), size: fmtBytes(size), scale: `${scale}x`, url }];
45
+ } catch {
46
+ return [{ status: 'download-failed', file: '-', size: '-', scale: `${scale}x`, url }];
47
+ }
48
+ },
49
+ });
@@ -0,0 +1,202 @@
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
+
11
+ import * as fs from 'node:fs';
12
+ import * as path from 'node:path';
13
+ import type { IPage } from '../../types.js';
14
+ import { CliError } from '../../errors.js';
15
+
16
+ export const YOLLOMI_DOMAIN = 'yollomi.com';
17
+
18
+ /**
19
+ * Ensure the browser tab is on yollomi.com.
20
+ * The framework pre-nav sometimes silently fails, leaving the page on about:blank.
21
+ */
22
+ export async function ensureOnYollomi(page: IPage): Promise<void> {
23
+ const currentUrl = await page.evaluate(`(() => location.href)()`) as string;
24
+ if (!currentUrl || !currentUrl.includes('yollomi.com')) {
25
+ await page.goto('https://yollomi.com');
26
+ await page.wait(3);
27
+ }
28
+ }
29
+
30
+ /**
31
+ * POST to a Yollomi /api/ai/* route via the browser session.
32
+ * Uses relative paths (e.g. `/api/ai/flux`) — same as the frontend.
33
+ */
34
+ export async function yollomiPost(page: IPage, apiPath: string, body: Record<string, unknown>): Promise<any> {
35
+ const bodyJson = JSON.stringify(body);
36
+ await ensureOnYollomi(page);
37
+
38
+ const result = await page.evaluate(`
39
+ (async () => {
40
+ try {
41
+ const res = await fetch(${JSON.stringify(apiPath)}, {
42
+ method: 'POST',
43
+ headers: { 'Content-Type': 'application/json' },
44
+ credentials: 'include',
45
+ body: ${JSON.stringify(bodyJson)},
46
+ });
47
+ const text = await res.text();
48
+ return { ok: res.ok, status: res.status, body: text };
49
+ } catch (err) {
50
+ return { ok: false, status: 0, body: err.message || 'fetch failed (on ' + location.href + ')' };
51
+ }
52
+ })()
53
+ `);
54
+
55
+ if (!result || result.status === 0) {
56
+ throw new CliError(
57
+ 'FETCH_ERROR',
58
+ `Network error: ${result?.body || 'Failed to fetch'}`,
59
+ 'Make sure Chrome is logged in to https://yollomi.com and the Browser Bridge is running',
60
+ );
61
+ }
62
+
63
+ if (!result.ok) {
64
+ let detail = result.body;
65
+ try { detail = JSON.parse(result.body)?.error || JSON.parse(result.body)?.message || result.body; } catch {}
66
+ throw new CliError(
67
+ 'API_ERROR',
68
+ `Yollomi API ${result.status}: ${detail}`,
69
+ result.status === 401
70
+ ? 'Not logged in — open Chrome, go to https://yollomi.com and log in'
71
+ : result.status === 402
72
+ ? 'Insufficient credits — top up at https://yollomi.com/pricing'
73
+ : result.status === 429
74
+ ? 'Rate limited — wait a moment and retry'
75
+ : 'Check the model and parameters',
76
+ );
77
+ }
78
+
79
+ try {
80
+ return JSON.parse(result.body);
81
+ } catch {
82
+ throw new CliError('API_ERROR', 'Invalid JSON response', 'Try again');
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Resolve an image input: local file → base64 data URL, URL → as-is.
88
+ */
89
+ export function resolveImageInput(input: string): string {
90
+ if (input.startsWith('http://') || input.startsWith('https://') || input.startsWith('data:')) {
91
+ return input;
92
+ }
93
+
94
+ const resolved = path.resolve(input);
95
+ if (!fs.existsSync(resolved)) {
96
+ throw new CliError('FILE_NOT_FOUND', `File not found: ${resolved}`, 'Provide a valid file path or URL');
97
+ }
98
+
99
+ const ext = path.extname(resolved).toLowerCase();
100
+ const mimeMap: Record<string, string> = {
101
+ '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
102
+ '.png': 'image/png', '.gif': 'image/gif',
103
+ '.webp': 'image/webp', '.bmp': 'image/bmp',
104
+ };
105
+ const mime = mimeMap[ext] || 'image/png';
106
+ const data = fs.readFileSync(resolved);
107
+ return `data:${mime};base64,${data.toString('base64')}`;
108
+ }
109
+
110
+ export async function downloadOutput(
111
+ url: string, outputDir: string, filename: string,
112
+ ): Promise<{ path: string; size: number }> {
113
+ fs.mkdirSync(outputDir, { recursive: true });
114
+ const destPath = path.join(outputDir, filename);
115
+ const resp = await fetch(url);
116
+ if (!resp.ok) throw new CliError('DOWNLOAD_ERROR', `Download failed: HTTP ${resp.status}`, 'URL may have expired');
117
+ const buffer = Buffer.from(await resp.arrayBuffer());
118
+ fs.writeFileSync(destPath, buffer);
119
+ return { path: destPath, size: buffer.length };
120
+ }
121
+
122
+ export function fmtBytes(bytes: number): string {
123
+ if (bytes === 0) return '0 B';
124
+ const k = 1024;
125
+ const sizes = ['B', 'KB', 'MB', 'GB'];
126
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
127
+ return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
128
+ }
129
+
130
+ /** Per-model API route mapping (matches frontend model.apiEndpoint). */
131
+ export const MODEL_ROUTES: Record<string, string> = {
132
+ 'flux': '/api/ai/flux',
133
+ 'flux-schnell': '/api/ai/flux-schnell',
134
+ 'flux-2-pro': '/api/ai/flux-2-pro',
135
+ 'flux-kontext-pro': '/api/ai/flux-kontext-pro',
136
+ 'nano-banana': '/api/ai/nano-banana',
137
+ 'nano-banana-pro': '/api/ai/nano-banana-pro',
138
+ 'nano-banana-2': '/api/ai/nano-banana-2',
139
+ 'z-image-turbo': '/api/ai/z-image-turbo',
140
+ 'imagen-4-ultra': '/api/ai/imagen-4-ultra',
141
+ 'imagen-4-fast': '/api/ai/imagen-4-fast',
142
+ 'ideogram-v3-turbo': '/api/ai/ideogram-v3-turbo',
143
+ 'stable-diffusion-3-5-large':'/api/ai/stable-diffusion-3-5-large',
144
+ 'seedream-4-5': '/api/ai/seedream-4-5',
145
+ 'seedream-5-lite': '/api/ai/seedream-5-lite',
146
+ 'qwen-image-edit': '/api/ai/qwen-image-edit',
147
+ 'qwen-image-edit-plus': '/api/ai/qwen-image-edit-plus',
148
+ 'remove-bg': '/api/ai/remove-bg',
149
+ 'image-upscaler': '/api/ai/image-upscaler',
150
+ 'face-swap': '/api/ai/face-swap',
151
+ 'virtual-try-on': '/api/ai/virtual-try-on',
152
+ 'photo-restoration': '/api/ai/photo-restoration',
153
+ 'ai-background-generator': '/api/ai/ai-background-generator',
154
+ 'object-remover': '/api/ai/object-remover',
155
+ };
156
+
157
+ /** Well-known image model IDs and their credit costs. */
158
+ export const IMAGE_MODELS: Record<string, { credits: number; description: string }> = {
159
+ 'z-image-turbo': { credits: 1, description: 'Alibaba Qwen turbo (cheapest, 1 credit)' },
160
+ 'flux-schnell': { credits: 2, description: 'High-speed Flux generation' },
161
+ 'ideogram-v3-turbo': { credits: 3, description: 'Ideogram V3 Turbo' },
162
+ 'imagen-4-fast': { credits: 3, description: 'Google Imagen 4 Fast' },
163
+ 'seedream-4-5': { credits: 4, description: 'Seedream 4.5 (ByteDance)' },
164
+ 'seedream-5-lite': { credits: 4, description: 'Seedream 5 Lite — 2K/3K' },
165
+ 'flux': { credits: 4, description: 'Flux 1.1 Pro' },
166
+ 'nano-banana': { credits: 4, description: 'Google Nano Banana' },
167
+ 'flux-kontext-pro': { credits: 4, description: 'Flux Kontext Pro (img2img)' },
168
+ 'imagen-4-ultra': { credits: 6, description: 'Google Imagen 4 Ultra' },
169
+ 'nano-banana-2': { credits: 7, description: 'Google Nano Banana 2' },
170
+ 'stable-diffusion-3-5-large':{ credits: 7, description: 'Stable Diffusion 3.5 Large' },
171
+ 'nano-banana-pro': { credits: 15, description: 'Nano Banana Pro' },
172
+ 'flux-2-pro': { credits: 15, description: 'Flux 2 Pro (premium)' },
173
+ };
174
+
175
+ export const VIDEO_MODELS: Record<string, { credits: number; description: string }> = {
176
+ 'kling-v2-6-motion-control': { credits: 7, description: 'Kling v2.6 Motion Control' },
177
+ 'bytedance-seedance-1-pro-fast': { credits: 8, description: 'Seedance 1.0 Pro Fast' },
178
+ 'kling-2-1': { credits: 9, description: 'Kling 2.1' },
179
+ 'minimax-hailuo-2-3': { credits: 9, description: 'Hailuo 2.3' },
180
+ 'pixverse-5': { credits: 9, description: 'PixVerse 5' },
181
+ 'wan-2-5-t2v': { credits: 9, description: 'Wan 2.5 Text-to-Video' },
182
+ 'wan-2-5-i2v': { credits: 9, description: 'Wan 2.5 Image-to-Video' },
183
+ 'google-veo-3-fast': { credits: 9, description: 'Google Veo 3 Fast' },
184
+ 'google-veo-3-1-fast': { credits: 9, description: 'Google Veo 3.1 Fast' },
185
+ 'openai-sora-2': { credits: 10, description: 'Sora 2' },
186
+ 'google-veo-3': { credits: 10, description: 'Google Veo 3' },
187
+ 'google-veo-3-1': { credits: 10, description: 'Google Veo 3.1' },
188
+ 'wan-2-6-t2v': { credits: 29, description: 'Wan 2.6 T2V (premium)' },
189
+ 'wan-2-6-i2v': { credits: 29, description: 'Wan 2.6 I2V (premium)' },
190
+ };
191
+
192
+ export const TOOL_MODELS: Record<string, { credits: number; description: string }> = {
193
+ 'remove-bg': { credits: 0, description: 'Remove background (free)' },
194
+ 'image-upscaler': { credits: 1, description: 'Enhance image resolution' },
195
+ 'object-remover': { credits: 3, description: 'Remove unwanted objects' },
196
+ 'face-swap': { credits: 3, description: 'Swap faces in photos' },
197
+ 'virtual-try-on': { credits: 3, description: 'Try clothes on photos' },
198
+ 'qwen-image-edit': { credits: 3, description: 'Edit image with text prompt' },
199
+ 'qwen-image-edit-plus': { credits: 3, description: 'Advanced image editing' },
200
+ 'photo-restoration': { credits: 4, description: 'Revive old/damaged photos' },
201
+ 'ai-background-generator':{ credits: 5, description: 'Generate custom backgrounds' },
202
+ };
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Yollomi video generation — POST /api/ai/video
3
+ * Matches the frontend video-generator.tsx request format exactly.
4
+ */
5
+
6
+ import * as path from 'node:path';
7
+ import chalk from 'chalk';
8
+ import { cli, Strategy } from '../../registry.js';
9
+ import { CliError } from '../../errors.js';
10
+ import { YOLLOMI_DOMAIN, yollomiPost, downloadOutput, fmtBytes } from './utils.js';
11
+
12
+ cli({
13
+ site: 'yollomi',
14
+ name: 'video',
15
+ description: 'Generate videos with AI (text-to-video or image-to-video)',
16
+ domain: YOLLOMI_DOMAIN,
17
+ strategy: Strategy.COOKIE,
18
+ args: [
19
+ { name: 'prompt', positional: true, required: true, help: 'Text prompt describing the video' },
20
+ { name: 'model', default: 'kling-2-1', help: 'Model (kling-2-1, openai-sora-2, google-veo-3-1, wan-2-5-t2v, ...)' },
21
+ { name: 'image', help: 'Input image URL for image-to-video' },
22
+ { name: 'ratio', default: '16:9', choices: ['1:1', '16:9', '9:16', '4:3', '3:4'], help: 'Aspect ratio' },
23
+ { name: 'output', default: './yollomi-output', help: 'Output directory' },
24
+ { name: 'no-download', type: 'boolean', default: false, help: 'Only show URL, skip download' },
25
+ ],
26
+ columns: ['status', 'file', 'size', 'credits', 'url'],
27
+ func: async (page, kwargs) => {
28
+ const prompt = kwargs.prompt as string;
29
+ const modelId = kwargs.model as string;
30
+
31
+ const inputs: Record<string, unknown> = {
32
+ aspect_ratio: kwargs.ratio as string,
33
+ };
34
+ if (kwargs.image) inputs.image = kwargs.image as string;
35
+
36
+ const body = { modelId, prompt, inputs };
37
+
38
+ process.stderr.write(chalk.dim(`Generating video with ${modelId} (may take a while)...\n`));
39
+ const data = await yollomiPost(page, '/api/ai/video', body);
40
+
41
+ const videoUrl: string = data.video || '';
42
+ if (!videoUrl) throw new CliError('EMPTY_RESPONSE', 'No video returned', 'Try a different prompt or model');
43
+
44
+ const credits = data.remainingCredits;
45
+ const noDownload = kwargs['no-download'] as boolean;
46
+ const outputDir = kwargs.output as string;
47
+
48
+ if (noDownload) {
49
+ return [{ status: 'generated', file: '-', size: '-', credits: credits ?? '-', url: videoUrl }];
50
+ }
51
+
52
+ try {
53
+ const filename = `yollomi_${modelId}_${Date.now()}.mp4`;
54
+ const { path: fp, size } = await downloadOutput(videoUrl, outputDir, filename);
55
+ if (credits !== undefined) process.stderr.write(chalk.dim(`Credits remaining: ${credits}\n`));
56
+ return [{ status: 'saved', file: path.relative('.', fp), size: fmtBytes(size), credits: credits ?? '-', url: videoUrl }];
57
+ } catch {
58
+ return [{ status: 'download-failed', file: '-', size: '-', credits: credits ?? '-', url: videoUrl }];
59
+ }
60
+ },
61
+ });
@@ -1,12 +1,14 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { htmlToMarkdown } from './download.js';
2
+ import TurndownService from 'turndown';
3
3
 
4
- describe('htmlToMarkdown', () => {
4
+ describe('article markdown conversion', () => {
5
5
  it('renders ordered lists with the original list item content', () => {
6
6
  const html = '<ol><li>First item</li><li>Second item</li></ol>';
7
+ const td = new TurndownService({ headingStyle: 'atx', bulletListMarker: '-' });
8
+ const md = td.turndown(html);
7
9
 
8
- expect(htmlToMarkdown(html)).toContain('1. First item');
9
- expect(htmlToMarkdown(html)).toContain('2. Second item');
10
- expect(htmlToMarkdown(html)).not.toContain('$1');
10
+ expect(md).toMatch(/1\.\s+First item/);
11
+ expect(md).toMatch(/2\.\s+Second item/);
12
+ expect(md).not.toContain('$1');
11
13
  });
12
14
  });
@@ -5,85 +5,8 @@
5
5
  * opencli zhihu download --url "https://zhuanlan.zhihu.com/p/xxx" --output ./zhihu
6
6
  */
7
7
 
8
- import * as fs from 'node:fs';
9
- import * as path from 'node:path';
10
8
  import { cli, Strategy } from '../../registry.js';
11
- import { sanitizeFilename, httpDownload, formatCookieHeader } from '../../download/index.js';
12
- import { formatBytes } from '../../download/progress.js';
13
-
14
- /**
15
- * Convert HTML content to Markdown.
16
- * This is a simplified converter for Zhihu article content.
17
- */
18
- export function htmlToMarkdown(html: string): string {
19
- let md = html;
20
-
21
- // Remove script and style tags
22
- md = md.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '');
23
- md = md.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '');
24
-
25
- // Convert headers
26
- md = md.replace(/<h1[^>]*>(.*?)<\/h1>/gi, '# $1\n\n');
27
- md = md.replace(/<h2[^>]*>(.*?)<\/h2>/gi, '## $1\n\n');
28
- md = md.replace(/<h3[^>]*>(.*?)<\/h3>/gi, '### $1\n\n');
29
- md = md.replace(/<h4[^>]*>(.*?)<\/h4>/gi, '#### $1\n\n');
30
-
31
- // Convert paragraphs
32
- md = md.replace(/<p[^>]*>([\s\S]*?)<\/p>/gi, '$1\n\n');
33
-
34
- // Convert links
35
- md = md.replace(/<a[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi, '[$2]($1)');
36
-
37
- // Convert images
38
- md = md.replace(/<img[^>]*src="([^"]*)"[^>]*alt="([^"]*)"[^>]*\/?>/gi, '![$2]($1)');
39
- md = md.replace(/<img[^>]*src="([^"]*)"[^>]*\/?>/gi, '![]($1)');
40
-
41
- // Convert lists
42
- md = md.replace(/<ul[^>]*>([\s\S]*?)<\/ul>/gi, (match, content) => {
43
- return content.replace(/<li[^>]*>([\s\S]*?)<\/li>/gi, '- $1\n') + '\n';
44
- });
45
- md = md.replace(/<ol[^>]*>([\s\S]*?)<\/ol>/gi, (match, content) => {
46
- let index = 0;
47
- return content.replace(
48
- /<li[^>]*>([\s\S]*?)<\/li>/gi,
49
- (_itemMatch: string, itemContent: string) => `${++index}. ${itemContent}\n`,
50
- ) + '\n';
51
- });
52
-
53
- // Convert bold and italic
54
- md = md.replace(/<strong[^>]*>(.*?)<\/strong>/gi, '**$1**');
55
- md = md.replace(/<b[^>]*>(.*?)<\/b>/gi, '**$1**');
56
- md = md.replace(/<em[^>]*>(.*?)<\/em>/gi, '*$1*');
57
- md = md.replace(/<i[^>]*>(.*?)<\/i>/gi, '*$1*');
58
-
59
- // Convert code blocks
60
- md = md.replace(/<pre[^>]*><code[^>]*>([\s\S]*?)<\/code><\/pre>/gi, '```\n$1\n```\n\n');
61
- md = md.replace(/<code[^>]*>(.*?)<\/code>/gi, '`$1`');
62
-
63
- // Convert blockquotes
64
- md = md.replace(/<blockquote[^>]*>([\s\S]*?)<\/blockquote>/gi, (match, content) => {
65
- return content.split('\n').map((line: string) => `> ${line}`).join('\n') + '\n\n';
66
- });
67
-
68
- // Convert line breaks
69
- md = md.replace(/<br\s*\/?>/gi, '\n');
70
-
71
- // Remove remaining HTML tags
72
- md = md.replace(/<[^>]+>/g, '');
73
-
74
- // Decode HTML entities
75
- md = md.replace(/&nbsp;/g, ' ');
76
- md = md.replace(/&lt;/g, '<');
77
- md = md.replace(/&gt;/g, '>');
78
- md = md.replace(/&amp;/g, '&');
79
- md = md.replace(/&quot;/g, '"');
80
-
81
- // Clean up extra whitespace
82
- md = md.replace(/\n{3,}/g, '\n\n');
83
- md = md.trim();
84
-
85
- return md;
86
- }
9
+ import { downloadArticle } from '../../download/article-download.js';
87
10
 
88
11
  cli({
89
12
  site: 'zhihu',
@@ -92,18 +15,17 @@ cli({
92
15
  domain: 'zhuanlan.zhihu.com',
93
16
  strategy: Strategy.COOKIE,
94
17
  args: [
95
- { name: 'url', required: true, positional: true, help: 'Article URL (zhuanlan.zhihu.com/p/xxx)' },
18
+ { name: 'url', required: true, help: 'Article URL (zhuanlan.zhihu.com/p/xxx)' },
96
19
  { name: 'output', default: './zhihu-articles', help: 'Output directory' },
97
20
  { name: 'download-images', type: 'boolean', default: false, help: 'Download images locally' },
98
21
  ],
99
- columns: ['title', 'author', 'status', 'size'],
22
+ columns: ['title', 'author', 'publish_time', 'status', 'size'],
100
23
  func: async (page, kwargs) => {
101
24
  const url = kwargs.url;
102
- const output = kwargs.output;
103
- const downloadImages = kwargs['download-images'];
104
25
 
105
26
  // Navigate to article page
106
27
  await page.goto(url);
28
+ await page.wait(3);
107
29
 
108
30
  // Extract article content
109
31
  const data = await page.evaluate(`
@@ -111,9 +33,9 @@ cli({
111
33
  const result = {
112
34
  title: '',
113
35
  author: '',
114
- content: '',
115
36
  publishTime: '',
116
- images: []
37
+ contentHtml: '',
38
+ imageUrls: []
117
39
  };
118
40
 
119
41
  // Get title
@@ -131,13 +53,13 @@ cli({
131
53
  // Get content HTML
132
54
  const contentEl = document.querySelector('.Post-RichTextContainer, .RichText, .ArticleContent');
133
55
  if (contentEl) {
134
- result.content = contentEl.innerHTML;
56
+ result.contentHtml = contentEl.innerHTML;
135
57
 
136
58
  // Extract image URLs
137
59
  contentEl.querySelectorAll('img').forEach(img => {
138
60
  const src = img.getAttribute('data-original') || img.getAttribute('data-actualsrc') || img.src;
139
61
  if (src && !src.includes('data:image')) {
140
- result.images.push(src);
62
+ result.imageUrls.push(src);
141
63
  }
142
64
  });
143
65
  }
@@ -146,77 +68,20 @@ cli({
146
68
  })()
147
69
  `);
148
70
 
149
- if (!data || !data.content) {
150
- return [{
151
- title: 'Error',
152
- author: '-',
153
- status: 'failed',
154
- size: 'Could not extract article content',
155
- }];
156
- }
157
-
158
- // Create output directory
159
- fs.mkdirSync(output, { recursive: true });
160
-
161
- // Convert HTML to Markdown
162
- let markdown = htmlToMarkdown(data.content);
163
-
164
- // Create frontmatter
165
- const frontmatter = [
166
- '---',
167
- `title: "${data.title.replace(/"/g, '\\"')}"`,
168
- `author: "${data.author.replace(/"/g, '\\"')}"`,
169
- `source: "${url}"`,
170
- data.publishTime ? `date: "${data.publishTime}"` : '',
171
- '---',
172
- '',
173
- ].filter(Boolean).join('\n');
174
-
175
- // Download images if requested
176
- if (downloadImages && data.images && data.images.length > 0) {
177
- const imagesDir = path.join(output, 'images');
178
- fs.mkdirSync(imagesDir, { recursive: true });
179
-
180
- const cookies = formatCookieHeader(await page.getCookies({ domain: 'zhihu.com' }));
181
-
182
- for (let i = 0; i < data.images.length; i++) {
183
- const imgUrl = data.images[i];
184
- const ext = imgUrl.match(/\.(jpg|jpeg|png|gif|webp)/i)?.[1] || 'jpg';
185
- const imgFilename = `img_${i + 1}.${ext}`;
186
- const imgPath = path.join(imagesDir, imgFilename);
187
-
188
- try {
189
- await httpDownload(imgUrl, imgPath, {
190
- cookies,
191
- timeout: 30000,
192
- });
193
-
194
- // Replace image URL in markdown with local path
195
- markdown = markdown.replace(
196
- new RegExp(imgUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'),
197
- `./images/${imgFilename}`,
198
- );
199
- } catch {
200
- // Keep original URL if download fails
201
- }
202
- }
203
- }
204
-
205
- // Write markdown file
206
- const safeTitle = sanitizeFilename(data.title, 100);
207
- const filename = `${safeTitle}.md`;
208
- const filePath = path.join(output, filename);
209
-
210
- const fullContent = frontmatter + '\n' + markdown;
211
- fs.writeFileSync(filePath, fullContent, 'utf-8');
212
-
213
- const size = Buffer.byteLength(fullContent, 'utf-8');
214
-
215
- return [{
216
- title: data.title,
217
- author: data.author,
218
- status: 'success',
219
- size: formatBytes(size),
220
- }];
71
+ return downloadArticle(
72
+ {
73
+ title: data?.title || '',
74
+ author: data?.author,
75
+ publishTime: data?.publishTime,
76
+ sourceUrl: url,
77
+ contentHtml: data?.contentHtml || '',
78
+ imageUrls: data?.imageUrls,
79
+ },
80
+ {
81
+ output: kwargs.output,
82
+ downloadImages: kwargs['download-images'],
83
+ imageHeaders: { Referer: 'https://zhuanlan.zhihu.com/' },
84
+ },
85
+ );
221
86
  },
222
87
  });
@@ -1,4 +1,5 @@
1
1
  import { cli, Strategy } from '../../registry.js';
2
+ import { AuthRequiredError } from '../../errors.js';
2
3
 
3
4
  cli({
4
5
  site: 'zhihu',
@@ -31,7 +32,7 @@ cli({
31
32
  }
32
33
  `);
33
34
 
34
- if (!result || result.error) throw new Error('Failed to fetch question. Are you logged in?');
35
+ if (!result || result.error) throw new AuthRequiredError('www.zhihu.com', 'Failed to fetch question data from Zhihu');
35
36
 
36
37
  const answers = (result.answers ?? []).slice(0, Number(limit)).map((a: any, i: number) => ({
37
38
  rank: i + 1,
@@ -16,11 +16,7 @@ import { type CliCommand, fullName, getRegistry } from './registry.js';
16
16
  import { formatRegistryHelpText } from './serialization.js';
17
17
  import { render as renderOutput } from './output.js';
18
18
  import { executeCommand } from './execution.js';
19
- import { CliError } from './errors.js';
20
-
21
- function getErrorMessage(error: unknown): string {
22
- return error instanceof Error ? error.message : String(error);
23
- }
19
+ import { CliError, ERROR_ICONS, getErrorMessage } from './errors.js';
24
20
 
25
21
  /**
26
22
  * Register a single CliCommand as a Commander subcommand.
@@ -90,8 +86,9 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi
90
86
  });
91
87
  } catch (err) {
92
88
  if (err instanceof CliError) {
93
- console.error(chalk.red(`Error [${err.code}]: ${err.message}`));
94
- if (err.hint) console.error(chalk.yellow(`Hint: ${err.hint}`));
89
+ const icon = ERROR_ICONS[err.code] ?? '⚠️';
90
+ console.error(chalk.red(`${icon} ${err.message}`));
91
+ if (err.hint) console.error(chalk.yellow(`→ ${err.hint}`));
95
92
  } else if (optionsRecord.verbose === true && err instanceof Error && err.stack) {
96
93
  console.error(chalk.red(err.stack));
97
94
  } else {
package/src/daemon.ts CHANGED
@@ -150,11 +150,14 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise
150
150
  return;
151
151
  }
152
152
 
153
+ const timeoutMs = typeof body.timeout === 'number' && body.timeout > 0
154
+ ? body.timeout * 1000
155
+ : 120000;
153
156
  const result = await new Promise<unknown>((resolve, reject) => {
154
157
  const timer = setTimeout(() => {
155
158
  pending.delete(body.id);
156
- reject(new Error('Command timeout (120s)'));
157
- }, 120000);
159
+ reject(new Error(`Command timeout (${timeoutMs / 1000}s)`));
160
+ }, timeoutMs);
158
161
  pending.set(body.id, { resolve, reject, timer });
159
162
  extensionWs!.send(JSON.stringify(body));
160
163
  });