9b395f5cc3
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
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;
|
|
}
|