9b395f5cc3
Build Chrome Extension / build (push) Waiting to run
Trigger Website Rebuild (Docs Updated) / dispatch (push) Waiting to run
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
158 lines
5.6 KiB
JavaScript
158 lines
5.6 KiB
JavaScript
/**
|
|
* Shared helpers for dianping (大众点评) adapters.
|
|
*
|
|
* Mobile m.dianping.com is intentionally crippled to push users to the
|
|
* native app — body renders as 2 chars when probed from desktop UA.
|
|
* www.dianping.com (PC site) returns the full search HTML server-side
|
|
* and does NOT require JS hydration, so adapters target it directly.
|
|
*/
|
|
|
|
import {
|
|
ArgumentError,
|
|
AuthRequiredError,
|
|
CommandExecutionError,
|
|
EmptyResultError,
|
|
} from '@jackwener/opencli/errors';
|
|
|
|
/**
|
|
* Common Chinese cities → dianping cityId.
|
|
* Source: dianping.com /citylist URL structure (cityId is the integer in
|
|
* the search path: /search/keyword/{cityId}/0_keyword).
|
|
*/
|
|
export const CITY_ID = {
|
|
beijing: 2, '北京': 2,
|
|
shanghai: 1, '上海': 1,
|
|
guangzhou: 4, '广州': 4,
|
|
shenzhen: 7, '深圳': 7,
|
|
hangzhou: 3, '杭州': 3,
|
|
chengdu: 8, '成都': 8,
|
|
chongqing: 9, '重庆': 9,
|
|
nanjing: 5, '南京': 5,
|
|
suzhou: 6, '苏州': 6,
|
|
xian: 17, '西安': 17,
|
|
wuhan: 16, '武汉': 16,
|
|
tianjin: 10, '天津': 10,
|
|
qingdao: 21, '青岛': 21,
|
|
changsha: 344, '长沙': 344,
|
|
dalian: 19, '大连': 19,
|
|
shenyang: 18, '沈阳': 18,
|
|
kunming: 25, '昆明': 25,
|
|
fuzhou: 110, '福州': 110,
|
|
xiamen: 14, '厦门': 14,
|
|
hefei: 26, '合肥': 26,
|
|
};
|
|
|
|
export const SEARCH_COLUMNS = ['rank', 'shop_id', 'name', 'rating', 'reviews', 'price', 'cuisine', 'district', 'url'];
|
|
export const SHOP_COLUMNS = ['field', 'value'];
|
|
|
|
/**
|
|
* Resolve a city argument (name or id) to a numeric cityId.
|
|
* Returns null when the cookie's default city should be used.
|
|
*/
|
|
export function resolveCityId(cityArg) {
|
|
if (cityArg == null || cityArg === '') return null;
|
|
const raw = String(cityArg).trim().toLowerCase();
|
|
if (/^\d+$/.test(raw)) return Number(raw);
|
|
const id = CITY_ID[raw];
|
|
if (!id) {
|
|
const names = Object.keys(CITY_ID).filter((k) => /^[a-z]+$/.test(k)).join(', ');
|
|
throw new ArgumentError(
|
|
'city',
|
|
`unknown city '${cityArg}'. pass a numeric cityId or one of: ${names}`,
|
|
);
|
|
}
|
|
return id;
|
|
}
|
|
|
|
export function requireSearchLimit(value) {
|
|
const raw = value == null || value === '' ? 15 : value;
|
|
const limit = typeof raw === 'number' ? raw : Number(String(raw).trim());
|
|
if (!Number.isInteger(limit) || limit < 1 || limit > 15) {
|
|
throw new ArgumentError('limit must be an integer between 1 and 15 (dianping single page)');
|
|
}
|
|
return limit;
|
|
}
|
|
|
|
export function normalizeShopId(rawInput) {
|
|
const raw = String(rawInput || '').trim();
|
|
if (!raw) throw new ArgumentError('shop_id must be a non-empty string');
|
|
|
|
const idMatch = raw.match(/\/shop\/([^?#/]+)/);
|
|
const shopId = idMatch ? idMatch[1] : raw;
|
|
if (!/^[A-Za-z0-9_-]+$/.test(shopId)) {
|
|
throw new ArgumentError(`'${raw}' does not look like a dianping shop id`);
|
|
}
|
|
return shopId;
|
|
}
|
|
|
|
export function wrapDianpingStep(label, fn) {
|
|
return Promise.resolve()
|
|
.then(fn)
|
|
.catch((err) => {
|
|
if (err?.code) throw err;
|
|
const message = err?.message || String(err);
|
|
throw new CommandExecutionError(`dianping ${label} failed: ${message}`);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Throw the right typed error for a dianping page that didn't render data.
|
|
* The site short-circuits HTML when bot/login checks trip — typically
|
|
* redirects to verify.meituan.com (Yoda icon-tap captcha) or to a login
|
|
* page when the cookie is missing.
|
|
*/
|
|
export function detectAuthOrPageFailure({ text = '', url = '' }, contextHint, { emptyPatterns = [] } = {}) {
|
|
const signal = `${url} ${text}`;
|
|
if (/verify\.meituan\.com|verifyimg|身份核实|请依次点击|美团安全验证|Yoda/i.test(signal)) {
|
|
throw new AuthRequiredError(
|
|
'dianping.com',
|
|
`dianping ${contextHint} blocked by captcha — open ${url || 'www.dianping.com'} manually in this profile and solve the captcha, then retry`,
|
|
);
|
|
}
|
|
if (/login\.dianping\.com|account\.dianping\.com|请先登录|未登录|请登录/.test(signal)) {
|
|
throw new AuthRequiredError(
|
|
'dianping.com',
|
|
`dianping ${contextHint} requires login — sign in to dianping.com in this profile, then retry`,
|
|
);
|
|
}
|
|
if (emptyPatterns.some((pattern) => pattern.test(signal))) {
|
|
throw new EmptyResultError(`dianping ${contextHint}`);
|
|
}
|
|
const sample = text ? `; sample: ${String(text).slice(0, 160)}` : '';
|
|
throw new CommandExecutionError(
|
|
`dianping ${contextHint} did not render expected data${sample}`,
|
|
'This usually means dianping changed its HTML, returned an unexpected error page, or the browser profile hit an unrecognized anti-bot state.',
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Parse "21231" / "1.2万" review-count strings into integers.
|
|
* Returns null when the input has no parseable digits.
|
|
*/
|
|
export function parseReviewCount(raw) {
|
|
if (raw == null) return null;
|
|
const s = String(raw).trim();
|
|
if (!s) return null;
|
|
const wanMatch = s.match(/^([\d.]+)\s*万/);
|
|
if (wanMatch) {
|
|
const n = Number(wanMatch[1]);
|
|
return Number.isFinite(n) ? Math.round(n * 10000) : null;
|
|
}
|
|
const plainMatch = s.match(/(\d+(?:\.\d+)?)/);
|
|
if (!plainMatch) return null;
|
|
const n = Number(plainMatch[1]);
|
|
return Number.isFinite(n) ? Math.round(n) : null;
|
|
}
|
|
|
|
/**
|
|
* Parse "¥109" / "¥109" / "人均¥109" into a numeric yuan value.
|
|
* Returns null when no price is present (some shops omit price entirely).
|
|
*/
|
|
export function parsePrice(raw) {
|
|
if (!raw) return null;
|
|
const m = String(raw).match(/[¥¥]\s*(\d+(?:\.\d+)?)/);
|
|
if (!m) return null;
|
|
const n = Number(m[1]);
|
|
return Number.isFinite(n) ? n : null;
|
|
}
|