@jackwener/opencli 1.7.7 → 1.7.9

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 (280) hide show
  1. package/README.md +49 -14
  2. package/README.zh-CN.md +30 -10
  3. package/cli-manifest.json +782 -55
  4. package/clis/36kr/news.js +1 -1
  5. package/clis/amazon/discussion.js +37 -6
  6. package/clis/amazon/discussion.test.js +147 -32
  7. package/clis/apple-podcasts/commands.test.js +4 -4
  8. package/clis/apple-podcasts/episodes.js +1 -1
  9. package/clis/apple-podcasts/search.js +1 -1
  10. package/clis/apple-podcasts/top.js +1 -1
  11. package/clis/arxiv/paper.js +1 -1
  12. package/clis/arxiv/search.js +1 -1
  13. package/clis/band/mentions.js +3 -3
  14. package/clis/bbc/news.js +1 -1
  15. package/clis/bilibili/subtitle.js +2 -2
  16. package/clis/bloomberg/businessweek.js +1 -1
  17. package/clis/bloomberg/economics.js +1 -1
  18. package/clis/bloomberg/industries.js +1 -1
  19. package/clis/bloomberg/main.js +1 -1
  20. package/clis/bloomberg/markets.js +1 -1
  21. package/clis/bloomberg/opinions.js +1 -1
  22. package/clis/bloomberg/politics.js +1 -1
  23. package/clis/bloomberg/tech.js +1 -1
  24. package/clis/boss/search.js +49 -8
  25. package/clis/boss/search.test.js +78 -0
  26. package/clis/boss/send.js +3 -3
  27. package/clis/chatgpt/image.js +37 -8
  28. package/clis/chatgpt/image.test.js +92 -0
  29. package/clis/chatgpt/utils.js +39 -6
  30. package/clis/chatgpt/utils.test.js +63 -0
  31. package/clis/chatgpt-app/ask.js +4 -20
  32. package/clis/chatgpt-app/ax.js +135 -2
  33. package/clis/chatgpt-app/ax.test.js +35 -0
  34. package/clis/chatgpt-app/model.js +1 -1
  35. package/clis/chatgpt-app/new.js +1 -1
  36. package/clis/chatgpt-app/read.js +1 -1
  37. package/clis/chatgpt-app/send.js +3 -22
  38. package/clis/chatgpt-app/status.js +1 -1
  39. package/clis/chatwise/ask.js +2 -2
  40. package/clis/chatwise/model.js +2 -2
  41. package/clis/chatwise/send.js +2 -2
  42. package/clis/claude/ask.js +128 -0
  43. package/clis/claude/ask.test.js +338 -0
  44. package/clis/claude/commands.test.js +118 -0
  45. package/clis/claude/detail.js +29 -0
  46. package/clis/claude/history.js +31 -0
  47. package/clis/claude/new.js +21 -0
  48. package/clis/claude/read.js +24 -0
  49. package/clis/claude/send.js +41 -0
  50. package/clis/claude/status.js +24 -0
  51. package/clis/claude/utils.js +440 -0
  52. package/clis/claude/utils.test.js +148 -0
  53. package/clis/codex/ask.js +2 -2
  54. package/clis/codex/send.js +2 -2
  55. package/clis/ctrip/search.js +1 -1
  56. package/clis/ctrip/search.test.js +4 -4
  57. package/clis/cursor/ask.js +2 -2
  58. package/clis/cursor/composer.js +2 -2
  59. package/clis/cursor/send.js +2 -2
  60. package/clis/deepseek/ask.js +49 -10
  61. package/clis/deepseek/ask.test.js +150 -3
  62. package/clis/deepseek/utils.js +60 -22
  63. package/clis/deepseek/utils.test.js +124 -5
  64. package/clis/doubao/utils.js +53 -11
  65. package/clis/doubao/utils.test.js +22 -2
  66. package/clis/eastmoney/announcement.js +1 -1
  67. package/clis/eastmoney/convertible.js +1 -1
  68. package/clis/eastmoney/etf.js +1 -1
  69. package/clis/eastmoney/holders.js +1 -1
  70. package/clis/eastmoney/index-board.js +1 -1
  71. package/clis/eastmoney/kline.js +1 -1
  72. package/clis/eastmoney/kuaixun.js +1 -1
  73. package/clis/eastmoney/longhu.js +1 -1
  74. package/clis/eastmoney/money-flow.js +1 -1
  75. package/clis/eastmoney/northbound.js +1 -1
  76. package/clis/eastmoney/quote.js +1 -1
  77. package/clis/eastmoney/rank.js +1 -1
  78. package/clis/eastmoney/sectors.js +1 -1
  79. package/clis/facebook/marketplace-inbox.js +83 -0
  80. package/clis/facebook/marketplace-listings.js +83 -0
  81. package/clis/facebook/marketplace.test.js +91 -0
  82. package/clis/google/news.js +1 -1
  83. package/clis/google/suggest.js +1 -1
  84. package/clis/google/trends.js +1 -1
  85. package/clis/google-scholar/cite.js +74 -0
  86. package/clis/google-scholar/cite.test.js +47 -0
  87. package/clis/google-scholar/profile.js +92 -0
  88. package/clis/google-scholar/profile.test.js +49 -0
  89. package/clis/google-scholar/search.js +1 -1
  90. package/clis/google-scholar/search.test.js +15 -0
  91. package/clis/hf/top.js +1 -1
  92. package/clis/jd/item.js +679 -47
  93. package/clis/jd/item.test.js +318 -7
  94. package/clis/jd/item.test.ts +517 -0
  95. package/clis/lesswrong/comments.js +1 -1
  96. package/clis/lesswrong/curated.js +1 -1
  97. package/clis/lesswrong/frontpage.js +1 -1
  98. package/clis/lesswrong/new.js +1 -1
  99. package/clis/lesswrong/read.js +1 -1
  100. package/clis/lesswrong/sequences.js +1 -1
  101. package/clis/lesswrong/shortform.js +1 -1
  102. package/clis/lesswrong/tag.js +1 -1
  103. package/clis/lesswrong/tags.js +1 -1
  104. package/clis/lesswrong/top-month.js +1 -1
  105. package/clis/lesswrong/top-week.js +1 -1
  106. package/clis/lesswrong/top-year.js +1 -1
  107. package/clis/lesswrong/top.js +1 -1
  108. package/clis/lesswrong/user-posts.js +1 -1
  109. package/clis/lesswrong/user.js +1 -1
  110. package/clis/paperreview/commands.test.js +6 -6
  111. package/clis/paperreview/feedback.js +1 -1
  112. package/clis/paperreview/review.js +1 -1
  113. package/clis/paperreview/submit.js +1 -1
  114. package/clis/powerchina/search.js +250 -0
  115. package/clis/powerchina/search.test.js +67 -0
  116. package/clis/producthunt/posts.js +1 -1
  117. package/clis/producthunt/today.js +1 -1
  118. package/clis/sinablog/search.js +1 -1
  119. package/clis/sinafinance/news.js +1 -1
  120. package/clis/sinafinance/stock.js +6 -3
  121. package/clis/sinafinance/stock.test.js +59 -0
  122. package/clis/spotify/spotify.js +6 -6
  123. package/clis/substack/search.js +1 -1
  124. package/clis/toutiao/articles.js +80 -0
  125. package/clis/toutiao/articles.test.js +30 -0
  126. package/clis/twitter/followers.js +2 -2
  127. package/clis/twitter/following.js +224 -73
  128. package/clis/twitter/following.test.js +277 -0
  129. package/clis/twitter/post.js +184 -47
  130. package/clis/twitter/post.test.js +114 -34
  131. package/clis/uiverse/_shared.js +63 -4
  132. package/clis/uiverse/_shared.test.js +7 -0
  133. package/clis/uiverse/code.js +1 -0
  134. package/clis/uiverse/navigation.test.js +12 -0
  135. package/clis/uiverse/preview.js +1 -0
  136. package/clis/web/read.js +319 -81
  137. package/clis/web/read.test.js +221 -5
  138. package/clis/weibo/favorites.js +169 -0
  139. package/clis/weibo/favorites.test.js +114 -0
  140. package/clis/weibo/publish.js +282 -0
  141. package/clis/weibo/publish.test.js +183 -0
  142. package/clis/weixin/create-draft.js +225 -0
  143. package/clis/weixin/drafts.js +65 -0
  144. package/clis/weixin/drafts.test.js +65 -0
  145. package/clis/weread/ranking.js +1 -1
  146. package/clis/weread/search-regression.test.js +8 -8
  147. package/clis/weread/search.js +1 -1
  148. package/clis/wikipedia/random.js +1 -1
  149. package/clis/wikipedia/search.js +1 -1
  150. package/clis/wikipedia/summary.js +1 -1
  151. package/clis/wikipedia/trending.js +1 -1
  152. package/clis/xianyu/chat.js +3 -3
  153. package/clis/xianyu/item.js +2 -2
  154. package/clis/xianyu/item.test.js +3 -3
  155. package/clis/xiaohongshu/search.js +17 -2
  156. package/clis/xiaohongshu/search.test.js +37 -1
  157. package/clis/xiaoyuzhou/download.js +1 -1
  158. package/clis/xiaoyuzhou/download.test.js +3 -3
  159. package/clis/xiaoyuzhou/episode.js +1 -1
  160. package/clis/xiaoyuzhou/podcast-episodes.js +1 -1
  161. package/clis/xiaoyuzhou/podcast-episodes.test.js +2 -2
  162. package/clis/xiaoyuzhou/podcast.js +1 -1
  163. package/clis/xiaoyuzhou/transcript.js +1 -1
  164. package/clis/xiaoyuzhou/transcript.test.js +5 -5
  165. package/clis/yollomi/models.js +1 -1
  166. package/clis/youtube/channel.js +24 -1
  167. package/clis/youtube/channel.test.js +59 -0
  168. package/clis/zhihu/answer.js +21 -162
  169. package/clis/zhihu/answer.test.js +26 -53
  170. package/clis/zhihu/collection.js +197 -0
  171. package/clis/zhihu/collection.test.js +290 -0
  172. package/clis/zhihu/collections.js +127 -0
  173. package/clis/zhihu/collections.test.js +182 -0
  174. package/clis/zhihu/comment.js +24 -305
  175. package/clis/zhihu/comment.test.js +31 -35
  176. package/clis/zhihu/favorite.js +44 -182
  177. package/clis/zhihu/favorite.test.js +30 -167
  178. package/clis/zhihu/follow.js +25 -56
  179. package/clis/zhihu/follow.test.js +20 -23
  180. package/clis/zhihu/like.js +22 -67
  181. package/clis/zhihu/like.test.js +19 -42
  182. package/clis/zhihu/search.js +3 -2
  183. package/clis/zhihu/write-shared.js +8 -1
  184. package/clis/zhihu/write-shared.test.js +1 -0
  185. package/clis/zlibrary/commands.test.js +75 -0
  186. package/clis/zlibrary/info.js +47 -0
  187. package/clis/zlibrary/search.js +46 -0
  188. package/clis/zlibrary/utils.js +136 -0
  189. package/dist/src/adapter-source.d.ts +11 -0
  190. package/dist/src/adapter-source.js +24 -0
  191. package/dist/src/adapter-source.test.js +29 -0
  192. package/dist/src/browser/base-page.d.ts +3 -1
  193. package/dist/src/browser/base-page.js +76 -1
  194. package/dist/src/browser/base-page.test.d.ts +1 -0
  195. package/dist/src/browser/base-page.test.js +74 -0
  196. package/dist/src/browser/bridge.d.ts +1 -0
  197. package/dist/src/browser/bridge.js +36 -9
  198. package/dist/src/browser/cdp.d.ts +1 -0
  199. package/dist/src/browser/cdp.js +3 -3
  200. package/dist/src/browser/daemon-client.d.ts +38 -4
  201. package/dist/src/browser/daemon-client.js +24 -7
  202. package/dist/src/browser/daemon-client.test.js +49 -0
  203. package/dist/src/browser/errors.js +3 -0
  204. package/dist/src/browser/errors.test.js +3 -0
  205. package/dist/src/browser/network-cache.d.ts +1 -0
  206. package/dist/src/browser/page.d.ts +3 -1
  207. package/dist/src/browser/page.js +10 -2
  208. package/dist/src/browser/profile.d.ts +14 -0
  209. package/dist/src/browser/profile.js +85 -0
  210. package/dist/src/build-manifest.d.ts +2 -0
  211. package/dist/src/build-manifest.js +13 -3
  212. package/dist/src/build-manifest.test.js +20 -2
  213. package/dist/src/cli.d.ts +6 -0
  214. package/dist/src/cli.js +462 -32
  215. package/dist/src/cli.test.js +209 -2
  216. package/dist/src/commanderAdapter.js +29 -9
  217. package/dist/src/commanderAdapter.test.js +78 -2
  218. package/dist/src/commands/daemon.js +6 -0
  219. package/dist/src/completion-shared.js +1 -2
  220. package/dist/src/completion.test.js +3 -2
  221. package/dist/src/daemon.js +125 -41
  222. package/dist/src/doctor.d.ts +4 -6
  223. package/dist/src/doctor.js +80 -22
  224. package/dist/src/doctor.test.js +82 -0
  225. package/dist/src/engine.test.js +6 -5
  226. package/dist/src/errors.d.ts +14 -8
  227. package/dist/src/errors.js +36 -30
  228. package/dist/src/errors.test.js +5 -5
  229. package/dist/src/execution.d.ts +4 -0
  230. package/dist/src/execution.js +173 -25
  231. package/dist/src/execution.test.js +171 -1
  232. package/dist/src/main.js +10 -0
  233. package/dist/src/observation/artifact.d.ts +16 -0
  234. package/dist/src/observation/artifact.js +260 -0
  235. package/dist/src/observation/artifact.test.d.ts +1 -0
  236. package/dist/src/observation/artifact.test.js +121 -0
  237. package/dist/src/observation/events.d.ts +89 -0
  238. package/dist/src/observation/events.js +1 -0
  239. package/dist/src/observation/index.d.ts +7 -0
  240. package/dist/src/observation/index.js +7 -0
  241. package/dist/src/observation/manager.d.ts +9 -0
  242. package/dist/src/observation/manager.js +27 -0
  243. package/dist/src/observation/manager.test.d.ts +1 -0
  244. package/dist/src/observation/manager.test.js +13 -0
  245. package/dist/src/observation/redaction.d.ts +11 -0
  246. package/dist/src/observation/redaction.js +81 -0
  247. package/dist/src/observation/redaction.test.d.ts +1 -0
  248. package/dist/src/observation/redaction.test.js +32 -0
  249. package/dist/src/observation/retention.d.ts +32 -0
  250. package/dist/src/observation/retention.js +160 -0
  251. package/dist/src/observation/retention.test.d.ts +1 -0
  252. package/dist/src/observation/retention.test.js +118 -0
  253. package/dist/src/observation/ring-buffer.d.ts +22 -0
  254. package/dist/src/observation/ring-buffer.js +45 -0
  255. package/dist/src/observation/ring-buffer.test.d.ts +1 -0
  256. package/dist/src/observation/ring-buffer.test.js +22 -0
  257. package/dist/src/observation/session.d.ts +25 -0
  258. package/dist/src/observation/session.js +50 -0
  259. package/dist/src/pipeline/executor.test.js +1 -0
  260. package/dist/src/pipeline/steps/download.test.js +1 -0
  261. package/dist/src/pipeline/steps/fetch.js +1 -21
  262. package/dist/src/pipeline/steps/fetch.test.js +6 -12
  263. package/dist/src/plugin-scaffold.js +1 -1
  264. package/dist/src/plugin-scaffold.test.js +1 -1
  265. package/dist/src/registry.d.ts +40 -9
  266. package/dist/src/registry.js +3 -1
  267. package/dist/src/runtime-detect.d.ts +10 -0
  268. package/dist/src/runtime-detect.js +19 -0
  269. package/dist/src/runtime-detect.test.js +12 -1
  270. package/dist/src/runtime.d.ts +2 -0
  271. package/dist/src/runtime.js +1 -0
  272. package/dist/src/types.d.ts +22 -0
  273. package/dist/src/update-check.d.ts +31 -1
  274. package/dist/src/update-check.js +62 -16
  275. package/dist/src/update-check.test.js +86 -1
  276. package/package.json +1 -1
  277. package/dist/src/diagnostic.d.ts +0 -63
  278. package/dist/src/diagnostic.js +0 -292
  279. package/dist/src/diagnostic.test.js +0 -302
  280. /package/dist/src/{diagnostic.test.d.ts → adapter-source.test.d.ts} +0 -0
