@jackwener/opencli 1.7.14 → 1.7.16

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 (153) hide show
  1. package/README.md +9 -6
  2. package/README.zh-CN.md +9 -6
  3. package/cli-manifest.json +374 -74
  4. package/clis/bilibili/subtitle.js +1 -1
  5. package/clis/chatgpt/ask.js +2 -1
  6. package/clis/chatgpt/detail.js +6 -1
  7. package/clis/chatgpt/read.js +2 -1
  8. package/clis/chatgpt/send.js +2 -1
  9. package/clis/chatgpt/utils.js +54 -12
  10. package/clis/chatgpt/utils.test.js +36 -1
  11. package/clis/claude/ask.js +22 -7
  12. package/clis/claude/detail.js +9 -2
  13. package/clis/claude/new.js +8 -2
  14. package/clis/claude/read.js +2 -1
  15. package/clis/claude/send.js +8 -3
  16. package/clis/claude/utils.js +27 -4
  17. package/clis/deepseek/ask.js +21 -8
  18. package/clis/deepseek/detail.js +9 -1
  19. package/clis/deepseek/new.js +13 -2
  20. package/clis/deepseek/read.js +2 -1
  21. package/clis/deepseek/utils.js +8 -1
  22. package/clis/dianping/cityResolver.js +185 -0
  23. package/clis/dianping/dianping.test.js +154 -0
  24. package/clis/dianping/search.js +6 -3
  25. package/clis/douyin/_shared/browser-fetch.js +14 -2
  26. package/clis/douyin/_shared/browser-fetch.test.js +13 -0
  27. package/clis/douyin/stats.js +1 -1
  28. package/clis/douyin/update.js +1 -1
  29. package/clis/jike/search.js +1 -1
  30. package/clis/linkedin/search.js +8 -11
  31. package/clis/maimai/search-talents.js +10 -6
  32. package/clis/openreview/author.js +58 -0
  33. package/clis/openreview/openreview.test.js +83 -1
  34. package/clis/openreview/utils.js +14 -0
  35. package/clis/reddit/comment.js +1 -0
  36. package/clis/reddit/frontpage.js +1 -0
  37. package/clis/reddit/popular.js +1 -0
  38. package/clis/reddit/read.js +2 -0
  39. package/clis/reddit/read.test.js +4 -0
  40. package/clis/reddit/save.js +1 -0
  41. package/clis/reddit/saved.js +1 -0
  42. package/clis/reddit/search.js +2 -1
  43. package/clis/reddit/subreddit.js +2 -1
  44. package/clis/reddit/subscribe.js +1 -0
  45. package/clis/reddit/upvote.js +1 -0
  46. package/clis/reddit/upvoted.js +1 -0
  47. package/clis/reddit/user-comments.js +2 -1
  48. package/clis/reddit/user-posts.js +2 -1
  49. package/clis/reddit/user.js +2 -1
  50. package/clis/twitter/article.js +9 -5
  51. package/clis/twitter/bookmark-folder.js +187 -0
  52. package/clis/twitter/bookmark-folder.test.js +337 -0
  53. package/clis/twitter/bookmark-folders.js +115 -0
  54. package/clis/twitter/bookmark-folders.test.js +152 -0
  55. package/clis/twitter/bookmark.js +15 -6
  56. package/clis/twitter/bookmark.test.js +74 -0
  57. package/clis/twitter/bookmarks.js +10 -10
  58. package/clis/twitter/delete.js +11 -35
  59. package/clis/twitter/delete.test.js +21 -9
  60. package/clis/twitter/download.js +6 -5
  61. package/clis/twitter/followers.js +10 -3
  62. package/clis/twitter/following.js +14 -11
  63. package/clis/twitter/following.test.js +2 -1
  64. package/clis/twitter/hide-reply.js +24 -5
  65. package/clis/twitter/hide-reply.test.js +76 -0
  66. package/clis/twitter/like.js +21 -11
  67. package/clis/twitter/like.test.js +73 -0
  68. package/clis/twitter/likes.js +11 -11
  69. package/clis/twitter/list-add.js +8 -7
  70. package/clis/twitter/list-add.test.js +23 -1
  71. package/clis/twitter/list-remove.js +8 -7
  72. package/clis/twitter/list-remove.test.js +23 -1
  73. package/clis/twitter/list-tweets.js +9 -9
  74. package/clis/twitter/lists.js +6 -8
  75. package/clis/twitter/notifications.js +3 -2
  76. package/clis/twitter/profile.js +11 -7
  77. package/clis/twitter/quote.js +60 -32
  78. package/clis/twitter/quote.test.js +96 -8
  79. package/clis/twitter/reply.js +24 -178
  80. package/clis/twitter/reply.test.js +29 -11
  81. package/clis/twitter/retweet.js +9 -14
  82. package/clis/twitter/retweet.test.js +5 -1
  83. package/clis/twitter/search.js +176 -23
  84. package/clis/twitter/search.test.js +266 -1
  85. package/clis/twitter/shared.js +43 -0
  86. package/clis/twitter/shared.test.js +107 -1
  87. package/clis/twitter/thread.js +11 -11
  88. package/clis/twitter/timeline.js +13 -13
  89. package/clis/twitter/trending.js +4 -4
  90. package/clis/twitter/tweets.js +8 -9
  91. package/clis/twitter/unbookmark.js +13 -6
  92. package/clis/twitter/unbookmark.test.js +73 -0
  93. package/clis/twitter/unlike.js +6 -13
  94. package/clis/twitter/unlike.test.js +5 -2
  95. package/clis/twitter/unretweet.js +9 -14
  96. package/clis/twitter/unretweet.test.js +5 -1
  97. package/clis/twitter/utils.js +286 -0
  98. package/clis/twitter/utils.test.js +169 -0
  99. package/clis/youtube/like.js +6 -2
  100. package/clis/youtube/subscribe.js +6 -2
  101. package/clis/youtube/unlike.js +6 -2
  102. package/clis/youtube/unsubscribe.js +6 -2
  103. package/clis/youtube/utils.js +19 -13
  104. package/clis/youtube/utils.test.js +17 -1
  105. package/dist/src/browser/ax-snapshot.d.ts +37 -0
  106. package/dist/src/browser/ax-snapshot.js +217 -0
  107. package/dist/src/browser/ax-snapshot.test.d.ts +1 -0
  108. package/dist/src/browser/ax-snapshot.test.js +91 -0
  109. package/dist/src/browser/base-page.d.ts +51 -0
  110. package/dist/src/browser/base-page.js +545 -2
  111. package/dist/src/browser/base-page.test.js +520 -4
  112. package/dist/src/browser/bridge.d.ts +1 -0
  113. package/dist/src/browser/bridge.js +1 -1
  114. package/dist/src/browser/cdp-click-fixture.test.d.ts +1 -0
  115. package/dist/src/browser/cdp-click-fixture.test.js +87 -0
  116. package/dist/src/browser/cdp.d.ts +1 -0
  117. package/dist/src/browser/cdp.js +5 -0
  118. package/dist/src/browser/cdp.test.js +1 -0
  119. package/dist/src/browser/daemon-client.d.ts +5 -3
  120. package/dist/src/browser/daemon-client.js +6 -3
  121. package/dist/src/browser/daemon-client.test.js +10 -0
  122. package/dist/src/browser/find.d.ts +9 -1
  123. package/dist/src/browser/find.js +219 -0
  124. package/dist/src/browser/find.test.js +61 -1
  125. package/dist/src/browser/page.d.ts +4 -2
  126. package/dist/src/browser/page.js +18 -1
  127. package/dist/src/browser/page.test.js +28 -0
  128. package/dist/src/browser/target-errors.d.ts +3 -1
  129. package/dist/src/browser/target-errors.js +2 -0
  130. package/dist/src/browser/target-resolver.d.ts +14 -0
  131. package/dist/src/browser/target-resolver.js +28 -0
  132. package/dist/src/browser/visual-refs.d.ts +11 -0
  133. package/dist/src/browser/visual-refs.js +108 -0
  134. package/dist/src/build-manifest.d.ts +23 -0
  135. package/dist/src/build-manifest.js +34 -0
  136. package/dist/src/build-manifest.test.js +108 -1
  137. package/dist/src/cli.js +630 -60
  138. package/dist/src/cli.test.js +731 -1
  139. package/dist/src/commanderAdapter.js +7 -0
  140. package/dist/src/doctor.js +2 -2
  141. package/dist/src/doctor.test.js +4 -4
  142. package/dist/src/execution.d.ts +2 -0
  143. package/dist/src/execution.js +31 -6
  144. package/dist/src/execution.test.js +43 -16
  145. package/dist/src/external-clis.yaml +24 -0
  146. package/dist/src/help.d.ts +33 -0
  147. package/dist/src/help.js +174 -0
  148. package/dist/src/main.js +4 -14
  149. package/dist/src/runtime.d.ts +3 -0
  150. package/dist/src/runtime.js +1 -0
  151. package/dist/src/types.d.ts +83 -1
  152. package/package.json +1 -1
  153. package/scripts/typed-error-lint-baseline.json +18 -18
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Async city → cityId resolver for dianping adapters.
3
+ *
4
+ * Wraps the synchronous static-map `resolveCityId` from utils.js and falls
5
+ * back to a live lookup against www.dianping.com when the input is not in
6
+ * the curated map. Resolves both pinyin slugs (e.g. "shantou") and Chinese
7
+ * names (e.g. "汕头") by reading dianping itself, so the adapter no longer
8
+ * has to ship a complete static city table.
9
+ *
10
+ * Strategy:
11
+ * 1. Empty / null → null (let the cookie's default city stand).
12
+ * 2. All-digits → numeric cityId pass-through.
13
+ * 3. Static map → fast path, no network. Reuses utils.CITY_ID.
14
+ * 4. Pinyin slug → goto https://www.dianping.com/<slug>, parse the
15
+ * cityId out of any /search/keyword/{id}/ link.
16
+ * 5. Chinese name → goto https://www.dianping.com/citylist, build a
17
+ * Chinese-name → pinyin map, then resolve the slug
18
+ * as in step 4.
19
+ *
20
+ * Resolved (input → cityId) pairs are memoized in a module-level Map so a
21
+ * second search in the same process skips both navigations.
22
+ */
23
+
24
+ import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
25
+ import { CITY_ID, resolveCityId } from './utils.js';
26
+
27
+ const CHINESE_RE = /^[一-龥]+$/;
28
+ const PINYIN_RE = /^[a-z]+$/;
29
+
30
+ const RESOLVE_CACHE = new Map();
31
+
32
+ /**
33
+ * Reset the in-process resolver cache. Exposed for tests so each case
34
+ * starts from a clean slate; production code never needs to call this.
35
+ */
36
+ export function clearCityResolverCache() {
37
+ RESOLVE_CACHE.clear();
38
+ }
39
+
40
+ /**
41
+ * Async resolver. Falls back to live dianping pages only when the input
42
+ * is not in the static map.
43
+ *
44
+ * @param {{ goto: Function, evaluate: Function }} page page handle from the adapter func
45
+ * @param {string|number|null|undefined} cityArg user-supplied city (name, pinyin, or numeric id)
46
+ * @returns {Promise<number|null>} numeric cityId, or null to use the cookie default
47
+ */
48
+ export async function resolveCityIdAsync(page, cityArg) {
49
+ if (cityArg == null || cityArg === '') return null;
50
+ const raw = String(cityArg).trim();
51
+ if (!raw) return null;
52
+ if (/^\d+$/.test(raw)) return Number(raw);
53
+
54
+ const lowered = raw.toLowerCase();
55
+
56
+ // Fast path: reuse the synchronous static map. resolveCityId throws
57
+ // ArgumentError when the input is unknown — that's the trigger to fall
58
+ // back to dynamic resolution rather than surface the error to the user.
59
+ try {
60
+ const staticId = resolveCityId(raw);
61
+ if (staticId != null) return staticId;
62
+ } catch (err) {
63
+ if (err?.code !== 'ARGUMENT') throw err;
64
+ }
65
+
66
+ if (RESOLVE_CACHE.has(lowered)) return RESOLVE_CACHE.get(lowered);
67
+ if (RESOLVE_CACHE.has(raw)) return RESOLVE_CACHE.get(raw);
68
+
69
+ let pinyin = null;
70
+ if (PINYIN_RE.test(lowered)) {
71
+ pinyin = lowered;
72
+ } else if (CHINESE_RE.test(raw)) {
73
+ pinyin = await lookupPinyinFromCitylist(page, raw);
74
+ if (!pinyin) {
75
+ const known = Object.keys(CITY_ID).filter((k) => /^[a-z]+$/.test(k)).join(', ');
76
+ throw new ArgumentError(
77
+ 'city',
78
+ `unknown city '${cityArg}'. pass a numeric cityId, a pinyin slug (e.g. shantou), `
79
+ + `a Chinese name listed on dianping.com/citylist, or one of: ${known}`,
80
+ );
81
+ }
82
+ } else {
83
+ const known = Object.keys(CITY_ID).filter((k) => /^[a-z]+$/.test(k)).join(', ');
84
+ throw new ArgumentError(
85
+ 'city',
86
+ `unknown city '${cityArg}'. pass a numeric cityId, a pinyin slug (e.g. shantou), `
87
+ + `a Chinese name listed on dianping.com/citylist, or one of: ${known}`,
88
+ );
89
+ }
90
+
91
+ const cityId = await fetchCityIdByPinyin(page, pinyin);
92
+ if (!cityId) {
93
+ throw new CommandExecutionError(
94
+ `dianping could not resolve cityId for '${cityArg}' (pinyin=${pinyin}); `
95
+ + `the city page rendered without a /search/keyword/{id}/ link`,
96
+ );
97
+ }
98
+
99
+ RESOLVE_CACHE.set(lowered, cityId);
100
+ RESOLVE_CACHE.set(pinyin, cityId);
101
+ if (CHINESE_RE.test(raw)) RESOLVE_CACHE.set(raw, cityId);
102
+ return cityId;
103
+ }
104
+
105
+ /**
106
+ * Read https://www.dianping.com/citylist and return a Chinese-name → pinyin
107
+ * slug map for every city link present on the page. Used when the user
108
+ * supplied a Chinese name that isn't in the static map.
109
+ */
110
+ async function lookupPinyinFromCitylist(page, chineseName) {
111
+ await page.goto('https://www.dianping.com/citylist');
112
+ const map = await page.evaluate(`(${buildCitylistMap.toString()})()`);
113
+ if (!map || typeof map !== 'object' || Object.keys(map).length === 0) {
114
+ throw new CommandExecutionError(
115
+ 'dianping citylist did not render any city anchors; cannot resolve Chinese city names',
116
+ );
117
+ }
118
+ if (map && typeof map === 'object' && map[chineseName]) {
119
+ return String(map[chineseName]).toLowerCase();
120
+ }
121
+ return null;
122
+ }
123
+
124
+ /**
125
+ * Pure DOM extractor for /citylist. Walks every anchor on the page and
126
+ * keeps the ones whose href matches the per-city slug shape and whose
127
+ * text is a pure-Chinese label. Defined at module scope so the same code
128
+ * can be exercised from JSDOM tests via toString() injection.
129
+ */
130
+ export function buildCitylistMap() {
131
+ const map = {};
132
+ const anchors = document.querySelectorAll('a');
133
+ anchors.forEach((a) => {
134
+ const hrefRaw = a.getAttribute('href') || '';
135
+ const text = ((a.textContent || '').trim());
136
+ if (!text || !/^[一-龥]+$/.test(text)) return;
137
+ const href = hrefRaw.replace(/^https?:/, '');
138
+ const m = href.match(/^\/\/(?:www\.)?dianping\.com\/([a-z]+)\/?$/)
139
+ || href.match(/^\/([a-z]+)\/?$/);
140
+ if (!m) return;
141
+ const slug = m[1].toLowerCase();
142
+ // Filter out non-city slugs that share the shape (e.g. /citylist itself,
143
+ // /promo, /events). Only register the first slug per Chinese label.
144
+ if (slug === 'citylist' || slug === 'promo' || slug === 'events') return;
145
+ if (!map[text]) map[text] = slug;
146
+ });
147
+ return map;
148
+ }
149
+
150
+ /**
151
+ * Visit https://www.dianping.com/<slug> and pull the cityId out of any
152
+ * /search/keyword/{id}/ anchor. The PC city landing page renders these
153
+ * links server-side for every category card, so a single goto + DOM read
154
+ * is enough — no extra clicks or hydration wait.
155
+ */
156
+ async function fetchCityIdByPinyin(page, pinyin) {
157
+ await page.goto(`https://www.dianping.com/${pinyin}`);
158
+ const cityId = await page.evaluate(`(${extractCityIdFromPage.toString()})()`);
159
+ return Number.isInteger(cityId) && cityId > 0 ? cityId : null;
160
+ }
161
+
162
+ /**
163
+ * Pure DOM extractor for the per-city landing page. Defined at module
164
+ * scope so the same code is exercised in JSDOM tests via toString().
165
+ */
166
+ export function extractCityIdFromPage() {
167
+ const baseHref = (typeof location !== 'undefined' && location.href) || 'https://www.dianping.com/';
168
+ const anchors = document.querySelectorAll('a[href]');
169
+ for (const a of anchors) {
170
+ const hrefRaw = a.getAttribute('href') || '';
171
+ let url;
172
+ try {
173
+ url = new URL(hrefRaw, baseHref);
174
+ } catch {
175
+ continue;
176
+ }
177
+ if (url.protocol !== 'https:') continue;
178
+ if (url.hostname !== 'www.dianping.com' && url.hostname !== 'dianping.com') continue;
179
+ const m = url.pathname.match(/^\/search\/keyword\/(\d+)(?:\/|$)/);
180
+ if (!m) continue;
181
+ const n = Number(m[1]);
182
+ if (Number.isInteger(n) && n > 0) return n;
183
+ }
184
+ return null;
185
+ }
@@ -22,6 +22,12 @@ import {
22
22
  } from './utils.js';
23
23
  import { extractSearchRows } from './search.js';
24
24
  import { extractShopFields } from './shop.js';
25
+ import {
26
+ buildCitylistMap,
27
+ clearCityResolverCache,
28
+ extractCityIdFromPage,
29
+ resolveCityIdAsync,
30
+ } from './cityResolver.js';
25
31
 
26
32
  const __dirname = dirname(fileURLToPath(import.meta.url));
27
33
  const SHOP_FIXTURE = readFileSync(join(__dirname, '__fixtures__/shop.html'), 'utf8');
@@ -104,6 +110,154 @@ describe('dianping adapter — helpers', () => {
104
110
  });
105
111
  });
