@jackwener/opencli 1.3.1 → 1.3.3

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 (241) hide show
  1. package/CHANGELOG.md +128 -0
  2. package/README.md +48 -9
  3. package/README.zh-CN.md +48 -9
  4. package/SKILL.md +317 -6
  5. package/TESTING.md +4 -4
  6. package/dist/browser/cdp.js +10 -1
  7. package/dist/browser/daemon-client.js +2 -1
  8. package/dist/browser/discover.js +2 -1
  9. package/dist/browser/errors.d.ts +2 -1
  10. package/dist/browser/errors.js +10 -10
  11. package/dist/browser/index.d.ts +1 -0
  12. package/dist/browser/index.js +1 -0
  13. package/dist/browser/page.js +12 -0
  14. package/dist/browser/stealth.d.ts +18 -0
  15. package/dist/browser/stealth.js +140 -0
  16. package/dist/browser.test.js +47 -1
  17. package/dist/build-manifest.js +1 -3
  18. package/dist/cli-manifest.json +2573 -989
  19. package/dist/cli.js +42 -2
  20. package/dist/clis/bilibili/download.js +20 -65
  21. package/dist/clis/bilibili/utils.js +2 -1
  22. package/dist/clis/chaoxing/assignments.js +2 -1
  23. package/dist/clis/doubao/ask.d.ts +1 -0
  24. package/dist/clis/doubao/ask.js +35 -0
  25. package/dist/clis/doubao/common.d.ts +23 -0
  26. package/dist/clis/doubao/common.js +564 -0
  27. package/dist/clis/doubao/new.d.ts +1 -0
  28. package/dist/clis/doubao/new.js +20 -0
  29. package/dist/clis/doubao/read.d.ts +1 -0
  30. package/dist/clis/doubao/read.js +19 -0
  31. package/dist/clis/doubao/send.d.ts +1 -0
  32. package/dist/clis/doubao/send.js +22 -0
  33. package/dist/clis/doubao/status.d.ts +1 -0
  34. package/dist/clis/doubao/status.js +24 -0
  35. package/dist/clis/doubao-app/ask.d.ts +1 -0
  36. package/dist/clis/doubao-app/ask.js +53 -0
  37. package/dist/clis/doubao-app/common.d.ts +37 -0
  38. package/dist/clis/doubao-app/common.js +110 -0
  39. package/dist/clis/doubao-app/dump.d.ts +1 -0
  40. package/dist/clis/doubao-app/dump.js +24 -0
  41. package/dist/clis/doubao-app/new.d.ts +1 -0
  42. package/dist/clis/doubao-app/new.js +20 -0
  43. package/dist/clis/doubao-app/read.d.ts +1 -0
  44. package/dist/clis/doubao-app/read.js +18 -0
  45. package/dist/clis/doubao-app/screenshot.d.ts +1 -0
  46. package/dist/clis/doubao-app/screenshot.js +18 -0
  47. package/dist/clis/doubao-app/send.d.ts +1 -0
  48. package/dist/clis/doubao-app/send.js +27 -0
  49. package/dist/clis/doubao-app/status.d.ts +1 -0
  50. package/dist/clis/doubao-app/status.js +16 -0
  51. package/dist/clis/hackernews/ask.yaml +38 -0
  52. package/dist/clis/hackernews/best.yaml +38 -0
  53. package/dist/clis/hackernews/jobs.yaml +36 -0
  54. package/dist/clis/hackernews/new.yaml +38 -0
  55. package/dist/clis/hackernews/search.yaml +44 -0
  56. package/dist/clis/hackernews/show.yaml +38 -0
  57. package/dist/clis/hackernews/top.yaml +3 -1
  58. package/dist/clis/hackernews/user.yaml +25 -0
  59. package/dist/clis/twitter/download.js +13 -97
  60. package/dist/clis/twitter/thread.js +2 -1
  61. package/dist/clis/v2ex/member.yaml +29 -0
  62. package/dist/clis/v2ex/node.yaml +34 -0
  63. package/dist/clis/v2ex/nodes.yaml +31 -0
  64. package/dist/clis/v2ex/replies.yaml +32 -0
  65. package/dist/clis/v2ex/user.yaml +34 -0
  66. package/dist/clis/weibo/search.d.ts +1 -0
  67. package/dist/clis/weibo/search.js +73 -0
  68. package/dist/clis/weixin/download.d.ts +12 -0
  69. package/dist/clis/weixin/download.js +183 -0
  70. package/dist/clis/xiaohongshu/download.js +12 -60
  71. package/dist/clis/xiaohongshu/publish.d.ts +18 -0
  72. package/dist/clis/xiaohongshu/publish.js +352 -0
  73. package/dist/clis/xiaohongshu/search.js +47 -15
  74. package/dist/clis/xiaohongshu/search.test.d.ts +1 -0
  75. package/dist/clis/xiaohongshu/search.test.js +114 -0
  76. package/dist/clis/yollomi/background.d.ts +4 -0
  77. package/dist/clis/yollomi/background.js +45 -0
  78. package/dist/clis/yollomi/edit.d.ts +5 -0
  79. package/dist/clis/yollomi/edit.js +56 -0
  80. package/dist/clis/yollomi/face-swap.d.ts +5 -0
  81. package/dist/clis/yollomi/face-swap.js +43 -0
  82. package/dist/clis/yollomi/generate.d.ts +9 -0
  83. package/dist/clis/yollomi/generate.js +100 -0
  84. package/dist/clis/yollomi/models.d.ts +1 -0
  85. package/dist/clis/yollomi/models.js +33 -0
  86. package/dist/clis/yollomi/object-remover.d.ts +4 -0
  87. package/dist/clis/yollomi/object-remover.js +42 -0
  88. package/dist/clis/yollomi/remove-bg.d.ts +4 -0
  89. package/dist/clis/yollomi/remove-bg.js +38 -0
  90. package/dist/clis/yollomi/restore.d.ts +4 -0
  91. package/dist/clis/yollomi/restore.js +38 -0
  92. package/dist/clis/yollomi/try-on.d.ts +4 -0
  93. package/dist/clis/yollomi/try-on.js +46 -0
  94. package/dist/clis/yollomi/upload.d.ts +7 -0
  95. package/dist/clis/yollomi/upload.js +71 -0
  96. package/dist/clis/yollomi/upscale.d.ts +4 -0
  97. package/dist/clis/yollomi/upscale.js +53 -0
  98. package/dist/clis/yollomi/utils.d.ts +45 -0
  99. package/dist/clis/yollomi/utils.js +180 -0
  100. package/dist/clis/yollomi/video.d.ts +5 -0
  101. package/dist/clis/yollomi/video.js +56 -0
  102. package/dist/clis/zhihu/download.d.ts +1 -5
  103. package/dist/clis/zhihu/download.js +20 -126
  104. package/dist/clis/zhihu/download.test.js +7 -5
  105. package/dist/clis/zhihu/question.js +2 -1
  106. package/dist/commanderAdapter.js +4 -6
  107. package/dist/constants.d.ts +2 -0
  108. package/dist/constants.js +2 -0
  109. package/dist/daemon.js +7 -3
  110. package/dist/discovery.js +10 -10
  111. package/dist/doctor.js +2 -1
  112. package/dist/download/article-download.d.ts +59 -0
  113. package/dist/download/article-download.js +178 -0
  114. package/dist/download/media-download.d.ts +49 -0
  115. package/dist/download/media-download.js +112 -0
  116. package/dist/errors.d.ts +23 -2
  117. package/dist/errors.js +58 -2
  118. package/dist/errors.test.d.ts +1 -0
  119. package/dist/errors.test.js +59 -0
  120. package/dist/execution.js +9 -10
  121. package/dist/explore.js +4 -2
  122. package/dist/external.d.ts +15 -0
  123. package/dist/external.js +48 -2
  124. package/dist/external.test.d.ts +1 -0
  125. package/dist/external.test.js +64 -0
  126. package/dist/main.js +10 -0
  127. package/dist/plugin.d.ts +4 -0
  128. package/dist/plugin.js +45 -23
  129. package/dist/plugin.test.js +6 -1
  130. package/dist/record.d.ts +47 -0
  131. package/dist/record.js +545 -0
  132. package/dist/registry.d.ts +7 -2
  133. package/dist/registry.js +2 -6
  134. package/dist/runtime.d.ts +3 -1
  135. package/dist/runtime.js +10 -3
  136. package/dist/validate.js +1 -3
  137. package/docs/.vitepress/config.mts +1 -0
  138. package/docs/adapters/browser/douban.md +18 -8
  139. package/docs/adapters/browser/doubao.md +35 -0
  140. package/docs/adapters/browser/hackernews.md +20 -4
  141. package/docs/adapters/browser/tiktok.md +1 -1
  142. package/docs/adapters/browser/v2ex.md +31 -10
  143. package/docs/adapters/browser/weibo.md +4 -0
  144. package/docs/adapters/browser/weixin.md +33 -0
  145. package/docs/adapters/browser/wikipedia.md +0 -9
  146. package/docs/adapters/browser/xiaohongshu.md +8 -6
  147. package/docs/adapters/browser/yollomi.md +69 -0
  148. package/docs/adapters/desktop/antigravity.md +0 -3
  149. package/docs/adapters/desktop/doubao-app.md +35 -0
  150. package/docs/adapters/index.md +19 -8
  151. package/docs/advanced/download.md +4 -0
  152. package/package.json +3 -1
  153. package/src/browser/cdp.ts +9 -1
  154. package/src/browser/daemon-client.ts +4 -3
  155. package/src/browser/discover.ts +2 -1
  156. package/src/browser/errors.ts +18 -11
  157. package/src/browser/index.ts +1 -0
  158. package/src/browser/page.ts +11 -0
  159. package/src/browser/stealth.ts +142 -0
  160. package/src/browser.test.ts +51 -1
  161. package/src/build-manifest.ts +1 -3
  162. package/src/cli.ts +45 -2
  163. package/src/clis/bilibili/download.ts +25 -83
  164. package/src/clis/bilibili/utils.ts +2 -1
  165. package/src/clis/chaoxing/assignments.ts +2 -1
  166. package/src/clis/doubao/ask.ts +40 -0
  167. package/src/clis/doubao/common.ts +619 -0
  168. package/src/clis/doubao/new.ts +22 -0
  169. package/src/clis/doubao/read.ts +20 -0
  170. package/src/clis/doubao/send.ts +25 -0
  171. package/src/clis/doubao/status.ts +27 -0
  172. package/src/clis/doubao-app/ask.ts +60 -0
  173. package/src/clis/doubao-app/common.ts +116 -0
  174. package/src/clis/doubao-app/dump.ts +28 -0
  175. package/src/clis/doubao-app/new.ts +21 -0
  176. package/src/clis/doubao-app/read.ts +21 -0
  177. package/src/clis/doubao-app/screenshot.ts +19 -0
  178. package/src/clis/doubao-app/send.ts +30 -0
  179. package/src/clis/doubao-app/status.ts +17 -0
  180. package/src/clis/hackernews/ask.yaml +38 -0
  181. package/src/clis/hackernews/best.yaml +38 -0
  182. package/src/clis/hackernews/jobs.yaml +36 -0
  183. package/src/clis/hackernews/new.yaml +38 -0
  184. package/src/clis/hackernews/search.yaml +44 -0
  185. package/src/clis/hackernews/show.yaml +38 -0
  186. package/src/clis/hackernews/top.yaml +3 -1
  187. package/src/clis/hackernews/user.yaml +25 -0
  188. package/src/clis/twitter/download.ts +13 -111
  189. package/src/clis/twitter/thread.ts +2 -1
  190. package/src/clis/v2ex/member.yaml +29 -0
  191. package/src/clis/v2ex/node.yaml +34 -0
  192. package/src/clis/v2ex/nodes.yaml +31 -0
  193. package/src/clis/v2ex/replies.yaml +32 -0
  194. package/src/clis/v2ex/user.yaml +34 -0
  195. package/src/clis/weibo/search.ts +78 -0
  196. package/src/clis/weixin/download.ts +199 -0
  197. package/src/clis/xiaohongshu/download.ts +12 -71
  198. package/src/clis/xiaohongshu/publish.ts +392 -0
  199. package/src/clis/xiaohongshu/search.test.ts +134 -0
  200. package/src/clis/xiaohongshu/search.ts +49 -15
  201. package/src/clis/yollomi/background.ts +48 -0
  202. package/src/clis/yollomi/edit.ts +58 -0
  203. package/src/clis/yollomi/face-swap.ts +45 -0
  204. package/src/clis/yollomi/generate.ts +95 -0
  205. package/src/clis/yollomi/models.ts +38 -0
  206. package/src/clis/yollomi/object-remover.ts +44 -0
  207. package/src/clis/yollomi/remove-bg.ts +40 -0
  208. package/src/clis/yollomi/restore.ts +40 -0
  209. package/src/clis/yollomi/try-on.ts +48 -0
  210. package/src/clis/yollomi/upload.ts +78 -0
  211. package/src/clis/yollomi/upscale.ts +49 -0
  212. package/src/clis/yollomi/utils.ts +202 -0
  213. package/src/clis/yollomi/video.ts +61 -0
  214. package/src/clis/zhihu/download.test.ts +7 -5
  215. package/src/clis/zhihu/download.ts +23 -158
  216. package/src/clis/zhihu/question.ts +2 -1
  217. package/src/commanderAdapter.ts +4 -7
  218. package/src/constants.ts +3 -0
  219. package/src/daemon.ts +7 -3
  220. package/src/discovery.ts +26 -26
  221. package/src/doctor.ts +2 -1
  222. package/src/download/article-download.ts +272 -0
  223. package/src/download/media-download.ts +178 -0
  224. package/src/errors.test.ts +79 -0
  225. package/src/errors.ts +92 -2
  226. package/src/execution.ts +14 -10
  227. package/src/explore.ts +4 -2
  228. package/src/external.test.ts +88 -0
  229. package/src/external.ts +56 -2
  230. package/src/generate.ts +2 -1
  231. package/src/main.ts +10 -0
  232. package/src/plugin.test.ts +7 -1
  233. package/src/plugin.ts +49 -25
  234. package/src/record.ts +617 -0
  235. package/src/registry.ts +9 -5
  236. package/src/runtime.ts +16 -4
  237. package/src/validate.ts +1 -3
  238. package/tests/e2e/browser-auth.test.ts +10 -1
  239. package/tests/e2e/browser-public.test.ts +13 -8
  240. package/tests/e2e/public-commands.test.ts +209 -21
  241. package/tests/smoke/api-health.test.ts +65 -6