@@ -18,7 +18,7 @@ cli({
18
18
  },
19
19
  ],
20
20
  columns: ['field', 'value'],
21
- func: async (_page, kwargs) => {
21
+ func: async (kwargs) => {
22
22
  const slug = gqlEscape(String(kwargs.username).toLowerCase());
23
23
  const query = `query UserProfile {
24
24
  user(input: {selector: {slug: "${slug}"}}) {
@@ -38,7 +38,7 @@ describe('paperreview submit command', () => {
38
38
  resolvedPath: '/tmp/paper.pdf',
39
39
  sizeBytes: 4096,
40
40
  });
41
- const result = await cmd.func(null, {
41
+ const result = await cmd.func({
42
42
  pdf: './paper.pdf',
43
43
  email: 'wang2629651228@gmail.com',
44
44
  venue: 'RAL',
@@ -80,7 +80,7 @@ describe('paperreview submit command', () => {
80
80
  message: 'Submission accepted',
81
81
  },
82
82
  });
83
- const result = await cmd.func(null, {
83
+ const result = await cmd.func({
84
84
  pdf: './paper.pdf',
85
85
  email: 'wang2629651228@gmail.com',
86
86
  venue: 'RAL',
@@ -112,7 +112,7 @@ describe('paperreview submit command', () => {
112
112
  s3_key: 'uploads/paper.pdf',
113
113
  },
114
114
  });
115
- const result = await cmd.func(null, {
115
+ const result = await cmd.func({
116
116
  pdf: './paper.pdf',
117
117
  email: 'wang2629651228@gmail.com',
118
118
  venue: 'RAL',
@@ -153,7 +153,7 @@ describe('paperreview submit command', () => {
153
153
  message: 'Submission accepted',
154
154
  },
155
155
  });
156
- const result = await cmd.func(null, {
156
+ const result = await cmd.func({
157
157
  pdf: './paper.pdf',
158
158
  email: 'wang2629651228@gmail.com',
159
159
  venue: 'RAL',
@@ -190,7 +190,7 @@ describe('paperreview review command', () => {
190
190
  response: { status: 202 },
191
191
  payload: { detail: 'Review is still processing.' },
192
192
  });
193
- const result = await cmd.func(null, { token: 'tok_123' });
193
+ const result = await cmd.func({ token: 'tok_123' });
194
194
  expect(result).toMatchObject({
195
195
  status: 'processing',
196
196
  token: 'tok_123',
@@ -214,7 +214,7 @@ describe('paperreview feedback command', () => {
214
214
  response: { ok: true, status: 200 },
215
215
  payload: { message: 'Thanks for the feedback.' },
216
216
  });
217
- const result = await cmd.func(null, {
217
+ const result = await cmd.func({
218
218
  token: 'tok_123',
219
219
  helpfulness: 4,
220
220
  'critical-error': 'yes',
@@ -17,7 +17,7 @@ cli({
17
17
  { name: 'additional-comments', help: 'Optional free-text feedback' },
18
18
  ],
19
19
  columns: ['status', 'token', 'helpfulness', 'critical_error', 'actionable_suggestions', 'message'],
20
- func: async (_page, kwargs) => {
20
+ func: async (kwargs) => {
21
21
  const token = String(kwargs.token ?? '').trim();
22
22
  if (!token) {
23
23
  throw new CliError('ARGUMENT', 'A review token is required.');
@@ -13,7 +13,7 @@ cli({
13
13
  { name: 'token', positional: true, required: true, help: 'Review token returned by paperreview.ai' },
14
14
  ],
15
15
  columns: ['status', 'title', 'venue', 'numerical_score', 'has_feedback', 'review_url'],
16
- func: async (_page, kwargs) => {
16
+ func: async (kwargs) => {
17
17
  const token = String(kwargs.token ?? '').trim();
18
18
  if (!token) {
19
19
  throw new CliError('ARGUMENT', 'A review token is required.');
@@ -24,7 +24,7 @@ cli({
24
24
  return 'prepared only';
25
25
  return undefined;
26
26
  },
27
- func: async (_page, kwargs) => {
27
+ func: async (kwargs) => {
28
28
  const pdfFile = await readPdfFile(kwargs.pdf);
29
29
  const email = String(kwargs.email ?? '').trim();
30
30
  const venue = normalizeVenue(kwargs.venue);
@@ -0,0 +1,250 @@
1
+ /**
2
+ * PowerChina search — browser DOM extraction with multi-entry URL probing.
3
+ */
4
+ import { cli, Strategy } from '@jackwener/opencli/registry';
5
+ import { AuthRequiredError } from '@jackwener/opencli/errors';
6
+ import {
7
+ cleanText,
8
+ normalizeDate,
9
+ toProcurementSearchRecords,
10
+ } from '../jianyu/shared/procurement-contract.js';
11
+ import { searchRowsFromEntries } from '../jianyu/shared/china-bid-search.js';
12
+
13
+ const SEARCH_ENTRIES = [
14
+ 'https://bid.powerchina.cn/search',
15
+ 'https://bid.powerchina.cn/',
16
+ ];
17
+ const API_LIST_ENDPOINT = 'https://bid.powerchina.cn/newcbs/recpro-newmember/BidAnnouncementSummary/list';
18
+ const API_DETAIL_ENDPOINT = 'https://bid.powerchina.cn/newcbs/recpro-newmember/BidAnnouncementSummary/getInfo';
19
+ const API_DEFAULT_ANNOUNCEMENT_TYPE = '招采公告';
20
+
21
+ const PROCUREMENT_TITLE_HINT = /(公告|招标|采购|中标|成交|项目|notice|tender|bidding)/i;
22
+ const NAVIGATION_TITLE_HINT = /^(english|中文|chinese|language|home|首页|搜索|search)$/i;
23
+ const RETRYABLE_SEARCH_ERROR_HINT = /(detached while handling command|execution context was destroyed|target closed|cannot find context with specified id)/i;
24
+
25
+ export function buildSearchCandidates(query) {
26
+ const keyword = query.trim();
27
+ if (!keyword) return [...SEARCH_ENTRIES];
28
+ const encoded = encodeURIComponent(keyword);
29
+ return [
30
+ `https://bid.powerchina.cn/search?keyword=${encoded}`,
31
+ `https://bid.powerchina.cn/search?keywords=${encoded}`,
32
+ `https://bid.powerchina.cn/search?q=${encoded}`,
33
+ ...SEARCH_ENTRIES,
34
+ ];
35
+ }
36
+
37
+ function dedupeCandidates(items) {
38
+ const deduped = [];
39
+ const seen = new Set();
40
+ for (const item of items) {
41
+ const key = `${item.title}\t${item.url}`;
42
+ if (seen.has(key)) continue;
43
+ seen.add(key);
44
+ deduped.push(item);
45
+ }
46
+ return deduped;
47
+ }
48
+
49
+ function isLikelyNavigationUrl(rawUrl) {
50
+ const urlText = cleanText(rawUrl);
51
+ if (!urlText) return true;
52
+ try {
53
+ const parsed = new URL(urlText);
54
+ const pathname = parsed.pathname.toLowerCase().replace(/\/+$/, '') || '/';
55
+ const hash = cleanText(parsed.hash).toLowerCase();
56
+ if (pathname === '/' || pathname === '/index') return true;
57
+ if (pathname === '/search') return true;
58
+ if (pathname === '/old' || pathname.startsWith('/old/')) return true;
59
+ if (pathname === '/en' || pathname.startsWith('/en/')) return true;
60
+ if (pathname === '/zh' || pathname.startsWith('/zh/')) return true;
61
+ if (hash === '#/' || hash === '#/index' || hash.startsWith('#/search')) return true;
62
+ return false;
63
+ } catch {
64
+ return true;
65
+ }
66
+ }
67
+
68
+ function isLikelyNavigationTitle(rawTitle) {
69
+ const title = cleanText(rawTitle);
70
+ if (!title) return true;
71
+ const normalized = title.toLowerCase();
72
+ if (NAVIGATION_TITLE_HINT.test(normalized)) return true;
73
+ if (normalized.length <= 10 && (normalized === 'en' || normalized === 'zh' || normalized.includes('english'))) {
74
+ return true;
75
+ }
76
+ return false;
77
+ }
78
+
79
+ function filterNavigationRows(items) {
80
+ return items.filter((item) => {
81
+ const title = cleanText(item.title);
82
+ const url = cleanText(item.url);
83
+ if (!url || !title) return false;
84
+ if (isLikelyNavigationUrl(url)) return false;
85
+ if (isLikelyNavigationTitle(title) && !PROCUREMENT_TITLE_HINT.test(title)) return false;
86
+ return true;
87
+ });
88
+ }
89
+
90
+ export function buildApiDetailUrl(id) {
91
+ const normalizedId = cleanText(id);
92
+ if (!normalizedId) return '';
93
+ return `${API_DETAIL_ENDPOINT}/${encodeURIComponent(normalizedId)}`;
94
+ }
95
+
96
+ function toApiCandidate(row) {
97
+ const id = cleanText(row.id);
98
+ const title = cleanText(row.title);
99
+ if (!id || !title) return null;
100
+
101
+ const url = buildApiDetailUrl(id);
102
+ if (!url) return null;
103
+
104
+ const contextText = cleanText([
105
+ row.announcementType,
106
+ row.titleTypeName,
107
+ row.source,
108
+ row.publishTime,
109
+ row.registrationDeadline,
110
+ row.submissionDeadline,
111
+ row.bidOpenTime,
112
+ ].filter(Boolean).join(' | '));
113
+
114
+ const date = normalizeDate(cleanText(row.publishTime || row.bidOpenTime || row.submissionDeadline || ''));
115
+ return {
116
+ title,
117
+ url,
118
+ date,
119
+ contextText,
120
+ };
121
+ }
122
+
123
+ async function searchRowsFromApi(query, limit) {
124
+ const keyword = cleanText(query);
125
+ const pageSize = Math.max(20, Math.min(100, Math.max(limit * 3, limit)));
126
+ const payload = {
127
+ pageNum: 1,
128
+ pageSize,
129
+ announcementType: API_DEFAULT_ANNOUNCEMENT_TYPE,
130
+ companyType: '3',
131
+ time: Date.now(),
132
+ };
133
+ if (keyword) payload.keyWords = keyword;
134
+
135
+ const response = await fetch(API_LIST_ENDPOINT, {
136
+ method: 'POST',
137
+ headers: {
138
+ 'Content-Type': 'application/json;charset=utf-8',
139
+ },
140
+ body: JSON.stringify(payload),
141
+ });
142
+
143
+ if (!response.ok) {
144
+ throw new Error(`[taxonomy=relay_unavailable] site=powerchina command=search api HTTP ${response.status}`);
145
+ }
146
+
147
+ const data = await response.json();
148
+ if ((data.code ?? 200) !== 200) {
149
+ throw new Error(`[taxonomy=relay_unavailable] site=powerchina command=search api code=${data.code ?? 'unknown'} msg=${cleanText(data.msg)}`);
150
+ }
151
+
152
+ const rows = Array.isArray(data.rows) ? data.rows : [];
153
+ const mapped = rows
154
+ .map((row) => toApiCandidate(row))
155
+ .filter(Boolean);
156
+ return dedupeCandidates(mapped).slice(0, limit);
157
+ }
158
+
159
+ cli({
160
+ site: 'powerchina',
161
+ name: 'search',
162
+ description: '搜索中国电建阳光采购公告',
163
+ domain: 'bid.powerchina.cn',
164
+ strategy: Strategy.COOKIE,
165
+ browser: true,
166
+ args: [
167
+ { name: 'query', required: true, positional: true, help: 'Search keyword, e.g. "procurement"' },
168
+ { name: 'limit', type: 'int', default: 20, help: 'Number of results (max 50)' },
169
+ ],
170
+ columns: ['rank', 'content_type', 'title', 'publish_time', 'project_code', 'budget_or_limit', 'url'],
171
+ func: async (page, kwargs) => {
172
+ const query = cleanText(kwargs.query);
173
+ const limit = Math.max(1, Math.min(Number(kwargs.limit) || 20, 50));
174
+ let extractedRows = [];
175
+ let apiFailure = null;
176
+ let apiSucceeded = false;
177
+
178
+ try {
179
+ const apiRows = await searchRowsFromApi(query, limit);
180
+ extractedRows = apiRows;
181
+ apiSucceeded = true;
182
+ } catch (error) {
183
+ apiFailure = cleanText(error instanceof Error ? error.message : String(error || ''));
184
+ }
185
+
186
+ if (apiSucceeded && extractedRows.length === 0) {
187
+ return [];
188
+ }
189
+
190
+ if (!apiSucceeded) {
191
+ try {
192
+ extractedRows = await searchRowsFromEntries(page, {
193
+ query,
194
+ candidateUrls: buildSearchCandidates(query),
195
+ allowedHostFragments: ['bid.powerchina.cn', 'powerchina.cn'],
196
+ limit,
197
+ });
198
+ } catch (error) {
199
+ const message = cleanText(error instanceof Error ? error.message : String(error || ''));
200
+ if (RETRYABLE_SEARCH_ERROR_HINT.test(message)) {
201
+ throw new Error(`[taxonomy=relay_unavailable] site=powerchina command=search detached browser context: ${message}`);
202
+ }
203
+ throw error;
204
+ }
205
+ }
206
+
207
+ const rows = filterNavigationRows(
208
+ dedupeCandidates(extractedRows).map((item) => ({
209
+ title: cleanText(item.title),
210
+ url: cleanText(item.url),
211
+ date: normalizeDate(cleanText(item.date)),
212
+ contextText: cleanText(item.contextText),
213
+ })),
214
+ );
215
+
216
+ if (rows.length === 0 && extractedRows.length > 0) {
217
+ throw new Error('[taxonomy=empty_result] site=powerchina command=search extracted only navigation/portal rows');
218
+ }
219
+
220
+ if (rows.length === 0) {
221
+ const pageText = cleanText(await page.evaluate('document.body ? document.body.innerText : ""'));
222
+ if (/(请先登录|未登录|登录后|验证码|人机验证)/.test(pageText)) {
223
+ throw new AuthRequiredError(
224
+ 'bid.powerchina.cn',
225
+ '[taxonomy=selector_drift] site=powerchina command=search login required or human verification',
226
+ );
227
+ }
228
+ if (apiFailure) {
229
+ throw new Error(`[taxonomy=empty_result] site=powerchina command=search api/dom yielded no result: ${apiFailure}`);
230
+ }
231
+ }
232
+
233
+ return toProcurementSearchRecords(rows, {
234
+ site: 'powerchina',
235
+ query,
236
+ limit,
237
+ });
238
+ },
239
+ });
240
+
241
+ export const __test__ = {
242
+ buildSearchCandidates,
243
+ normalizeDate,
244
+ dedupeCandidates,
245
+ filterNavigationRows,
246
+ isLikelyNavigationUrl,
247
+ isLikelyNavigationTitle,
248
+ buildApiDetailUrl,
249
+ toApiCandidate,
250
+ };
@@ -0,0 +1,67 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { __test__ } from './search.js';
3
+
4
+ describe('powerchina search helpers', () => {
5
+ it('builds candidate URLs with keyword variants', () => {
6
+ const candidates = __test__.buildSearchCandidates('procurement');
7
+ expect(candidates[0]).toContain('keyword=procurement');
8
+ expect(candidates.some((item) => item.includes('/search?keywords='))).toBe(true);
9
+ expect(candidates.some((item) => item === 'https://bid.powerchina.cn/search')).toBe(true);
10
+ });
11
+
12
+ it('normalizes date text', () => {
13
+ expect(__test__.normalizeDate('2026-4-7')).toBe('2026-04-07');
14
+ expect(__test__.normalizeDate('公告时间:2026年04月07日')).toBe('2026-04-07');
15
+ });
16
+
17
+ it('deduplicates title/url pairs', () => {
18
+ const deduped = __test__.dedupeCandidates([
19
+ { title: 'A', url: 'https://a.com/1', date: '2026-04-07' },
20
+ { title: 'A', url: 'https://a.com/1', date: '2026-04-07' },
21
+ { title: 'B', url: 'https://a.com/1', date: '2026-04-07' },
22
+ ]);
23
+ expect(deduped).toHaveLength(2);
24
+ });
25
+
26
+ it('filters obvious navigation rows before quality gate', () => {
27
+ const filtered = __test__.filterNavigationRows([
28
+ { title: '搜索', url: 'https://bid.powerchina.cn/search', date: '2026-04-07' },
29
+ { title: '首页', url: 'https://bid.powerchina.cn/', date: '2026-04-07' },
30
+ { title: 'English', url: 'https://bid.powerchina.cn/old/en', date: '' },
31
+ { title: '某项目电梯采购公告', url: 'https://bid.powerchina.cn/notice/detail?id=123', date: '2026-04-07' },
32
+ ]);
33
+ expect(filtered).toHaveLength(1);
34
+ expect(filtered[0].title).toContain('电梯采购公告');
35
+ });
36
+
37
+ it('treats old/en language switch urls as navigation', () => {
38
+ expect(__test__.isLikelyNavigationUrl('https://bid.powerchina.cn/old/en')).toBe(true);
39
+ });
40
+
41
+ it('treats language-toggle labels as navigation titles', () => {
42
+ expect(__test__.isLikelyNavigationTitle('English')).toBe(true);
43
+ expect(__test__.isLikelyNavigationTitle('EN')).toBe(true);
44
+ });
45
+
46
+ it('builds api detail urls with stable id', () => {
47
+ const url = __test__.buildApiDetailUrl('2409419657');
48
+ expect(url).toBe('https://bid.powerchina.cn/newcbs/recpro-newmember/BidAnnouncementSummary/getInfo/2409419657');
49
+ });
50
+
51
+ it('maps api rows into normalized search candidates', () => {
52
+ const mapped = __test__.toApiCandidate({
53
+ id: '2409419657',
54
+ title: '某项目电梯采购公告',
55
+ announcementType: '招采公告',
56
+ companyType: '3',
57
+ titleTypeName: '货物类',
58
+ source: '设备物资集中采购电子平台',
59
+ publishTime: '2026-04-07 17:05:02',
60
+ submissionDeadline: '2026-04-14',
61
+ });
62
+ expect(mapped).not.toBeNull();
63
+ expect(mapped?.title).toContain('电梯采购公告');
64
+ expect(mapped?.date).toBe('2026-04-07');
65
+ expect(mapped?.url).toBe('https://bid.powerchina.cn/newcbs/recpro-newmember/BidAnnouncementSummary/getInfo/2409419657');
66
+ });
67
+ });
@@ -19,7 +19,7 @@ cli({
19
19
  },
20
20
  ],
