@jackwener/opencli 1.7.22 → 1.8.0

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 (222) hide show
  1. package/README.md +30 -148
  2. package/README.zh-CN.md +37 -211
  3. package/cli-manifest.json +6423 -4260
  4. package/clis/12306/me.js +73 -0
  5. package/clis/12306/orders.js +96 -0
  6. package/clis/12306/passengers.js +90 -0
  7. package/clis/12306/price.js +166 -0
  8. package/clis/12306/stations.js +66 -0
  9. package/clis/12306/train.js +91 -0
  10. package/clis/12306/trains.js +119 -0
  11. package/clis/12306/utils.js +272 -0
  12. package/clis/12306/utils.test.js +331 -0
  13. package/clis/36kr/article.js +6 -3
  14. package/clis/36kr/article.test.js +46 -0
  15. package/clis/apple-podcasts/commands.test.js +20 -0
  16. package/clis/apple-podcasts/search.js +2 -2
  17. package/clis/barchart/greeks.js +144 -56
  18. package/clis/barchart/greeks.test.js +138 -0
  19. package/clis/bilibili/summary.js +167 -0
  20. package/clis/bilibili/summary.test.js +210 -0
  21. package/clis/booking/booking.test.js +356 -0
  22. package/clis/booking/search.js +351 -0
  23. package/clis/chatgpt/envelope.test.js +108 -0
  24. package/clis/chatgpt/image.js +2 -2
  25. package/clis/chatgpt/image.test.js +6 -0
  26. package/clis/chatgpt/utils.js +148 -41
  27. package/clis/chatgpt/utils.test.js +92 -2
  28. package/clis/douyin/_shared/browser-fetch.js +44 -20
  29. package/clis/douyin/_shared/browser-fetch.test.js +22 -1
  30. package/clis/douyin/_shared/evaluate-result.js +16 -0
  31. package/clis/douyin/_shared/tos-upload.js +105 -69
  32. package/clis/douyin/_shared/vod-upload.js +212 -0
  33. package/clis/douyin/_shared/vod-upload.test.js +38 -0
  34. package/clis/douyin/delete.js +137 -4
  35. package/clis/douyin/delete.test.js +90 -1
  36. package/clis/douyin/publish-upload-id.test.js +170 -0
  37. package/clis/douyin/publish.js +88 -42
  38. package/clis/douyin/user-videos.js +9 -2
  39. package/clis/douyin/user-videos.test.js +43 -0
  40. package/clis/flomo/memos.js +228 -0
  41. package/clis/flomo/memos.test.js +144 -0
  42. package/clis/gitee/search.js +2 -2
  43. package/clis/gitee/search.test.js +65 -0
  44. package/clis/jike/post.js +27 -17
  45. package/clis/jike/read.test.js +86 -0
  46. package/clis/jike/topic.js +32 -19
  47. package/clis/jike/user.js +33 -20
  48. package/clis/lesswrong/comments.js +1 -1
  49. package/clis/lesswrong/curated.js +1 -1
  50. package/clis/lesswrong/frontpage.js +1 -1
  51. package/clis/lesswrong/frontpage.test.js +37 -0
  52. package/clis/lesswrong/new.js +1 -1
  53. package/clis/lesswrong/read.js +1 -1
  54. package/clis/lesswrong/sequences.js +1 -1
  55. package/clis/lesswrong/shortform.js +1 -1
  56. package/clis/lesswrong/tag.js +1 -1
  57. package/clis/lesswrong/top-month.js +1 -1
  58. package/clis/lesswrong/top-week.js +1 -1
  59. package/clis/lesswrong/top-year.js +1 -1
  60. package/clis/lesswrong/top.js +1 -1
  61. package/clis/linkedin/connect.js +401 -0
  62. package/clis/linkedin/connect.test.js +213 -0
  63. package/clis/linkedin/inbox.js +234 -0
  64. package/clis/linkedin/inbox.test.js +152 -0
  65. package/clis/linkedin/people-search.js +262 -0
  66. package/clis/linkedin/people-search.test.js +216 -0
  67. package/clis/linkedin/safe-send.js +357 -0
  68. package/clis/linkedin/safe-send.test.js +204 -0
  69. package/clis/linkedin/salesnav-inbox.js +210 -0
  70. package/clis/linkedin/salesnav-inbox.test.js +113 -0
  71. package/clis/linkedin/salesnav-message.js +360 -0
  72. package/clis/linkedin/salesnav-message.test.js +172 -0
  73. package/clis/linkedin/salesnav-search.js +186 -0
  74. package/clis/linkedin/salesnav-search.test.js +76 -0
  75. package/clis/linkedin/salesnav-thread.js +212 -0
  76. package/clis/linkedin/salesnav-thread.test.js +79 -0
  77. package/clis/linkedin/sent-invitations.js +92 -0
  78. package/clis/linkedin/sent-invitations.test.js +62 -0
  79. package/clis/linkedin/thread-snapshot.js +214 -0
  80. package/clis/linkedin/thread-snapshot.test.js +89 -0
  81. package/clis/linkedin-learning/course.js +138 -0
  82. package/clis/linkedin-learning/course.test.js +114 -0
  83. package/clis/linkedin-learning/search.js +155 -0
  84. package/clis/linkedin-learning/search.test.js +144 -0
  85. package/clis/linkedin-learning/trending.js +133 -0
  86. package/clis/linkedin-learning/trending.test.js +123 -0
  87. package/clis/powerchina/search.js +3 -3
  88. package/clis/powerchina/search.test.js +27 -1
  89. package/clis/reddit/extract-media.test.js +149 -0
  90. package/clis/reddit/frontpage.js +47 -9
  91. package/clis/reddit/frontpage.test.js +34 -0
  92. package/clis/reddit/home.js +31 -1
  93. package/clis/reddit/home.test.js +46 -3
  94. package/clis/reddit/hot.js +32 -1
  95. package/clis/reddit/hot.test.js +15 -1
  96. package/clis/reddit/popular.js +39 -1
  97. package/clis/reddit/popular.test.js +26 -0
  98. package/clis/reddit/saved.js +1 -1
  99. package/clis/reddit/search.js +38 -1
  100. package/clis/reddit/search.test.js +26 -0
  101. package/clis/reddit/subreddit.js +52 -7
  102. package/clis/reddit/subreddit.test.js +31 -0
  103. package/clis/reddit/subscribed.js +165 -0
  104. package/clis/reddit/subscribed.test.js +168 -0
  105. package/clis/reddit/upvoted.js +1 -1
  106. package/clis/suno/commands.test.js +188 -0
  107. package/clis/suno/download.js +140 -0
  108. package/clis/suno/download.test.js +151 -0
  109. package/clis/suno/generate.js +226 -0
  110. package/clis/suno/generate.test.js +243 -0
  111. package/clis/suno/list.js +79 -0
  112. package/clis/suno/status.js +62 -0
  113. package/clis/suno/utils.js +540 -0
  114. package/clis/suno/utils.test.js +223 -0
  115. package/clis/twitter/device-follow.js +193 -0
  116. package/clis/twitter/device-follow.test.js +287 -0
  117. package/clis/twitter/download.js +443 -73
  118. package/clis/twitter/download.test.js +457 -0
  119. package/clis/twitter/list-create.js +155 -0
  120. package/clis/twitter/list-create.test.js +169 -0
  121. package/clis/twitter/list-remove.js +12 -5
  122. package/clis/twitter/list-remove.test.js +74 -0
  123. package/clis/twitter/list-tweets.js +6 -2
  124. package/clis/twitter/list-tweets.test.js +41 -1
  125. package/clis/twitter/lists.js +31 -4
  126. package/clis/twitter/lists.test.js +152 -16
  127. package/clis/twitter/search.js +6 -2
  128. package/clis/twitter/search.test.js +6 -0
  129. package/clis/twitter/shared.js +144 -0
  130. package/clis/twitter/shared.test.js +429 -1
  131. package/clis/twitter/thread.js +10 -2
  132. package/clis/twitter/thread.test.js +58 -0
  133. package/clis/twitter/timeline.js +6 -2
  134. package/clis/twitter/timeline.test.js +2 -0
  135. package/clis/twitter/tweets.js +3 -2
  136. package/clis/twitter/tweets.test.js +1 -1
  137. package/clis/weibo/delete.js +172 -0
  138. package/clis/weibo/delete.test.js +94 -0
  139. package/clis/weibo/publish.js +37 -14
  140. package/clis/weibo/publish.test.js +14 -5
  141. package/clis/weibo/user-posts.js +234 -0
  142. package/clis/weibo/user-posts.test.js +92 -0
  143. package/clis/weread/search-regression.test.js +18 -11
  144. package/clis/weread/search.js +15 -7
  145. package/clis/weread-official/book.js +135 -0
  146. package/clis/weread-official/commands.test.js +385 -0
  147. package/clis/weread-official/discover.js +107 -0
  148. package/clis/weread-official/list-apis.js +95 -0
  149. package/clis/weread-official/notes.js +171 -0
  150. package/clis/weread-official/readdata.js +158 -0
  151. package/clis/weread-official/review.js +93 -0
  152. package/clis/weread-official/search.js +106 -0
  153. package/clis/weread-official/shelf.js +97 -0
  154. package/clis/weread-official/utils.js +293 -0
  155. package/clis/weread-official/utils.test.js +242 -0
  156. package/clis/wikipedia/trending.js +7 -3
  157. package/clis/wikipedia/trending.test.js +57 -0
  158. package/clis/xianyu/chat.js +24 -109
  159. package/clis/xianyu/chat.test.js +5 -0
  160. package/clis/xianyu/im.js +322 -0
  161. package/clis/xianyu/im.test.js +253 -0
  162. package/clis/xianyu/inbox.js +96 -0
  163. package/clis/xianyu/messages.js +91 -0
  164. package/clis/xianyu/reply.js +82 -0
  165. package/clis/xiaohongshu/creator-note-detail.js +2 -1
  166. package/clis/xiaohongshu/creator-note-detail.test.js +11 -0
  167. package/clis/xiaohongshu/creator-notes-summary.js +2 -1
  168. package/clis/xiaohongshu/creator-notes-summary.test.js +7 -0
  169. package/clis/xiaohongshu/creator-notes.js +2 -1
  170. package/clis/xiaohongshu/creator-notes.test.js +12 -0
  171. package/clis/xiaohongshu/creator-stats.js +2 -1
  172. package/clis/xiaohongshu/creator-stats.test.js +24 -0
  173. package/clis/xiaohongshu/delete-note.js +260 -0
  174. package/clis/xiaohongshu/delete-note.test.js +172 -0
  175. package/clis/xiaohongshu/publish.js +48 -8
  176. package/clis/xiaohongshu/publish.test.js +65 -10
  177. package/clis/xiaohongshu/user-helpers.test.js +41 -0
  178. package/clis/xiaohongshu/user.js +27 -4
  179. package/clis/xiaoyuzhou/download.js +1 -1
  180. package/clis/xiaoyuzhou/transcript.js +1 -1
  181. package/clis/youdao/note.js +258 -0
  182. package/clis/youdao/note.test.js +99 -0
  183. package/clis/youtube/transcript.js +397 -24
  184. package/clis/youtube/transcript.test.js +196 -6
  185. package/clis/zhihu/answer-comments.js +299 -0
  186. package/clis/zhihu/answer-comments.test.js +287 -0
  187. package/clis/zhihu/answer-detail.js +12 -0
  188. package/clis/zhihu/answer-detail.test.js +8 -0
  189. package/clis/zhihu/collection.js +15 -2
  190. package/clis/zhihu/collection.test.js +46 -0
  191. package/clis/zhihu/download.js +1 -1
  192. package/clis/zhihu/question.js +42 -9
  193. package/clis/zhihu/question.test.js +111 -9
  194. package/clis/zhihu/search.js +206 -43
  195. package/clis/zhihu/search.test.js +198 -0
  196. package/dist/src/browser/errors.js +4 -2
  197. package/dist/src/browser/errors.test.js +6 -0
  198. package/dist/src/browser/page.js +30 -4
  199. package/dist/src/browser/page.test.js +42 -0
  200. package/dist/src/browser/utils.d.ts +1 -1
  201. package/dist/src/cli-argv-preprocess.d.ts +26 -0
  202. package/dist/src/cli-argv-preprocess.js +138 -0
  203. package/dist/src/cli-argv-preprocess.test.js +79 -0
  204. package/dist/src/convention-audit.js +15 -8
  205. package/dist/src/convention-audit.test.js +21 -0
  206. package/dist/src/download/media-download.js +15 -2
  207. package/dist/src/download/media-download.test.d.ts +1 -0
  208. package/dist/src/download/media-download.test.js +110 -0
  209. package/dist/src/electron-apps.js +1 -1
  210. package/dist/src/electron-apps.test.js +7 -2
  211. package/dist/src/errors.d.ts +17 -0
  212. package/dist/src/errors.js +22 -0
  213. package/dist/src/external-clis.yaml +8 -0
  214. package/dist/src/main.js +14 -2
  215. package/dist/src/utils.d.ts +43 -0
  216. package/dist/src/utils.js +97 -0
  217. package/dist/src/utils.test.d.ts +1 -0
  218. package/dist/src/utils.test.js +155 -0
  219. package/package.json +8 -2
  220. package/scripts/silent-column-drop-baseline.json +0 -52
  221. package/scripts/typed-error-lint-baseline.json +28 -380
  222. package/clis/slock/_utils.js +0 -12
