@jackwener/opencli 1.5.4 → 1.5.6

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 (256) hide show
  1. package/README.md +27 -2
  2. package/README.zh-CN.md +36 -4
  3. package/dist/browser/daemon-client.d.ts +5 -1
  4. package/dist/browser/page.d.ts +6 -0
  5. package/dist/browser/page.js +15 -0
  6. package/dist/cli-manifest.json +1284 -67
  7. package/dist/cli.js +14 -14
  8. package/dist/clis/antigravity/serve.js +2 -2
  9. package/dist/clis/band/bands.d.ts +1 -0
  10. package/dist/clis/band/bands.js +72 -0
  11. package/dist/clis/band/mentions.d.ts +1 -0
  12. package/dist/clis/band/mentions.js +127 -0
  13. package/dist/clis/band/post.d.ts +1 -0
  14. package/dist/clis/band/post.js +175 -0
  15. package/dist/clis/band/posts.d.ts +1 -0
  16. package/dist/clis/band/posts.js +94 -0
  17. package/dist/clis/doubao/detail.d.ts +1 -0
  18. package/dist/clis/doubao/detail.js +33 -0
  19. package/dist/clis/doubao/detail.test.d.ts +1 -0
  20. package/dist/clis/doubao/detail.test.js +42 -0
  21. package/dist/clis/doubao/history.d.ts +1 -0
  22. package/dist/clis/doubao/history.js +28 -0
  23. package/dist/clis/doubao/history.test.d.ts +1 -0
  24. package/dist/clis/doubao/history.test.js +37 -0
  25. package/dist/clis/doubao/meeting-summary.d.ts +1 -0
  26. package/dist/clis/doubao/meeting-summary.js +39 -0
  27. package/dist/clis/doubao/meeting-transcript.d.ts +1 -0
  28. package/dist/clis/doubao/meeting-transcript.js +36 -0
  29. package/dist/clis/doubao/utils.d.ts +27 -0
  30. package/dist/clis/doubao/utils.js +317 -0
  31. package/dist/clis/doubao/utils.test.d.ts +1 -0
  32. package/dist/clis/doubao/utils.test.js +24 -0
  33. package/dist/clis/douyin/_shared/public-api.d.ts +33 -0
  34. package/dist/clis/douyin/_shared/public-api.js +29 -0
  35. package/dist/clis/douyin/user-videos.d.ts +5 -0
  36. package/dist/clis/douyin/user-videos.js +74 -0
  37. package/dist/clis/douyin/user-videos.test.d.ts +1 -0
  38. package/dist/clis/douyin/user-videos.test.js +108 -0
  39. package/dist/clis/ones/common.d.ts +32 -0
  40. package/dist/clis/ones/common.js +144 -0
  41. package/dist/clis/ones/enrich-tasks.d.ts +5 -0
  42. package/dist/clis/ones/enrich-tasks.js +37 -0
  43. package/dist/clis/ones/login.d.ts +1 -0
  44. package/dist/clis/ones/login.js +80 -0
  45. package/dist/clis/ones/logout.d.ts +1 -0
  46. package/dist/clis/ones/logout.js +17 -0
  47. package/dist/clis/ones/me.d.ts +1 -0
  48. package/dist/clis/ones/me.js +30 -0
  49. package/dist/clis/ones/my-tasks.d.ts +1 -0
  50. package/dist/clis/ones/my-tasks.js +120 -0
  51. package/dist/clis/ones/resolve-labels.d.ts +10 -0
  52. package/dist/clis/ones/resolve-labels.js +64 -0
  53. package/dist/clis/ones/task-helpers.d.ts +29 -0
  54. package/dist/clis/ones/task-helpers.js +212 -0
  55. package/dist/clis/ones/task-helpers.test.d.ts +1 -0
  56. package/dist/clis/ones/task-helpers.test.js +12 -0
  57. package/dist/clis/ones/task.d.ts +1 -0
  58. package/dist/clis/ones/task.js +66 -0
  59. package/dist/clis/ones/tasks.d.ts +1 -0
  60. package/dist/clis/ones/tasks.js +79 -0
  61. package/dist/clis/ones/token-info.d.ts +1 -0
  62. package/dist/clis/ones/token-info.js +42 -0
  63. package/dist/clis/ones/worklog.d.ts +11 -0
  64. package/dist/clis/ones/worklog.js +267 -0
  65. package/dist/clis/ones/worklog.test.d.ts +1 -0
  66. package/dist/clis/ones/worklog.test.js +20 -0
  67. package/dist/clis/sinafinance/rolling-news.d.ts +4 -0
  68. package/dist/clis/sinafinance/rolling-news.js +40 -0
  69. package/dist/clis/sinafinance/stock.d.ts +8 -0
  70. package/dist/clis/sinafinance/stock.js +117 -0
  71. package/dist/clis/spotify/spotify.d.ts +1 -0
  72. package/dist/clis/spotify/spotify.js +316 -0
  73. package/dist/clis/spotify/utils.d.ts +21 -0
  74. package/dist/clis/spotify/utils.js +66 -0
  75. package/dist/clis/spotify/utils.test.d.ts +1 -0
  76. package/dist/clis/spotify/utils.test.js +67 -0
  77. package/dist/clis/tieba/commands.test.d.ts +4 -0
  78. package/dist/clis/tieba/commands.test.js +79 -0
  79. package/dist/clis/tieba/hot.d.ts +1 -0
  80. package/dist/clis/tieba/hot.js +48 -0
  81. package/dist/clis/tieba/posts.d.ts +1 -0
  82. package/dist/clis/tieba/posts.js +85 -0
  83. package/dist/clis/tieba/read.d.ts +1 -0
  84. package/dist/clis/tieba/read.js +140 -0
  85. package/dist/clis/tieba/search.d.ts +1 -0
  86. package/dist/clis/tieba/search.js +108 -0
  87. package/dist/clis/tieba/utils.d.ts +101 -0
  88. package/dist/clis/tieba/utils.js +240 -0
  89. package/dist/clis/tieba/utils.test.d.ts +1 -0
  90. package/dist/clis/tieba/utils.test.js +290 -0
  91. package/dist/clis/weread/book.js +100 -13
  92. package/dist/clis/weread/commands.test.js +221 -0
  93. package/dist/clis/weread/private-api-regression.test.d.ts +1 -0
  94. package/dist/{weread-private-api-regression.test.js → clis/weread/private-api-regression.test.js} +92 -30
  95. package/dist/clis/weread/search-regression.test.d.ts +1 -0
  96. package/dist/clis/weread/search-regression.test.js +407 -0
  97. package/dist/clis/weread/search.js +143 -7
  98. package/dist/clis/weread/shelf.js +13 -95
  99. package/dist/clis/weread/utils.d.ts +46 -0
  100. package/dist/clis/weread/utils.js +214 -7
  101. package/dist/clis/weread/utils.test.js +71 -1
  102. package/dist/clis/xiaohongshu/publish.d.ts +1 -1
  103. package/dist/clis/xiaohongshu/publish.js +78 -31
  104. package/dist/clis/xiaohongshu/publish.test.js +66 -1
  105. package/dist/clis/xiaohongshu/user-helpers.d.ts +1 -0
  106. package/dist/clis/xiaohongshu/user-helpers.js +2 -0
  107. package/dist/clis/xiaohongshu/user-helpers.test.js +18 -0
  108. package/dist/clis/xueqiu/comments.d.ts +118 -0
  109. package/dist/clis/xueqiu/comments.js +354 -0
  110. package/dist/clis/xueqiu/comments.test.d.ts +1 -0
  111. package/dist/clis/xueqiu/comments.test.js +696 -0
  112. package/dist/clis/youtube/transcript.js +2 -4
  113. package/dist/clis/youtube/utils.d.ts +9 -0
  114. package/dist/clis/youtube/utils.js +67 -3
  115. package/dist/clis/youtube/utils.test.d.ts +1 -0
  116. package/dist/clis/youtube/utils.test.js +37 -0
  117. package/dist/clis/youtube/video.js +16 -15
  118. package/dist/clis/zsxq/dynamics.d.ts +1 -0
  119. package/dist/clis/zsxq/dynamics.js +47 -0
  120. package/dist/clis/zsxq/groups.d.ts +1 -0
  121. package/dist/clis/zsxq/groups.js +32 -0
  122. package/dist/clis/zsxq/search.d.ts +1 -0
  123. package/dist/clis/zsxq/search.js +43 -0
  124. package/dist/clis/zsxq/search.test.d.ts +1 -0
  125. package/dist/clis/zsxq/search.test.js +24 -0
  126. package/dist/clis/zsxq/topic.d.ts +1 -0
  127. package/dist/clis/zsxq/topic.js +47 -0
  128. package/dist/clis/zsxq/topic.test.d.ts +1 -0
  129. package/dist/clis/zsxq/topic.test.js +29 -0
  130. package/dist/clis/zsxq/topics.d.ts +1 -0
  131. package/dist/clis/zsxq/topics.js +25 -0
  132. package/dist/clis/zsxq/topics.test.d.ts +1 -0
  133. package/dist/clis/zsxq/topics.test.js +24 -0
  134. package/dist/clis/zsxq/utils.d.ts +97 -0
  135. package/dist/clis/zsxq/utils.js +230 -0
  136. package/dist/commanderAdapter.js +27 -4
  137. package/dist/commanderAdapter.test.js +39 -0
  138. package/dist/daemon.js +5 -4
  139. package/dist/errors.d.ts +29 -1
  140. package/dist/errors.js +49 -11
  141. package/dist/external-clis.yaml +17 -0
  142. package/dist/external.js +3 -3
  143. package/dist/main.js +2 -1
  144. package/dist/tui.js +2 -1
  145. package/dist/types.d.ts +5 -0
  146. package/docs/.vitepress/config.mts +3 -0
  147. package/docs/adapters/browser/band.md +63 -0
  148. package/docs/adapters/browser/ones.md +59 -0
  149. package/docs/adapters/browser/sinafinance.md +56 -6
  150. package/docs/adapters/browser/spotify.md +62 -0
  151. package/docs/adapters/browser/tieba.md +45 -0
  152. package/docs/adapters/browser/xueqiu.md +5 -0
  153. package/docs/adapters/browser/zsxq.md +49 -0
  154. package/docs/adapters/index.md +5 -2
  155. package/docs/adapters-doc/ones.md +32 -0
  156. package/extension/dist/background.js +1 -2
  157. package/extension/manifest.json +1 -1
  158. package/extension/package.json +1 -1
  159. package/extension/src/background.ts +17 -1
  160. package/extension/src/cdp.ts +42 -0
  161. package/extension/src/protocol.ts +5 -1
  162. package/package.json +1 -1
  163. package/scripts/postinstall.js +16 -0
  164. package/src/browser/daemon-client.ts +5 -1
  165. package/src/browser/page.ts +16 -0
  166. package/src/cli.ts +14 -14
  167. package/src/clis/antigravity/serve.ts +2 -2
  168. package/src/clis/band/bands.ts +76 -0
  169. package/src/clis/band/mentions.ts +134 -0
  170. package/src/clis/band/post.ts +187 -0
  171. package/src/clis/band/posts.ts +106 -0
  172. package/src/clis/doubao/detail.test.ts +53 -0
  173. package/src/clis/doubao/detail.ts +41 -0
  174. package/src/clis/doubao/history.test.ts +45 -0
  175. package/src/clis/doubao/history.ts +32 -0
  176. package/src/clis/doubao/meeting-summary.ts +53 -0
  177. package/src/clis/doubao/meeting-transcript.ts +48 -0
  178. package/src/clis/doubao/utils.test.ts +45 -0
  179. package/src/clis/doubao/utils.ts +371 -0
  180. package/src/clis/douyin/_shared/public-api.ts +84 -0
  181. package/src/clis/douyin/user-videos.test.ts +122 -0
  182. package/src/clis/douyin/user-videos.ts +101 -0
  183. package/src/clis/ones/common.ts +187 -0
  184. package/src/clis/ones/enrich-tasks.ts +47 -0
  185. package/src/clis/ones/login.ts +103 -0
  186. package/src/clis/ones/logout.ts +19 -0
  187. package/src/clis/ones/me.ts +34 -0
  188. package/src/clis/ones/my-tasks.ts +148 -0
  189. package/src/clis/ones/resolve-labels.ts +80 -0
  190. package/src/clis/ones/task-helpers.test.ts +14 -0
  191. package/src/clis/ones/task-helpers.ts +214 -0
  192. package/src/clis/ones/task.ts +79 -0
  193. package/src/clis/ones/tasks.ts +92 -0
  194. package/src/clis/ones/token-info.ts +46 -0
  195. package/src/clis/ones/worklog.test.ts +24 -0
  196. package/src/clis/ones/worklog.ts +306 -0
  197. package/src/clis/sinafinance/rolling-news.ts +42 -0
  198. package/src/clis/sinafinance/stock.ts +127 -0
  199. package/src/clis/spotify/spotify.ts +328 -0
  200. package/src/clis/spotify/utils.test.ts +87 -0
  201. package/src/clis/spotify/utils.ts +92 -0
  202. package/src/clis/tieba/commands.test.ts +86 -0
  203. package/src/clis/tieba/hot.ts +52 -0
  204. package/src/clis/tieba/posts.ts +108 -0
  205. package/src/clis/tieba/read.ts +158 -0
  206. package/src/clis/tieba/search.ts +119 -0
  207. package/src/clis/tieba/utils.test.ts +322 -0
  208. package/src/clis/tieba/utils.ts +348 -0
  209. package/src/clis/weread/book.ts +116 -13
  210. package/src/clis/weread/commands.test.ts +249 -0
  211. package/src/{weread-private-api-regression.test.ts → clis/weread/private-api-regression.test.ts} +108 -30
  212. package/src/clis/weread/search-regression.test.ts +440 -0
  213. package/src/clis/weread/search.ts +189 -9
  214. package/src/clis/weread/shelf.ts +20 -122
  215. package/src/clis/weread/utils.test.ts +81 -1
  216. package/src/clis/weread/utils.ts +264 -7
  217. package/src/clis/xiaohongshu/publish.test.ts +79 -1
  218. package/src/clis/xiaohongshu/publish.ts +84 -30
  219. package/src/clis/xiaohongshu/user-helpers.test.ts +23 -0
  220. package/src/clis/xiaohongshu/user-helpers.ts +4 -0
  221. package/src/clis/xueqiu/comments.test.ts +823 -0
  222. package/src/clis/xueqiu/comments.ts +461 -0
  223. package/src/clis/youtube/transcript.ts +2 -4
  224. package/src/clis/youtube/utils.test.ts +43 -0
  225. package/src/clis/youtube/utils.ts +69 -0
  226. package/src/clis/youtube/video.ts +16 -15
  227. package/src/clis/zsxq/dynamics.ts +60 -0
  228. package/src/clis/zsxq/groups.ts +41 -0
  229. package/src/clis/zsxq/search.test.ts +29 -0
  230. package/src/clis/zsxq/search.ts +54 -0
  231. package/src/clis/zsxq/topic.test.ts +34 -0
  232. package/src/clis/zsxq/topic.ts +68 -0
  233. package/src/clis/zsxq/topics.test.ts +29 -0
  234. package/src/clis/zsxq/topics.ts +36 -0
  235. package/src/clis/zsxq/utils.ts +351 -0
  236. package/src/commanderAdapter.test.ts +47 -0
  237. package/src/commanderAdapter.ts +26 -3
  238. package/src/daemon.ts +5 -4
  239. package/src/errors.ts +71 -10
  240. package/src/external-clis.yaml +17 -0
  241. package/src/external.ts +3 -3
  242. package/src/main.ts +2 -1
  243. package/src/tui.ts +2 -1
  244. package/src/types.ts +5 -0
  245. package/tests/e2e/band-auth.test.ts +20 -0
  246. package/tests/e2e/browser-auth-helpers.ts +18 -0
  247. package/tests/e2e/browser-auth.test.ts +35 -47
  248. package/tests/e2e/browser-public.test.ts +288 -0
  249. package/tests/e2e/management.test.ts +1 -1
  250. package/tests/e2e/plugin-management.test.ts +1 -1
  251. package/vitest.config.ts +1 -0
  252. package/SKILL.md +0 -879
  253. package/dist/weread-private-api-regression.test.d.ts +0 -1
  254. package/dist/weread-search-regression.test.d.ts +0 -1
  255. package/dist/weread-search-regression.test.js +0 -39
  256. package/src/weread-search-regression.test.ts +0 -44