106
112
 
113
+ describe('dianping adapter — async city resolver', () => {
114
+ beforeEach(() => {
115
+ clearCityResolverCache();
116
+ });
117
+
118
+ it('returns null/numeric/static-map ids without ever touching the page', async () => {
119
+ const page = createPageMock({});
120
+
121
+ expect(await resolveCityIdAsync(page, undefined)).toBeNull();
122
+ expect(await resolveCityIdAsync(page, '')).toBeNull();
123
+ expect(await resolveCityIdAsync(page, ' ')).toBeNull();
124
+ expect(await resolveCityIdAsync(page, 47)).toBe(47);
125
+ expect(await resolveCityIdAsync(page, '47')).toBe(47);
126
+ expect(await resolveCityIdAsync(page, '北京')).toBe(2);
127
+ expect(await resolveCityIdAsync(page, 'shanghai')).toBe(1);
128
+
129
+ expect(page.goto).not.toHaveBeenCalled();
130
+ expect(page.evaluate).not.toHaveBeenCalled();
131
+ });
132
+
133
+ it('falls back to /<pinyin> for an unknown lowercase slug, then caches', async () => {
134
+ const goto = vi.fn().mockResolvedValue(undefined);
135
+ const evaluate = vi.fn().mockResolvedValue(207);
136
+ const page = { goto, evaluate, wait: vi.fn() };
137
+
138
+ expect(await resolveCityIdAsync(page, 'shantou')).toBe(207);
139
+ expect(goto).toHaveBeenCalledTimes(1);
140
+ expect(goto).toHaveBeenCalledWith('https://www.dianping.com/shantou');
141
+
142
+ // Second call hits the in-process cache, no extra navigation.
143
+ expect(await resolveCityIdAsync(page, 'shantou')).toBe(207);
144
+ expect(goto).toHaveBeenCalledTimes(1);
145
+ });
146
+
147
+ it('resolves a Chinese name via /citylist + /<pinyin>, then caches both forms', async () => {
148
+ const goto = vi.fn().mockResolvedValue(undefined);
149
+ const evaluate = vi.fn()
150
+ .mockResolvedValueOnce({ '汕头': 'shantou', '佛山': 'foshan' })
151
+ .mockResolvedValueOnce(207);
152
+ const page = { goto, evaluate, wait: vi.fn() };
153
+
154
+ expect(await resolveCityIdAsync(page, '汕头')).toBe(207);
155
+ expect(goto).toHaveBeenNthCalledWith(1, 'https://www.dianping.com/citylist');
156
+ expect(goto).toHaveBeenNthCalledWith(2, 'https://www.dianping.com/shantou');
157
+
158
+ // Cached for the Chinese name AND the discovered pinyin.
159
+ expect(await resolveCityIdAsync(page, '汕头')).toBe(207);
160
+ expect(await resolveCityIdAsync(page, 'shantou')).toBe(207);
161
+ expect(goto).toHaveBeenCalledTimes(2);
162
+ });
163
+
164
+ it('rejects mixed/garbage input with ArgumentError before any navigation', async () => {
165
+ const page = createPageMock({});
166
+ await expect(resolveCityIdAsync(page, 'not-a-city!')).rejects.toThrow(ArgumentError);
167
+ expect(page.goto).not.toHaveBeenCalled();
168
+ });
169
+
170
+ it('rejects a Chinese name that is not on /citylist with ArgumentError', async () => {
171
+ const goto = vi.fn().mockResolvedValue(undefined);
172
+ const evaluate = vi.fn().mockResolvedValueOnce({ '北京': 'beijing' });
173
+ const page = { goto, evaluate, wait: vi.fn() };
174
+
175
+ await expect(resolveCityIdAsync(page, '某虚构城')).rejects.toThrow(ArgumentError);
176
+ expect(goto).toHaveBeenCalledTimes(1);
177
+ expect(goto).toHaveBeenCalledWith('https://www.dianping.com/citylist');
178
+ });
179
+
180
+ it('throws CommandExecutionError when citylist renders without city anchors', async () => {
181
+ const goto = vi.fn().mockResolvedValue(undefined);
182
+ const evaluate = vi.fn().mockResolvedValueOnce({});
183
+ const page = { goto, evaluate, wait: vi.fn() };
184
+
185
+ await expect(resolveCityIdAsync(page, '汕头')).rejects.toThrow(CommandExecutionError);
186
+ expect(goto).toHaveBeenCalledTimes(1);
187
+ expect(goto).toHaveBeenCalledWith('https://www.dianping.com/citylist');
188
+ });
189
+
190
+ it('throws CommandExecutionError when the per-city page lacks a /search/keyword/{id}/ link', async () => {
191
+ const goto = vi.fn().mockResolvedValue(undefined);
192
+ const evaluate = vi.fn().mockResolvedValueOnce(null);
193
+ const page = { goto, evaluate, wait: vi.fn() };
194
+
195
+ await expect(resolveCityIdAsync(page, 'newcity')).rejects.toThrow(CommandExecutionError);
196
+ });
197
+
198
+ it('buildCitylistMap keeps Chinese-labeled city slugs and drops non-city paths', () => {
199
+ const dom = new JSDOM(`
200
+ <html><body>
201
+ <a href="//www.dianping.com/shanghai">上海</a>
202
+ <a href="//www.dianping.com/shantou">汕头</a>
203
+ <a href="/beijing">北京</a>
204
+ <a href="//www.dianping.com/citylist">更多城市 ></a>
205
+ <a href="//www.dianping.com/promo">优惠</a>
206
+ <a href="//www.dianping.com/shanghai">上海</a>
207
+ <a href="https://www.dianping.com/member/123">可乐不加冰</a>
208
+ <a href="https://example.com/notacity">东京</a>
209
+ </body></html>
210
+ `);
211
+ globalThis.document = dom.window.document;
212
+
213
+ try {
214
+ const map = buildCitylistMap();
215
+ expect(map['上海']).toBe('shanghai');
216
+ expect(map['汕头']).toBe('shantou');
217
+ expect(map['北京']).toBe('beijing');
218
+ expect(map['更多城市 >']).toBeUndefined();
219
+ expect(map['优惠']).toBeUndefined();
220
+ expect(map['可乐不加冰']).toBeUndefined();
221
+ expect(map['东京']).toBeUndefined();
222
+ } finally {
223
+ delete globalThis.document;
224
+ }
225
+ });
226
+
227
+ it('extractCityIdFromPage pulls the cityId from the first /search/keyword/{id}/ link', () => {
228
+ const dom = new JSDOM(`
229
+ <html><body>
230
+ <script>window.bad = "/search/keyword/999/";</script>
231
+ <a href="https://example.com/search/keyword/888/0_x">wrong host</a>
232
+ <a href="https://www.dianping.com.evil.com/search/keyword/666/0_x">host suffix</a>
233
+ <a href="http://www.dianping.com/search/keyword/777/0_x">non-https</a>
234
+ <a href="/search/keyword/207/0_%E5%88%BA%E8%BA%AB">刺身</a>
235
+ <a href="/search/category/207/10">美食</a>
236
+ </body></html>
237
+ `, { url: 'https://www.dianping.com/shantou' });
238
+ globalThis.document = dom.window.document;
239
+ globalThis.location = dom.window.location;
240
+
241
+ try {
242
+ expect(extractCityIdFromPage()).toBe(207);
243
+ } finally {
244
+ delete globalThis.document;
245
+ delete globalThis.location;
246
+ }
247
+ });
248
+
249
+ it('extractCityIdFromPage returns null when no /search/keyword/{id}/ link exists', () => {
250
+ const dom = new JSDOM(`<html><body><main>blocked</main></body></html>`);
251
+ globalThis.document = dom.window.document;
252
+
253
+ try {
254
+ expect(extractCityIdFromPage()).toBeNull();
255
+ } finally {
256
+ delete globalThis.document;
257
+ }
258
+ });
259
+ });
260
+
107
261
  describe('dianping adapter — search runtime', () => {
108
262
  const command = getRegistry().get('dianping/search');
109
263
 
@@ -19,9 +19,9 @@ import {
19
19
  parsePrice,
20
20
  parseReviewCount,
21
21
  requireSearchLimit,
22
- resolveCityId,
23
22
  wrapDianpingStep,
24
23
  } from './utils.js';
24
+ import { resolveCityIdAsync } from './cityResolver.js';
25
25
 
26
26
  /**
27
27
  * Pure DOM extractor for the dianping search-results page.
@@ -98,7 +98,7 @@ cli({
98
98
  strategy: Strategy.COOKIE,
99
99
  args: [
100
100
  { name: 'keyword', required: true, positional: true, help: '搜索关键词,例如 "火锅"' },
101
- { name: 'city', help: '城市名(北京/上海/beijing/...)或 cityId 数字。不传则使用 cookie 默认城市' },
101
+ { name: 'city', help: '城市名(北京/上海/汕头/beijing/shantou/...)或 cityId 数字。未在静态表中的城市会通过 dianping.com 在线解析。不传则使用 cookie 默认城市' },
102
102
  { name: 'limit', type: 'int', default: 15, help: '返回的店铺数量(最多 15,dianping 单页固定 15 条)' },
103
103
  ],
104
104
  columns: SEARCH_COLUMNS,
@@ -108,7 +108,10 @@ cli({
108
108
 
109
109
  const limit = requireSearchLimit(kwargs.limit);
110
110
 
111
- const cityId = resolveCityId(kwargs.city);
111
+ const cityId = await wrapDianpingStep(
112
+ 'city resolve',
113
+ () => resolveCityIdAsync(page, kwargs.city),
114
+ );
112
115
  const path = cityId
113
116
  ? `/search/keyword/${cityId}/0_${encodeURIComponent(keyword)}`
114
117
  : `/search/keyword/0/0_${encodeURIComponent(keyword)}`;
@@ -15,10 +15,22 @@ export async function browserFetch(page, method, url, options = {}) {
15
15
  },
16
16
  ${options.body ? `body: JSON.stringify(${JSON.stringify(options.body)}),` : ''}
17
17
  });
18
- return res.json();
18
+ const text = await res.text();
19
+ if (!text) return null;
20
+ return JSON.parse(text);
19
21
  })()
20
22
  `;
21
- const result = await page.evaluate(js);
23
+ let result;
24
+ try {
25
+ result = await page.evaluate(js);
26
+ }
27
+ catch (error) {
28
+ const message = error instanceof Error ? error.message : String(error);
29
+ throw new CommandExecutionError(`Douyin API request failed: ${message}`);
30
+ }
31
+ if (result === null || result === undefined) {
32
+ throw new CommandExecutionError('Empty response from Douyin API');
33
+ }
22
34
  if (result && typeof result === 'object' && 'status_code' in result) {
23
35
  const code = result.status_code;
24
36
  if (code !== 0) {
@@ -27,4 +27,17 @@ describe('browserFetch', () => {
27
27
  const result = await browserFetch(page, 'GET', 'https://creator.douyin.com/api/test');
28
28
  expect(result).toEqual({ some_field: 'value' });
29
29
  });
30
+ it('throws on empty response body (null from evaluate)', async () => {
31
+ const page = makePage(null);
32
+ await expect(browserFetch(page, 'GET', 'https://creator.douyin.com/api/test')).rejects.toThrow('Empty response from Douyin API');
33
+ });
34
+ it('throws on undefined response body', async () => {
35
+ const page = makePage(undefined);
36
+ await expect(browserFetch(page, 'GET', 'https://creator.douyin.com/api/test')).rejects.toThrow('Empty response from Douyin API');
37
+ });
38
+ it('wraps browser-side fetch or JSON parse failures', async () => {
39
+ const page = makePage(null);
40
+ page.evaluate.mockRejectedValueOnce(new SyntaxError('Unexpected token < in JSON'));
41
+ await expect(browserFetch(page, 'GET', 'https://creator.douyin.com/api/test')).rejects.toThrow('Douyin API request failed: Unexpected token < in JSON');
42
+ });
30
43
  });
@@ -8,7 +8,7 @@ cli({
8
8
  domain: 'creator.douyin.com',
9
9
  strategy: Strategy.COOKIE,
10
10
  args: [
11
- { name: 'aweme_id', required: true, positional: true },
11
+ { name: 'aweme_id', required: true, positional: true, help: '抖音作品 ID(aweme_id,可从作品 URL 末尾获取)' },
12
12
  ],
13
13
  columns: ['metric', 'value'],
14
14
  func: async (page, kwargs) => {
@@ -10,7 +10,7 @@ cli({
10
10
  domain: 'creator.douyin.com',
11
11
  strategy: Strategy.COOKIE,
12
12
  args: [
13
- { name: 'aweme_id', required: true, positional: true },
13
+ { name: 'aweme_id', required: true, positional: true, help: '抖音作品 ID(aweme_id,可从作品 URL 末尾获取)' },
14
14
  { name: 'reschedule', default: '', help: '新的发布时间(ISO8601 或 Unix 秒)' },
15
15
  { name: 'caption', default: '', help: '新的正文内容' },
16
16
  ],
@@ -15,7 +15,7 @@ cli({
15
15
  strategy: Strategy.COOKIE,
16
16
  browser: true,
17
17
  args: [
18
- { name: 'query', type: 'string', required: true, positional: true },
18
+ { name: 'query', type: 'string', required: true, positional: true, help: '即刻搜索关键词' },
19
19
  { name: 'limit', type: 'int', default: 20 },
20
20
  ],
21
21
  columns: ['id', 'author', 'content', 'likes', 'comments', 'time', 'url'],
@@ -245,23 +245,20 @@ async function fetchJobCards(page, input) {
245
245
  const MAX_BATCH = 25;
246
246
  const allJobs = [];
247
247
  let offset = input.start;
248
+ // Read JSESSIONID directly from the cookie store via CDP — zero page.evaluate round-trip
249
+ const cookies = await page.getCookies({ url: 'https://www.linkedin.com' });
250
+ const jsession = cookies.find((c) => c.name === 'JSESSIONID')?.value;
251
+ if (!jsession) {
252
+ throw new AuthRequiredError(LINKEDIN_DOMAIN, 'LinkedIn JSESSIONID cookie not found. Please sign in to LinkedIn in the browser.');
253
+ }
254
+ const csrf = jsession.replace(/^"|"$/g, '');
248
255
  while (allJobs.length < input.limit) {
249
256
  const count = Math.min(MAX_BATCH, input.limit - allJobs.length);
250
257
  const apiPath = buildVoyagerUrl(input, offset, count);
251
258
  const batch = await page.evaluate(`(async () => {
252
- const jsession = document.cookie.split(';').map(p => p.trim())
253
- .find(p => p.startsWith('JSESSIONID='))?.slice('JSESSIONID='.length);
254
- if (!jsession) {
255
- return {
256
- authRequired: true,
257
- error: 'LinkedIn JSESSIONID cookie not found. Please sign in to LinkedIn in the browser.'
258
- };
259
- }
260
-
261
- const csrf = jsession.replace(/^"|"$/g, '');
262
259
  const res = await fetch(${JSON.stringify(apiPath)}, {
263
260
  credentials: 'include',
264
- headers: { 'csrf-token': csrf, 'x-restli-protocol-version': '2.0.0' },
261
+ headers: { 'csrf-token': ${JSON.stringify(csrf)}, 'x-restli-protocol-version': '2.0.0' },
265
262
  });
266
263
  if (res.status === 401 || res.status === 403) {
267
264
  const text = await res.text();
@@ -80,18 +80,22 @@ cli({
80
80
  },
81
81
  };
82
82
 
83
+ // Read csrftoken directly from the cookie store via CDP — zero page.evaluate round-trip
84
+ const cookies = await page.getCookies({ url: 'https://maimai.cn' });
85
+ const csrftokenFromCookie = cookies.find((c) => c.name === 'csrftoken')?.value || '';
86
+
83
87
  // Execute the search API call in browser context
84
- const data = await page.evaluate(async (body) => {
85
- // Get CSRF token from cookie or meta tag
86
- let csrftoken = document.cookie.split('; ')
87
- .find(row => row.startsWith('csrftoken='))
88
- ?.split('=')[1] || '';
88
+ const data = await page.evaluate(`async () => {
89
+ // Prefer cookie-derived csrftoken (hoisted from CDP); fall back to meta tag
90
+ let csrftoken = ${JSON.stringify(csrftokenFromCookie)};
89
91
 
90
92
  if (!csrftoken) {
91
93
  const meta = document.querySelector('meta[name="csrf-token"]');
92
94
  if (meta) csrftoken = meta.getAttribute('content') || '';
93
95
  }
94
96
 
97
+ const body = ${JSON.stringify(requestBody)};
98
+
95
99
  const res = await fetch('https://maimai.cn/api/ent/discover/search?channel=www&data_version=3.0&version=1.0.0', {
96
100
  method: 'POST',
97
101
  headers: {
@@ -117,7 +121,7 @@ cli({
117
121
  }
118
122
 
119
123
  return result;
120
- }, requestBody);
124
+ }`);
121
125
 
122
126
  // Extract talent list from response
123
127
  const talentList = data.data?.list || data.data?.talent_list || data.list || data.talent_list || [];
@@ -0,0 +1,58 @@
1
+ /**
2
+ * OpenReview submissions by author profile id (newest first).
3
+ *
4
+ * Pairs with `openreview paper <id>` and `openreview reviews <id>` for the
5
+ * full read-side workflow: list every submission an author put on
6
+ * OpenReview, then drill into a specific paper or its review thread.
7
+ *
8
+ * Uses the public v2 endpoint `/notes?content.authorids=~<profile-id>`,
9
+ * which returns the same note shape as `paper`, sorted by `cdate:desc`.
10
+ */
11
+ import { cli, Strategy } from '@jackwener/opencli/registry';
12
+ import { EmptyResultError } from '@jackwener/opencli/errors';
13
+ import {
14
+ noteToRow,
15
+ openreviewFetch,
16
+ requireBoundedInt,
17
+ requireProfileId,
18
+ } from './utils.js';
19
+
20
+ cli({
21
+ site: 'openreview',
22
+ name: 'author',
23
+ access: 'read',
24
+ description: 'List OpenReview submissions by an author profile id (newest first)',
25
+ domain: 'openreview.net',
26
+ strategy: Strategy.PUBLIC,
27
+ browser: false,
28
+ args: [
29
+ { name: 'profile', positional: true, required: true, help: 'OpenReview profile id (e.g. "~Yoshua_Bengio1"). Find it on the author profile URL on openreview.net.' },
30
+ { name: 'limit', type: 'int', default: 50, help: 'Max submissions (1-1000)' },
31
+ ],
32
+ columns: ['rank', 'id', 'title', 'authors', 'venue', 'pdate', 'url'],
33
+ func: async (args) => {
34
+ const profile = requireProfileId(args.profile);
35
+ const limit = requireBoundedInt(args.limit, 50, 1000);
36
+ const path = `/notes?content.authorids=${encodeURIComponent(profile)}&limit=${limit}&sort=cdate:desc`;
37
+ const json = await openreviewFetch(path, `openreview author ${profile}`);
38
+ const notes = Array.isArray(json?.notes) ? json.notes : [];
39
+ if (!notes.length) {
40
+ throw new EmptyResultError(
41
+ 'openreview author',
42
+ `No OpenReview submissions found for profile "${profile}". Confirm the id format (~First_LastN) and that the profile has public submissions.`,
43
+ );
44
+ }
45
+ return notes.slice(0, limit).map((note, i) => {
46
+ const row = noteToRow(note);
47
+ return {
48
+ rank: i + 1,
49
+ id: row.id,
50
+ title: row.title,
51
+ authors: row.authors,
52
+ venue: row.venue,
53
+ pdate: row.pdate,
54
+ url: row.url,
55
+ };
56
+ });
57
+ },
58
+ });