package/src/runtime.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  import { BrowserBridge, CDPBridge } from './browser/index.js';
2
2
  import type { IPage } from './types.js';
3
+ import { TimeoutError } from './errors.js';
3
4
 
4
5
  /**
5
6
  * Returns the appropriate browser factory based on environment config.
6
7
  * Uses CDPBridge when OPENCLI_CDP_ENDPOINT is set, otherwise BrowserBridge.
7
8
  */
8
9
  export function getBrowserFactory(): new () => IBrowserFactory {
9
- return (process.env.OPENCLI_CDP_ENDPOINT ? CDPBridge : BrowserBridge) as any;
10
+ return (process.env.OPENCLI_CDP_ENDPOINT ? CDPBridge : BrowserBridge) as unknown as new () => IBrowserFactory;
10
11
  }
11
12
 
12
13
  export const DEFAULT_BROWSER_CONNECT_TIMEOUT = parseInt(process.env.OPENCLI_BROWSER_CONNECT_TIMEOUT ?? '30', 10);
@@ -21,15 +22,26 @@ export async function runWithTimeout<T>(
21
22
  promise: Promise<T>,
22
23
  opts: { timeout: number; label?: string },
23
24
  ): Promise<T> {
24
- return withTimeoutMs(promise, opts.timeout * 1000, `${opts.label ?? 'Operation'} timed out after ${opts.timeout}s`);
25
+ const label = opts.label ?? 'Operation';
26
+ return withTimeoutMs(promise, opts.timeout * 1000,
27
+ () => new TimeoutError(label, opts.timeout));
25
28
  }
