@jackwener/opencli 1.7.8 → 1.7.10

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 (281) hide show
  1. package/README.md +49 -14
  2. package/README.zh-CN.md +30 -10
  3. package/cli-manifest.json +646 -30
  4. package/clis/36kr/news.js +1 -1
  5. package/clis/apple-podcasts/commands.test.js +4 -4
  6. package/clis/apple-podcasts/episodes.js +1 -1
  7. package/clis/apple-podcasts/search.js +1 -1
  8. package/clis/apple-podcasts/top.js +1 -1
  9. package/clis/arxiv/paper.js +1 -1
  10. package/clis/arxiv/search.js +1 -1
  11. package/clis/band/mentions.js +3 -3
  12. package/clis/bbc/news.js +1 -1
  13. package/clis/bilibili/subtitle.js +2 -2
  14. package/clis/bloomberg/businessweek.js +1 -1
  15. package/clis/bloomberg/economics.js +1 -1
  16. package/clis/bloomberg/industries.js +1 -1
  17. package/clis/bloomberg/main.js +1 -1
  18. package/clis/bloomberg/markets.js +1 -1
  19. package/clis/bloomberg/opinions.js +1 -1
  20. package/clis/bloomberg/politics.js +1 -1
  21. package/clis/bloomberg/tech.js +1 -1
  22. package/clis/boss/search.js +49 -8
  23. package/clis/boss/search.test.js +78 -0
  24. package/clis/boss/send.js +3 -3
  25. package/clis/chatgpt/image.js +37 -8
  26. package/clis/chatgpt/image.test.js +92 -0
  27. package/clis/chatgpt/utils.js +39 -6
  28. package/clis/chatgpt/utils.test.js +63 -0
  29. package/clis/chatgpt-app/ask.js +1 -1
  30. package/clis/chatgpt-app/ax.js +4 -2
  31. package/clis/chatgpt-app/ax.test.js +12 -0
  32. package/clis/chatgpt-app/model.js +1 -1
  33. package/clis/chatgpt-app/new.js +1 -1
  34. package/clis/chatgpt-app/read.js +1 -1
  35. package/clis/chatgpt-app/send.js +1 -1
  36. package/clis/chatgpt-app/status.js +1 -1
  37. package/clis/chatwise/ask.js +2 -2
  38. package/clis/chatwise/model.js +2 -2
  39. package/clis/chatwise/send.js +2 -2
  40. package/clis/claude/ask.js +128 -0
  41. package/clis/claude/ask.test.js +338 -0
  42. package/clis/claude/commands.test.js +118 -0
  43. package/clis/claude/detail.js +29 -0
  44. package/clis/claude/history.js +31 -0
  45. package/clis/claude/new.js +21 -0
  46. package/clis/claude/read.js +24 -0
  47. package/clis/claude/send.js +41 -0
  48. package/clis/claude/status.js +24 -0
  49. package/clis/claude/utils.js +440 -0
  50. package/clis/claude/utils.test.js +148 -0
  51. package/clis/codex/ask.js +2 -2
  52. package/clis/codex/send.js +2 -2
  53. package/clis/ctrip/search.js +1 -1
  54. package/clis/ctrip/search.test.js +4 -4
  55. package/clis/cursor/ask.js +2 -2
  56. package/clis/cursor/composer.js +2 -2
  57. package/clis/cursor/send.js +2 -2
  58. package/clis/deepseek/ask.js +17 -4
  59. package/clis/deepseek/ask.test.js +46 -0
  60. package/clis/deepseek/utils.js +55 -16
  61. package/clis/deepseek/utils.test.js +124 -5
  62. package/clis/doubao/utils.js +53 -11
  63. package/clis/doubao/utils.test.js +22 -2
  64. package/clis/eastmoney/announcement.js +1 -1
  65. package/clis/eastmoney/convertible.js +1 -1
  66. package/clis/eastmoney/etf.js +1 -1
  67. package/clis/eastmoney/holders.js +1 -1
  68. package/clis/eastmoney/index-board.js +1 -1
  69. package/clis/eastmoney/kline.js +1 -1
  70. package/clis/eastmoney/kuaixun.js +1 -1
  71. package/clis/eastmoney/longhu.js +1 -1
  72. package/clis/eastmoney/money-flow.js +1 -1
  73. package/clis/eastmoney/northbound.js +1 -1
  74. package/clis/eastmoney/quote.js +1 -1
  75. package/clis/eastmoney/rank.js +1 -1
  76. package/clis/eastmoney/sectors.js +1 -1
  77. package/clis/facebook/marketplace-inbox.js +83 -0
  78. package/clis/facebook/marketplace-listings.js +83 -0
  79. package/clis/facebook/marketplace.test.js +91 -0
  80. package/clis/google/news.js +1 -1
  81. package/clis/google/suggest.js +1 -1
  82. package/clis/google/trends.js +1 -1
  83. package/clis/google-scholar/cite.js +74 -0
  84. package/clis/google-scholar/cite.test.js +47 -0
  85. package/clis/google-scholar/profile.js +92 -0
  86. package/clis/google-scholar/profile.test.js +49 -0
  87. package/clis/google-scholar/search.js +1 -1
  88. package/clis/google-scholar/search.test.js +15 -0
  89. package/clis/hf/top.js +1 -1
  90. package/clis/instagram/collection-create.js +57 -0
  91. package/clis/instagram/saved.js +21 -7
  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/producthunt/posts.js +1 -1
  115. package/clis/producthunt/today.js +1 -1
  116. package/clis/sinablog/search.js +1 -1
  117. package/clis/sinafinance/news.js +1 -1
  118. package/clis/sinafinance/stock.js +1 -1
  119. package/clis/sinafinance/stock.test.js +2 -2
  120. package/clis/spotify/spotify.js +6 -6
  121. package/clis/substack/search.js +1 -1
  122. package/clis/toutiao/articles.js +5 -6
  123. package/clis/toutiao/articles.test.js +22 -15
  124. package/clis/twitter/followers.js +2 -2
  125. package/clis/twitter/following.js +224 -73
  126. package/clis/twitter/following.test.js +277 -0
  127. package/clis/twitter/post.js +184 -47
  128. package/clis/twitter/post.test.js +114 -34
  129. package/clis/uiverse/_shared.js +63 -4
  130. package/clis/uiverse/_shared.test.js +7 -0
  131. package/clis/uiverse/code.js +1 -0
  132. package/clis/uiverse/navigation.test.js +12 -0
  133. package/clis/uiverse/preview.js +1 -0
  134. package/clis/web/read.js +319 -81
  135. package/clis/web/read.test.js +221 -5
  136. package/clis/weibo/favorites.js +169 -0
  137. package/clis/weibo/favorites.test.js +114 -0
  138. package/clis/weibo/publish.js +282 -0
  139. package/clis/weibo/publish.test.js +183 -0
  140. package/clis/weread/ranking.js +1 -1
  141. package/clis/weread/search-regression.test.js +8 -8
  142. package/clis/weread/search.js +1 -1
  143. package/clis/wikipedia/random.js +1 -1
  144. package/clis/wikipedia/search.js +1 -1
  145. package/clis/wikipedia/summary.js +1 -1
  146. package/clis/wikipedia/trending.js +1 -1
  147. package/clis/xianyu/chat.js +3 -3
  148. package/clis/xianyu/item.js +2 -2
  149. package/clis/xianyu/item.test.js +3 -3
  150. package/clis/xiaohongshu/search.js +17 -2
  151. package/clis/xiaohongshu/search.test.js +37 -1
  152. package/clis/xiaoyuzhou/download.js +1 -1
  153. package/clis/xiaoyuzhou/download.test.js +3 -3
  154. package/clis/xiaoyuzhou/episode.js +1 -1
  155. package/clis/xiaoyuzhou/podcast-episodes.js +1 -1
  156. package/clis/xiaoyuzhou/podcast-episodes.test.js +2 -2
  157. package/clis/xiaoyuzhou/podcast.js +1 -1
  158. package/clis/xiaoyuzhou/transcript.js +1 -1
  159. package/clis/xiaoyuzhou/transcript.test.js +5 -5
  160. package/clis/yollomi/models.js +1 -1
  161. package/clis/youtube/channel.js +24 -1
  162. package/clis/youtube/channel.test.js +59 -0
  163. package/clis/zhihu/answer.js +21 -162
  164. package/clis/zhihu/answer.test.js +26 -53
  165. package/clis/zhihu/collection.js +197 -0
  166. package/clis/zhihu/collection.test.js +290 -0
  167. package/clis/zhihu/collections.js +127 -0
  168. package/clis/zhihu/collections.test.js +182 -0
  169. package/clis/zhihu/comment.js +24 -305
  170. package/clis/zhihu/comment.test.js +31 -35
  171. package/clis/zhihu/favorite.js +44 -182
  172. package/clis/zhihu/favorite.test.js +30 -167
  173. package/clis/zhihu/follow.js +25 -56
  174. package/clis/zhihu/follow.test.js +20 -23
  175. package/clis/zhihu/like.js +22 -67
  176. package/clis/zhihu/like.test.js +19 -42
  177. package/clis/zhihu/search.js +3 -2
  178. package/clis/zhihu/write-shared.js +8 -1
  179. package/clis/zhihu/write-shared.test.js +1 -0
  180. package/clis/zlibrary/commands.test.js +75 -0
  181. package/clis/zlibrary/info.js +47 -0
  182. package/clis/zlibrary/search.js +46 -0
  183. package/clis/zlibrary/utils.js +136 -0
  184. package/dist/src/adapter-source.d.ts +11 -0
  185. package/dist/src/adapter-source.js +24 -0
  186. package/dist/src/adapter-source.test.js +29 -0
  187. package/dist/src/browser/base-page.d.ts +3 -1
  188. package/dist/src/browser/base-page.js +76 -1
  189. package/dist/src/browser/base-page.test.d.ts +1 -0
  190. package/dist/src/browser/base-page.test.js +74 -0
  191. package/dist/src/browser/bridge.d.ts +1 -2
  192. package/dist/src/browser/bridge.js +40 -41
  193. package/dist/src/browser/cdp.d.ts +1 -0
  194. package/dist/src/browser/cdp.js +3 -3
  195. package/dist/src/browser/daemon-client.d.ts +38 -4
  196. package/dist/src/browser/daemon-client.js +24 -7
  197. package/dist/src/browser/daemon-client.test.js +49 -0
  198. package/dist/src/browser/daemon-lifecycle.d.ts +23 -0
  199. package/dist/src/browser/daemon-lifecycle.js +67 -0
  200. package/dist/src/browser/daemon-version.d.ts +4 -0
  201. package/dist/src/browser/daemon-version.js +12 -0
  202. package/dist/src/browser/errors.js +3 -0
  203. package/dist/src/browser/errors.test.js +3 -0
  204. package/dist/src/browser/network-cache.d.ts +1 -0
  205. package/dist/src/browser/page.d.ts +3 -1
  206. package/dist/src/browser/page.js +10 -2
  207. package/dist/src/browser/profile.d.ts +14 -0
  208. package/dist/src/browser/profile.js +85 -0
  209. package/dist/src/build-manifest.d.ts +2 -0
  210. package/dist/src/build-manifest.js +13 -3
  211. package/dist/src/build-manifest.test.js +20 -2
  212. package/dist/src/cli.d.ts +6 -0
  213. package/dist/src/cli.js +477 -35
  214. package/dist/src/cli.test.js +303 -2
  215. package/dist/src/commanderAdapter.js +17 -9
  216. package/dist/src/commanderAdapter.test.js +67 -2
  217. package/dist/src/commands/daemon.d.ts +2 -0
  218. package/dist/src/commands/daemon.js +42 -1
  219. package/dist/src/commands/daemon.test.js +103 -2
  220. package/dist/src/completion-shared.js +1 -2
  221. package/dist/src/completion.test.js +3 -2
  222. package/dist/src/daemon.js +125 -41
  223. package/dist/src/doctor.d.ts +5 -6
  224. package/dist/src/doctor.js +77 -19
  225. package/dist/src/doctor.test.js +117 -0
  226. package/dist/src/engine.test.js +6 -5
  227. package/dist/src/errors.d.ts +14 -8
  228. package/dist/src/errors.js +36 -30
  229. package/dist/src/errors.test.js +5 -5
  230. package/dist/src/execution.d.ts +4 -0
  231. package/dist/src/execution.js +173 -25
  232. package/dist/src/execution.test.js +171 -1
  233. package/dist/src/main.js +10 -0
  234. package/dist/src/observation/artifact.d.ts +16 -0
  235. package/dist/src/observation/artifact.js +260 -0
  236. package/dist/src/observation/artifact.test.d.ts +1 -0
  237. package/dist/src/observation/artifact.test.js +121 -0
  238. package/dist/src/observation/events.d.ts +89 -0
  239. package/dist/src/observation/events.js +1 -0
  240. package/dist/src/observation/index.d.ts +7 -0
  241. package/dist/src/observation/index.js +7 -0
  242. package/dist/src/observation/manager.d.ts +9 -0
  243. package/dist/src/observation/manager.js +27 -0
  244. package/dist/src/observation/manager.test.d.ts +1 -0
  245. package/dist/src/observation/manager.test.js +13 -0
  246. package/dist/src/observation/redaction.d.ts +11 -0
  247. package/dist/src/observation/redaction.js +81 -0
  248. package/dist/src/observation/redaction.test.d.ts +1 -0
  249. package/dist/src/observation/redaction.test.js +32 -0
  250. package/dist/src/observation/retention.d.ts +32 -0
  251. package/dist/src/observation/retention.js +160 -0
  252. package/dist/src/observation/retention.test.d.ts +1 -0
  253. package/dist/src/observation/retention.test.js +118 -0
  254. package/dist/src/observation/ring-buffer.d.ts +22 -0
  255. package/dist/src/observation/ring-buffer.js +45 -0
  256. package/dist/src/observation/ring-buffer.test.d.ts +1 -0
  257. package/dist/src/observation/ring-buffer.test.js +22 -0
  258. package/dist/src/observation/session.d.ts +25 -0
  259. package/dist/src/observation/session.js +50 -0
  260. package/dist/src/pipeline/executor.test.js +1 -0
  261. package/dist/src/pipeline/steps/download.test.js +1 -0
  262. package/dist/src/pipeline/steps/fetch.js +1 -21
  263. package/dist/src/pipeline/steps/fetch.test.js +6 -12
  264. package/dist/src/plugin-scaffold.js +1 -1
  265. package/dist/src/plugin-scaffold.test.js +1 -1
  266. package/dist/src/registry.d.ts +40 -9
  267. package/dist/src/registry.js +3 -1
  268. package/dist/src/runtime-detect.d.ts +10 -0
  269. package/dist/src/runtime-detect.js +19 -0
  270. package/dist/src/runtime-detect.test.js +12 -1
  271. package/dist/src/runtime.d.ts +2 -0
  272. package/dist/src/runtime.js +1 -0
  273. package/dist/src/types.d.ts +22 -0
  274. package/dist/src/update-check.d.ts +31 -1
  275. package/dist/src/update-check.js +62 -16
  276. package/dist/src/update-check.test.js +86 -1
  277. package/package.json +1 -1
  278. package/dist/src/diagnostic.d.ts +0 -63
  279. package/dist/src/diagnostic.js +0 -292
  280. package/dist/src/diagnostic.test.js +0 -302
  281. /package/dist/src/{diagnostic.test.d.ts → adapter-source.test.d.ts} +0 -0