21
21
  columns: ['rank', 'name', 'tagline', 'author', 'date', 'url'],
22
- func: async (_page, args) => {
22
+ func: async (args) => {
23
23
  const count = Math.min(Number(args.limit) || 20, 50);
24
24
  const category = String(args.category ?? '').trim() || undefined;
25
25
  const posts = await fetchFeed(category);
@@ -16,7 +16,7 @@ cli({
16
16
  { name: 'limit', type: 'int', default: 20, help: 'Max results' },
17
17
  ],
18
18
  columns: ['rank', 'name', 'tagline', 'author', 'url'],
19
- func: async (_page, args) => {
19
+ func: async (args) => {
20
20
  const count = Math.min(Number(args.limit) || 20, 50);
21
21
  const posts = await fetchFeed();
22
22
  if (posts.length === 0)
@@ -47,5 +47,5 @@ cli({
47
47
  { name: 'limit', type: 'int', default: 20, help: '返回的文章数量' },
48
48
  ],
49
49
  columns: ['rank', 'title', 'author', 'date', 'description', 'url'],
50
- func: async (_page, args) => searchSinaBlog(args.keyword, Math.max(1, Math.min(Number(args.limit) || 20, 50))),
50
+ func: async (args) => searchSinaBlog(args.keyword, Math.max(1, Math.min(Number(args.limit) || 20, 50))),
51
51
  });
@@ -34,7 +34,7 @@ cli({
34
34
  { name: 'type', type: 'int', default: 0, help: 'News type: 0=全部 1=A股 2=宏观 3=公司 4=数据 5=市场 6=国际 7=观点 8=央行 9=其它' },
35
35
  ],
36
36
  columns: ['id', 'time', 'content', 'views'],
37
- func: async (_page, args) => {
37
+ func: async (args) => {
38
38
  const limit = Math.max(1, Math.min(Number(args.limit), 50));
39
39
  const apiTag = TYPE_MAP[args.type] ?? 0;
40
40
  const params = new URLSearchParams({
@@ -62,7 +62,7 @@ cli({
62
62
  { name: 'market', type: 'string', default: 'auto', help: 'Market: cn, hk, us, auto (default: auto searches cn → hk → us)' },
63
63
  ],
64
64
  columns: ['Symbol', 'Name', 'Price', 'Change', 'ChangePercent', 'Open', 'High', 'Low', 'Volume', 'MarketCap'],
65
- func: async (_page, args) => {
65
+ func: async (args) => {
66
66
  const key = String(args.key);
67
67
  const market = String(args.market);
68
68
  const marketMap = {
@@ -79,12 +79,15 @@ cli({
79
79
  if (!entries.length) {
80
80
  throw new CliError('NOT_FOUND', `No stock found for "${key}"`, 'Try a different name, code, or --market');
81
81
  }
82
- // Pick best match: score by name similarity, tiebreak by market priority
82
+ // Pick best match: score by name/symbol similarity, tiebreak by market priority
83
83
  const needle = key.toLowerCase();
84
84
  const score = (e) => {
85
85
  const n = e.name.toLowerCase();
86
- if (n === needle)
86
+ const s = e.symbol.toLowerCase();
87
+ if (s === needle || n === needle)
87
88
  return 1;
89
+ if (s.includes(needle))
90
+ return needle.length / s.length;
88
91
  if (n.includes(needle))
89
92
  return needle.length / n.length;
90
93
  return 0;
@@ -0,0 +1,59 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import './stock.js';
4
+
5
+ function textResponse(body) {
6
+ return {
7
+ ok: true,
8
+ arrayBuffer: async () => Buffer.from(body, 'utf8'),
9
+ };
10
+ }
11
+
12
+ describe('sinafinance stock command', () => {
13
+ beforeEach(() => {
14
+ vi.restoreAllMocks();
15
+ vi.stubGlobal('TextDecoder', class {
16
+ decode(buf) {
17
+ return Buffer.from(buf).toString('utf8');
18
+ }
19
+ });
20
+ });
21
+
22
+ it('prefers exact symbol match over partial symbol and name misses', async () => {
23
+ const cmd = getRegistry().get('sinafinance/stock');
24
+ expect(cmd?.func).toBeTypeOf('function');
25
+
26
+ const fetchMock = vi.fn()
27
+ .mockResolvedValueOnce(textResponse('var suggestvalue="x,41,,AAPL,苹果;x,41,,AAPLU,Apple Units";'))
28
+ .mockResolvedValueOnce(textResponse('var hq_str_gb_AAPL="Apple Inc,189.98,1.23,0,1.56,0,188.50,180.00,195.00,175.00,1200000,0,3000000000000";'));
29
+ vi.stubGlobal('fetch', fetchMock);
30
+
31
+ const result = await cmd.func({ key: 'AAPL', market: 'auto' });
32
+
33
+ expect(fetchMock).toHaveBeenNthCalledWith(1, 'https://suggest3.sinajs.cn/suggest/type=11,31,41&key=AAPL', expect.any(Object));
34
+ expect(fetchMock).toHaveBeenNthCalledWith(2, 'https://hq.sinajs.cn/list=gb_AAPL', expect.any(Object));
35
+ expect(result[0]).toMatchObject({
36
+ Symbol: 'AAPL',
37
+ Name: 'Apple Inc',
38
+ Price: '189.98',
39
+ });
40
+ });
41
+
42
+ it('still matches by display name when the query targets the company name', async () => {
43
+ const cmd = getRegistry().get('sinafinance/stock');
44
+ expect(cmd?.func).toBeTypeOf('function');
45
+
46
+ const fetchMock = vi.fn()
47
+ .mockResolvedValueOnce(textResponse('var suggestvalue="x,41,,AAPL,苹果;x,41,,AAPLU,Apple Units";'))
48
+ .mockResolvedValueOnce(textResponse('var hq_str_gb_AAPL="苹果公司,189.98,1.23,0,1.56,0,188.50,180.00,195.00,175.00,1200000,0,3000000000000";'));
49
+ vi.stubGlobal('fetch', fetchMock);
50
+
51
+ const result = await cmd.func({ key: '苹果', market: 'auto' });
52
+
53
+ expect(fetchMock).toHaveBeenNthCalledWith(2, 'https://hq.sinajs.cn/list=gb_AAPL', expect.any(Object));
54
+ expect(result[0]).toMatchObject({
55
+ Symbol: 'AAPL',
56
+ Name: '苹果公司',
57
+ });
58
+ });
59
+ });
@@ -198,7 +198,7 @@ cli({
198
198
  browser: false,
199
199
  args: [{ name: 'query', type: 'str', default: '', positional: true, help: 'Track or artist to play (optional)' }],
200
200
  columns: ['track', 'artist', 'status'],
201
- func: async (_page, kwargs) => {
201
+ func: async (kwargs) => {
202
202
  if (kwargs.query) {
203
203
  const { uri, name, artist } = await findTrackUri(kwargs.query);
204
204
  await api('PUT', '/me/player/play', { uris: [uri] });
@@ -246,7 +246,7 @@ cli({
246
246
  browser: false,
247
247
  args: [{ name: 'level', type: 'int', default: 50, positional: true, required: true, help: 'Volume 0–100' }],
248
248
  columns: ['volume'],
249
- func: async (_page, kwargs) => {
249
+ func: async (kwargs) => {
250
250
  const level = Math.round(kwargs.level);
251
251
  if (level < 0 || level > 100)
252
252
  throw new CliError('INVALID_ARGS', 'Volume must be between 0 and 100');
@@ -265,7 +265,7 @@ cli({
265
265
  { name: 'limit', type: 'int', default: 10, help: 'Number of results (default: 10)' },
266
266
  ],
267
267
  columns: ['track', 'artist', 'album', 'uri'],
268
- func: async (_page, kwargs) => {
268
+ func: async (kwargs) => {
269
269
  const limit = Math.min(50, Math.max(1, Math.round(kwargs.limit)));
270
270
  const data = await api('GET', `/search?q=${encodeURIComponent(kwargs.query)}&type=track&limit=${limit}`);
271
271
  const results = mapSpotifyTrackResults(data);
@@ -282,7 +282,7 @@ cli({
282
282
  browser: false,
283
283
  args: [{ name: 'query', type: 'str', required: true, positional: true, help: 'Track to add to queue' }],
284
284
  columns: ['track', 'artist', 'status'],
285
- func: async (_page, kwargs) => {
285
+ func: async (kwargs) => {
286
286
  const { uri, name, artist } = await findTrackUri(kwargs.query);
287
287
  await api('POST', `/me/player/queue?uri=${encodeURIComponent(uri)}`);
288
288
  return [{ track: name, artist, status: 'added to queue' }];
@@ -296,7 +296,7 @@ cli({
296
296
  browser: false,
297
297
  args: [{ name: 'state', type: 'str', default: 'on', positional: true, choices: ['on', 'off'], help: 'on or off' }],
298
298
  columns: ['shuffle'],
299
- func: async (_page, kwargs) => {
299
+ func: async (kwargs) => {
300
300
  await api('PUT', `/me/player/shuffle?state=${kwargs.state === 'on'}`);
301
301
  return [{ shuffle: kwargs.state }];
302
302
  },
@@ -309,7 +309,7 @@ cli({
309
309
  browser: false,
310
310
  args: [{ name: 'mode', type: 'str', default: 'context', positional: true, choices: ['off', 'track', 'context'], help: 'off / track / context' }],
311
311
  columns: ['repeat'],
312
- func: async (_page, kwargs) => {
312
+ func: async (kwargs) => {
313
313
  await api('PUT', `/me/player/repeat?state=${kwargs.mode}`);
314
314
  return [{ repeat: kwargs.mode }];
315
315
  },
@@ -69,7 +69,7 @@ cli({
69
69
  { name: 'limit', type: 'int', default: 20, help: '返回结果数量' },
70
70
  ],
71
71
  columns: ['rank', 'title', 'author', 'date', 'description', 'url'],
72
- func: async (_page, args) => {
72
+ func: async (args) => {
73
73
  const limit = Math.max(1, Math.min(Number(args.limit) || 20, 50));
74
74
  return args.type === 'publications'
75
75
  ? searchPublications(args.keyword, limit)