@@ -0,0 +1,461 @@
1
+ import type { IPage } from '../../types.js';
2
+ import { cli, Strategy } from '../../registry.js';
3
+ import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '../../errors.js';
4
+ import { log } from '../../logger.js';
5
+ import { isRecord } from '../../utils.js';
6
+
7
+ /**
8
+ * Minimal browser-response shape used by the classifier.
9
+ */
10
+ export interface XueqiuCommentsResponse {
11
+ status: number;
12
+ contentType: string;
13
+ json: unknown;
14
+ textSnippet: string;
15
+ }
16
+
17
+ /**
18
+ * Minimal normalized row shape used during pagination and deduplication.
19
+ */
20
+ export interface XueqiuCommentRow {
21
+ id: string;
22
+ author: string;
23
+ text?: string;
24
+ likes?: number;
25
+ replies?: number;
26
+ retweets?: number;
27
+ created_at?: string | null;
28
+ url?: string | null;
29
+ }
30
+
31
+ /**
32
+ * Public CLI row shape. This intentionally omits the internal stable ID used
33
+ * only for deduplication, so machine-readable output matches the command
34
+ * contract and table columns.
35
+ */
36
+ export type XueqiuCommentOutputRow = Omit<XueqiuCommentRow, 'id'>;
37
+
38
+ /**
39
+ * Pagination options for collecting enough rows to satisfy `--limit`.
40
+ */
41
+ export interface CollectCommentRowsOptions {
42
+ symbol: string;
43
+ limit: number;
44
+ pageSize: number;
45
+ maxRequests: number;
46
+ fetchPage: (pageNumber: number, pageSize: number) => Promise<XueqiuCommentsResponse>;
47
+ warn?: (message: string) => void;
48
+ }
49
+
50
+ type XueqiuCommentsKind = 'auth' | 'anti-bot' | 'argument' | 'empty' | 'incompatible' | 'unknown';
51
+
52
+ const XUEQIU_SYMBOL_PATTERN = /^(?:[A-Z]{2}\d{5,6}|\d{4,6}|[A-Z]{1,5}(?:[.-][A-Z]{1,2})?)$/;
53
+
54
+ const FAILURE_REASON_BY_KIND: Record<XueqiuCommentsKind, string> = {
55
+ auth: 'auth failure',
56
+ 'anti-bot': 'anti-bot challenge',
57
+ argument: 'invalid symbol',
58
+ empty: 'no more discussion data',
59
+ incompatible: 'unexpected response shape',
60
+ unknown: 'unknown request failure',
61
+ };
62
+
63
+ function getCommentList(json: Record<string, unknown>): unknown[] | null {
64
+ if (Array.isArray(json.list)) return json.list;
65
+ if (isRecord(json.data) && Array.isArray(json.data.list)) return json.data.list;
66
+ return null;
67
+ }
68
+
69
+ function isAntiBotHtml(response: XueqiuCommentsResponse, envelopeText: string): boolean {
70
+ const htmlText = `${envelopeText} ${response.textSnippet}`.toLowerCase();
71
+ return response.contentType.includes('text/html')
72
+ && (
73
+ /captcha|challenge|aliyun_waf|risk/i.test(htmlText)
74
+ || /_WAF_|_waf_|renderData|aliyun_waf/i.test(response.textSnippet)
75
+ );
76
+ }
77
+
78
+ function toFiniteCount(value: unknown): number {
79
+ const count = Number(value ?? 0);
80
+ return Number.isFinite(count) ? count : 0;
81
+ }
82
+
83
+ function normalizeIdentifier(value: unknown): string {
84
+ if (typeof value === 'string') return value.trim();
85
+ if (typeof value === 'number' && Number.isFinite(value)) return String(value);
86
+ return '';
87
+ }
88
+
89
+ function buildPaginationStopMessage(
90
+ requestNumber: number,
91
+ collected: number,
92
+ target: number,
93
+ reason: string,
94
+ ): string {
95
+ return `xueqiu comments pagination stopped after request ${requestNumber}, `
96
+ + `collected ${collected}/${target} items, `
97
+ + `reason: ${reason}`;
98
+ }
99
+
100
+ function throwFirstPageFailure(kind: XueqiuCommentsKind, symbol: string): never {
101
+ if (kind === 'auth' || kind === 'anti-bot') {
102
+ throw new AuthRequiredError('xueqiu.com', 'Stock discussions require login or challenge clearance');
103
+ }
104
+ if (kind === 'argument') {
105
+ throw new ArgumentError(`xueqiu comments received an invalid symbol: ${symbol}`);
106
+ }
107
+ if (kind === 'empty') {
108
+ throw new EmptyResultError(
109
+ `xueqiu/comments ${symbol}`,
110
+ `No discussion data found for ${symbol}`,
111
+ );
112
+ }
113
+ throw new CommandExecutionError(
114
+ `Unexpected response while loading xueqiu comments for ${symbol}`,
115
+ 'Run the command again with --verbose to inspect the raw site response.',
116
+ );
117
+ }
118
+
119
+ /**
120
+ * Extract the raw item list from one classified JSON payload.
121
+ *
122
+ * @param json Raw parsed JSON payload from browser fetch.
123
+ * @returns Discussion items when the response shape is usable.
124
+ */
125
+ export function getCommentItems(json: unknown): Record<string, any>[] {
126
+ if (!isRecord(json)) return [];
127
+
128
+ const list = getCommentList(json) ?? [];
129
+
130
+ return list.filter((item): item is Record<string, any> => !!item && typeof item === 'object');
131
+ }
132
+
133
+ /**
134
+ * Classify one raw browser response before command-level error handling.
135
+ *
136
+ * @param response Structured browser response payload.
137
+ * @returns Tagged result describing the response class.
138
+ */
139
+ export function classifyXueqiuCommentsResponse(response: XueqiuCommentsResponse): { kind: XueqiuCommentsKind } {
140
+ const jsonRecord = isRecord(response.json) ? response.json : null;
141
+ const commentList = jsonRecord ? getCommentList(jsonRecord) : null;
142
+ const envelopeText = [
143
+ jsonRecord?.error,
144
+ jsonRecord?.errors,
145
+ jsonRecord?.code,
146
+ jsonRecord?.message,
147
+ jsonRecord?.msg,
148
+ ].filter(Boolean).join(' ').toLowerCase();
149
+ const responseText = `${envelopeText} ${response.textSnippet}`.toLowerCase();
150
+
151
+ if (isAntiBotHtml(response, envelopeText)) {
152
+ return { kind: 'anti-bot' };
153
+ }
154
+ if (response.status === 401 || response.status === 403) {
155
+ return { kind: 'auth' };
156
+ }
157
+ if (/login required|unauthorized|unauthorised|forbidden|not logged in|need login/.test(responseText)) {
158
+ return { kind: 'auth' };
159
+ }
160
+ if (/invalid symbol|invalid code|bad symbol/.test(envelopeText)) {
161
+ return { kind: 'argument' };
162
+ }
163
+ if (/no data|no result|not found|no matching/.test(envelopeText)) {
164
+ return { kind: 'empty' };
165
+ }
166
+ if (commentList && commentList.length === 0) {
167
+ return { kind: 'empty' };
168
+ }
169
+ if (response.contentType.includes('application/json') && jsonRecord && commentList === null) {
170
+ return { kind: 'incompatible' };
171
+ }
172
+ return { kind: 'unknown' };
173
+ }
174
+
175
+ /**
176
+ * Merge one new page of rows while preserving the first occurrence of each ID.
177
+ *
178
+ * @param current Rows already collected.
179
+ * @param incoming Rows from the next page.
180
+ * @returns Deduplicated merged rows.
181
+ */
182
+ export function mergeUniqueCommentRows(
183
+ current: XueqiuCommentRow[],
184
+ incoming: XueqiuCommentRow[],
185
+ ): XueqiuCommentRow[] {
186
+ const merged = [...current];
187
+ const seen = new Set(current.map(item => item.id));
188
+
189
+ for (const row of incoming) {
190
+ if (seen.has(row.id)) continue;
191
+ seen.add(row.id);
192
+ merged.push(row);
193
+ }
194
+ return merged;
195
+ }
196
+
197
+ /**
198
+ * Normalize one raw xueqiu discussion item into the CLI row shape.
199
+ *
200
+ * Returned rows represent stock-scoped discussion posts, not replies under
201
+ * one parent post.
202
+ *
203
+ * @param item Raw API item.
204
+ * @returns Cleaned CLI row.
205
+ */
206
+ export function normalizeCommentItem(item: Record<string, any>): XueqiuCommentRow {
207
+ const text = String(item.description ?? '')
208
+ .replace(/<[^>]+>/g, ' ')
209
+ .replace(/&nbsp;/g, ' ')
210
+ .replace(/&amp;/g, '&')
211
+ .replace(/&lt;/g, '<')
212
+ .replace(/&gt;/g, '>')
213
+ .replace(/\s+/g, ' ')
214
+ .trim();
215
+
216
+ const id = normalizeIdentifier(item.id);
217
+ const userId = normalizeIdentifier(item.user?.id);
218
+
219
+ const createdAtDate = item.created_at ? new Date(item.created_at) : null;
220
+ const createdAt = createdAtDate && !Number.isNaN(createdAtDate.getTime())
221
+ ? createdAtDate.toISOString()
222
+ : null;
223
+
224
+ return {
225
+ id,
226
+ author: String(item.user?.screen_name ?? ''),
227
+ text,
228
+ likes: toFiniteCount(item.fav_count),
229
+ replies: toFiniteCount(item.reply_count),
230
+ retweets: toFiniteCount(item.retweet_count),
231
+ created_at: createdAt,
232
+ url: userId && id ? `https://xueqiu.com/${userId}/${id}` : null,
233
+ };
234
+ }
235
+
236
+ /**
237
+ * Remove internal-only fields before returning rows to the CLI renderer.
238
+ *
239
+ * @param row Internal row shape used during pagination.
240
+ * @returns Public output row that matches the documented command contract.
241
+ */
242
+ export function toCommentOutputRow(row: XueqiuCommentRow): XueqiuCommentOutputRow {
243
+ const { id: _id, ...outputRow } = row;
244
+ return outputRow;
245
+ }
246
+
247
+ /**
248
+ * Convert response classification into a compact warning phrase.
249
+ *
250
+ * @param kind Classifier result kind.
251
+ * @returns Human-readable reason fragment for stderr warnings.
252
+ */
253
+ export function describeFailureKind(kind: XueqiuCommentsKind): string {
254
+ return FAILURE_REASON_BY_KIND[kind];
255
+ }
256
+
257
+ /**
258
+ * Fetch one discussion page from inside the browser context so cookies and
259
+ * any site-side request state stay attached to the request.
260
+ *
261
+ * @param page Active browser page.
262
+ * @param symbol Normalized stock symbol.
263
+ * @param pageNumber Internal page counter, starting from 1.
264
+ * @param pageSize Item count per internal request.
265
+ * @returns Structured response for command-side classification.
266
+ */
267
+ export async function fetchCommentsPage(
268
+ page: IPage,
269
+ symbol: string,
270
+ pageNumber: number,
271
+ pageSize: number,
272
+ ): Promise<XueqiuCommentsResponse> {
273
+ const url = new URL('https://xueqiu.com/query/v1/symbol/search/status');
274
+ url.searchParams.set('symbol', symbol);
275
+ url.searchParams.set('count', String(pageSize));
276
+ url.searchParams.set('page', String(pageNumber));
277
+ url.searchParams.set('sort', 'time');
278
+
279
+ return page.evaluate(`
280
+ (async () => {
281
+ try {
282
+ const response = await fetch(${JSON.stringify(url.toString())}, {
283
+ credentials: 'include',
284
+ headers: {
285
+ 'accept': 'application/json, text/plain, */*',
286
+ 'x-requested-with': 'XMLHttpRequest',
287
+ },
288
+ referrer: ${JSON.stringify(`https://xueqiu.com/S/${symbol}`)},
289
+ referrerPolicy: 'strict-origin-when-cross-origin',
290
+ });
291
+ const contentType = response.headers.get('content-type') || '';
292
+ const text = await response.text();
293
+ let json = null;
294
+ if (contentType.includes('application/json')) {
295
+ try {
296
+ json = JSON.parse(text);
297
+ } catch {
298
+ json = null;
299
+ }
300
+ }
301
+ return {
302
+ status: response.status,
303
+ contentType,
304
+ json,
305
+ textSnippet: text.slice(0, 2000),
306
+ };
307
+ } catch (error) {
308
+ return {
309
+ status: 0,
310
+ contentType: 'text/plain',
311
+ json: null,
312
+ textSnippet: error instanceof Error ? error.message : String(error),
313
+ };
314
+ }
315
+ })()
316
+ `) as Promise<XueqiuCommentsResponse>;
317
+ }
318
+
319
+ /**
320
+ * Collect enough stock discussion rows to satisfy the requested limit.
321
+ *
322
+ * This helper owns the internal pagination policy so the public command
323
+ * contract can stay small and expose only `--limit`.
324
+ *
325
+ * @param options Pagination inputs and a page-fetch callback.
326
+ * @returns Deduplicated normalized rows, possibly partial with a warning.
327
+ */
328
+ export async function collectCommentRows(options: CollectCommentRowsOptions): Promise<XueqiuCommentRow[]> {
329
+ const warn = options.warn ?? log.warn;
330
+ let rows: XueqiuCommentRow[] = [];
331
+ const seenIds = new Set<string>();
332
+
333
+ for (let requestNumber = 1; requestNumber <= options.maxRequests; requestNumber += 1) {
334
+ const response = await options.fetchPage(requestNumber, options.pageSize);
335
+ const classified = classifyXueqiuCommentsResponse(response);
336
+
337
+ if (requestNumber === 1 && classified.kind !== 'unknown') {
338
+ throwFirstPageFailure(classified.kind, options.symbol);
339
+ } else if (classified.kind === 'empty') {
340
+ break;
341
+ } else if (classified.kind !== 'unknown') {
342
+ warn(buildPaginationStopMessage(
343
+ requestNumber,
344
+ rows.length,
345
+ options.limit,
346
+ describeFailureKind(classified.kind),
347
+ ));
348
+ break;
349
+ }
350
+
351
+ const rawItems = getCommentItems(response.json);
352
+ const pageRows = rawItems
353
+ .map(item => normalizeCommentItem(item))
354
+ .filter(row => row.id);
355
+ if (pageRows.length === 0) {
356
+ if (requestNumber === 1) {
357
+ throw new CommandExecutionError(
358
+ `Unexpected response while loading xueqiu comments for ${options.symbol}`,
359
+ 'Run the command again with --verbose to inspect the raw site response.',
360
+ );
361
+ }
362
+ if (classified.kind === 'unknown') {
363
+ warn(buildPaginationStopMessage(
364
+ requestNumber,
365
+ rows.length,
366
+ options.limit,
367
+ describeFailureKind(classified.kind),
368
+ ));
369
+ }
370
+ break;
371
+ }
372
+
373
+ let advanced = false;
374
+ for (const row of pageRows) {
375
+ if (seenIds.has(row.id)) continue;
376
+ seenIds.add(row.id);
377
+ rows.push(row);
378
+ advanced = true;
379
+ }
380
+
381
+ if (rows.length >= options.limit) {
382
+ return rows.slice(0, options.limit);
383
+ }
384
+ if (rawItems.length < options.pageSize) {
385
+ break;
386
+ }
387
+ if (!advanced) {
388
+ warn(buildPaginationStopMessage(
389
+ requestNumber,
390
+ rows.length,
391
+ options.limit,
392
+ 'pagination did not advance',
393
+ ));
394
+ break;
395
+ }
396
+ if (requestNumber === options.maxRequests) {
397
+ warn(buildPaginationStopMessage(requestNumber, rows.length, options.limit, 'reached safety cap'));
398
+ }
399
+ }
400
+
401
+ return rows.slice(0, options.limit);
402
+ }
403
+
404
+ cli({
405
+ site: 'xueqiu',
406
+ name: 'comments',
407
+ description: '获取单只股票的讨论动态',
408
+ domain: 'xueqiu.com',
409
+ strategy: Strategy.COOKIE,
410
+ browser: true,
411
+ navigateBefore: false,
412
+ args: [
413
+ {
414
+ name: 'symbol',
415
+ positional: true,
416
+ required: true,
417
+ help: 'Stock symbol, e.g. SH600519, AAPL, or 00700',
418
+ },
419
+ { name: 'limit', type: 'int', default: 20, help: 'Number of discussion posts to return' },
420
+ ],
421
+ columns: ['author', 'text', 'likes', 'replies', 'retweets', 'created_at', 'url'],
422
+ func: async (page, args) => {
423
+ const symbol = normalizeSymbolInput(args.symbol);
424
+ const limit = Number(args.limit);
425
+ if (!Number.isInteger(limit) || limit <= 0) {
426
+ throw new ArgumentError('xueqiu comments requires --limit to be a positive integer');
427
+ }
428
+ if (limit > 100) {
429
+ throw new ArgumentError('xueqiu comments supports --limit up to 100');
430
+ }
431
+ const pageSize = Math.min(limit, 20);
432
+ await page.goto('https://xueqiu.com');
433
+ const rows = await collectCommentRows({
434
+ symbol,
435
+ limit,
436
+ pageSize,
437
+ maxRequests: 5,
438
+ fetchPage: (pageNumber, currentPageSize) => fetchCommentsPage(page, symbol, pageNumber, currentPageSize),
439
+ warn: log.warn,
440
+ });
441
+ return rows.map(row => toCommentOutputRow(row));
442
+ },
443
+ });
444
+
445
+ /**
446
+ * Convert raw CLI input into a normalized stock symbol.
447
+ *
448
+ * @param raw User-provided CLI argument.
449
+ * @returns Upper-cased symbol string.
450
+ */
451
+ export function normalizeSymbolInput(raw: unknown): string {
452
+ const symbol = String(raw ?? '').trim().toUpperCase();
453
+ if (!symbol) throw new ArgumentError('xueqiu comments requires a symbol');
454
+ if (/^HTTPS?:\/\//.test(symbol)) {
455
+ throw new ArgumentError('xueqiu comments only accepts a symbol, not a URL');
456
+ }
457
+ if (!XUEQIU_SYMBOL_PATTERN.test(symbol)) {
458
+ throw new ArgumentError(`xueqiu comments received an invalid symbol: ${symbol}`);
459
+ }
460
+ return symbol;
461
+ }
@@ -10,7 +10,7 @@
10
10
  * --mode raw: every caption segment as-is with precise timestamps
11
11
  */
12
12
  import { cli, Strategy } from '../../registry.js';
13
- import { parseVideoId } from './utils.js';
13
+ import { parseVideoId, prepareYoutubeApiPage } from './utils.js';
14
14
  import {
15
15
  groupTranscriptSegments,
16
16
  formatGroupedTranscript,
@@ -34,9 +34,7 @@ cli({
34
34
  // so we let the renderer auto-detect columns from the data keys.
35
35
  func: async (page, kwargs) => {
36
36
  const videoId = parseVideoId(kwargs.url);
37
- const videoUrl = `https://www.youtube.com/watch?v=${videoId}`;
38
- await page.goto(videoUrl);
39
- await page.wait(3);
37
+ await prepareYoutubeApiPage(page);
40
38
 