@@ -47,6 +47,11 @@ export function noteIdToDate(url) {
47
47
  // Offset by UTC+8 (China Standard Time) so the date matches what XHS users see
48
48
  return new Date((ts + 8 * 3600) * 1000).toISOString().slice(0, 10);
49
49
  }
50
+ export function stripXhsAuthorDateSuffix(value) {
51
+ const text = (value || '').replace(/\s+/g, ' ').trim();
52
+ const stripped = text.replace(/\s*(?:\d{1,2}天前|\d+小时前|\d+分钟前|\d+秒前|刚刚|昨天|前天|\d+周前|\d+个月前|\d{1,2}-\d{1,2}|\d{4}-\d{1,2}-\d{1,2})$/u, '').trim();
53
+ return stripped || text;
54
+ }
50
55
  cli({
51
56
  site: 'xiaohongshu',
52
57
  name: 'search',
@@ -81,6 +86,7 @@ cli({
81
86
  };
82
87
 
83
88
  const cleanText = (value) => (value || '').replace(/\\s+/g, ' ').trim();
89
+ const stripXhsAuthorDateSuffix = ${stripXhsAuthorDateSuffix.toString()};
84
90
 
85
91
  const results = [];
86
92
  const seen = new Set();
@@ -90,7 +96,13 @@ cli({
90
96
  if (el.classList.contains('query-note-item')) return;
91
97
 
92
98
  const titleEl = el.querySelector('.title, .note-title, a.title, .footer .title span');
93
- const nameEl = el.querySelector('a.author .name, .name, .author-name, .nick-name, a.author');
99
+ const nameEl = el.querySelector('a.author .name, .author-name, .nick-name, .name');
100
+ const authorWrapEl = el.querySelector('a.author');
101
+ let author = cleanText(nameEl?.textContent || '');
102
+ if (!author && authorWrapEl) {
103
+ const nameChild = authorWrapEl.querySelector('.name');
104
+ author = nameChild ? cleanText(nameChild.textContent || '') : stripXhsAuthorDateSuffix(authorWrapEl.textContent || '');
105
+ }
94
106
  const likesEl = el.querySelector('.count, .like-count, .like-wrapper .count');
95
107
  // Prefer search_result link (preserves xsec_token) over generic /explore/ link
96
108
  const detailLinkEl =
@@ -109,7 +121,7 @@ cli({
109
121
 
110
122
  results.push({
111
123
  title: cleanText(titleEl?.textContent || ''),
112
- author: cleanText(nameEl?.textContent || ''),
124
+ author,
113
125
  likes: cleanText(likesEl?.textContent || '0'),
114
126
  url,
115
127
  author_url: normalizeUrl(authorLinkEl?.getAttribute('href') || ''),
@@ -130,3 +142,6 @@ cli({
130
142
  }));
131
143
  },
132
144
  });
145
+ export const __test__ = {
146
+ stripXhsAuthorDateSuffix,
147
+ };
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
2
  import { getRegistry } from '@jackwener/opencli/registry';
3
- import { noteIdToDate } from './search.js';
3
+ import { JSDOM } from 'jsdom';
4
+ import { __test__, noteIdToDate } from './search.js';
4
5
  function createPageMock(evaluateResults) {
5
6
  const evaluate = vi.fn();
6
7
  for (const result of evaluateResults) {
@@ -127,6 +128,41 @@ describe('xiaohongshu search', () => {
127
128
  // Two evaluate calls: wait + extraction
128
129
  expect(page.evaluate).toHaveBeenCalledTimes(2);
129
130
  });
131
+ it('separates fallback author text from appended relative date', async () => {
132
+ const cmd = getRegistry().get('xiaohongshu/search');
133
+ expect(cmd?.func).toBeTypeOf('function');
134
+ const dom = new JSDOM(`
135
+ <section class="note-item">
136
+ <a class="cover mask" href="/search_result/68e90be80000000004022e66?xsec_token=test-token"></a>
137
+ <div class="title">数字作者测试</div>
138
+ <a class="author" href="/user/profile/author123">
139
+ <span>数字3天前端</span><span>3天前</span>
140
+ </a>
141
+ <span class="count">8</span>
142
+ </section>
143
+ `, { url: 'https://www.xiaohongshu.com/search_result?keyword=test' });
144
+ const page = createPageMock([]);
145
+ page.evaluate.mockImplementationOnce(async () => 'content');
146
+ page.evaluate.mockImplementationOnce(async (script) => Function('document', `return (${script})`)(dom.window.document));
147
+
148
+ const result = await cmd.func(page, { query: '测试', limit: 1 });
149
+
150
+ expect(result[0]).toMatchObject({
151
+ title: '数字作者测试',
152
+ author: '数字3天前端',
153
+ likes: '8',
154
+ author_url: 'https://www.xiaohongshu.com/user/profile/author123',
155
+ });
156
+ });
157
+ });
158
+ describe('stripXhsAuthorDateSuffix', () => {
159
+ it('only strips trailing date suffixes and preserves date-like author text', () => {
160
+ expect(__test__.stripXhsAuthorDateSuffix('作者名 3天前')).toBe('作者名');
161
+ expect(__test__.stripXhsAuthorDateSuffix('作者名2026-04-01')).toBe('作者名');
162
+ expect(__test__.stripXhsAuthorDateSuffix('3天前端工程师')).toBe('3天前端工程师');
163
+ expect(__test__.stripXhsAuthorDateSuffix('刚刚好')).toBe('刚刚好');
164
+ expect(__test__.stripXhsAuthorDateSuffix('刚刚')).toBe('刚刚');
165
+ });
130
166
  });
131
167
  describe('noteIdToDate (ObjectID timestamp parsing)', () => {
132
168
  it('parses a known note ID to the correct China-timezone date', () => {
@@ -18,7 +18,7 @@ cli({
18
18
  { name: 'output', default: './xiaoyuzhou-downloads', help: 'Output directory' },
19
19
  ],
20
20
  columns: ['title', 'podcast', 'status', 'size', 'file'],
21
- func: async (_page, args) => {
21
+ func: async (args) => {
22
22
  const credentials = loadXiaoyuzhouCredentials();
23
23
  const response = await requestXiaoyuzhouJson('/v1/episode/get', {
24
24
  query: { eid: args.id },
@@ -68,7 +68,7 @@ describe('xiaoyuzhou download', () => {
68
68
  });
69
69
  mockHttpDownload.mockResolvedValue({ success: true, size: 1234 });
70
70
 
71
- const result = await cmd.func(null, {
71
+ const result = await cmd.func({
72
72
  id: 'ep123',
73
73
  output: '/tmp/xiaoyuzhou-test',
74
74
  });
@@ -106,7 +106,7 @@ describe('xiaoyuzhou download', () => {
106
106
  });
107
107
  mockHttpDownload.mockResolvedValue({ success: true, size: 2048 });
108
108
 
109
- const result = await cmd.func(null, {
109
+ const result = await cmd.func({
110
110
  id: 'ep456',
111
111
  output: '/tmp/xiaoyuzhou-test',
112
112
  });
@@ -125,7 +125,7 @@ describe('xiaoyuzhou download', () => {
125
125
  },
126
126
  });
127
127
 
128
- await expect(cmd.func(null, { id: 'ep789', output: '/tmp/xiaoyuzhou-test' })).rejects.toMatchObject({
128
+ await expect(cmd.func({ id: 'ep789', output: '/tmp/xiaoyuzhou-test' })).rejects.toMatchObject({
129
129
  code: 'PARSE_ERROR',
130
130
  message: 'Audio URL not found in episode payload',
131
131
  hint: 'Episode payload does not expose media.source.url',
@@ -11,7 +11,7 @@ cli({
11
11
  browser: false,
12
12
  args: [{ name: 'id', positional: true, required: true, help: 'Episode ID (eid from podcast-episodes output)' }],
13
13
  columns: ['title', 'podcast', 'duration', 'plays', 'comments', 'likes', 'date'],
14
- func: async (_page, args) => {
14
+ func: async (args) => {
15
15
  const credentials = loadXiaoyuzhouCredentials();
16
16
  const response = await requestXiaoyuzhouJson('/v1/episode/get', {
17
17
  query: { eid: args.id },
@@ -14,7 +14,7 @@ cli({
14
14
  { name: 'limit', type: 'int', default: 20, help: 'Max episodes to show' },
15
15
  ],
16
16
  columns: ['eid', 'title', 'duration', 'plays', 'date'],
17
- func: async (_page, args) => {
17
+ func: async (args) => {
18
18
  const requestedLimit = Number(args.limit);
19
19
  if (!Number.isInteger(requestedLimit) || requestedLimit < 1) {
20
20
  throw new CliError('INVALID_ARGUMENT', 'limit must be a positive integer', 'Example: --limit 5');
@@ -44,7 +44,7 @@ describe('xiaoyuzhou podcast-episodes', () => {
44
44
  ],
45
45
  });
46
46
 
47
- const result = await cmd.func(null, {
47
+ const result = await cmd.func({
48
48
  id: 'podcast-1',
49
49
  limit: 3,
50
50
  });
@@ -66,7 +66,7 @@ describe('xiaoyuzhou podcast-episodes', () => {
66
66
  });
67
67
 
68
68
  it('rejects non-positive limits before hitting the API', async () => {
69
- await expect(cmd.func(null, {
69
+ await expect(cmd.func({
70
70
  id: 'podcast-1',
71
71
  limit: 0,
72
72
  })).rejects.toMatchObject({
@@ -11,7 +11,7 @@ cli({
11
11
  browser: false,
12
12
  args: [{ name: 'id', positional: true, required: true, help: 'Podcast ID (from xiaoyuzhoufm.com URL)' }],
13
13
  columns: ['title', 'author', 'description', 'subscribers', 'episodes', 'updated'],
14
- func: async (_page, args) => {
14
+ func: async (args) => {
15
15
  const credentials = loadXiaoyuzhouCredentials();
16
16
  const response = await requestXiaoyuzhouJson('/v1/podcast/get', {
17
17
  query: { pid: args.id },
@@ -18,7 +18,7 @@ cli({
18
18
  { name: 'text', type: 'boolean', default: true, help: 'Save extracted transcript text file' },
19
19
  ],
20
20
  columns: ['title', 'podcast', 'status', 'segments', 'json_file', 'text_file'],
21
- func: async (_page, kwargs) => {
21
+ func: async (kwargs) => {
22
22
  if (kwargs.json === false && kwargs.text === false) {
23
23
  throw new ArgumentError('At least one of --json or --text must be enabled', 'Example: opencli xiaoyuzhou transcript 69dd0c98e2c8be31551f6a33 --text true');
24
24
  }
@@ -67,7 +67,7 @@ describe('xiaoyuzhou transcript', () => {
67
67
  mockFetchTranscriptBody.mockResolvedValue(JSON.stringify({
68
68
  segments: [{ text: 'hello' }, { text: 'world' }],
69
69
  }));
70
- const result = await cmd.func(null, {
70
+ const result = await cmd.func({
71
71
  id: 'ep123',
72
72
  output: '/tmp/xiaoyuzhou-transcripts',
73
73
  json: true,
@@ -112,7 +112,7 @@ describe('xiaoyuzhou transcript', () => {
112
112
  },
113
113
  });
114
114
  mockFetchTranscriptBody.mockResolvedValue(JSON.stringify({ text: 'hello' }));
115
- await cmd.func(null, {
115
+ await cmd.func({
116
116
  id: 'ep456',
117
117
  output: '/tmp/xiaoyuzhou-transcripts',
118
118
  json: false,
@@ -137,7 +137,7 @@ describe('xiaoyuzhou transcript', () => {
137
137
  credentials: { access_token: 'access-1', refresh_token: 'refresh-1' },
138
138
  data: {},
139
139
  });
140
- await expect(cmd.func(null, {
140
+ await expect(cmd.func({
141
141
  id: 'ep123',
142
142
  output: '/tmp/xiaoyuzhou-transcripts',
143
143
  json: true,
@@ -168,7 +168,7 @@ describe('xiaoyuzhou transcript', () => {
168
168
  mockFetchTranscriptBody.mockResolvedValue(JSON.stringify({
169
169
  segments: [{ startAt: 0, endAt: 1 }],
170
170
  }));
171
- await expect(cmd.func(null, {
171
+ await expect(cmd.func({
172
172
  id: 'ep123',
173
173
  output: '/tmp/xiaoyuzhou-transcripts',
174
174
  json: true,
@@ -181,7 +181,7 @@ describe('xiaoyuzhou transcript', () => {
181
181
  });
182
182
 
183
183
  it('rejects disabling both json and text outputs', async () => {
184
- await expect(cmd.func(null, {
184
+ await expect(cmd.func({
185
185
  id: 'ep123',
186
186
  output: '/tmp/xiaoyuzhou-transcripts',
187
187
  json: false,
@@ -10,7 +10,7 @@ cli({
10
10
  { name: 'type', default: 'all', choices: ['all', 'image', 'video', 'tool'], help: 'Filter by model type' },
11
11
  ],
12
12
  columns: ['type', 'model', 'credits', 'description'],
13
- func: async (_page, kwargs) => {
13
+ func: async (kwargs) => {
14
14
  const filter = kwargs.type;
15
15
  const rows = [];
16
16
  if (filter === 'all' || filter === 'image') {
@@ -3,6 +3,21 @@
3
3
  */
4
4
  import { cli, Strategy } from '@jackwener/opencli/registry';
5
5
  import { CommandExecutionError } from '@jackwener/opencli/errors';
6
+
7
+ export function extractSelectedRichGridContents(browseData) {
8
+ const tabs = browseData?.contents?.twoColumnBrowseResultsRenderer?.tabs || [];
9
+ const readRichGrid = (tab) => tab?.tabRenderer?.content?.richGridRenderer?.contents;
10
+ const selectedTab = tabs.find(t => t?.tabRenderer?.selected);
11
+ const selectedContents = readRichGrid(selectedTab);
12
+ if (Array.isArray(selectedContents))
13
+ return selectedContents;
14
+ const fallbackContents = readRichGrid(tabs.find(t => {
15
+ const contents = readRichGrid(t);
16
+ return Array.isArray(contents) && contents.length > 0;
17
+ })) || readRichGrid(tabs.find(t => Array.isArray(readRichGrid(t))));
18
+ return Array.isArray(fallbackContents) ? fallbackContents : [];
19
+ }
20
+
6
21
  cli({
7
22
  site: 'youtube',
8
23
  name: 'channel',
@@ -27,6 +42,7 @@ cli({
27
42
  const apiKey = cfg.INNERTUBE_API_KEY;
28
43
  const context = cfg.INNERTUBE_CONTEXT;
29
44
  if (!apiKey || !context) return {error: 'YouTube config not found'};
45
+ const extractSelectedRichGridContents = ${extractSelectedRichGridContents.toString()};
30
46
 
31
47
  // Resolve handle to browseId if needed
32
48
  let browseId = channelId;
@@ -133,7 +149,10 @@ cli({
133
149
  });
134
150
  if (videosResp.ok) {
135
151
  const videosData = await videosResp.json();
136
- const richGrid = videosData.contents?.twoColumnBrowseResultsRenderer?.tabs?.[0]?.tabRenderer?.content?.richGridRenderer?.contents || [];
152
+ // The InnerTube response includes ALL tabs (Home/Videos/Shorts/...),
153
+ // not just the requested one. Prefer the selected tab, but keep
154
+ // older single-tab responses working when YouTube omits selected.
155
+ const richGrid = extractSelectedRichGridContents(videosData);
137
156
  for (const item of richGrid) {
138
157
  if (recentVideos.length >= limit) break;
139
158
  const v = item.richItemRenderer?.content?.videoRenderer;
@@ -183,3 +202,7 @@ cli({
183
202
  return rows;
184
203
  },
185
204
  });
205
+
206
+ export const __test__ = {
207
+ extractSelectedRichGridContents,
208
+ };
@@ -0,0 +1,59 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { __test__ } from './channel.js';
3
+
4
+ function tab(title, contents, selected = false) {
5
+ return {
6
+ tabRenderer: {
7
+ title,
8
+ selected,
9
+ content: {
10
+ richGridRenderer: {
11
+ contents,
12
+ },
13
+ },
14
+ },
15
+ };
16
+ }
17
+
18
+ function browseData(tabs) {
19
+ return {
20
+ contents: {
21
+ twoColumnBrowseResultsRenderer: {
22
+ tabs,
23
+ },
24
+ },
25
+ };
26
+ }
27
+
28
+ describe('youtube channel helpers', () => {
29
+ it('uses the selected rich-grid tab instead of the first tab', () => {
30
+ const home = [{ richItemRenderer: { content: { videoRenderer: { videoId: 'home' } } } }];
31
+ const videos = [{ richItemRenderer: { content: { videoRenderer: { videoId: 'videos' } } } }];
32
+
33
+ expect(__test__.extractSelectedRichGridContents(browseData([
34
+ tab('Home', home),
35
+ tab('Videos', videos, true),
36
+ ]))).toBe(videos);
37
+ });
38
+
39
+ it('falls back to the first non-empty rich-grid tab when no tab is selected', () => {
40
+ const videos = [{ richItemRenderer: { content: { videoRenderer: { videoId: 'only' } } } }];
41
+
42
+ expect(__test__.extractSelectedRichGridContents(browseData([
43
+ tab('Home', []),
44
+ tab('Videos', videos),
45
+ ]))).toBe(videos);
46
+ });
47
+
48
+ it('is self-contained for browser evaluate injection', () => {
49
+ const extractSelectedRichGridContents = Function(
50
+ `return ${__test__.extractSelectedRichGridContents.toString()}`
51
+ )();
52
+ const videos = [{ richItemRenderer: { content: { videoRenderer: { videoId: 'serialized' } } } }];
53
+
54
+ expect(extractSelectedRichGridContents(browseData([
55
+ tab('Home', []),
56
+ tab('Videos', videos, true),
57
+ ]))).toEqual(videos);
58
+ });
59
+ });
@@ -2,13 +2,12 @@ import { CliError, CommandExecutionError } from '@jackwener/opencli/errors';
2
2
  import { cli, Strategy } from '@jackwener/opencli/registry';
3
3
  import { assertAllowedKinds, parseTarget } from './target.js';
4
4
  import { buildResultRow, requireExecute, resolveCurrentUserIdentity, resolvePayload } from './write-shared.js';
5
- const ANSWER_AUTHOR_SCOPE_SELECTOR = '.AuthorInfo, .AnswerItem-authorInfo, .ContentItem-meta, [itemprop="author"]';
6
5
  cli({
7
6
  site: 'zhihu',
8
7
  name: 'answer',
9
8
  description: 'Answer a Zhihu question',
10
9
  domain: 'www.zhihu.com',
11
- strategy: Strategy.UI,
10
+ strategy: Strategy.COOKIE,
12
11
  browser: true,
13
12
  args: [
14
13
  { name: 'target', positional: true, required: true, help: 'Zhihu question URL or typed target' },
@@ -23,171 +22,31 @@ cli({
23
22
  requireExecute(kwargs);
24
23
  const rawTarget = String(kwargs.target);
25
24
  const target = assertAllowedKinds('answer', parseTarget(rawTarget));
26
- const questionTarget = target;
27
25
  const payload = await resolvePayload(kwargs);
28
26
  await page.goto(target.url);
27
+ await page.wait(3);
29
28
  const authorIdentity = await resolveCurrentUserIdentity(page);
30
- const entryPath = await page.evaluate(`(() => {
31
- const currentUserSlug = ${JSON.stringify(authorIdentity)};
32
- const answerAuthorScopeSelector = ${JSON.stringify(ANSWER_AUTHOR_SCOPE_SELECTOR)};
33
- const readAnswerAuthorSlug = (node) => {
34
- const authorScopes = Array.from(node.querySelectorAll(answerAuthorScopeSelector));
35
- const slugs = Array.from(new Set(authorScopes
36
- .flatMap((scope) => Array.from(scope.querySelectorAll('a[href^="/people/"]')))
37
- .map((link) => (link.getAttribute('href') || '').match(/^\\/people\\/([A-Za-z0-9_-]+)/)?.[1] || null)
38
- .filter(Boolean)));
39
- return slugs.length === 1 ? slugs[0] : null;
40
- };
41
- const restoredDraft = !!document.querySelector('[contenteditable="true"][data-draft-restored], textarea[data-draft-restored]');
42
- const composerCandidates = Array.from(document.querySelectorAll('[contenteditable="true"], textarea')).map((editor) => {
43
- const container = editor.closest('form, .AnswerForm, .DraftEditor-root, [data-za-module*="Answer"]') || editor.parentElement;
44
- const text = 'value' in editor ? editor.value || '' : (editor.textContent || '');
45
- const submitButton = Array.from((container || document).querySelectorAll('button')).find((node) => /发布|提交/.test(node.textContent || ''));
46
- const nestedComment = Boolean(container?.closest('[data-comment-id], .CommentItem'));
47
- return { editor, container, text, submitButton, nestedComment };
48
- }).filter((candidate) => candidate.container && candidate.submitButton && !candidate.nestedComment);
49
- const hasExistingAnswerByCurrentUser = Array.from(document.querySelectorAll('[data-zop-question-answer], article')).some((node) => {
50
- return readAnswerAuthorSlug(node) === currentUserSlug;
51
- });
52
- return {
53
- entryPathSafe: composerCandidates.length === 1
54
- && !String(composerCandidates[0].text || '').trim()
55
- && !restoredDraft
56
- && !hasExistingAnswerByCurrentUser,
57
- hasExistingAnswerByCurrentUser,
58
- };
59
- })()`);
60
- if (entryPath.hasExistingAnswerByCurrentUser) {
61
- throw new CliError('ACTION_NOT_AVAILABLE', 'zhihu answer only supports creating a new answer when the current user has not already answered this question');
62
- }
63
- if (!entryPath.entryPathSafe) {
64
- throw new CliError('ACTION_NOT_AVAILABLE', 'Answer editor entry path was not proven side-effect free');
65
- }
66
- const editorState = await page.evaluate(`(async () => {
67
- const composerCandidates = Array.from(document.querySelectorAll('[contenteditable="true"], textarea')).map((editor) => {
68
- const container = editor.closest('form, .AnswerForm, .DraftEditor-root, [data-za-module*="Answer"]') || editor.parentElement;
69
- const text = 'value' in editor ? editor.value || '' : (editor.textContent || '');
70
- const submitButton = Array.from((container || document).querySelectorAll('button')).find((node) => /发布|提交/.test(node.textContent || ''));
71
- const nestedComment = Boolean(container?.closest('[data-comment-id], .CommentItem'));
72
- return { editor, container, text, submitButton, nestedComment };
73
- }).filter((candidate) => candidate.container && candidate.submitButton && !candidate.nestedComment);
74
- if (composerCandidates.length !== 1) return { editorState: 'unsafe', anonymousMode: 'unknown' };
75
- const { editor, text } = composerCandidates[0];
76
- const anonymousLabeledControl =
77
- (composerCandidates[0].container && composerCandidates[0].container.querySelector('[aria-label*="匿名"], [title*="匿名"]'))
78
- || Array.from((composerCandidates[0].container || document).querySelectorAll('label, button, [role="switch"], [role="checkbox"]')).find((node) => /匿名/.test(node.textContent || ''))
79
- || null;
80
- const anonymousToggle =
81
- anonymousLabeledControl?.matches?.('input[type="checkbox"], [role="switch"], [role="checkbox"], button')
82
- ? anonymousLabeledControl
83
- : anonymousLabeledControl?.querySelector?.('input[type="checkbox"], [role="switch"], [role="checkbox"], button')
84
- || null;
85
- let anonymousMode = 'unknown';
86
- if (anonymousToggle) {
87
- const ariaChecked = anonymousToggle.getAttribute && anonymousToggle.getAttribute('aria-checked');
88
- const checked = 'checked' in anonymousToggle ? anonymousToggle.checked === true : false;
89
- if (ariaChecked === 'true' || checked) anonymousMode = 'on';
90
- else if (ariaChecked === 'false' || ('checked' in anonymousToggle && anonymousToggle.checked === false)) anonymousMode = 'off';
91
- }
92
- return {
93
- editorState: editor && !text.trim() ? 'fresh_empty' : 'unsafe',
94
- anonymousMode,
95
- };
96
- })()`);
97
- if (editorState.editorState !== 'fresh_empty') {
98
- throw new CliError('ACTION_NOT_AVAILABLE', 'Answer editor was not fresh and empty');
99
- }
100
- if (editorState.anonymousMode !== 'off') {
101
- throw new CliError('ACTION_NOT_AVAILABLE', 'Anonymous answer mode could not be proven off for zhihu answer');
102
- }
103
- const editorCheck = await page.evaluate(`(async () => {
104
- const textToInsert = ${JSON.stringify(payload)};
105
- const composerCandidates = Array.from(document.querySelectorAll('[contenteditable="true"], textarea')).map((editor) => {
106
- const container = editor.closest('form, .AnswerForm, .DraftEditor-root, [data-za-module*="Answer"]') || editor.parentElement;
107
- const submitButton = Array.from((container || document).querySelectorAll('button')).find((node) => /发布|提交/.test(node.textContent || ''));
108
- const nestedComment = Boolean(container?.closest('[data-comment-id], .CommentItem'));
109
- return { editor, container, submitButton, nestedComment };
110
- }).filter((candidate) => candidate.container && candidate.submitButton && !candidate.nestedComment);
111
- if (composerCandidates.length !== 1) return { editorContent: '', bodyMatches: false };
112
- const { editor } = composerCandidates[0];
113
- editor.focus();
114
- if ('value' in editor) {
115
- editor.value = '';
116
- editor.dispatchEvent(new Event('input', { bubbles: true }));
117
- editor.value = textToInsert;
118
- editor.dispatchEvent(new Event('input', { bubbles: true }));
119
- } else {
120
- editor.textContent = '';
121
- document.execCommand('insertText', false, textToInsert);
122
- editor.dispatchEvent(new InputEvent('input', { bubbles: true, data: textToInsert, inputType: 'insertText' }));
123
- }
124
- await new Promise((resolve) => setTimeout(resolve, 200));
125
- const content = 'value' in editor ? editor.value : (editor.textContent || '');
126
- return { editorContent: content, bodyMatches: content === textToInsert };
127
- })()`);
128
- if (editorCheck.editorContent !== payload || !editorCheck.bodyMatches) {
129
- throw new CliError('OUTCOME_UNKNOWN', 'Answer editor content did not exactly match the requested payload before publish');
130
- }
131
- const proof = await page.evaluate(`(async () => {
132
- const normalize = (value) => value.replace(/\\s+/g, ' ').trim();
133
- const answerAuthorScopeSelector = ${JSON.stringify(ANSWER_AUTHOR_SCOPE_SELECTOR)};
134
- const readAnswerAuthorSlug = (node) => {
135
- const authorScopes = Array.from(node.querySelectorAll(answerAuthorScopeSelector));
136
- const slugs = Array.from(new Set(authorScopes
137
- .flatMap((scope) => Array.from(scope.querySelectorAll('a[href^="/people/"]')))
138
- .map((link) => (link.getAttribute('href') || '').match(/^\\/people\\/([A-Za-z0-9_-]+)/)?.[1] || null)
139
- .filter(Boolean)));
140
- return slugs.length === 1 ? slugs[0] : null;
141
- };
142
- const composerCandidates = Array.from(document.querySelectorAll('[contenteditable="true"], textarea')).map((editor) => {
143
- const container = editor.closest('form, [role="dialog"], .AnswerForm, .DraftEditor-root, [data-za-module*="Answer"]') || editor.parentElement;
144
- const submitButton = Array.from((container || document).querySelectorAll('button')).find((node) => /发布|提交/.test(node.textContent || ''));
145
- const nestedComment = Boolean(container?.closest('[data-comment-id], .CommentItem'));
146
- return { editor, container, submitButton, nestedComment };
147
- }).filter((candidate) => candidate.container && candidate.submitButton && !candidate.nestedComment);
148
- if (composerCandidates.length !== 1) return { createdTarget: null, createdUrl: null, authorIdentity: null, bodyMatches: false };
149
- const submitScope = composerCandidates[0].container || document;
150
- const submit = Array.from(submitScope.querySelectorAll('button')).find((node) => /发布|提交/.test(node.textContent || ''));
151
- submit && submit.click();
152
- await new Promise((resolve) => setTimeout(resolve, 1500));
153
- const href = location.href;
154
- const match = href.match(/question\\/(\\d+)\\/answer\\/(\\d+)/);
155
- const targetHref = match ? '/question/' + match[1] + '/answer/' + match[2] : null;
156
- const answerContainer = targetHref
157
- ? Array.from(document.querySelectorAll('[data-zop-question-answer], article')).find((node) => {
158
- const dataAnswerId = node.getAttribute('data-answerid') || node.getAttribute('data-zop-question-answer') || '';
159
- if (dataAnswerId && dataAnswerId.includes(match[2])) return true;
160
- return Array.from(node.querySelectorAll('a[href*="/answer/"]')).some((link) => {
161
- const hrefValue = link.getAttribute('href') || '';
162
- return hrefValue.includes(targetHref);
29
+ const apiResult = await page.evaluate(`(async () => {
30
+ var questionId = ${JSON.stringify(target.id)};
31
+ var content = ${JSON.stringify(payload)};
32
+ var url = 'https://www.zhihu.com/api/v4/questions/' + questionId + '/answers';
33
+ var resp = await fetch(url, {
34
+ method: 'POST',
35
+ credentials: 'include',
36
+ headers: { 'Content-Type': 'application/json' },
37
+ body: JSON.stringify({ content: content, reshipment_settings: 'disallowed' }),
163
38
  });
164
- })
165
- : null;
166
- const authorSlug = answerContainer ? readAnswerAuthorSlug(answerContainer) : null;
167
- const bodyNode =
168
- answerContainer?.querySelector('[itemprop="text"]')
169
- || answerContainer?.querySelector('.RichContent-inner')
170
- || answerContainer?.querySelector('.RichText')
171
- || answerContainer;
172
- const bodyText = normalize(bodyNode?.textContent || '');
173
- return match
174
- ? {
175
- createdTarget: 'answer:' + match[1] + ':' + match[2],
176
- createdUrl: href,
177
- authorIdentity: authorSlug,
178
- bodyMatches: bodyText === normalize(${JSON.stringify(payload)}),
179
- }
180
- : { createdTarget: null, createdUrl: null, authorIdentity: authorSlug, bodyMatches: false };
181
- })()`);
182
- if (proof.authorIdentity !== authorIdentity) {
183
- throw new CliError('OUTCOME_UNKNOWN', 'Answer was created but authorship could not be proven for the frozen current user');
184
- }
185
- if (!proof.createdTarget || !proof.bodyMatches || proof.createdTarget.split(':')[1] !== questionTarget.id) {
186
- throw new CliError('OUTCOME_UNKNOWN', 'Created answer proof did not match the requested question or payload');
39
+ var data = await resp.json();
40
+ if (!resp.ok) return { ok: false, status: resp.status, message: data.error ? data.error.message : 'unknown error' };
41
+ if (!data || !data.id) return { ok: false, status: resp.status, message: 'Answer API response did not include a created answer id' };
42
+ return { ok: true, id: String(data.id), url: data.url || ('https://www.zhihu.com/question/' + questionId + '/answer/' + data.id) };
43
+ })()`);
44
+ if (!apiResult?.ok) {
45
+ throw new CliError('COMMAND_EXEC', apiResult?.message || 'Failed to create answer');
187
46
  }
188
- return buildResultRow(`Answered question ${questionTarget.id}`, target.kind, rawTarget, 'created', {
189
- created_target: proof.createdTarget,
190
- created_url: proof.createdUrl,
47
+ return buildResultRow(`Answered question ${target.id}`, target.kind, rawTarget, 'created', {
48
+ created_target: 'answer:' + target.id + ':' + apiResult.id,
49
+ created_url: apiResult.url,
191
50
  author_identity: authorIdentity,
192
51
  });
193
52
  },