@jackwener/opencli 1.7.14 → 1.7.15
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/cli-manifest.json +215 -45
- package/clis/bilibili/subtitle.js +1 -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/reddit/search.js +1 -1
- package/clis/reddit/subreddit.js +1 -1
- package/clis/reddit/user-comments.js +1 -1
- package/clis/reddit/user-posts.js +1 -1
- package/clis/reddit/user.js +1 -1
- package/clis/twitter/article.js +2 -1
- package/clis/twitter/bookmark-folder.js +189 -0
- package/clis/twitter/bookmark-folder.test.js +334 -0
- package/clis/twitter/bookmark-folders.js +117 -0
- package/clis/twitter/bookmark-folders.test.js +150 -0
- package/clis/twitter/bookmark.js +15 -6
- package/clis/twitter/bookmark.test.js +74 -0
- package/clis/twitter/bookmarks.js +7 -5
- package/clis/twitter/delete.js +11 -35
- package/clis/twitter/delete.test.js +21 -9
- package/clis/twitter/download.js +5 -5
- package/clis/twitter/followers.js +9 -3
- package/clis/twitter/following.js +11 -5
- 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 +8 -6
- package/clis/twitter/list-add.js +4 -4
- package/clis/twitter/list-remove.js +4 -4
- package/clis/twitter/list-tweets.js +6 -4
- package/clis/twitter/lists.js +3 -3
- package/clis/twitter/notifications.js +2 -2
- package/clis/twitter/profile.js +4 -3
- 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 +175 -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 +6 -4
- package/clis/twitter/timeline.js +8 -6
- package/clis/twitter/tweets.js +5 -3
- 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/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/cdp-click-fixture.test.d.ts +1 -0
- package/dist/src/browser/cdp-click-fixture.test.js +87 -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 +3 -1
- 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 +2 -1
- package/dist/src/browser/page.js +13 -0
- 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 +560 -58
- package/dist/src/cli.test.js +598 -0
- package/dist/src/help.d.ts +32 -0
- package/dist/src/help.js +145 -0
- package/dist/src/types.d.ts +82 -0
- 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/reddit/search.js
CHANGED
|
@@ -8,7 +8,7 @@ cli({
|
|
|
8
8
|
strategy: Strategy.COOKIE,
|
|
9
9
|
browser: true,
|
|
10
10
|
args: [
|
|
11
|
-
{ name: 'query', type: 'string', required: true, positional: true },
|
|
11
|
+
{ name: 'query', type: 'string', required: true, positional: true, help: 'Reddit search query' },
|
|
12
12
|
{
|
|
13
13
|
name: 'subreddit',
|
|
14
14
|
type: 'string',
|
package/clis/reddit/subreddit.js
CHANGED
|
@@ -8,7 +8,7 @@ cli({
|
|
|
8
8
|
strategy: Strategy.COOKIE,
|
|
9
9
|
browser: true,
|
|
10
10
|
args: [
|
|
11
|
-
{ name: 'name', type: 'string', required: true, positional: true },
|
|
11
|
+
{ name: 'name', type: 'string', required: true, positional: true, help: 'Subreddit name (no `r/` prefix; e.g. `python`)' },
|
|
12
12
|
{
|
|
13
13
|
name: 'sort',
|
|
14
14
|
type: 'string',
|
|
@@ -8,7 +8,7 @@ cli({
|
|
|
8
8
|
strategy: Strategy.COOKIE,
|
|
9
9
|
browser: true,
|
|
10
10
|
args: [
|
|
11
|
-
{ name: 'username', type: 'string', required: true, positional: true },
|
|
11
|
+
{ name: 'username', type: 'string', required: true, positional: true, help: 'Reddit username (no `u/` prefix needed)' },
|
|
12
12
|
{ name: 'limit', type: 'int', default: 15 },
|
|
13
13
|
],
|
|
14
14
|
columns: ['subreddit', 'score', 'body', 'url'],
|
|
@@ -8,7 +8,7 @@ cli({
|
|
|
8
8
|
strategy: Strategy.COOKIE,
|
|
9
9
|
browser: true,
|
|
10
10
|
args: [
|
|
11
|
-
{ name: 'username', type: 'string', required: true, positional: true },
|
|
11
|
+
{ name: 'username', type: 'string', required: true, positional: true, help: 'Reddit username (no `u/` prefix needed)' },
|
|
12
12
|
{ name: 'limit', type: 'int', default: 15 },
|
|
13
13
|
],
|
|
14
14
|
columns: ['title', 'subreddit', 'score', 'comments', 'url'],
|
package/clis/reddit/user.js
CHANGED
|
@@ -8,7 +8,7 @@ cli({
|
|
|
8
8
|
strategy: Strategy.COOKIE,
|
|
9
9
|
browser: true,
|
|
10
10
|
args: [
|
|
11
|
-
{ name: 'username', type: 'string', required: true, positional: true },
|
|
11
|
+
{ name: 'username', type: 'string', required: true, positional: true, help: 'Reddit username (no `u/` prefix needed)' },
|
|
12
12
|
],
|
|
13
13
|
columns: ['field', 'value'],
|
|
14
14
|
pipeline: [
|
package/clis/twitter/article.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
2
2
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
3
3
|
import { resolveTwitterQueryId } from './shared.js';
|
|
4
|
+
import { TWITTER_BEARER_TOKEN } from './utils.js';
|
|
4
5
|
const TWEET_RESULT_BY_REST_ID_QUERY_ID = '7xflPyRiUxGVbJd4uWmbfg';
|
|
5
6
|
cli({
|
|
6
7
|
site: 'twitter',
|
|
@@ -57,7 +58,7 @@ cli({
|
|
|
57
58
|
const ct0 = document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('ct0='))?.split('=')[1];
|
|
58
59
|
if (!ct0) return {error: 'No ct0 cookie — not logged into x.com'};
|
|
59
60
|
|
|
60
|
-
const bearer =
|
|
61
|
+
const bearer = ${JSON.stringify(TWITTER_BEARER_TOKEN)};
|
|
61
62
|
const headers = {
|
|
62
63
|
'Authorization': 'Bearer ' + decodeURIComponent(bearer),
|
|
63
64
|
'X-Csrf-Token': ct0,
|