41
39
  const lang = kwargs.lang || '';
42
40
  const mode = kwargs.mode || 'grouped';
@@ -0,0 +1,43 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { extractJsonAssignmentFromHtml, prepareYoutubeApiPage } from './utils.js';
3
+
4
+ describe('youtube utils', () => {
5
+ it('extractJsonAssignmentFromHtml parses bootstrap objects with nested braces in strings', () => {
6
+ const html = `
7
+ <script>
8
+ var ytInitialPlayerResponse = {
9
+ "title": "brace { inside } string",
10
+ "nested": { "count": 2, "text": "quote \\"value\\"" }
11
+ };
12
+ </script>
13
+ `;
14
+
15
+ expect(extractJsonAssignmentFromHtml(html, 'ytInitialPlayerResponse')).toEqual({
16
+ title: 'brace { inside } string',
17
+ nested: { count: 2, text: 'quote "value"' },
18
+ });
19
+ });
20
+
21
+ it('extractJsonAssignmentFromHtml supports window assignments', () => {
22
+ const html = `
23
+ <script>
24
+ window["ytInitialData"] = {"contents":{"items":[1,2,3]}};
25
+ </script>
26
+ `;
27
+
28
+ expect(extractJsonAssignmentFromHtml(html, 'ytInitialData')).toEqual({
29
+ contents: { items: [1, 2, 3] },
30
+ });
31
+ });
32
+
33
+ it('prepareYoutubeApiPage loads the quiet API bootstrap page', async () => {
34
+ const page = {
35
+ goto: vi.fn().mockResolvedValue(undefined),
36
+ wait: vi.fn().mockResolvedValue(undefined),
37
+ };
38
+
39
+ await expect(prepareYoutubeApiPage(page as any)).resolves.toBeUndefined();
40
+ expect(page.goto).toHaveBeenCalledWith('https://www.youtube.com', { waitUntil: 'none' });
41
+ expect(page.wait).toHaveBeenCalledWith(2);
42
+ });
43
+ });
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Shared YouTube utilities — URL parsing, video ID extraction, etc.
3
3
  */
