mirror of
https://github.com/jackwener/OpenCLI.git
synced 2026-09-14 18:25:42 +08:00
85ea18c93b
* feat(dianping): resolve unknown cities live from www.dianping.com
The static CITY_ID map in clis/dianping/utils.js only covers ~20 cities,
so passing --city 汕头 (or any other Chinese name / pinyin slug not on
that list) fails with ArgumentError. Adding the missing cityIds by hand
doesn't scale to dianping's full city list and silently goes stale when
the site renumbers cities.
This change adds an async resolver that falls back to dianping.com when
the static map misses:
- Numeric input → pass through unchanged.
- Static map hit → fast path, no network (utils.CITY_ID untouched).
- Pinyin slug (e.g. "shantou") → goto /<slug>, parse cityId out of
any /search/keyword/{id}/ link rendered on the per-city landing page.
- Chinese name (e.g. "汕头") → goto /citylist, walk anchors to build a
Chinese-name → pinyin map, then resolve the slug as above.
Resolved (input → cityId) pairs are memoized per-process so repeat
searches skip both navigations.
Implemented as a new module (clis/dianping/cityResolver.js) so utils.js
stays minimal and the existing synchronous resolveCityId / CITY_ID API
keeps working for direct callers and tests.
Tested:
- Unit tests cover null/numeric/static fast paths, pinyin fallback +
cache, Chinese-name fallback via /citylist + cache for both forms,
rejection of garbage input, rejection of Chinese names not on
/citylist, and CommandExecutionError when the per-city page lacks
a /search/keyword/{id}/ link.
- JSDOM tests cover the pure DOM extractors (buildCitylistMap and
extractCityIdFromPage) against curated HTML fixtures.
- npm test: 3196 passed, 1 skipped (no new failures).
- npx tsc --noEmit: clean.
- opencli validate: 0 errors.
* fix(dianping): require city resolver links to be authoritative
---------
Co-authored-by: jackwener <jakevingoo@gmail.com>
186 lines
7.4 KiB
JavaScript
186 lines
7.4 KiB
JavaScript
/**
|
|
* Async city → cityId resolver for dianping adapters.
|
|
*
|
|
* Wraps the synchronous static-map `resolveCityId` from utils.js and falls
|
|
* back to a live lookup against www.dianping.com when the input is not in
|
|
* the curated map. Resolves both pinyin slugs (e.g. "shantou") and Chinese
|
|
* names (e.g. "汕头") by reading dianping itself, so the adapter no longer
|
|
* has to ship a complete static city table.
|
|
*
|
|
* Strategy:
|
|
* 1. Empty / null → null (let the cookie's default city stand).
|
|
* 2. All-digits → numeric cityId pass-through.
|
|
* 3. Static map → fast path, no network. Reuses utils.CITY_ID.
|
|
* 4. Pinyin slug → goto https://www.dianping.com/<slug>, parse the
|
|
* cityId out of any /search/keyword/{id}/ link.
|
|
* 5. Chinese name → goto https://www.dianping.com/citylist, build a
|
|
* Chinese-name → pinyin map, then resolve the slug
|
|
* as in step 4.
|
|
*
|
|
* Resolved (input → cityId) pairs are memoized in a module-level Map so a
|
|
* second search in the same process skips both navigations.
|
|
*/
|
|
|
|
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
import { CITY_ID, resolveCityId } from './utils.js';
|
|
|
|
const CHINESE_RE = /^[一-龥]+$/;
|
|
const PINYIN_RE = /^[a-z]+$/;
|
|
|
|
const RESOLVE_CACHE = new Map();
|
|
|
|
/**
|
|
* Reset the in-process resolver cache. Exposed for tests so each case
|
|
* starts from a clean slate; production code never needs to call this.
|
|
*/
|
|
export function clearCityResolverCache() {
|
|
RESOLVE_CACHE.clear();
|
|
}
|
|
|
|
/**
|
|
* Async resolver. Falls back to live dianping pages only when the input
|
|
* is not in the static map.
|
|
*
|
|
* @param {{ goto: Function, evaluate: Function }} page page handle from the adapter func
|
|
* @param {string|number|null|undefined} cityArg user-supplied city (name, pinyin, or numeric id)
|
|
* @returns {Promise<number|null>} numeric cityId, or null to use the cookie default
|
|
*/
|
|
export async function resolveCityIdAsync(page, cityArg) {
|
|
if (cityArg == null || cityArg === '') return null;
|
|
const raw = String(cityArg).trim();
|
|
if (!raw) return null;
|
|
if (/^\d+$/.test(raw)) return Number(raw);
|
|
|
|
const lowered = raw.toLowerCase();
|
|
|
|
// Fast path: reuse the synchronous static map. resolveCityId throws
|
|
// ArgumentError when the input is unknown — that's the trigger to fall
|
|
// back to dynamic resolution rather than surface the error to the user.
|
|
try {
|
|
const staticId = resolveCityId(raw);
|
|
if (staticId != null) return staticId;
|
|
} catch (err) {
|
|
if (err?.code !== 'ARGUMENT') throw err;
|
|
}
|
|
|
|
if (RESOLVE_CACHE.has(lowered)) return RESOLVE_CACHE.get(lowered);
|
|
if (RESOLVE_CACHE.has(raw)) return RESOLVE_CACHE.get(raw);
|
|
|
|
let pinyin = null;
|
|
if (PINYIN_RE.test(lowered)) {
|
|
pinyin = lowered;
|
|
} else if (CHINESE_RE.test(raw)) {
|
|
pinyin = await lookupPinyinFromCitylist(page, raw);
|
|
if (!pinyin) {
|
|
const known = Object.keys(CITY_ID).filter((k) => /^[a-z]+$/.test(k)).join(', ');
|
|
throw new ArgumentError(
|
|
'city',
|
|
`unknown city '${cityArg}'. pass a numeric cityId, a pinyin slug (e.g. shantou), `
|
|
+ `a Chinese name listed on dianping.com/citylist, or one of: ${known}`,
|
|
);
|
|
}
|
|
} else {
|
|
const known = Object.keys(CITY_ID).filter((k) => /^[a-z]+$/.test(k)).join(', ');
|
|
throw new ArgumentError(
|
|
'city',
|
|
`unknown city '${cityArg}'. pass a numeric cityId, a pinyin slug (e.g. shantou), `
|
|
+ `a Chinese name listed on dianping.com/citylist, or one of: ${known}`,
|
|
);
|
|
}
|
|
|
|
const cityId = await fetchCityIdByPinyin(page, pinyin);
|
|
if (!cityId) {
|
|
throw new CommandExecutionError(
|
|
`dianping could not resolve cityId for '${cityArg}' (pinyin=${pinyin}); `
|
|
+ `the city page rendered without a /search/keyword/{id}/ link`,
|
|
);
|
|
}
|
|
|
|
RESOLVE_CACHE.set(lowered, cityId);
|
|
RESOLVE_CACHE.set(pinyin, cityId);
|
|
if (CHINESE_RE.test(raw)) RESOLVE_CACHE.set(raw, cityId);
|
|
return cityId;
|
|
}
|
|
|
|
/**
|
|
* Read https://www.dianping.com/citylist and return a Chinese-name → pinyin
|
|
* slug map for every city link present on the page. Used when the user
|
|
* supplied a Chinese name that isn't in the static map.
|
|
*/
|
|
async function lookupPinyinFromCitylist(page, chineseName) {
|
|
await page.goto('https://www.dianping.com/citylist');
|
|
const map = await page.evaluate(`(${buildCitylistMap.toString()})()`);
|
|
if (!map || typeof map !== 'object' || Object.keys(map).length === 0) {
|
|
throw new CommandExecutionError(
|
|
'dianping citylist did not render any city anchors; cannot resolve Chinese city names',
|
|
);
|
|
}
|
|
if (map && typeof map === 'object' && map[chineseName]) {
|
|
return String(map[chineseName]).toLowerCase();
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Pure DOM extractor for /citylist. Walks every anchor on the page and
|
|
* keeps the ones whose href matches the per-city slug shape and whose
|
|
* text is a pure-Chinese label. Defined at module scope so the same code
|
|
* can be exercised from JSDOM tests via toString() injection.
|
|
*/
|
|
export function buildCitylistMap() {
|
|
const map = {};
|
|
const anchors = document.querySelectorAll('a');
|
|
anchors.forEach((a) => {
|
|
const hrefRaw = a.getAttribute('href') || '';
|
|
const text = ((a.textContent || '').trim());
|
|
if (!text || !/^[一-龥]+$/.test(text)) return;
|
|
const href = hrefRaw.replace(/^https?:/, '');
|
|
const m = href.match(/^\/\/(?:www\.)?dianping\.com\/([a-z]+)\/?$/)
|
|
|| href.match(/^\/([a-z]+)\/?$/);
|
|
if (!m) return;
|
|
const slug = m[1].toLowerCase();
|
|
// Filter out non-city slugs that share the shape (e.g. /citylist itself,
|
|
// /promo, /events). Only register the first slug per Chinese label.
|
|
if (slug === 'citylist' || slug === 'promo' || slug === 'events') return;
|
|
if (!map[text]) map[text] = slug;
|
|
});
|
|
return map;
|
|
}
|
|
|
|
/**
|
|
* Visit https://www.dianping.com/<slug> and pull the cityId out of any
|
|
* /search/keyword/{id}/ anchor. The PC city landing page renders these
|
|
* links server-side for every category card, so a single goto + DOM read
|
|
* is enough — no extra clicks or hydration wait.
|
|
*/
|
|
async function fetchCityIdByPinyin(page, pinyin) {
|
|
await page.goto(`https://www.dianping.com/${pinyin}`);
|
|
const cityId = await page.evaluate(`(${extractCityIdFromPage.toString()})()`);
|
|
return Number.isInteger(cityId) && cityId > 0 ? cityId : null;
|
|
}
|
|
|
|
/**
|
|
* Pure DOM extractor for the per-city landing page. Defined at module
|
|
* scope so the same code is exercised in JSDOM tests via toString().
|
|
*/
|
|
export function extractCityIdFromPage() {
|
|
const baseHref = (typeof location !== 'undefined' && location.href) || 'https://www.dianping.com/';
|
|
const anchors = document.querySelectorAll('a[href]');
|
|
for (const a of anchors) {
|
|
const hrefRaw = a.getAttribute('href') || '';
|
|
let url;
|
|
try {
|
|
url = new URL(hrefRaw, baseHref);
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (url.protocol !== 'https:') continue;
|
|
if (url.hostname !== 'www.dianping.com' && url.hostname !== 'dianping.com') continue;
|
|
const m = url.pathname.match(/^\/search\/keyword\/(\d+)(?:\/|$)/);
|
|
if (!m) continue;
|
|
const n = Number(m[1]);
|
|
if (Number.isInteger(n) && n > 0) return n;
|
|
}
|
|
return null;
|
|
}
|