26
29
 
27
30
  /**
28
31
  * Timeout with milliseconds unit. Used for low-level internal timeouts.
32
+ * Accepts a factory function to create the rejection error, keeping this
33
+ * utility decoupled from specific error types.
29
34
  */
30
- export function withTimeoutMs<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
35
+ export function withTimeoutMs<T>(
36
+ promise: Promise<T>,
37
+ timeoutMs: number,
38
+ makeError: string | (() => Error) = 'Operation timed out',
39
+ ): Promise<T> {
40
+ const reject_ = typeof makeError === 'string'
41
+ ? () => new Error(makeError)
42
+ : makeError;
31
43
  return new Promise<T>((resolve, reject) => {
32
- const timer = setTimeout(() => reject(new Error(message)), timeoutMs);
44
+ const timer = setTimeout(() => reject(reject_()), timeoutMs);
33
45
  promise.then(
34
46
  (value) => { clearTimeout(timer); resolve(value); },
35
47
  (error) => { clearTimeout(timer); reject(error); },
package/src/validate.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  import * as fs from 'node:fs';
3
3
  import * as path from 'node:path';
4
4
  import yaml from 'js-yaml';
5
+ import { getErrorMessage } from './errors.js';
5
6
 
6
7
  /** All recognized pipeline step names */
7
8
  const KNOWN_STEP_NAMES = new Set([
@@ -37,9 +38,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
37
38
  return typeof value === 'object' && value !== null && !Array.isArray(value);
38
39
  }
39
40
 
40
- function getErrorMessage(error: unknown): string {
41
- return error instanceof Error ? error.message : String(error);
42
- }
43
41
 
44
42
  export function validateClisWithTarget(dirs: string[], target?: string): ValidationReport {
45
43
  const results: FileValidationResult[] = [];
@@ -101,7 +101,7 @@ describe('login-required commands — graceful failure', () => {
101
101
  }, 60_000);
102
102
 
103
103
  it('linux-do search fails gracefully without login', async () => {
104
- await expectGracefulAuthFailure(['linux-do', 'search', '--keyword', 'test', '--limit', '3', '-f', 'json'], 'linux-do search');
104
+ await expectGracefulAuthFailure(['linux-do', 'search', 'test', '--limit', '3', '-f', 'json'], 'linux-do search');
105
105
  }, 60_000);
106
106
 
107
107
  // ── xiaohongshu (requires login) ──
@@ -112,4 +112,13 @@ describe('login-required commands — graceful failure', () => {
112
112
  it('xiaohongshu notifications fails gracefully without login', async () => {
113
113
  await expectGracefulAuthFailure(['xiaohongshu', 'notifications', '--limit', '3', '-f', 'json'], 'xiaohongshu notifications');
114
114
  }, 60_000);
115
+
116
+ // ── yollomi (requires login session) ──
117
+ it('yollomi generate fails gracefully without login', async () => {
118
+ await expectGracefulAuthFailure(['yollomi', 'generate', 'a cute cat', '--no-download', '-f', 'json'], 'yollomi generate');
119
+ }, 60_000);
120
+
121
+ it('yollomi video fails gracefully without login', async () => {
122
+ await expectGracefulAuthFailure(['yollomi', 'video', 'a sunset over the ocean', '--no-download', '-f', 'json'], 'yollomi video');
123
+ }, 60_000);
115
124
  });
@@ -92,7 +92,7 @@ describe('browser public-data commands E2E', () => {
92
92
  }, 60_000);
93
93
 
94
94
  it('bilibili search returns results', async () => {
95
- const data = await tryBrowserCommand(['bilibili', 'search', '--keyword', 'typescript', '--limit', '3', '-f', 'json']);
95
+ const data = await tryBrowserCommand(['bilibili', 'search', 'typescript', '--limit', '3', '-f', 'json']);
96
96
  expectDataOrSkip(data, 'bilibili search');
97
97
  }, 60_000);
98
98
 
@@ -102,6 +102,11 @@ describe('browser public-data commands E2E', () => {
102
102
  expectDataOrSkip(data, 'weibo hot');
103
103
  }, 60_000);
104
104
 
105
+ it('weibo search returns results', async () => {
106
+ const data = await tryBrowserCommand(['weibo', 'search', 'openai', '--limit', '3', '-f', 'json']);
107
+ expectDataOrSkip(data, 'weibo search');
108
+ }, 60_000);
109
+
105
110
  // ── zhihu (browser: true, cookie strategy) ──
106
111
  it('zhihu hot returns trending questions', async () => {
107
112
  const data = await tryBrowserCommand(['zhihu', 'hot', '--limit', '5', '-f', 'json']);
@@ -112,7 +117,7 @@ describe('browser public-data commands E2E', () => {
112
117
  }, 60_000);
113
118
 
114
119
  it('zhihu search returns results', async () => {
115
- const data = await tryBrowserCommand(['zhihu', 'search', '--keyword', 'playwright', '--limit', '3', '-f', 'json']);
120
+ const data = await tryBrowserCommand(['zhihu', 'search', 'playwright', '--limit', '3', '-f', 'json']);
116
121
  expectDataOrSkip(data, 'zhihu search');
117
122
  }, 60_000);
118
123
 
@@ -146,25 +151,25 @@ describe('browser public-data commands E2E', () => {
146
151
 
147
152
  // ── reuters (browser: true) ──
148
153
  it('reuters search returns articles', async () => {
149
- const data = await tryBrowserCommand(['reuters', 'search', '--keyword', 'technology', '--limit', '3', '-f', 'json']);
154
+ const data = await tryBrowserCommand(['reuters', 'search', 'technology', '--limit', '3', '-f', 'json']);
150
155
  expectDataOrSkip(data, 'reuters search');
151
156
  }, 60_000);
152
157
 
153
158
  // ── youtube (browser: true) ──
154
159
  it('youtube search returns videos', async () => {
155
- const data = await tryBrowserCommand(['youtube', 'search', '--keyword', 'typescript tutorial', '--limit', '3', '-f', 'json']);
160
+ const data = await tryBrowserCommand(['youtube', 'search', 'typescript tutorial', '--limit', '3', '-f', 'json']);
156
161
  expectDataOrSkip(data, 'youtube search');
157
162
  }, 60_000);
158
163
 
159
164
  // ── smzdm (browser: true) ──
160
165
  it('smzdm search returns deals', async () => {
161
- const data = await tryBrowserCommand(['smzdm', 'search', '--keyword', '键盘', '--limit', '3', '-f', 'json']);
166
+ const data = await tryBrowserCommand(['smzdm', 'search', '键盘', '--limit', '3', '-f', 'json']);
162
167
  expectDataOrSkip(data, 'smzdm search');
163
168
  }, 60_000);
164
169
 
165
170
  // ── boss (browser: true) ──
166
171
  it('boss search returns jobs', async () => {
167
- const data = await tryBrowserCommand(['boss', 'search', '--keyword', 'golang', '--limit', '3', '-f', 'json']);
172
+ const data = await tryBrowserCommand(['boss', 'search', 'golang', '--limit', '3', '-f', 'json']);
168
173
  expectDataOrSkip(data, 'boss search');
169
174
  }, 60_000);
170
175
 
@@ -176,13 +181,13 @@ describe('browser public-data commands E2E', () => {
176
181
 
177
182
  // ── coupang (browser: true) ──
178
183
  it('coupang search returns products', async () => {
179
- const data = await tryBrowserCommand(['coupang', 'search', '--keyword', 'laptop', '--limit', '3', '-f', 'json']);
184
+ const data = await tryBrowserCommand(['coupang', 'search', 'laptop', '--limit', '3', '-f', 'json']);
180
185
  expectDataOrSkip(data, 'coupang search');
181
186
  }, 60_000);
182
187
 
183
188
  // ── xiaohongshu (browser: true) ──
184
189
  it('xiaohongshu search returns notes', async () => {
185
- const data = await tryBrowserCommand(['xiaohongshu', 'search', '--keyword', '美食', '--limit', '3', '-f', 'json']);
190
+ const data = await tryBrowserCommand(['xiaohongshu', 'search', '美食', '--limit', '3', '-f', 'json']);
186
191
  expectDataOrSkip(data, 'xiaohongshu search');
187
192
  }, 60_000);
188
193
 
@@ -3,12 +3,15 @@
3
3
  * These commands use Node.js fetch directly — no browser needed.
4
4
  */
5
5
 
6
- import { describe, it, expect } from 'vitest';
7
- import { runCli, parseJsonOutput } from './helpers.js';
6
+ import { describe, expect, it } from 'vitest';
7
+ import { parseJsonOutput, runCli } from './helpers.js';
8
8
 
9
9
  function isExpectedChineseSiteRestriction(code: number, stderr: string): boolean {
10
10
  if (code === 0) return false;
11
- return /Error \[FETCH_ERROR\]: HTTP (403|429|451|503)\b/.test(stderr);
11
+ // Overseas CI runners may get HTTP errors, geo-blocks, DNS failures,
12
+ // or receive mangled HTML that fails parsing.
13
+ return /Error \[(FETCH_ERROR|PARSE_ERROR|NOT_FOUND)\]/.test(stderr)
14
+ || /fetch failed/.test(stderr);
12
15
  }
13
16
 
14
17
  function isExpectedApplePodcastsRestriction(code: number, stderr: string): boolean {
@@ -40,21 +43,25 @@ describe('public commands E2E', () => {
40
43
  expect(Array.isArray(data[0].mediaLinks)).toBe(true);
41
44
  }, 30_000);
42
45
 
43
- it.each(['markets', 'economics', 'industries', 'tech', 'politics', 'businessweek', 'opinions'])(
44
- 'bloomberg %s returns structured RSS items',
45
- async (section) => {
46
- const { stdout, code } = await runCli(['bloomberg', section, '--limit', '1', '-f', 'json']);
47
- expect(code).toBe(0);
48
- const data = parseJsonOutput(stdout);
49
- expect(Array.isArray(data)).toBe(true);
50
- expect(data.length).toBe(1);
51
- expect(data[0]).toHaveProperty('title');
52
- expect(data[0]).toHaveProperty('summary');
53
- expect(data[0]).toHaveProperty('link');
54
- expect(data[0]).toHaveProperty('mediaLinks');
55
- },
56
- 30_000,
57
- );
46
+ it.each([
47
+ 'markets',
48
+ 'economics',
49
+ 'industries',
50
+ 'tech',
51
+ 'politics',
52
+ 'businessweek',
53
+ 'opinions',
54
+ ])('bloomberg %s returns structured RSS items', async (section) => {
55
+ const { stdout, code } = await runCli(['bloomberg', section, '--limit', '1', '-f', 'json']);
56
+ expect(code).toBe(0);
57
+ const data = parseJsonOutput(stdout);
58
+ expect(Array.isArray(data)).toBe(true);
59
+ expect(data.length).toBe(1);
60
+ expect(data[0]).toHaveProperty('title');
61
+ expect(data[0]).toHaveProperty('summary');
62
+ expect(data[0]).toHaveProperty('link');
63
+ expect(data[0]).toHaveProperty('mediaLinks');
64
+ }, 30_000);
58
65
 
59
66
  it('bloomberg feeds lists the supported RSS aliases', async () => {
60
67
  const { stdout, code } = await runCli(['bloomberg', 'feeds', '-f', 'json']);
@@ -95,7 +102,16 @@ describe('public commands E2E', () => {
95
102
  }, 30_000);
96
103
 
97
104
  it('apple-podcasts top returns ranked podcasts', async () => {
98
- const { stdout, stderr, code } = await runCli(['apple-podcasts', 'top', '--limit', '3', '--country', 'us', '-f', 'json']);
105
+ const { stdout, stderr, code } = await runCli([
106
+ 'apple-podcasts',
107
+ 'top',
108
+ '--limit',
109
+ '3',
110
+ '--country',
111
+ 'us',
112
+ '-f',
113
+ 'json',
114
+ ]);
99
115
  if (isExpectedApplePodcastsRestriction(code, stderr)) {
100
116
  console.warn(`apple-podcasts top skipped: ${stderr.trim()}`);
101
117
  return;
@@ -128,6 +144,76 @@ describe('public commands E2E', () => {
128
144
  expect(data.length).toBe(1);
129
145
  }, 30_000);
130
146
 
147
+ it('hackernews new returns newest stories', async () => {
148
+ const { stdout, code } = await runCli(['hackernews', 'new', '--limit', '3', '-f', 'json']);
149
+ expect(code).toBe(0);
150
+ const data = parseJsonOutput(stdout);
151
+ expect(Array.isArray(data)).toBe(true);
152
+ expect(data.length).toBeGreaterThanOrEqual(1);
153
+ expect(data[0]).toHaveProperty('title');
154
+ expect(data[0]).toHaveProperty('score');
155
+ expect(data[0]).toHaveProperty('rank');
156
+ }, 30_000);
157
+
158
+ it('hackernews best returns best stories', async () => {
159
+ const { stdout, code } = await runCli(['hackernews', 'best', '--limit', '3', '-f', 'json']);
160
+ expect(code).toBe(0);
161
+ const data = parseJsonOutput(stdout);
162
+ expect(Array.isArray(data)).toBe(true);
163
+ expect(data.length).toBeGreaterThanOrEqual(1);
164
+ expect(data[0]).toHaveProperty('title');
165
+ expect(data[0]).toHaveProperty('score');
166
+ }, 30_000);
167
+
168
+ it('hackernews ask returns Ask HN posts', async () => {
169
+ const { stdout, code } = await runCli(['hackernews', 'ask', '--limit', '3', '-f', 'json']);
170
+ expect(code).toBe(0);
171
+ const data = parseJsonOutput(stdout);
172
+ expect(Array.isArray(data)).toBe(true);
173
+ expect(data.length).toBeGreaterThanOrEqual(1);
174
+ expect(data[0]).toHaveProperty('title');
175
+ }, 30_000);
176
+
177
+ it('hackernews show returns Show HN posts', async () => {
178
+ const { stdout, code } = await runCli(['hackernews', 'show', '--limit', '3', '-f', 'json']);
179
+ expect(code).toBe(0);
180
+ const data = parseJsonOutput(stdout);
181
+ expect(Array.isArray(data)).toBe(true);
182
+ expect(data.length).toBeGreaterThanOrEqual(1);
183
+ expect(data[0]).toHaveProperty('title');
184
+ }, 30_000);
185
+
186
+ it('hackernews jobs returns job postings', async () => {
187
+ const { stdout, code } = await runCli(['hackernews', 'jobs', '--limit', '3', '-f', 'json']);
188
+ expect(code).toBe(0);
189
+ const data = parseJsonOutput(stdout);
190
+ expect(Array.isArray(data)).toBe(true);
191
+ expect(data.length).toBeGreaterThanOrEqual(1);
192
+ expect(data[0]).toHaveProperty('title');
193
+ expect(data[0]).toHaveProperty('url');
194
+ }, 30_000);
195
+
196
+ it('hackernews search returns results for query', async () => {
197
+ const { stdout, code } = await runCli(['hackernews', 'search', 'typescript', '--limit', '3', '-f', 'json']);
198
+ expect(code).toBe(0);
199
+ const data = parseJsonOutput(stdout);
200
+ expect(Array.isArray(data)).toBe(true);
201
+ expect(data.length).toBe(3);
202
+ expect(data[0]).toHaveProperty('title');
203
+ expect(data[0]).toHaveProperty('score');
204
+ expect(data[0]).toHaveProperty('author');
205
+ }, 30_000);
206
+
207
+ it('hackernews user returns user profile', async () => {
208
+ const { stdout, code } = await runCli(['hackernews', 'user', 'pg', '-f', 'json']);
209
+ expect(code).toBe(0);
210
+ const data = parseJsonOutput(stdout);
211
+ expect(Array.isArray(data)).toBe(true);
212
+ expect(data.length).toBe(1);
213
+ expect(data[0]).toHaveProperty('username', 'pg');
214
+ expect(data[0]).toHaveProperty('karma');
215
+ }, 30_000);
216
+
131
217
  // ── v2ex (public API, browser: false) ──
132
218
  it('v2ex hot returns topics', async () => {
133
219
  const { stdout, code } = await runCli(['v2ex', 'hot', '--limit', '3', '-f', 'json']);
@@ -156,6 +242,69 @@ describe('public commands E2E', () => {
156
242
  }
157
243
  }, 30_000);
158
244
 
245
+ it('v2ex node returns topics for a given node', async () => {
246
+ const { stdout, code } = await runCli(['v2ex', 'node', 'python', '--limit', '3', '-f', 'json']);
247
+ // V2EX may rate-limit; only assert when successful
248
+ if (code === 0) {
249
+ const data = parseJsonOutput(stdout);
250
+ expect(Array.isArray(data)).toBe(true);
251
+ expect(data.length).toBeGreaterThanOrEqual(1);
252
+ expect(data.length).toBeLessThanOrEqual(3);
253
+ expect(data[0]).toHaveProperty('title');
254
+ expect(data[0]).toHaveProperty('author');
255
+ expect(data[0]).toHaveProperty('url');
256
+ }
257
+ }, 30_000);
258
+
259
+ it('v2ex user returns topics by username', async () => {
260
+ const { stdout, code } = await runCli(['v2ex', 'user', 'Livid', '--limit', '3', '-f', 'json']);
261
+ if (code === 0) {
262
+ const data = parseJsonOutput(stdout);
263
+ expect(Array.isArray(data)).toBe(true);
264
+ expect(data.length).toBeGreaterThanOrEqual(1);
265
+ expect(data.length).toBeLessThanOrEqual(3);
266
+ expect(data[0]).toHaveProperty('title');
267
+ expect(data[0]).toHaveProperty('node');
268
+ expect(data[0]).toHaveProperty('url');
269
+ }
270
+ }, 30_000);
271
+
272
+ it('v2ex member returns user profile', async () => {
273
+ const { stdout, code } = await runCli(['v2ex', 'member', 'Livid', '-f', 'json']);
274
+ if (code === 0) {
275
+ const data = parseJsonOutput(stdout);
276
+ expect(Array.isArray(data)).toBe(true);
277
+ expect(data.length).toBe(1);
278
+ expect(data[0].username).toBe('Livid');
279
+ }
280
+ }, 30_000);
281
+
282
+ it('v2ex replies returns topic replies', async () => {
283
+ const { stdout, code } = await runCli(['v2ex', 'replies', '1000', '--limit', '3', '-f', 'json']);
284
+ if (code === 0) {
285
+ const data = parseJsonOutput(stdout);
286
+ expect(Array.isArray(data)).toBe(true);
287
+ expect(data.length).toBeGreaterThanOrEqual(1);
288
+ expect(data.length).toBeLessThanOrEqual(3);
289
+ expect(data[0]).toHaveProperty('author');
290
+ expect(data[0]).toHaveProperty('content');
291
+ }
292
+ }, 30_000);
293
+
294
+ it('v2ex nodes returns node list sorted by topics', async () => {
295
+ const { stdout, code } = await runCli(['v2ex', 'nodes', '--limit', '5', '-f', 'json']);
296
+ if (code === 0) {
297
+ const data = parseJsonOutput(stdout);
298
+ expect(Array.isArray(data)).toBe(true);
299
+ expect(data.length).toBe(5);
300
+ expect(data[0]).toHaveProperty('name');
301
+ expect(data[0]).toHaveProperty('title');
302
+ expect(data[0]).toHaveProperty('topics');
303
+ // Verify descending sort by topic count
304
+ expect(Number(data[0].topics)).toBeGreaterThanOrEqual(Number(data[data.length - 1].topics));
305
+ }
306
+ }, 30_000);
307
+
159
308
  // ── xiaoyuzhou (Chinese site — may return empty on overseas CI runners) ──
160
309
  it('xiaoyuzhou podcast returns podcast profile', async () => {
161
310
  const { stdout, stderr, code } = await runCli(['xiaoyuzhou', 'podcast', '6013f9f58e2f7ee375cf4216', '-f', 'json']);
@@ -173,7 +322,13 @@ describe('public commands E2E', () => {
173
322
  }, 30_000);
174
323
 
175
324
  it('xiaoyuzhou podcast-episodes returns episode list', async () => {
176
- const { stdout, stderr, code } = await runCli(['xiaoyuzhou', 'podcast-episodes', '6013f9f58e2f7ee375cf4216', '-f', 'json']);
325
+ const { stdout, stderr, code } = await runCli([
326
+ 'xiaoyuzhou',
327
+ 'podcast-episodes',
328
+ '6013f9f58e2f7ee375cf4216',
329
+ '-f',
330
+ 'json',
331
+ ]);
177
332
  if (isExpectedXiaoyuzhouRestriction(code, stderr)) {
178
333
  console.warn(`xiaoyuzhou podcast-episodes skipped: ${stderr.trim()}`);
179
334
  return;
@@ -204,7 +359,15 @@ describe('public commands E2E', () => {
204
359
  }, 30_000);
205
360
 
206
361
  it('xiaoyuzhou podcast-episodes rejects invalid limit', async () => {
207
- const { stderr, code } = await runCli(['xiaoyuzhou', 'podcast-episodes', '6013f9f58e2f7ee375cf4216', '--limit', 'abc', '-f', 'json']);
362
+ const { stderr, code } = await runCli([
363
+ 'xiaoyuzhou',
364
+ 'podcast-episodes',
365
+ '6013f9f58e2f7ee375cf4216',
366
+ '--limit',
367
+ 'abc',
368
+ '-f',
369
+ 'json',
370
+ ]);
208
371
  if (isExpectedXiaoyuzhouRestriction(code, stderr)) {
209
372
  console.warn(`xiaoyuzhou invalid-limit skipped: ${stderr.trim()}`);
210
373
  return;
@@ -300,4 +463,29 @@ describe('public commands E2E', () => {
300
463
  expect(data[0]).toHaveProperty('readingCount');
301
464
  expect(data[0]).toHaveProperty('bookId');
302
465
  }, 30_000);
466
+
467
+ // ── yollomi (browser: false, hardcoded data) ──
468
+ it('yollomi models returns model list with all types', async () => {
469
+ const { stdout, code } = await runCli(['yollomi', 'models', '-f', 'json']);
470
+ expect(code).toBe(0);
471
+ const data = parseJsonOutput(stdout);
472
+ expect(Array.isArray(data)).toBe(true);
473
+ expect(data.length).toBeGreaterThan(10);
474
+ expect(data[0]).toHaveProperty('type');
475
+ expect(data[0]).toHaveProperty('model');
476
+ expect(data[0]).toHaveProperty('credits');
477
+ expect(data[0]).toHaveProperty('description');
478
+ const types = new Set(data.map((d: any) => d.type));
479
+ expect(types.has('image')).toBe(true);
480
+ expect(types.has('video')).toBe(true);
481
+ expect(types.has('tool')).toBe(true);
482
+ }, 30_000);
483
+
484
+ it('yollomi models --type image filters correctly', async () => {
485
+ const { stdout, code } = await runCli(['yollomi', 'models', '--type', 'image', '-f', 'json']);
486
+ expect(code).toBe(0);
487
+ const data = parseJsonOutput(stdout);
488
+ expect(data.length).toBeGreaterThan(0);
489
+ expect(data.every((d: any) => d.type === 'image')).toBe(true);
490
+ }, 30_000);
303
491
  });
@@ -4,11 +4,10 @@
4
4
  * These verify that external APIs haven't changed their structure.
5
5
  */
6
6
 
7
- import { describe, it, expect } from 'vitest';
8
- import { runCli, parseJsonOutput } from '../e2e/helpers.js';
7
+ import { describe, expect, it } from 'vitest';
8
+ import { parseJsonOutput, runCli } from '../e2e/helpers.js';
9
9
 
10
10
  describe('API health smoke tests', () => {
11
-
12
11
  // ── Public API commands (should always work) ──
13
12
  it('hackernews API is responsive and returns expected structure', async () => {
14
13
  const { stdout, code } = await runCli(['hackernews', 'top', '--limit', '5', '-f', 'json']);
@@ -46,6 +45,53 @@ describe('API health smoke tests', () => {
46
45
  }
47
46
  }, 30_000);
48
47
 
48
+ it('v2ex node API is responsive', async () => {
49
+ const { stdout, code } = await runCli(['v2ex', 'node', 'python', '--limit', '3', '-f', 'json']);
50
+ if (code === 0) {
51
+ const data = parseJsonOutput(stdout);
52
+ expect(data.length).toBeGreaterThanOrEqual(1);
53
+ expect(data[0]).toHaveProperty('title');
54
+ expect(data[0]).toHaveProperty('author');
55
+ }
56
+ }, 30_000);
57
+
58
+ it('v2ex user API is responsive', async () => {
59
+ const { stdout, code } = await runCli(['v2ex', 'user', 'Livid', '--limit', '3', '-f', 'json']);
60
+ if (code === 0) {
61
+ const data = parseJsonOutput(stdout);
62
+ expect(data.length).toBeGreaterThanOrEqual(1);
63
+ expect(data[0]).toHaveProperty('title');
64
+ expect(data[0]).toHaveProperty('url');
65
+ }
66
+ }, 30_000);
67
+
68
+ it('v2ex member API is responsive', async () => {
69
+ const { stdout, code } = await runCli(['v2ex', 'member', 'Livid', '-f', 'json']);
70
+ if (code === 0) {
71
+ const data = parseJsonOutput(stdout);
72
+ expect(data.length).toBe(1);
73
+ expect(data[0].username).toBe('Livid');
74
+ }
75
+ }, 30_000);
76
+
77
+ it('v2ex replies API is responsive', async () => {
78
+ const { stdout, code } = await runCli(['v2ex', 'replies', '1000', '--limit', '3', '-f', 'json']);
79
+ if (code === 0) {
80
+ const data = parseJsonOutput(stdout);
81
+ expect(data.length).toBeGreaterThanOrEqual(1);
82
+ expect(data[0]).toHaveProperty('author');
83
+ }
84
+ }, 30_000);
85
+
86
+ it('v2ex nodes API is responsive', async () => {
87
+ const { stdout, code } = await runCli(['v2ex', 'nodes', '--limit', '5', '-f', 'json']);
88
+ if (code === 0) {
89
+ const data = parseJsonOutput(stdout);
90
+ expect(data.length).toBe(5);
91
+ expect(data[0]).toHaveProperty('topics');
92
+ }
93
+ }, 30_000);
94
+
49
95
  // ── Validate all adapters ──
50
96
  it('all adapter definitions are valid', async () => {
51
97
  const { stdout, code } = await runCli(['validate']);
@@ -61,9 +107,22 @@ describe('API health smoke tests', () => {
61
107
  const sites = new Set(data.map((d: any) => d.site));
62
108
  // Verify all 17 sites are present
63
109
  for (const expected of [
64
- 'hackernews', 'bbc', 'bilibili', 'v2ex', 'weibo', 'zhihu',
65
- 'twitter', 'reddit', 'xueqiu', 'reuters', 'youtube',
66
- 'smzdm', 'boss', 'ctrip', 'coupang', 'xiaohongshu',
110
+ 'hackernews',
111
+ 'bbc',
112
+ 'bilibili',
113
+ 'v2ex',
114
+ 'weibo',
115
+ 'zhihu',
116
+ 'twitter',
117
+ 'reddit',
118
+ 'xueqiu',
119
+ 'reuters',
120
+ 'youtube',
121
+ 'smzdm',
122
+ 'boss',
123
+ 'ctrip',
124
+ 'coupang',
125
+ 'xiaohongshu',
67
126
  'yahoo-finance',
68
127
  ]) {
69
128
  expect(sites.has(expected)).toBe(true);