4
+ import type { IPage } from '../../types.js';
4
5
 
5
6
  /**
6
7
  * Extract a YouTube video ID from a URL or bare video ID string.
@@ -26,3 +27,71 @@ export function parseVideoId(input: string): string {
26
27
 
27
28
  return input;
28
29
  }
30
+
31
+ /**
32
+ * Extract a JSON object assigned to a known bootstrap variable inside YouTube HTML.
33
+ */
34
+ export function extractJsonAssignmentFromHtml(html: string, keys: string | string[]): Record<string, unknown> | null {
35
+ const candidates = Array.isArray(keys) ? keys : [keys];
36
+ for (const key of candidates) {
37
+ const markers = [
38
+ `var ${key} = `,
39
+ `window["${key}"] = `,
40
+ `window.${key} = `,
41
+ `${key} = `,
42
+ ];
43
+ for (const marker of markers) {
44
+ const markerIndex = html.indexOf(marker);
45
+ if (markerIndex === -1) continue;
46
+
47
+ const jsonStart = html.indexOf('{', markerIndex + marker.length);
48
+ if (jsonStart === -1) continue;
49
+
50
+ let depth = 0;
51
+ let inString = false;
52
+ let escaping = false;
53
+ for (let i = jsonStart; i < html.length; i += 1) {
54
+ const ch = html[i];
55
+ if (inString) {
56
+ if (escaping) {
57
+ escaping = false;
58
+ } else if (ch === '\\') {
59
+ escaping = true;
60
+ } else if (ch === '"') {
61
+ inString = false;
62
+ }
63
+ continue;
64
+ }
65
+
66
+ if (ch === '"') {
67
+ inString = true;
68
+ continue;
69
+ }
70
+ if (ch === '{') {
71
+ depth += 1;
72
+ continue;
73
+ }
74
+ if (ch === '}') {
75
+ depth -= 1;
76
+ if (depth === 0) {
77
+ try {
78
+ return JSON.parse(html.slice(jsonStart, i + 1)) as Record<string, unknown>;
79
+ } catch {
80
+ break;
81
+ }
82
+ }
83
+ }
84
+ }
85
+ }
86
+ }
87
+
88
+ return null;
89
+ }
90
+
91
+ /**
92
+ * Prepare a quiet YouTube API-capable page without opening the watch UI.
93
+ */
94
+ export async function prepareYoutubeApiPage(page: IPage): Promise<void> {
95
+ await page.goto('https://www.youtube.com', { waitUntil: 'none' });
96
+ await page.wait(2);
97
+ }
@@ -1,8 +1,8 @@
1
1
  /**
2
- * YouTube video metadata — read ytInitialPlayerResponse + ytInitialData from video page.
2
+ * YouTube video metadata — fetch watch HTML and parse bootstrap data without opening the watch UI.
3
3
  */
