@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.
- package/README.md +9 -6
- package/README.zh-CN.md +9 -6
- package/cli-manifest.json +374 -74
- package/clis/bilibili/subtitle.js +1 -1
- package/clis/chatgpt/ask.js +2 -1
- package/clis/chatgpt/detail.js +6 -1
- package/clis/chatgpt/read.js +2 -1
- package/clis/chatgpt/send.js +2 -1
- package/clis/chatgpt/utils.js +54 -12
- package/clis/chatgpt/utils.test.js +36 -1
- package/clis/claude/ask.js +22 -7
- package/clis/claude/detail.js +9 -2
- package/clis/claude/new.js +8 -2
- package/clis/claude/read.js +2 -1
- package/clis/claude/send.js +8 -3
- package/clis/claude/utils.js +27 -4
- package/clis/deepseek/ask.js +21 -8
- package/clis/deepseek/detail.js +9 -1
- package/clis/deepseek/new.js +13 -2
- package/clis/deepseek/read.js +2 -1
- package/clis/deepseek/utils.js +8 -1
- package/clis/dianping/cityResolver.js +185 -0
- package/clis/dianping/dianping.test.js +154 -0
- package/clis/dianping/search.js +6 -3
- package/clis/douyin/_shared/browser-fetch.js +14 -2
- package/clis/douyin/_shared/browser-fetch.test.js +13 -0
- package/clis/douyin/stats.js +1 -1
- package/clis/douyin/update.js +1 -1
- package/clis/jike/search.js +1 -1
- package/clis/linkedin/search.js +8 -11
- package/clis/maimai/search-talents.js +10 -6
- package/clis/openreview/author.js +58 -0
- package/clis/openreview/openreview.test.js +83 -1
- package/clis/openreview/utils.js +14 -0
- package/clis/reddit/comment.js +1 -0
- package/clis/reddit/frontpage.js +1 -0
- package/clis/reddit/popular.js +1 -0
- package/clis/reddit/read.js +2 -0
- package/clis/reddit/read.test.js +4 -0
- package/clis/reddit/save.js +1 -0
- package/clis/reddit/saved.js +1 -0
- package/clis/reddit/search.js +2 -1
- package/clis/reddit/subreddit.js +2 -1
- package/clis/reddit/subscribe.js +1 -0
- package/clis/reddit/upvote.js +1 -0
- package/clis/reddit/upvoted.js +1 -0
- package/clis/reddit/user-comments.js +2 -1
- package/clis/reddit/user-posts.js +2 -1
- package/clis/reddit/user.js +2 -1
- package/clis/twitter/article.js +9 -5
- package/clis/twitter/bookmark-folder.js +187 -0
- package/clis/twitter/bookmark-folder.test.js +337 -0
- package/clis/twitter/bookmark-folders.js +115 -0
- package/clis/twitter/bookmark-folders.test.js +152 -0
- package/clis/twitter/bookmark.js +15 -6
- package/clis/twitter/bookmark.test.js +74 -0
- package/clis/twitter/bookmarks.js +10 -10
- package/clis/twitter/delete.js +11 -35
- package/clis/twitter/delete.test.js +21 -9
- package/clis/twitter/download.js +6 -5
- package/clis/twitter/followers.js +10 -3
- package/clis/twitter/following.js +14 -11
- package/clis/twitter/following.test.js +2 -1
- package/clis/twitter/hide-reply.js +24 -5
- package/clis/twitter/hide-reply.test.js +76 -0
- package/clis/twitter/like.js +21 -11
- package/clis/twitter/like.test.js +73 -0
- package/clis/twitter/likes.js +11 -11
- package/clis/twitter/list-add.js +8 -7
- package/clis/twitter/list-add.test.js +23 -1
- package/clis/twitter/list-remove.js +8 -7
- package/clis/twitter/list-remove.test.js +23 -1
- package/clis/twitter/list-tweets.js +9 -9
- package/clis/twitter/lists.js +6 -8
- package/clis/twitter/notifications.js +3 -2
- package/clis/twitter/profile.js +11 -7
- package/clis/twitter/quote.js +60 -32
- package/clis/twitter/quote.test.js +96 -8
- package/clis/twitter/reply.js +24 -178
- package/clis/twitter/reply.test.js +29 -11
- package/clis/twitter/retweet.js +9 -14
- package/clis/twitter/retweet.test.js +5 -1
- package/clis/twitter/search.js +176 -23
- package/clis/twitter/search.test.js +266 -1
- package/clis/twitter/shared.js +43 -0
- package/clis/twitter/shared.test.js +107 -1
- package/clis/twitter/thread.js +11 -11
- package/clis/twitter/timeline.js +13 -13
- package/clis/twitter/trending.js +4 -4
- package/clis/twitter/tweets.js +8 -9
- package/clis/twitter/unbookmark.js +13 -6
- package/clis/twitter/unbookmark.test.js +73 -0
- package/clis/twitter/unlike.js +6 -13
- package/clis/twitter/unlike.test.js +5 -2
- package/clis/twitter/unretweet.js +9 -14
- package/clis/twitter/unretweet.test.js +5 -1
- package/clis/twitter/utils.js +286 -0
- package/clis/twitter/utils.test.js +169 -0
- package/clis/youtube/like.js +6 -2
- package/clis/youtube/subscribe.js +6 -2
- package/clis/youtube/unlike.js +6 -2
- package/clis/youtube/unsubscribe.js +6 -2
- package/clis/youtube/utils.js +19 -13
- package/clis/youtube/utils.test.js +17 -1
- package/dist/src/browser/ax-snapshot.d.ts +37 -0
- package/dist/src/browser/ax-snapshot.js +217 -0
- package/dist/src/browser/ax-snapshot.test.d.ts +1 -0
- package/dist/src/browser/ax-snapshot.test.js +91 -0
- package/dist/src/browser/base-page.d.ts +51 -0
- package/dist/src/browser/base-page.js +545 -2
- package/dist/src/browser/base-page.test.js +520 -4
- package/dist/src/browser/bridge.d.ts +1 -0
- package/dist/src/browser/bridge.js +1 -1
- package/dist/src/browser/cdp-click-fixture.test.d.ts +1 -0
- package/dist/src/browser/cdp-click-fixture.test.js +87 -0
- package/dist/src/browser/cdp.d.ts +1 -0
- package/dist/src/browser/cdp.js +5 -0
- package/dist/src/browser/cdp.test.js +1 -0
- package/dist/src/browser/daemon-client.d.ts +5 -3
- package/dist/src/browser/daemon-client.js +6 -3
- package/dist/src/browser/daemon-client.test.js +10 -0
- package/dist/src/browser/find.d.ts +9 -1
- package/dist/src/browser/find.js +219 -0
- package/dist/src/browser/find.test.js +61 -1
- package/dist/src/browser/page.d.ts +4 -2
- package/dist/src/browser/page.js +18 -1
- package/dist/src/browser/page.test.js +28 -0
- package/dist/src/browser/target-errors.d.ts +3 -1
- package/dist/src/browser/target-errors.js +2 -0
- package/dist/src/browser/target-resolver.d.ts +14 -0
- package/dist/src/browser/target-resolver.js +28 -0
- package/dist/src/browser/visual-refs.d.ts +11 -0
- package/dist/src/browser/visual-refs.js +108 -0
- package/dist/src/build-manifest.d.ts +23 -0
- package/dist/src/build-manifest.js +34 -0
- package/dist/src/build-manifest.test.js +108 -1
- package/dist/src/cli.js +630 -60
- package/dist/src/cli.test.js +731 -1
- package/dist/src/commanderAdapter.js +7 -0
- package/dist/src/doctor.js +2 -2
- package/dist/src/doctor.test.js +4 -4
- package/dist/src/execution.d.ts +2 -0
- package/dist/src/execution.js +31 -6
- package/dist/src/execution.test.js +43 -16
- package/dist/src/external-clis.yaml +24 -0
- package/dist/src/help.d.ts +33 -0
- package/dist/src/help.js +174 -0
- package/dist/src/main.js +4 -14
- package/dist/src/runtime.d.ts +3 -0
- package/dist/src/runtime.js +1 -0
- package/dist/src/types.d.ts +83 -1
- package/package.json +1 -1
- 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
|
|
package/clis/dianping/search.js
CHANGED
|
@@ -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: '
|
|
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 =
|
|
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
|
-
|
|
18
|
+
const text = await res.text();
|
|
19
|
+
if (!text) return null;
|
|
20
|
+
return JSON.parse(text);
|
|
19
21
|
})()
|
|
20
22
|
`;
|
|
21
|
-
|
|
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
|
});
|
package/clis/douyin/stats.js
CHANGED
|
@@ -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) => {
|
package/clis/douyin/update.js
CHANGED
|
@@ -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
|
],
|
package/clis/jike/search.js
CHANGED
|
@@ -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'],
|
package/clis/linkedin/search.js
CHANGED
|
@@ -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 (
|
|
85
|
-
//
|
|
86
|
-
let csrftoken =
|
|
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
|
-
}
|
|
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
|
+
});
|