@jackwener/opencli 1.7.21 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (238) hide show
  1. package/README.md +31 -148
  2. package/README.zh-CN.md +38 -211
  3. package/cli-manifest.json +6423 -4260
  4. package/clis/12306/me.js +73 -0
  5. package/clis/12306/orders.js +96 -0
  6. package/clis/12306/passengers.js +90 -0
  7. package/clis/12306/price.js +166 -0
  8. package/clis/12306/stations.js +66 -0
  9. package/clis/12306/train.js +91 -0
  10. package/clis/12306/trains.js +119 -0
  11. package/clis/12306/utils.js +272 -0
  12. package/clis/12306/utils.test.js +331 -0
  13. package/clis/36kr/article.js +6 -3
  14. package/clis/36kr/article.test.js +46 -0
  15. package/clis/apple-podcasts/commands.test.js +20 -0
  16. package/clis/apple-podcasts/search.js +2 -2
  17. package/clis/barchart/greeks.js +144 -56
  18. package/clis/barchart/greeks.test.js +138 -0
  19. package/clis/bilibili/summary.js +167 -0
  20. package/clis/bilibili/summary.test.js +210 -0
  21. package/clis/booking/booking.test.js +356 -0
  22. package/clis/booking/search.js +351 -0
  23. package/clis/boss/utils.js +17 -1
  24. package/clis/boss/utils.test.js +34 -0
  25. package/clis/chatgpt/envelope.test.js +108 -0
  26. package/clis/chatgpt/image.js +2 -2
  27. package/clis/chatgpt/image.test.js +6 -0
  28. package/clis/chatgpt/utils.js +148 -41
  29. package/clis/chatgpt/utils.test.js +92 -2
  30. package/clis/douyin/_shared/browser-fetch.js +44 -20
  31. package/clis/douyin/_shared/browser-fetch.test.js +22 -1
  32. package/clis/douyin/_shared/evaluate-result.js +16 -0
  33. package/clis/douyin/_shared/tos-upload.js +105 -69
  34. package/clis/douyin/_shared/vod-upload.js +212 -0
  35. package/clis/douyin/_shared/vod-upload.test.js +38 -0
  36. package/clis/douyin/delete.js +137 -4
  37. package/clis/douyin/delete.test.js +90 -1
  38. package/clis/douyin/publish-upload-id.test.js +170 -0
  39. package/clis/douyin/publish.js +88 -42
  40. package/clis/douyin/user-videos.js +9 -2
  41. package/clis/douyin/user-videos.test.js +43 -0
  42. package/clis/flomo/memos.js +228 -0
  43. package/clis/flomo/memos.test.js +144 -0
  44. package/clis/gitee/search.js +2 -2
  45. package/clis/gitee/search.test.js +65 -0
  46. package/clis/jike/post.js +27 -17
  47. package/clis/jike/read.test.js +86 -0
  48. package/clis/jike/topic.js +32 -19
  49. package/clis/jike/user.js +33 -20
  50. package/clis/lesswrong/comments.js +1 -1
  51. package/clis/lesswrong/curated.js +1 -1
  52. package/clis/lesswrong/frontpage.js +1 -1
  53. package/clis/lesswrong/frontpage.test.js +37 -0
  54. package/clis/lesswrong/new.js +1 -1
  55. package/clis/lesswrong/read.js +1 -1
  56. package/clis/lesswrong/sequences.js +1 -1
  57. package/clis/lesswrong/shortform.js +1 -1
  58. package/clis/lesswrong/tag.js +1 -1
  59. package/clis/lesswrong/top-month.js +1 -1
  60. package/clis/lesswrong/top-week.js +1 -1
  61. package/clis/lesswrong/top-year.js +1 -1
  62. package/clis/lesswrong/top.js +1 -1
  63. package/clis/linkedin/connect.js +401 -0
  64. package/clis/linkedin/connect.test.js +213 -0
  65. package/clis/linkedin/inbox.js +234 -0
  66. package/clis/linkedin/inbox.test.js +152 -0
  67. package/clis/linkedin/people-search.js +262 -0
  68. package/clis/linkedin/people-search.test.js +216 -0
  69. package/clis/linkedin/safe-send.js +357 -0
  70. package/clis/linkedin/safe-send.test.js +204 -0
  71. package/clis/linkedin/salesnav-inbox.js +210 -0
  72. package/clis/linkedin/salesnav-inbox.test.js +113 -0
  73. package/clis/linkedin/salesnav-message.js +360 -0
  74. package/clis/linkedin/salesnav-message.test.js +172 -0
  75. package/clis/linkedin/salesnav-search.js +186 -0
  76. package/clis/linkedin/salesnav-search.test.js +76 -0
  77. package/clis/linkedin/salesnav-thread.js +212 -0
  78. package/clis/linkedin/salesnav-thread.test.js +79 -0
  79. package/clis/linkedin/sent-invitations.js +92 -0
  80. package/clis/linkedin/sent-invitations.test.js +62 -0
  81. package/clis/linkedin/thread-snapshot.js +214 -0
  82. package/clis/linkedin/thread-snapshot.test.js +89 -0
  83. package/clis/linkedin-learning/course.js +138 -0
  84. package/clis/linkedin-learning/course.test.js +114 -0
  85. package/clis/linkedin-learning/search.js +155 -0
  86. package/clis/linkedin-learning/search.test.js +144 -0
  87. package/clis/linkedin-learning/trending.js +133 -0
  88. package/clis/linkedin-learning/trending.test.js +123 -0
  89. package/clis/powerchina/search.js +3 -3
  90. package/clis/powerchina/search.test.js +27 -1
  91. package/clis/reddit/extract-media.test.js +149 -0
  92. package/clis/reddit/frontpage.js +47 -9
  93. package/clis/reddit/frontpage.test.js +34 -0
  94. package/clis/reddit/home.js +31 -1
  95. package/clis/reddit/home.test.js +46 -3
  96. package/clis/reddit/hot.js +32 -1
  97. package/clis/reddit/hot.test.js +15 -1
  98. package/clis/reddit/popular.js +39 -1
  99. package/clis/reddit/popular.test.js +26 -0
  100. package/clis/reddit/saved.js +1 -1
  101. package/clis/reddit/search.js +38 -1
  102. package/clis/reddit/search.test.js +26 -0
  103. package/clis/reddit/subreddit.js +52 -7
  104. package/clis/reddit/subreddit.test.js +31 -0
  105. package/clis/reddit/subscribed.js +165 -0
  106. package/clis/reddit/subscribed.test.js +168 -0
  107. package/clis/reddit/upvoted.js +1 -1
  108. package/clis/suno/commands.test.js +188 -0
  109. package/clis/suno/download.js +140 -0
  110. package/clis/suno/download.test.js +151 -0
  111. package/clis/suno/generate.js +226 -0
  112. package/clis/suno/generate.test.js +243 -0
  113. package/clis/suno/list.js +79 -0
  114. package/clis/suno/status.js +62 -0
  115. package/clis/suno/utils.js +540 -0
  116. package/clis/suno/utils.test.js +223 -0
  117. package/clis/twitter/device-follow.js +193 -0
  118. package/clis/twitter/device-follow.test.js +287 -0
  119. package/clis/twitter/download.js +443 -73
  120. package/clis/twitter/download.test.js +457 -0
  121. package/clis/twitter/list-create.js +155 -0
  122. package/clis/twitter/list-create.test.js +169 -0
  123. package/clis/twitter/list-remove.js +12 -5
  124. package/clis/twitter/list-remove.test.js +74 -0
  125. package/clis/twitter/list-tweets.js +6 -2
  126. package/clis/twitter/list-tweets.test.js +41 -1
  127. package/clis/twitter/lists.js +31 -4
  128. package/clis/twitter/lists.test.js +152 -16
  129. package/clis/twitter/search.js +6 -2
  130. package/clis/twitter/search.test.js +6 -0
  131. package/clis/twitter/shared.js +144 -0
  132. package/clis/twitter/shared.test.js +429 -1
  133. package/clis/twitter/thread.js +10 -2
  134. package/clis/twitter/thread.test.js +58 -0
  135. package/clis/twitter/timeline.js +6 -2
  136. package/clis/twitter/timeline.test.js +2 -0
  137. package/clis/twitter/tweets.js +3 -2
  138. package/clis/twitter/tweets.test.js +1 -1
  139. package/clis/weibo/comments.js +3 -4
  140. package/clis/weibo/delete.js +172 -0
  141. package/clis/weibo/delete.test.js +94 -0
  142. package/clis/weibo/envelope.test.js +85 -0
  143. package/clis/weibo/favorites.js +4 -4
  144. package/clis/weibo/feed.js +3 -5
  145. package/clis/weibo/hot.js +3 -4
  146. package/clis/weibo/me.js +3 -5
  147. package/clis/weibo/post.js +3 -4
  148. package/clis/weibo/publish.js +37 -14
  149. package/clis/weibo/publish.test.js +14 -5
  150. package/clis/weibo/search.js +4 -3
  151. package/clis/weibo/user-posts.js +234 -0
  152. package/clis/weibo/user-posts.test.js +92 -0
  153. package/clis/weibo/user.js +3 -4
  154. package/clis/weibo/utils.js +34 -5
  155. package/clis/weibo/utils.test.js +36 -0
  156. package/clis/weread/search-regression.test.js +18 -11
  157. package/clis/weread/search.js +15 -7
  158. package/clis/weread-official/book.js +135 -0
  159. package/clis/weread-official/commands.test.js +385 -0
  160. package/clis/weread-official/discover.js +107 -0
  161. package/clis/weread-official/list-apis.js +95 -0
  162. package/clis/weread-official/notes.js +171 -0
  163. package/clis/weread-official/readdata.js +158 -0
  164. package/clis/weread-official/review.js +93 -0
  165. package/clis/weread-official/search.js +106 -0
  166. package/clis/weread-official/shelf.js +97 -0
  167. package/clis/weread-official/utils.js +293 -0
  168. package/clis/weread-official/utils.test.js +242 -0
  169. package/clis/wikipedia/trending.js +7 -3
  170. package/clis/wikipedia/trending.test.js +57 -0
  171. package/clis/xianyu/chat.js +24 -109
  172. package/clis/xianyu/chat.test.js +5 -0
  173. package/clis/xianyu/im.js +322 -0
  174. package/clis/xianyu/im.test.js +253 -0
  175. package/clis/xianyu/inbox.js +96 -0
  176. package/clis/xianyu/messages.js +91 -0
  177. package/clis/xianyu/reply.js +82 -0
  178. package/clis/xiaohongshu/creator-note-detail.js +2 -1
  179. package/clis/xiaohongshu/creator-note-detail.test.js +11 -0
  180. package/clis/xiaohongshu/creator-notes-summary.js +2 -1
  181. package/clis/xiaohongshu/creator-notes-summary.test.js +7 -0
  182. package/clis/xiaohongshu/creator-notes.js +2 -1
  183. package/clis/xiaohongshu/creator-notes.test.js +12 -0
  184. package/clis/xiaohongshu/creator-stats.js +2 -1
  185. package/clis/xiaohongshu/creator-stats.test.js +24 -0
  186. package/clis/xiaohongshu/delete-note.js +260 -0
  187. package/clis/xiaohongshu/delete-note.test.js +172 -0
  188. package/clis/xiaohongshu/publish.js +48 -8
  189. package/clis/xiaohongshu/publish.test.js +65 -10
  190. package/clis/xiaohongshu/user-helpers.test.js +41 -0
  191. package/clis/xiaohongshu/user.js +27 -4
  192. package/clis/xiaoyuzhou/download.js +1 -1
  193. package/clis/xiaoyuzhou/transcript.js +1 -1
  194. package/clis/youdao/note.js +258 -0
  195. package/clis/youdao/note.test.js +99 -0
  196. package/clis/youtube/transcript.js +397 -24
  197. package/clis/youtube/transcript.test.js +196 -6
  198. package/clis/zhihu/answer-comments.js +299 -0
  199. package/clis/zhihu/answer-comments.test.js +287 -0
  200. package/clis/zhihu/answer-detail.js +12 -0
  201. package/clis/zhihu/answer-detail.test.js +8 -0
  202. package/clis/zhihu/collection.js +15 -2
  203. package/clis/zhihu/collection.test.js +46 -0
  204. package/clis/zhihu/download.js +1 -1
  205. package/clis/zhihu/question.js +42 -9
  206. package/clis/zhihu/question.test.js +111 -9
  207. package/clis/zhihu/search.js +206 -43
  208. package/clis/zhihu/search.test.js +198 -0
  209. package/dist/src/browser/errors.js +4 -2
  210. package/dist/src/browser/errors.test.js +6 -0
  211. package/dist/src/browser/page.js +30 -4
  212. package/dist/src/browser/page.test.js +42 -0
  213. package/dist/src/browser/utils.d.ts +1 -1
  214. package/dist/src/cli-argv-preprocess.d.ts +26 -0
  215. package/dist/src/cli-argv-preprocess.js +138 -0
  216. package/dist/src/cli-argv-preprocess.test.js +79 -0
  217. package/dist/src/cli.js +1 -1
  218. package/dist/src/convention-audit.js +15 -8
  219. package/dist/src/convention-audit.test.js +21 -0
  220. package/dist/src/download/media-download.js +15 -2
  221. package/dist/src/download/media-download.test.d.ts +1 -0
  222. package/dist/src/download/media-download.test.js +110 -0
  223. package/dist/src/electron-apps.js +1 -1
  224. package/dist/src/electron-apps.test.js +7 -2
  225. package/dist/src/errors.d.ts +17 -0
  226. package/dist/src/errors.js +22 -0
  227. package/dist/src/external-clis.yaml +20 -0
  228. package/dist/src/external.d.ts +6 -1
  229. package/dist/src/external.test.js +19 -0
  230. package/dist/src/main.js +14 -2
  231. package/dist/src/utils.d.ts +43 -0
  232. package/dist/src/utils.js +97 -0
  233. package/dist/src/utils.test.d.ts +1 -0
  234. package/dist/src/utils.test.js +155 -0
  235. package/package.json +8 -2
  236. package/scripts/silent-column-drop-baseline.json +0 -52
  237. package/scripts/typed-error-lint-baseline.json +28 -380
  238. package/clis/slock/_utils.js +0 -12