4
4
  import { cli, Strategy } from '../../registry.js';
5
- import { parseVideoId } from './utils.js';
5
+ import { extractJsonAssignmentFromHtml, parseVideoId, prepareYoutubeApiPage } from './utils.js';
6
6
  import { CommandExecutionError } from '../../errors.js';
7
7
 
8
8
  cli({
@@ -17,24 +17,29 @@ cli({
17
17
  columns: ['field', 'value'],
18
18
  func: async (page, kwargs) => {
19
19
  const videoId = parseVideoId(kwargs.url);
20
- const videoUrl = `https://www.youtube.com/watch?v=${videoId}`;
21
- await page.goto(videoUrl);
22
- await page.wait(3);
20
+ await prepareYoutubeApiPage(page);
23
21
 
24
22
  const data = await page.evaluate(`
25
23
  (async () => {
26
- const player = window.ytInitialPlayerResponse;
27
- const yt = window.ytInitialData;
28
- if (!player) return { error: 'ytInitialPlayerResponse not found' };
24
+ const extractJsonAssignmentFromHtml = ${extractJsonAssignmentFromHtml.toString()};
25
+
26
+ const watchResp = await fetch('/watch?v=' + encodeURIComponent(${JSON.stringify(videoId)}), {
27
+ credentials: 'include',
28
+ });
29
+ if (!watchResp.ok) return { error: 'Watch HTML returned HTTP ' + watchResp.status };
30
+
31
+ const html = await watchResp.text();
32
+ const player = extractJsonAssignmentFromHtml(html, 'ytInitialPlayerResponse');
33
+ const yt = extractJsonAssignmentFromHtml(html, 'ytInitialData');
34
+ if (!player) return { error: 'ytInitialPlayerResponse not found in watch HTML' };
29
35
 
30
36
  const details = player.videoDetails || {};
31
37
  const microformat = player.microformat?.playerMicroformatRenderer || {};
38
+ const contents = yt?.contents?.twoColumnWatchNextResults?.results?.results?.contents || [];
32
39
 
33
- // Try to get full description from ytInitialData
40
+ // Try to get full description from watch bootstrap data
34
41
  let fullDescription = details.shortDescription || '';
35
42
  try {
36
- const contents = yt?.contents?.twoColumnWatchNextResults
37
- ?.results?.results?.contents;
38
43
  if (contents) {
39
44
  for (const c of contents) {
40
45
  const desc = c.videoSecondaryInfoRenderer?.attributedDescription?.content;
@@ -46,8 +51,6 @@ cli({
46
51
  // Get like count if available
47
52
  let likes = '';
48
53
  try {
49
- const contents = yt?.contents?.twoColumnWatchNextResults
50
- ?.results?.results?.contents;
51
54
  if (contents) {
52
55
  for (const c of contents) {
53
56
  const buttons = c.videoPrimaryInfoRenderer?.videoActions
@@ -75,8 +78,6 @@ cli({
75
78
  // Get channel subscriber count if available
76
79
  let subscribers = '';
77
80
  try {
78
- const contents = yt?.contents?.twoColumnWatchNextResults
79
- ?.results?.results?.contents;
80
81
  if (contents) {
81
82
  for (const c of contents) {
82
83
  const owner = c.videoSecondaryInfoRenderer?.owner