@@ -97,6 +97,28 @@ export class PluginError extends CliError {
97
97
  super('PLUGIN', message, hint, EXIT_CODES.GENERIC_ERROR);
98
98
  }
99
99
  }
100
+ /**
101
+ * Thrown when a JSON endpoint returns HTML instead of JSON — typically a login
102
+ * wall, rate-limit page, or WAF challenge. Surfaced as a structured error so
103
+ * callers can show "re-login or wait out the rate limit" guidance instead of
104
+ * the cryptic `SyntaxError: Unexpected token '<', "<!DOCTYPE "...` that a naive
105
+ * JSON.parse on an HTML body produces.
106
+ *
107
+ * `bodyPreview` is the first 100 chars of the response body (after trimming
108
+ * leading whitespace) — useful for logs / debugging without dumping the full
109
+ * page.
110
+ */
111
+ export class LoginWallError extends CliError {
112
+ status;
113
+ url;
114
+ bodyPreview;
115
+ constructor(message, status, url, bodyPreview, hint) {
116
+ super('LOGIN_WALL', message, hint ?? 'The server returned an HTML page instead of JSON — likely a login wall, rate limit, or WAF challenge. Try re-logging in via your browser, or wait a few minutes before retrying.', EXIT_CODES.NOPERM);
117
+ this.status = status;
118
+ this.url = url;
119
+ this.bodyPreview = bodyPreview;
120
+ }
121
+ }
100
122
  // ── Utilities ───────────────────────────────────────────────────────────────