@@ -2,7 +2,7 @@
2
2
  * PowerChina search — browser DOM extraction with multi-entry URL probing.
3
3
  */
4
4
  import { cli, Strategy } from '@jackwener/opencli/registry';
5
- import { AuthRequiredError } from '@jackwener/opencli/errors';
5
+ import { AuthRequiredError, EmptyResultError } from '@jackwener/opencli/errors';
6
6
  import {
7
7
  cleanText,
8
8
  normalizeDate,
@@ -215,7 +215,7 @@ cli({
215
215
  );
216
216
 
217
217
  if (rows.length === 0 && extractedRows.length > 0) {
218
- throw new Error('[taxonomy=empty_result] site=powerchina command=search extracted only navigation/portal rows');
218
+ throw new EmptyResultError('powerchina search', 'extracted only navigation/portal rows, no bid entries matched');
219
219
  }
220
220
 
221
221
  if (rows.length === 0) {
@@ -227,7 +227,7 @@ cli({
227
227
  );
228
228
  }
229
229
  if (apiFailure) {
230
- throw new Error(`[taxonomy=empty_result] site=powerchina command=search api/dom yielded no result: ${apiFailure}`);
230
+ throw new EmptyResultError('powerchina search', `api/dom yielded no result: ${apiFailure}`);
231
231
  }
232
232
  }
233
233
 
@@ -1,5 +1,12 @@
1
- import { describe, expect, it } from 'vitest';
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { EmptyResultError } from '@jackwener/opencli/errors';
3
+ import { getRegistry } from '@jackwener/opencli/registry';
2
4
  import { __test__ } from './search.js';
5
+ import './search.js';
6
+
7
+ afterEach(() => {
8
+ vi.unstubAllGlobals();
9
+ });
3
10
 
4
11
  describe('powerchina search helpers', () => {
5
12
  it('builds candidate URLs with keyword variants', () => {
@@ -64,4 +71,23 @@ describe('powerchina search helpers', () => {
64
71
  expect(mapped?.date).toBe('2026-04-07');
65
72
  expect(mapped?.url).toBe('https://bid.powerchina.cn/newcbs/recpro-newmember/BidAnnouncementSummary/getInfo/2409419657');
66
73
  });
74
+
75
+ it('throws EmptyResultError when extraction only finds navigation rows', async () => {
76
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down')));
77
+ const cmd = getRegistry().get('powerchina/search');
78
+ const page = {
79
+ goto: vi.fn().mockResolvedValue(undefined),
80
+ wait: vi.fn().mockResolvedValue(undefined),
81
+ evaluate: vi.fn().mockResolvedValue([
82
+ {
83
+ title: '首页',
84
+ url: 'https://bid.powerchina.cn/',
85
+ date: '',
86
+ contextText: '首页',
87
+ },
88
+ ]),
89
+ };
90
+
91
+ await expect(cmd.func(page, { query: '电梯', limit: 1 })).rejects.toBeInstanceOf(EmptyResultError);
92
+ });
67
93
  });
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Behavioral tests for the extractRedditMedia helper.
3
+ *
4
+ * The helper is duplicated inline inside each adapter's browser-side evaluate()
5
+ * source (see popular.js / hot.js / search.js / frontpage.js / subreddit.js).
6
+ * Per-adapter tests grep that the same function-name + spread call is present.
7
+ * This file pins the helper's behavior against representative Reddit-JSON
8
+ * fixtures.
9
+ */
10
+ import { describe, expect, it } from 'vitest';
11
+
12
+ function decodeHtml(s) {
13
+ if (typeof s !== 'string' || !s) return '';
14
+ return s
15
+ .replace(/&/g, '&')
16
+ .replace(/&lt;/g, '<')
17
+ .replace(/&gt;/g, '>')
18
+ .replace(/&quot;/g, '"')
19
+ .replace(/&#x27;/gi, "'")
20
+ .replace(/&#39;/g, "'");
21
+ }
22
+ function extractRedditMedia(d) {
23
+ const post_hint = d?.post_hint || '';
24
+ const url_overridden_by_dest = d?.url_overridden_by_dest || '';
25
+ const preview_image_url = decodeHtml(d?.preview?.images?.[0]?.source?.url || '');
26
+ const gallery_urls = [];
27
+ const items = d?.gallery_data?.items;
28
+ const meta = d?.media_metadata;
29
+ if (Array.isArray(items) && meta) {
30
+ for (const it of items) {
31
+ const m = it && meta[it.media_id];
32
+ const u = m?.s?.u;
33
+ if (u) gallery_urls.push(decodeHtml(u));
34
+ }
35
+ }
36
+ return { post_hint, url_overridden_by_dest, preview_image_url, gallery_urls };
37
+ }
38
+
39
+ describe('extractRedditMedia', () => {
40
+ it('returns empty fields for a plain text/self post', () => {
41
+ expect(extractRedditMedia({ is_self: true, selftext: 'hi' })).toEqual({
42
+ post_hint: '',
43
+ url_overridden_by_dest: '',
44
+ preview_image_url: '',
45
+ gallery_urls: [],
46
+ });
47
+ });
48
+
49
+ it('extracts post_hint, url_overridden_by_dest, preview_image_url for a single-image post', () => {
50
+ const post = {
51
+ post_hint: 'image',
52
+ url_overridden_by_dest: 'https://i.redd.it/abc.jpg',
53
+ preview: {
54
+ images: [{ source: { url: 'https://preview.redd.it/abc.jpg?width=640&amp;s=xyz' } }],
55
+ },
56
+ };
57
+ expect(extractRedditMedia(post)).toEqual({
58
+ post_hint: 'image',
59
+ url_overridden_by_dest: 'https://i.redd.it/abc.jpg',
60
+ preview_image_url: 'https://preview.redd.it/abc.jpg?width=640&s=xyz',
61
+ gallery_urls: [],
62
+ });
63
+ });
64
+
65
+ it('extracts gallery_urls in gallery_data.items order, HTML-decoded', () => {
66
+ const post = {
67
+ post_hint: '',
68
+ url_overridden_by_dest: 'https://www.reddit.com/gallery/xyz',
69
+ gallery_data: {
70
+ items: [{ media_id: 'idB' }, { media_id: 'idA' }, { media_id: 'idC' }],
71
+ },
72
+ media_metadata: {
73
+ idA: { s: { u: 'https://preview.redd.it/idA.jpg?width=1&amp;a=1' } },
74
+ idB: { s: { u: 'https://preview.redd.it/idB.jpg?width=1&amp;a=2' } },
75
+ idC: { s: { u: 'https://preview.redd.it/idC.jpg?width=1&amp;a=3' } },
76
+ },
77
+ };
78
+ const out = extractRedditMedia(post);
79
+ expect(out.gallery_urls).toEqual([
80
+ 'https://preview.redd.it/idB.jpg?width=1&a=2',
81
+ 'https://preview.redd.it/idA.jpg?width=1&a=1',
82
+ 'https://preview.redd.it/idC.jpg?width=1&a=3',
83
+ ]);
84
+ expect(out.url_overridden_by_dest).toBe('https://www.reddit.com/gallery/xyz');
85
+ });
86
+
87
+ it('extracts post_hint for a hosted:video post', () => {
88
+ const post = {
89
+ post_hint: 'hosted:video',
90
+ url_overridden_by_dest: 'https://v.redd.it/xyz',
91
+ preview: { images: [{ source: { url: 'https://preview.redd.it/thumb.jpg' } }] },
92
+ };
93
+ const out = extractRedditMedia(post);
94
+ expect(out.post_hint).toBe('hosted:video');
95
+ expect(out.url_overridden_by_dest).toBe('https://v.redd.it/xyz');
96
+ expect(out.preview_image_url).toBe('https://preview.redd.it/thumb.jpg');
97
+ expect(out.gallery_urls).toEqual([]);
98
+ });
99
+
100
+ it('extracts post_hint for an external link post', () => {
101
+ const post = {
102
+ post_hint: 'link',
103
+ url_overridden_by_dest: 'https://example.com/article',
104
+ };
105
+ expect(extractRedditMedia(post)).toEqual({
106
+ post_hint: 'link',
107
+ url_overridden_by_dest: 'https://example.com/article',
108
+ preview_image_url: '',
109
+ gallery_urls: [],
110
+ });
111
+ });
112
+
113
+ it('HTML-decodes preview URLs that arrive with &amp; separators', () => {
114
+ const post = {
115
+ preview: {
116
+ images: [{ source: { url: 'https://x/?a=1&amp;b=2&amp;c=3' } }],
117
+ },
118
+ };
119
+ expect(extractRedditMedia(post).preview_image_url).toBe('https://x/?a=1&b=2&c=3');
120
+ });
121
+
122
+ it('skips gallery entries whose media_metadata is missing', () => {
123
+ const post = {
124
+ gallery_data: { items: [{ media_id: 'present' }, { media_id: 'orphan' }] },
125
+ media_metadata: {
126
+ present: { s: { u: 'https://preview.redd.it/present.jpg' } },
127
+ // 'orphan' intentionally absent
128
+ },
129
+ };
130
+ expect(extractRedditMedia(post).gallery_urls).toEqual([
131
+ 'https://preview.redd.it/present.jpg',
132
+ ]);
133
+ });
134
+
135
+ it('tolerates null/undefined input without throwing', () => {
136
+ expect(extractRedditMedia(null)).toEqual({
137
+ post_hint: '',
138
+ url_overridden_by_dest: '',
139
+ preview_image_url: '',
140
+ gallery_urls: [],
141
+ });
142
+ expect(extractRedditMedia(undefined)).toEqual({
143
+ post_hint: '',
144
+ url_overridden_by_dest: '',
145
+ preview_image_url: '',
146
+ gallery_urls: [],
147
+ });
148
+ });
149
+ });
@@ -10,22 +10,60 @@ cli({
10
10
  args: [
11
11
  { name: 'limit', type: 'int', default: 15 },
12
12
  ],
13
- columns: ['title', 'subreddit', 'author', 'upvotes', 'comments', 'url'],
13
+ columns: ['title', 'subreddit', 'author', 'upvotes', 'comments', 'url', 'post_hint', 'url_overridden_by_dest', 'preview_image_url', 'gallery_urls'],
14
14
  pipeline: [
15
15
  { navigate: 'https://www.reddit.com' },
16
16
  { evaluate: `(async () => {
17
- const res = await fetch('/r/all.json?limit=\${{ args.limit }}', { credentials: 'include' });
17
+ function decodeHtml(s) {
18
+ if (typeof s !== 'string' || !s) return '';
19
+ return s
20
+ .replace(/&amp;/g, '&')
21
+ .replace(/&lt;/g, '<')
22
+ .replace(/&gt;/g, '>')
23
+ .replace(/&quot;/g, '"')
24
+ .replace(/&#x27;/gi, "'")
25
+ .replace(/&#39;/g, "'");
26
+ }
27
+ function extractRedditMedia(d) {
28
+ const post_hint = d?.post_hint || '';
29
+ const url_overridden_by_dest = d?.url_overridden_by_dest || '';
30
+ const preview_image_url = decodeHtml(d?.preview?.images?.[0]?.source?.url || '');
31
+ const gallery_urls = [];
32
+ const items = d?.gallery_data?.items;
33
+ const meta = d?.media_metadata;
34
+ if (Array.isArray(items) && meta) {
35
+ for (const it of items) {
36
+ const m = it && meta[it.media_id];
37
+ const u = m?.s?.u;
38
+ if (u) gallery_urls.push(decodeHtml(u));
39
+ }
40
+ }
41
+ return { post_hint, url_overridden_by_dest, preview_image_url, gallery_urls };
42
+ }
43
+ const res = await fetch('/r/all.json?limit=\${{ args.limit }}&raw_json=1', { credentials: 'include' });
18
44
  const j = await res.json();
19
- return j?.data?.children || [];
45
+ return (j?.data?.children || []).map(c => ({
46
+ title: c.data.title,
47
+ subreddit: c.data.subreddit_name_prefixed,
48
+ author: c.data.author,
49
+ upvotes: c.data.score,
50
+ comments: c.data.num_comments,
51
+ url: 'https://www.reddit.com' + c.data.permalink,
52
+ ...extractRedditMedia(c.data),
53
+ }));
20
54
  })()
21
55
  ` },
22
56
  { map: {
23
- title: '${{ item.data.title }}',
24
- subreddit: '${{ item.data.subreddit_name_prefixed }}',
25
- author: '${{ item.data.author }}',
26
- upvotes: '${{ item.data.score }}',
27
- comments: '${{ item.data.num_comments }}',
28
- url: 'https://www.reddit.com${{ item.data.permalink }}',
57
+ title: '${{ item.title }}',
58
+ subreddit: '${{ item.subreddit }}',
59
+ author: '${{ item.author }}',
60
+ upvotes: '${{ item.upvotes }}',
61
+ comments: '${{ item.comments }}',
62
+ url: '${{ item.url }}',
63
+ post_hint: '${{ item.post_hint }}',
64
+ url_overridden_by_dest: '${{ item.url_overridden_by_dest }}',
65
+ preview_image_url: '${{ item.preview_image_url }}',
66
+ gallery_urls: '${{ item.gallery_urls }}',
29
67
  } },
30
68
  { limit: '${{ args.limit }}' },
31
69
  ],
@@ -0,0 +1,34 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import './frontpage.js';
4
+
5
+ describe('reddit frontpage adapter', () => {
6
+ const command = getRegistry().get('reddit/frontpage');
7
+
8
+ it('exposes the full frontpage shape including the 4 media columns', () => {
9
+ expect(command?.columns).toEqual([
10
+ 'title', 'subreddit', 'author', 'upvotes', 'comments', 'url',
11
+ 'post_hint', 'url_overridden_by_dest', 'preview_image_url', 'gallery_urls',
12
+ ]);
13
+ });
14
+
15
+ it('shapes children into the intermediate-object pattern with media spread in', () => {
16
+ // Refactored from item.data.* templating to a uniform pre-shaped object
17
+ // so gallery_urls (array-valued) can be set directly in evaluate.
18
+ expect(command?.pipeline?.[1]?.evaluate).toContain('function extractRedditMedia');
19
+ expect(command?.pipeline?.[1]?.evaluate).toContain('...extractRedditMedia(c.data)');
20
+ expect(command?.pipeline?.[1]?.evaluate).toContain('/r/all.json?limit=${{ args.limit }}&raw_json=1');
21
+ expect(command?.pipeline?.[2]?.map).toMatchObject({
22
+ title: '${{ item.title }}',
23
+ subreddit: '${{ item.subreddit }}',
24
+ author: '${{ item.author }}',
25
+ upvotes: '${{ item.upvotes }}',
26
+ comments: '${{ item.comments }}',
27
+ url: '${{ item.url }}',
28
+ post_hint: '${{ item.post_hint }}',
29
+ url_overridden_by_dest: '${{ item.url_overridden_by_dest }}',
30
+ preview_image_url: '${{ item.preview_image_url }}',
31
+ gallery_urls: '${{ item.gallery_urls }}',
32
+ });
33
+ });
34
+ });
@@ -15,6 +15,35 @@ export function parseRedditHomeLimit(raw) {
15
15
  return n;
16
16
  }
17
17
 
18
+ export function decodeRedditHtml(value) {
19
+ if (typeof value !== 'string' || !value) return '';
20
+ return value
21
+ .replace(/&amp;/g, '&')
22
+ .replace(/&lt;/g, '<')
23
+ .replace(/&gt;/g, '>')
24
+ .replace(/&quot;/g, '"')
25
+ .replace(/&#x27;/gi, "'")
26
+ .replace(/&#39;/g, "'");
27
+ }
28
+
29
+ export function extractRedditMedia(d) {
30
+ const galleryUrls = [];
31
+ const items = d?.gallery_data?.items;
32
+ const meta = d?.media_metadata;
33
+ if (Array.isArray(items) && meta && typeof meta === 'object') {
34
+ for (const item of items) {
35
+ const url = meta[item?.media_id]?.s?.u;
36
+ if (typeof url === 'string' && url) galleryUrls.push(decodeRedditHtml(url));
37
+ }
38
+ }
39
+ return {
40
+ post_hint: typeof d?.post_hint === 'string' ? d.post_hint : '',
41
+ url_overridden_by_dest: decodeRedditHtml(d?.url_overridden_by_dest || ''),
42
+ preview_image_url: decodeRedditHtml(d?.preview?.images?.[0]?.source?.url || ''),
43
+ gallery_urls: galleryUrls,
44
+ };
45
+ }
46
+
18
47
  cli({
19
48
  site: 'reddit',
20
49
  name: 'home',
@@ -26,7 +55,7 @@ cli({
26
55
  args: [
27
56
  { name: 'limit', type: 'int', default: 25, help: `Number of posts (1–${REDDIT_HOME_MAX_LIMIT})` },
28
57
  ],
29
- columns: ['rank', 'title', 'subreddit', 'score', 'comments', 'postId', 'author', 'url'],
58
+ columns: ['rank', 'title', 'subreddit', 'score', 'comments', 'postId', 'author', 'url', 'post_hint', 'url_overridden_by_dest', 'preview_image_url', 'gallery_urls'],
30
59
  func: async (page, kwargs) => {
31
60
  const limit = parseRedditHomeLimit(kwargs.limit);
32
61
  await page.goto('https://www.reddit.com');
@@ -103,6 +132,7 @@ cli({
103
132
  postId: d.id,
104
133
  author: typeof d.author === 'string' ? d.author : null,
105
134
  url: d.permalink ? 'https://www.reddit.com' + d.permalink : null,
135
+ ...extractRedditMedia(d),
106
136
  });
107
137
  }
108
138
  if (rows.length === 0) {
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
2
  import { getRegistry } from '@jackwener/opencli/registry';
3
3
  import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
4
- import { parseRedditHomeLimit } from './home.js';
4
+ import { extractRedditMedia, parseRedditHomeLimit } from './home.js';
5
5
  import './home.js';
6
6
 
7
7
  function makePage(result) {
@@ -33,7 +33,10 @@ describe('reddit home command', () => {
33
33
  expect(command).toBeDefined();
34
34
  expect(command.access).toBe('read');
35
35
  expect(command.browser).toBe(true);
36
- expect(command.columns).toEqual(['rank', 'title', 'subreddit', 'score', 'comments', 'postId', 'author', 'url']);
36
+ expect(command.columns).toEqual([
37
+ 'rank', 'title', 'subreddit', 'score', 'comments', 'postId', 'author', 'url',
38
+ 'post_hint', 'url_overridden_by_dest', 'preview_image_url', 'gallery_urls',
39
+ ]);
37
40
  });
38
41
 
39
42
  it('parseRedditHomeLimit accepts [1,100] and rejects out-of-range / non-integer without silent clamp', () => {
@@ -81,20 +84,60 @@ describe('reddit home command', () => {
81
84
  {
82
85
  rank: 1, title: 'Title for a1', subreddit: 'r/dummy', score: 100, comments: 10,
83
86
  postId: 'a1', author: 'someone', url: 'https://www.reddit.com/r/dummy/comments/a1/title/',
87
+ post_hint: '', url_overridden_by_dest: '', preview_image_url: '', gallery_urls: [],
84
88
  },
85
89
  {
86
90
  rank: 2, title: 'Title for b2', subreddit: 'r/dummy', score: 250, comments: 42,
87
91
  postId: 'b2', author: 'someone', url: 'https://www.reddit.com/r/dummy/comments/b2/title/',
92
+ post_hint: '', url_overridden_by_dest: '', preview_image_url: '', gallery_urls: [],
88
93
  },
89
94
  ]);
90
95
  // Row shape must match declared columns exactly.
91
96
  for (const row of rows) {
92
97
  expect(Object.keys(row).sort()).toEqual(
93
- ['author', 'comments', 'postId', 'rank', 'score', 'subreddit', 'title', 'url'],
98
+ [
99
+ 'author', 'comments', 'gallery_urls', 'postId', 'post_hint', 'preview_image_url',
100
+ 'rank', 'score', 'subreddit', 'title', 'url', 'url_overridden_by_dest',
101
+ ],
94
102
  );
95
103
  }
96
104
  });
97
105
 
106
+ it('surfaces media route fields from the personalized home feed', async () => {
107
+ const entries = [makeEntry('a1', {
108
+ post_hint: 'image',
109
+ url_overridden_by_dest: 'https://i.redd.it/a.jpg',
110
+ preview: {
111
+ images: [{ source: { url: 'https://preview.redd.it/a.jpg?x=1&amp;y=2' } }],
112
+ },
113
+ gallery_data: { items: [{ media_id: 'm2' }, { media_id: 'm1' }] },
114
+ media_metadata: {
115
+ m1: { s: { u: 'https://preview.redd.it/m1.jpg?x=1&amp;y=1' } },
116
+ m2: { s: { u: 'https://preview.redd.it/m2.jpg?x=1&amp;y=2' } },
117
+ },
118
+ })];
119
+ const rows = await command.func(makePage({ kind: 'ok', entries }), { limit: 25 });
120
+
121
+ expect(rows[0]).toMatchObject({
122
+ post_hint: 'image',
123
+ url_overridden_by_dest: 'https://i.redd.it/a.jpg',
124
+ preview_image_url: 'https://preview.redd.it/a.jpg?x=1&y=2',
125
+ gallery_urls: [
126
+ 'https://preview.redd.it/m2.jpg?x=1&y=2',
127
+ 'https://preview.redd.it/m1.jpg?x=1&y=1',
128
+ ],
129
+ });
130
+ });
131
+
132
+ it('extractRedditMedia tolerates missing media without throwing', () => {
133
+ expect(extractRedditMedia({ is_self: true })).toEqual({
134
+ post_hint: '',
135
+ url_overridden_by_dest: '',
136
+ preview_image_url: '',
137
+ gallery_urls: [],
138
+ });
139
+ });
140
+
98
141
  it('applies the post-fetch limit slice (defence in depth vs Reddit overshoot)', async () => {
99
142
  const entries = Array.from({ length: 30 }, (_, i) => makeEntry(`p${i}`));
100
143
  const rows = await command.func(makePage({ kind: 'ok', entries }), { limit: 5 });
@@ -13,10 +13,36 @@ cli({
13
13
  },
14
14
  { name: 'limit', type: 'int', default: 20, help: 'Number of posts' },
15
15
  ],
16
- columns: ['rank', 'title', 'subreddit', 'score', 'comments', 'postId', 'author', 'url'],
16
+ columns: ['rank', 'title', 'subreddit', 'score', 'comments', 'postId', 'author', 'url', 'post_hint', 'url_overridden_by_dest', 'preview_image_url', 'gallery_urls'],
17
17
  pipeline: [
18
18
  { navigate: 'https://www.reddit.com' },
19
19
  { evaluate: `(async () => {
20
+ function decodeHtml(s) {
21
+ if (typeof s !== 'string' || !s) return '';
22
+ return s
23
+ .replace(/&amp;/g, '&')
24
+ .replace(/&lt;/g, '<')
25
+ .replace(/&gt;/g, '>')
26
+ .replace(/&quot;/g, '"')
27
+ .replace(/&#x27;/gi, "'")
28
+ .replace(/&#39;/g, "'");
29
+ }
30
+ function extractRedditMedia(d) {
31
+ const post_hint = d?.post_hint || '';
32
+ const url_overridden_by_dest = d?.url_overridden_by_dest || '';
33
+ const preview_image_url = decodeHtml(d?.preview?.images?.[0]?.source?.url || '');
34
+ const gallery_urls = [];
35
+ const items = d?.gallery_data?.items;
36
+ const meta = d?.media_metadata;
37
+ if (Array.isArray(items) && meta) {
38
+ for (const it of items) {
39
+ const m = it && meta[it.media_id];
40
+ const u = m?.s?.u;
41
+ if (u) gallery_urls.push(decodeHtml(u));
42
+ }
43
+ }
44
+ return { post_hint, url_overridden_by_dest, preview_image_url, gallery_urls };
45
+ }
20
46
  const sub = \${{ args.subreddit | json }};
21
47
  const path = sub ? '/r/' + sub + '/hot.json' : '/hot.json';
22
48
  const limit = \${{ args.limit }};
@@ -32,6 +58,7 @@ cli({
32
58
  author: c.data.author,
33
59
  postId: c.data.id,
34
60
  url: 'https://www.reddit.com' + c.data.permalink,
61
+ ...extractRedditMedia(c.data),
35
62
  }));
36
63
  })()
37
64
  ` },
@@ -44,6 +71,10 @@ cli({
44
71
  postId: '${{ item.postId }}',
45
72
  author: '${{ item.author }}',
46
73
  url: '${{ item.url }}',
74
+ post_hint: '${{ item.post_hint }}',
75
+ url_overridden_by_dest: '${{ item.url_overridden_by_dest }}',
76
+ preview_image_url: '${{ item.preview_image_url }}',
77
+ gallery_urls: '${{ item.gallery_urls }}',
47
78
  } },
48
79
  { limit: '${{ args.limit }}' },
49
80
  ],
@@ -6,7 +6,10 @@ describe('reddit hot adapter', () => {
6
6
  const command = getRegistry().get('reddit/hot');
7
7
 
8
8
  it('registers postId, author, and url columns in the hot-list shape', () => {
9
- expect(command?.columns).toEqual(['rank', 'title', 'subreddit', 'score', 'comments', 'postId', 'author', 'url']);
9
+ expect(command?.columns).toEqual([
10
+ 'rank', 'title', 'subreddit', 'score', 'comments', 'postId', 'author', 'url',
11
+ 'post_hint', 'url_overridden_by_dest', 'preview_image_url', 'gallery_urls',
12
+ ]);
10
13
  expect(command?.pipeline?.[1]?.evaluate).toContain('postId: c.data.id');
11
14
  expect(command?.pipeline?.[1]?.evaluate).toContain("'https://www.reddit.com' + c.data.permalink");
12
15
  expect(command?.pipeline?.[2]?.map).toMatchObject({
@@ -15,4 +18,15 @@ describe('reddit hot adapter', () => {
15
18
  url: '${{ item.url }}',
16
19
  });
17
20
  });
21
+
22
+ it('surfaces post_hint, url_overridden_by_dest, preview_image_url, gallery_urls via extractRedditMedia', () => {
23
+ expect(command?.pipeline?.[1]?.evaluate).toContain('function extractRedditMedia');
24
+ expect(command?.pipeline?.[1]?.evaluate).toContain('...extractRedditMedia(c.data)');
25
+ expect(command?.pipeline?.[2]?.map).toMatchObject({
26
+ post_hint: '${{ item.post_hint }}',
27
+ url_overridden_by_dest: '${{ item.url_overridden_by_dest }}',
28
+ preview_image_url: '${{ item.preview_image_url }}',
29
+ gallery_urls: '${{ item.gallery_urls }}',
30
+ });
31
+ });
18
32
  });
@@ -10,32 +10,70 @@ cli({
10
10
  args: [
11
11
  { name: 'limit', type: 'int', default: 20 },
12
12
  ],
13
- columns: ['rank', 'title', 'subreddit', 'score', 'comments', 'url'],
13
+ columns: ['rank', 'id', 'title', 'subreddit', 'score', 'comments', 'author', 'url', 'created_utc', 'selftext', 'post_hint', 'url_overridden_by_dest', 'preview_image_url', 'gallery_urls'],
14
14
  pipeline: [
15
15
  { navigate: 'https://www.reddit.com' },
16
16
  { evaluate: `(async () => {
17
+ function decodeHtml(s) {
18
+ if (typeof s !== 'string' || !s) return '';
19
+ return s
20
+ .replace(/&amp;/g, '&')
21
+ .replace(/&lt;/g, '<')
22
+ .replace(/&gt;/g, '>')
23
+ .replace(/&quot;/g, '"')
24
+ .replace(/&#x27;/gi, "'")
25
+ .replace(/&#39;/g, "'");
26
+ }
27
+ function extractRedditMedia(d) {
28
+ const post_hint = d?.post_hint || '';
29
+ const url_overridden_by_dest = d?.url_overridden_by_dest || '';
30
+ const preview_image_url = decodeHtml(d?.preview?.images?.[0]?.source?.url || '');
31
+ const gallery_urls = [];
32
+ const items = d?.gallery_data?.items;
33
+ const meta = d?.media_metadata;
34
+ if (Array.isArray(items) && meta) {
35
+ for (const it of items) {
36
+ const m = it && meta[it.media_id];
37
+ const u = m?.s?.u;
38
+ if (u) gallery_urls.push(decodeHtml(u));
39
+ }
40
+ }
41
+ return { post_hint, url_overridden_by_dest, preview_image_url, gallery_urls };
42
+ }
17
43
  const limit = \${{ args.limit }};
18
44
  const res = await fetch('/r/popular.json?limit=' + limit + '&raw_json=1', {
19
45
  credentials: 'include'
20
46
  });
21
47
  const d = await res.json();
22
48
  return (d?.data?.children || []).map(c => ({
49
+ id: c.data.id,
23
50
  title: c.data.title,
24
51
  subreddit: c.data.subreddit_name_prefixed,
25
52
  score: c.data.score,
26
53
  comments: c.data.num_comments,
27
54
  author: c.data.author,
28
55
  url: 'https://www.reddit.com' + c.data.permalink,
56
+ created_utc: c.data.created_utc,
57
+ selftext: c.data.selftext || '',
58
+ ...extractRedditMedia(c.data),
29
59
  }));
30
60
  })()
31
61
  ` },
32
62
  { map: {
33
63
  rank: '${{ index + 1 }}',
64
+ id: '${{ item.id }}',
34
65
  title: '${{ item.title }}',
35
66
  subreddit: '${{ item.subreddit }}',
36
67
  score: '${{ item.score }}',
37
68
  comments: '${{ item.comments }}',
69
+ author: '${{ item.author }}',
38
70
  url: '${{ item.url }}',
71
+ created_utc: '${{ item.created_utc }}',
72
+ selftext: '${{ item.selftext }}',
73
+ post_hint: '${{ item.post_hint }}',
74
+ url_overridden_by_dest: '${{ item.url_overridden_by_dest }}',
75
+ preview_image_url: '${{ item.preview_image_url }}',
76
+ gallery_urls: '${{ item.gallery_urls }}',
39
77
  } },
40
78
  { limit: '${{ args.limit }}' },
41
79
  ],
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import './popular.js';
4
+
5
+ describe('reddit popular adapter', () => {
6
+ const command = getRegistry().get('reddit/popular');
7
+
8
+ it('exposes the full post-list shape including the 4 media columns', () => {
9
+ expect(command?.columns).toEqual([
10
+ 'rank', 'id', 'title', 'subreddit', 'score', 'comments', 'author', 'url',
11
+ 'created_utc', 'selftext',
12
+ 'post_hint', 'url_overridden_by_dest', 'preview_image_url', 'gallery_urls',
13
+ ]);
14
+ });
15
+
16
+ it('surfaces media via extractRedditMedia in evaluate + map', () => {
17
+ expect(command?.pipeline?.[1]?.evaluate).toContain('function extractRedditMedia');
18
+ expect(command?.pipeline?.[1]?.evaluate).toContain('...extractRedditMedia(c.data)');
19
+ expect(command?.pipeline?.[2]?.map).toMatchObject({
20
+ post_hint: '${{ item.post_hint }}',
21
+ url_overridden_by_dest: '${{ item.url_overridden_by_dest }}',
22
+ preview_image_url: '${{ item.preview_image_url }}',
23
+ gallery_urls: '${{ item.gallery_urls }}',
24
+ });
25
+ });
26
+ });
@@ -30,7 +30,7 @@ cli({
30
30
  });
31
31
  const d = await res.json();
32
32
  return (d?.data?.children || []).map(c => ({
33
- title: c.data.title || c.data.body?.slice(0, 100) || '-',
33
+ title: c.data.title || c.data.body?.slice(0, 100) || '',
34
34
  subreddit: c.data.subreddit_name_prefixed || 'r/' + (c.data.subreddit || '?'),
35
35
  score: c.data.score || 0,
36
36
  comments: c.data.num_comments || 0,