Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

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;
}