101
123
  /** Extract a human-readable message from an unknown caught value. */
102
124
  export function getErrorMessage(error) {
@@ -99,3 +99,11 @@
99
99
  tags: [wechat, messaging, search, export, ai-agent]
100
100
  install:
101
101
  default: "npm install -g @jackwener/wx-cli"
102
+
103
+ - name: wrangler
104
+ binary: wrangler
105
+ description: "Cloudflare Wrangler — deploy Workers, manage R2/D1/KV, publish Pages"
106
+ homepage: "https://developers.cloudflare.com/workers/wrangler/"
107
+ tags: [cloudflare, workers, serverless, edge, deployment]
108
+ install:
109
+ default: "npm install -g wrangler"
package/dist/src/main.js CHANGED
@@ -143,9 +143,21 @@ if (getCompIdx !== -1) {
143
143
  // can't combine a parent positional with subcommand dispatch) sees the internal
144
144
  // `--session <name>` flag form. Also refuses the retired `opencli browser
145
145
  // --session foo ...` user form with a friendly usage error.
146
- const { rewriteBrowserArgv, BrowserSessionArgvError } = await import('./cli-argv-preprocess.js');
146
+ const { rewriteBrowserArgv, BrowserSessionArgvError, escapeLeadingDashPositional } = await import('./cli-argv-preprocess.js');
147
147
  try {
148
- const rewritten = rewriteBrowserArgv(process.argv.slice(2));
148
+ let rewritten = rewriteBrowserArgv(process.argv.slice(2));
149
+ // Insert a `--` separator before a required positional whose value starts
150
+ // with `-` (e.g. BOSS 直聘 securityId tokens; #1160). Skipped when the
151
+ // manifest is unavailable so the user-cli / dev paths still work.
152
+ try {
153
+ const manifestPath = getCliManifestPath(BUILTIN_CLIS);
154
+ if (fs.existsSync(manifestPath)) {
155
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
156
+ if (Array.isArray(manifest))
157
+ rewritten = escapeLeadingDashPositional(rewritten, manifest);
158
+ }
159
+ }
160
+ catch { /* manifest unavailable; skip the dash escape */ }
149
161
  process.argv.splice(2, process.argv.length - 2, ...rewritten);
150
162
  }
151
163
  catch (err) {
@@ -12,3 +12,46 @@ export declare function sleep(ms: number): Promise<void>;
12
12
  export declare function saveBase64ToFile(base64: string, filePath: string): Promise<void>;
13
13
  export declare function createMarkdownConverter(configure?: (td: TurndownService) => void): TurndownService;
14
14
  export declare function htmlToMarkdown(value: string, configure?: (td: TurndownService) => void): string;
15
+ /** Sentinel shape that browser-side `fetch` wrappers return when they detect an
16
+ * HTML response in place of JSON. Kept as a plain object so it survives the
17
+ * `page.evaluate` JSON round-trip. */
18
+ export interface LoginWallSignal {
19
+ __loginWall: true;
20
+ status: number;
21
+ url: string;
22
+ contentType: string;
23
+ bodyPreview: string;
24
+ }
25
+ /** Throw a `LoginWallError` if `value` is the sentinel returned by the
26
+ * browser-side sniffer; otherwise return `value` unchanged. Adapters that
27
+ * fetch from inside `page.evaluate` call this on the result before consuming
28
+ * it, so the Node-side gets a typed error instead of a JSON-parse stack
29
+ * trace. */
30
+ export declare function throwIfLoginWall<T>(value: T, opts?: {
31
+ url?: string;
32
+ }): T;
33
+ /** Parse a `Response` body as JSON, throwing `LoginWallError` if the server
34
+ * returned an HTML page (login wall / rate limit / WAF interception) instead
35
+ * of the expected JSON. Catches the common case of `<!DOCTYPE` or `<html`
36
+ * leading the body \u2014 naive `JSON.parse` on these gives a cryptic
37
+ * `SyntaxError` that callers can't distinguish from "real" malformed JSON.
38
+ *
39
+ * On real (non-HTML) JSON-parse failures, throws a regular `Error` with a
40
+ * body preview attached so debugging doesn't require a packet capture. */
41
+ export declare function parseJsonOrThrowLoginWall(response: Response, opts?: {
42
+ url?: string;
43
+ }): Promise<unknown>;
44
+ /** Browser-side JS source fragment (as a string) that performs a `fetch` and
45
+ * either returns the parsed JSON body or a `LoginWallSignal` sentinel when
46
+ * the response is HTML. Intended to be embedded inside an adapter's
47
+ * `page.evaluate` block.
48
+ *
49
+ * Usage from inside a `page.evaluate` IIFE:
50
+ *
51
+ * ${BROWSER_JSON_SNIFF_FN}
52
+ * const res = await fetchJsonOrLoginWall('/some/path.json', { credentials: 'include' });
53
+ * // res is the parsed JSON object, OR { __loginWall: true, status, url, contentType, bodyPreview }
54
+ * return res;
55
+ *
56
+ * The Node side then calls `throwIfLoginWall(res, { url })` on the result. */
57
+ export declare const BROWSER_JSON_SNIFF_FN = "\nasync function fetchJsonOrLoginWall(input, init) {\n const r = await fetch(input, init);\n const contentType = r.headers.get('content-type') || '';\n const text = await r.text();\n const trimmed = text.replace(/^\\s+/, '');\n const looksLikeHtml =\n contentType.toLowerCase().includes('text/html')\n || trimmed.startsWith('<!DOCTYPE')\n || trimmed.startsWith('<!doctype')\n || trimmed.startsWith('<html')\n || trimmed.startsWith('<HTML');\n if (looksLikeHtml) {\n return {\n __loginWall: true,\n status: r.status,\n url: r.url || (typeof input === 'string' ? input : ''),\n contentType,\n bodyPreview: trimmed.slice(0, 100),\n };\n }\n if (!r.ok) {\n return { error: r.status };\n }\n try {\n return JSON.parse(text);\n } catch (err) {\n throw new Error(\n 'JSON parse failed (status=' + r.status + ', body[0..50]=' + JSON.stringify(trimmed.slice(0, 50)) + '): '\n + (err && err.message ? err.message : String(err))\n );\n }\n}\n";
package/dist/src/utils.js CHANGED
@@ -4,6 +4,7 @@
4
4
  import * as fs from 'node:fs';
5
5
  import * as path from 'node:path';
6
6
  import TurndownService from 'turndown';
7
+ import { LoginWallError } from './errors.js';
7
8
  /** Type guard: checks if a value is a non-null, non-array object. */
8
9
  export function isRecord(value) {
9
10
  return typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -53,3 +54,99 @@ export function htmlToMarkdown(value, configure) {
53
54
  .replace(/[ \t]+$/gm, '')
54
55
  .trim();
55
56
  }
57
+ function isLoginWallSignal(v) {
58
+ return (typeof v === 'object'
59
+ && v !== null
60
+ && v.__loginWall === true
61
+ && typeof v.status === 'number');
62
+ }
63
+ /** Throw a `LoginWallError` if `value` is the sentinel returned by the
64
+ * browser-side sniffer; otherwise return `value` unchanged. Adapters that
65
+ * fetch from inside `page.evaluate` call this on the result before consuming
66
+ * it, so the Node-side gets a typed error instead of a JSON-parse stack
67
+ * trace. */
68
+ export function throwIfLoginWall(value, opts = {}) {
69
+ if (isLoginWallSignal(value)) {
70
+ throw new LoginWallError(`Server returned HTML instead of JSON (status=${value.status}). `
71
+ + `Likely a login wall, rate limit, or WAF challenge.`, value.status, opts.url || value.url || '', value.bodyPreview);
72
+ }
73
+ return value;
74
+ }
75
+ /** Parse a `Response` body as JSON, throwing `LoginWallError` if the server
76
+ * returned an HTML page (login wall / rate limit / WAF interception) instead
77
+ * of the expected JSON. Catches the common case of `<!DOCTYPE` or `<html`
78
+ * leading the body \u2014 naive `JSON.parse` on these gives a cryptic
79
+ * `SyntaxError` that callers can't distinguish from "real" malformed JSON.
80
+ *
81
+ * On real (non-HTML) JSON-parse failures, throws a regular `Error` with a
82
+ * body preview attached so debugging doesn't require a packet capture. */
83
+ export async function parseJsonOrThrowLoginWall(response, opts = {}) {
84
+ const contentType = response.headers.get('content-type') || '';
85
+ const text = await response.text();
86
+ const trimmed = text.trimStart();
87
+ const looksLikeHtml = contentType.toLowerCase().includes('text/html')
88
+ || trimmed.startsWith('<!DOCTYPE')
89
+ || trimmed.startsWith('<!doctype')
90
+ || trimmed.startsWith('<html')
91
+ || trimmed.startsWith('<HTML');
92
+ if (looksLikeHtml) {
93
+ throw new LoginWallError(`Server returned HTML instead of JSON (status=${response.status}). `
94
+ + `Likely a login wall, rate limit, or WAF challenge.`, response.status, opts.url || response.url || '', trimmed.slice(0, 100));
95
+ }
96
+ try {
97
+ return JSON.parse(text);
98
+ }
99
+ catch (err) {
100
+ // Real malformed JSON \u2014 surface body preview alongside the parser message
101
+ // so we don't have to repro to know what came back.
102
+ throw new Error(`JSON parse failed (status=${response.status}, body[0..50]=${JSON.stringify(trimmed.slice(0, 50))}): `
103
+ + (err instanceof Error ? err.message : String(err)));
104
+ }
105
+ }
106
+ /** Browser-side JS source fragment (as a string) that performs a `fetch` and
107
+ * either returns the parsed JSON body or a `LoginWallSignal` sentinel when
108
+ * the response is HTML. Intended to be embedded inside an adapter's
109
+ * `page.evaluate` block.
110
+ *
111
+ * Usage from inside a `page.evaluate` IIFE:
112
+ *
113
+ * ${BROWSER_JSON_SNIFF_FN}
114
+ * const res = await fetchJsonOrLoginWall('/some/path.json', { credentials: 'include' });
115
+ * // res is the parsed JSON object, OR { __loginWall: true, status, url, contentType, bodyPreview }
116
+ * return res;
117
+ *
118
+ * The Node side then calls `throwIfLoginWall(res, { url })` on the result. */
119
+ export const BROWSER_JSON_SNIFF_FN = `
120
+ async function fetchJsonOrLoginWall(input, init) {
121
+ const r = await fetch(input, init);
122
+ const contentType = r.headers.get('content-type') || '';
123
+ const text = await r.text();
124
+ const trimmed = text.replace(/^\\s+/, '');
125
+ const looksLikeHtml =
126
+ contentType.toLowerCase().includes('text/html')
127
+ || trimmed.startsWith('<!DOCTYPE')
128
+ || trimmed.startsWith('<!doctype')
129
+ || trimmed.startsWith('<html')
130
+ || trimmed.startsWith('<HTML');
131
+ if (looksLikeHtml) {
132
+ return {
133
+ __loginWall: true,
134
+ status: r.status,
135
+ url: r.url || (typeof input === 'string' ? input : ''),
136
+ contentType,
137
+ bodyPreview: trimmed.slice(0, 100),
138
+ };
139
+ }
140
+ if (!r.ok) {
141
+ return { error: r.status };
142
+ }
143
+ try {
144
+ return JSON.parse(text);
145
+ } catch (err) {
146
+ throw new Error(
147
+ 'JSON parse failed (status=' + r.status + ', body[0..50]=' + JSON.stringify(trimmed.slice(0, 50)) + '): '
148
+ + (err && err.message ? err.message : String(err))
149
+ );
150
+ }
151
+ }
152
+ `;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,155 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { parseJsonOrThrowLoginWall, throwIfLoginWall, BROWSER_JSON_SNIFF_FN, } from './utils.js';
3
+ import { LoginWallError } from './errors.js';
4
+ function makeResponse(body, opts = {}) {
5
+ return new Response(body, {
6
+ status: opts.status ?? 200,
7
+ headers: { 'content-type': opts.contentType ?? 'application/json' },
8
+ });
9
+ }
10
+ describe('parseJsonOrThrowLoginWall', () => {
11
+ it('returns parsed JSON on a normal application/json response', async () => {
12
+ const res = makeResponse(JSON.stringify({ hello: 'world', n: 42 }));
13
+ const parsed = await parseJsonOrThrowLoginWall(res);
14
+ expect(parsed).toEqual({ hello: 'world', n: 42 });
15
+ });
16
+ it('throws LoginWallError when content-type is text/html', async () => {
17
+ const res = makeResponse('<html><body>Please log in</body></html>', {
18
+ status: 401,
19
+ contentType: 'text/html; charset=utf-8',
20
+ });
21
+ await expect(parseJsonOrThrowLoginWall(res, { url: 'https://example.com/api' })).rejects.toMatchObject({
22
+ name: 'LoginWallError',
23
+ code: 'LOGIN_WALL',
24
+ status: 401,
25
+ url: 'https://example.com/api',
26
+ });
27
+ });
28
+ it('throws LoginWallError when body starts with <!DOCTYPE even if content-type is missing', async () => {
29
+ // application/json content-type but body is actually HTML — WAFs sometimes do this
30
+ const res = new Response('<!DOCTYPE html><html><head></head><body>blocked</body></html>', {
31
+ status: 200,
32
+ headers: { 'content-type': 'application/json' },
33
+ });
34
+ try {
35
+ await parseJsonOrThrowLoginWall(res, { url: 'https://x.com/api/list' });
36
+ throw new Error('expected throw');
37
+ }
38
+ catch (err) {
39
+ expect(err).toBeInstanceOf(LoginWallError);
40
+ const e = err;
41
+ expect(e.status).toBe(200);
42
+ expect(e.url).toBe('https://x.com/api/list');
43
+ expect(e.bodyPreview.startsWith('<!DOCTYPE')).toBe(true);
44
+ expect(e.exitCode).toBe(77); // NOPERM
45
+ }
46
+ });
47
+ it('throws LoginWallError when body starts with <html (no DOCTYPE)', async () => {
48
+ const res = new Response('<html lang="en"><body>nope</body></html>', {
49
+ status: 429,
50
+ headers: { 'content-type': 'application/json' },
51
+ });
52
+ await expect(parseJsonOrThrowLoginWall(res)).rejects.toBeInstanceOf(LoginWallError);
53
+ });
54
+ it('throws LoginWallError when body has leading whitespace before <!DOCTYPE', async () => {
55
+ const res = new Response(' \n\n<!DOCTYPE html><html></html>', {
56
+ status: 200,
57
+ headers: { 'content-type': 'application/octet-stream' },
58
+ });
59
+ await expect(parseJsonOrThrowLoginWall(res)).rejects.toBeInstanceOf(LoginWallError);
60
+ });
61
+ it('throws a regular Error (NOT LoginWallError) on real malformed JSON', async () => {
62
+ const res = makeResponse('{not really json,');
63
+ try {
64
+ await parseJsonOrThrowLoginWall(res);
65
+ throw new Error('expected throw');
66
+ }
67
+ catch (err) {
68
+ expect(err).toBeInstanceOf(Error);
69
+ expect(err).not.toBeInstanceOf(LoginWallError);
70
+ const msg = err.message;
71
+ expect(msg).toContain('JSON parse failed');
72
+ expect(msg).toContain('body[0..50]=');
73
+ // body preview must be present so debugging doesn't require a repro
74
+ expect(msg).toContain('not really json');
75
+ }
76
+ });
77
+ it('preserves first 100 chars of body in bodyPreview', async () => {
78
+ const longHtml = '<!DOCTYPE html><html><head><title>' + 'x'.repeat(500) + '</title></head></html>';
79
+ const res = new Response(longHtml, { status: 403, headers: { 'content-type': 'text/html' } });
80
+ try {
81
+ await parseJsonOrThrowLoginWall(res);
82
+ throw new Error('expected throw');
83
+ }
84
+ catch (err) {
85
+ const e = err;
86
+ expect(e.bodyPreview.length).toBe(100);
87
+ expect(e.bodyPreview.startsWith('<!DOCTYPE')).toBe(true);
88
+ }
89
+ });
90
+ });
91
+ describe('throwIfLoginWall', () => {
92
+ it('returns the value unchanged when it is not a login-wall sentinel', () => {
93
+ const data = { data: { foo: 'bar' } };
94
+ expect(throwIfLoginWall(data)).toBe(data);
95
+ expect(throwIfLoginWall('hello')).toBe('hello');
96
+ expect(throwIfLoginWall(null)).toBe(null);
97
+ expect(throwIfLoginWall(undefined)).toBe(undefined);
98
+ expect(throwIfLoginWall(42)).toBe(42);
99
+ expect(throwIfLoginWall([1, 2, 3])).toEqual([1, 2, 3]);
100
+ });
101
+ it('returns objects that happen to have unrelated __loginWall-ish keys unchanged', () => {
102
+ // Must NOT trigger on partial matches — sentinel needs __loginWall === true AND numeric status
103
+ expect(throwIfLoginWall({ __loginWall: false })).toEqual({ __loginWall: false });
104
+ expect(throwIfLoginWall({ __loginWall: true })).toEqual({ __loginWall: true }); // missing status → not a real sentinel
105
+ });
106
+ it('throws LoginWallError when value is the browser-side sentinel', () => {
107
+ const signal = {
108
+ __loginWall: true,
109
+ status: 403,
110
+ url: 'https://x.com/i/api/graphql/...',
111
+ contentType: 'text/html',
112
+ bodyPreview: '<!DOCTYPE html><html><head><title>Login</title>',
113
+ };
114
+ try {
115
+ throwIfLoginWall(signal);
116
+ throw new Error('expected throw');
117
+ }
118
+ catch (err) {
119
+ expect(err).toBeInstanceOf(LoginWallError);
120
+ const e = err;
121
+ expect(e.status).toBe(403);
122
+ expect(e.url).toBe('https://x.com/i/api/graphql/...');
123
+ expect(e.bodyPreview).toContain('<!DOCTYPE');
124
+ expect(e.code).toBe('LOGIN_WALL');
125
+ }
126
+ });
127
+ it('opts.url overrides the URL embedded in the sentinel', () => {
128
+ const signal = {
129
+ __loginWall: true,
130
+ status: 401,
131
+ url: '',
132
+ contentType: 'text/html',
133
+ bodyPreview: '<html>',
134
+ };
135
+ try {
136
+ throwIfLoginWall(signal, { url: 'https://override.example.com/api' });
137
+ throw new Error('expected throw');
138
+ }
139
+ catch (err) {
140
+ expect(err.url).toBe('https://override.example.com/api');
141
+ }
142
+ });
143
+ });
144
+ describe('BROWSER_JSON_SNIFF_FN', () => {
145
+ it('is a non-empty string that defines fetchJsonOrLoginWall', () => {
146
+ expect(typeof BROWSER_JSON_SNIFF_FN).toBe('string');
147
+ expect(BROWSER_JSON_SNIFF_FN).toContain('fetchJsonOrLoginWall');
148
+ expect(BROWSER_JSON_SNIFF_FN).toContain('__loginWall');
149
+ });
150
+ it('can be evaluated as JS without syntax errors', () => {
151
+ // We can't run the actual fetch path here (no Response polyfill loop), but
152
+ // we CAN confirm the fragment parses cleanly when embedded inside an async IIFE.
153
+ expect(() => new Function(`(async () => { ${BROWSER_JSON_SNIFF_FN} })`)).not.toThrow();
154
+ });
155
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jackwener/opencli",
3
- "version": "1.7.22",
3
+ "version": "1.8.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -42,7 +42,7 @@
42
42
  "dev": "tsx src/main.ts",
43
43
  "dev:bun": "bun src/main.ts",
44
44
  "build": "npm run clean-dist && npm run copy-yaml && npm run build-manifest",
45
- "prebuild-manifest": "tsc --build",
45
+ "prebuild-manifest": "tsc --build && node -e \"require('fs').chmodSync('dist/src/main.js', 0o755)\"",
46
46
  "build-manifest": "tsx src/build-manifest.ts",
47
47
  "clean-dist": "node scripts/clean-dist.cjs",
48
48
  "copy-yaml": "node scripts/copy-yaml.cjs",
@@ -98,5 +98,11 @@
98
98
  "typescript": "^6.0.2",
99
99
  "vitepress": "^1.6.4",
100
100
  "vitest": "^4.1.0"
101
+ },
102
+ "overrides": {
103
+ "postcss": "^8.5.10",
104
+ "vitepress": {
105
+ "vite": "6.4.2"
106
+ }
101
107
  }
102
108
  }
@@ -582,13 +582,6 @@
582
582
  "voteCandidates"
583
583
  ]
584
584
  },
585
- {
586
- "command": "reddit/popular",
587
- "file": "clis/reddit/popular.js",
588
- "missing": [
589
- "author"
590
- ]
591
- },
592
585
  {
593
586
  "command": "tieba/search",
594
587
  "file": "clis/tieba/search.js",
@@ -612,31 +605,6 @@
612
605
  "name"
613
606
  ]
614
607
  },
615
- {
616
- "command": "twitter/download",
617
- "file": "clis/twitter/download.js",
618
- "missing": [
619
- "poster",
620
- "url"
621
- ]
622
- },
623
- {
624
- "command": "twitter/download",
625
- "file": "clis/twitter/download.js",
626
- "missing": [
627
- "url"
628
- ]
629
- },
630
- {
631
- "command": "twitter/list-add",
632
- "file": "clis/twitter/list-add.js",
633
- "missing": [
634
- "method",
635
- "ts",
636
- "url",
637
- "via"
638
- ]
639
- },
640
608
  {
641
609
  "command": "twitter/list-remove",
642
610
  "file": "clis/twitter/list-remove.js",
@@ -773,19 +741,6 @@
773
741
  "url"
774
742
  ]
775
743
  },
776
- {
777
- "command": "xianyu/chat",
778
- "file": "clis/xianyu/chat.js",
779
- "missing": [
780
- "can_input",
781
- "can_send",
782
- "item_url",
783
- "peer_masked_id",
784
- "requiresAuth",
785
- "title",
786
- "visible_messages"
787
- ]
788
- },
789
744
  {
790
745
  "command": "xianyu/item",
791
746
  "file": "clis/xianyu/item.js",
@@ -945,13 +900,6 @@
945
900
  "url"
946
901
  ]
947
902
  },
948
- {
949
- "command": "zhihu/search",
950
- "file": "clis/zhihu/search.js",
951
- "missing": [
952
- "excerpt"
953
- ]
954
- },
955
903
  {
956
904
  "command": "zsxq/groups",
957
905
  "file": "clis/zsxq/groups.js",