项目文件夹

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

174 行
7.1 KiB
JavaScript

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
/**
* dianping shop — read shop detail by shop ID (the alphanumeric handle in
* `https://www.dianping.com/shop/<shop_id>`).
*
* Returns a key/value sheet so the table view stays readable when fields
* are absent (phone is hidden on PC web — only shows in app — so the row
* surfaces it as `null` rather than fabricating).
*
* The DOM extractor is defined as a plain top-level function and injected
* into `page.evaluate` via `.toString()`, so the same code is exercised by
* a JSDOM-against-frozen-fixture unit test (see dianping.test.js). Mocked
* `page.evaluate` tests can't catch in-browser bugs like the original
* full-width `【】` vs ASCII `[]` mismatch — fixture tests can.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import {
SHOP_COLUMNS,
detectAuthOrPageFailure,
normalizeShopId,
parsePrice,
parseReviewCount,
wrapDianpingStep,
} from './utils.js';
/**
* Pure DOM extractor for the dianping shop page.
*
* Uses bare `document` / `location` so it runs identically in:
* - the live browser (injected via `${extractShopFields.toString()}`)
* - JSDOM unit tests (which swap `globalThis.document` / `globalThis.location`)
*
* Returns either `{ ok: true, ...fields }` on success or
* `{ ok: false, sample, url }` so the caller can classify the failure.
*/
export function extractShopFields() {
const head = document.querySelector('.shop-head');
if (!head) {
const body = document.body;
const sampleText = (body && (body.innerText || body.textContent)) || '';
return {
ok: false,
sample: sampleText.slice(0, 800),
url: location.href,
};
}
const headText = head.textContent.trim().replace(/\s+/g, ' ');
// Shop name: dianping puts it as "【芈重山老火锅(五道口店)】" at
// the head of document.title (full-width 【】, not ASCII []).
// Try selectors first, then fall back to title parsing.
const titleEl = document.querySelector('.shop-name, .shop-head h2, .shop-head h1');
let name = (titleEl && titleEl.textContent && titleEl.textContent.trim()) || '';
if (!name) {
const t = document.title || '';
const m = t.match(/【([^】]+)】/);
if (m) name = m[1].trim();
}
const ratingEl = document.querySelector('.star-score');
const ratingText = (ratingEl && ratingEl.textContent && ratingEl.textContent.trim()) || '';
const features = Array.from(document.querySelectorAll('.shop-feature'))
.map((f) => f.textContent.trim())
.filter(Boolean);
const addressEl = document.querySelector('.desc-info');
const address = (addressEl && addressEl.textContent && addressEl.textContent.trim()) || '';
const subwayMatch = headText.match(/距(?:地铁)?[^\s]+?步行\d+m/);
const subway = subwayMatch ? subwayMatch[0] : '';
// Reviews: prefer .reviews / .review-num selector — its text is
// "21241条" cleanly. Whitespace-collapsed headText fuses the
// rating "4.8" with review digits ("4.821241条"), so a
// head-wide /\d+条/ regex captures "4.821241" and rounds to 5.
const reviewEl = document.querySelector('.reviews, .review-num, .reviewCount, .reviewCountSentence');
let reviewsRaw = (reviewEl && reviewEl.textContent && reviewEl.textContent.trim()) || '';
if (!reviewsRaw) {
const titleReviewEl = document.querySelector('.review-title');
const titleText = titleReviewEl && titleReviewEl.textContent;
const titleM = titleText && titleText.match(/评价\(([\d.,万]+)\)/);
if (titleM) reviewsRaw = titleM[1];
}
// Shop-head text holds price + cuisine + district + rank.
const priceMatch = headText.match(/[¥¥]\s*\d+(?:\.\d+)?/);
// Try to read score breakdown ("口味:4.8 环境:4.8 服务:4.8 食材:4.9").
const breakdown = {};
const breakKeys = ['口味', '环境', '服务', '食材'];
for (const key of breakKeys) {
const m = headText.match(new RegExp(key + '[:]\\s*([0-9.]+)'));
if (m) breakdown[key] = Number(m[1]);
}
// Hours: "营业中 11:00-次日02:00" / "今日休息".
const hoursMatch = headText.match(/营业中[^\s]*\d{1,2}:\d{2}-(?:次日)?\d{1,2}:\d{2}|今日休息|暂停营业/);
// Rank line: "海淀区 重庆火锅 口味榜 · 第1名".
const rankMatch = headText.match(/[^\s]+?(?:口味|人气|环境|服务)榜\s*[·•]\s*第\d+名/);
return {
ok: true,
name,
rating: ratingText,
reviewsRaw,
priceRaw: (priceMatch && priceMatch[0]) || '',
breakdown,
features,
address,
subway,
hours: (hoursMatch && hoursMatch[0]) || '',
rank: (rankMatch && rankMatch[0]) || '',
url: location.href,
};
}
cli({
site: 'dianping',
name: 'shop',
access: 'read',
aliases: ['detail'],
description: '大众点评店铺详情(按 shop_id',
domain: 'www.dianping.com',
strategy: Strategy.COOKIE,
args: [
{ name: 'shop_id', required: true, positional: true, help: '店铺 ID(来自 search 的 shop_id 列,或 https://www.dianping.com/shop/<id> URL 段)' },
],
columns: SHOP_COLUMNS,
func: async (page, kwargs) => {
const shopId = normalizeShopId(kwargs.shop_id);
const url = `https://www.dianping.com/shop/${shopId}`;
await wrapDianpingStep(`shop ${shopId} navigation`, async () => {
await page.goto(url);
await page.wait(3);
});
const data = await wrapDianpingStep(
`shop ${shopId} extraction`,
() => page.evaluate(`(${extractShopFields.toString()})()`),
);
if (!data || !data.ok) {
detectAuthOrPageFailure(
{ text: String(data?.sample || ''), url: String(data?.url || url) },
`shop ${shopId}`,
{ emptyPatterns: [/商户不存在|店铺不存在|店铺已关闭|页面不存在|404|已下线|没有找到相关商户/i] },
);
}
const rating = data.rating ? Number(data.rating) : null;
const reviews = parseReviewCount(data.reviewsRaw);
const price = parsePrice(data.priceRaw);
const breakdown = data.breakdown || {};
const fields = [
['shop_id', shopId],
['name', data.name || ''],
['rating', Number.isFinite(rating) ? rating : null],
['reviews', reviews],
['price', price],
['rank', data.rank || ''],
['taste', Number.isFinite(breakdown['口味']) ? breakdown['口味'] : null],
['environment', Number.isFinite(breakdown['环境']) ? breakdown['环境'] : null],
['service', Number.isFinite(breakdown['服务']) ? breakdown['服务'] : null],
['ingredients', Number.isFinite(breakdown['食材']) ? breakdown['食材'] : null],
['hours', data.hours || ''],
['address', data.address || ''],
['subway', data.subway || ''],
['features', (data.features || []).join(', ')],
['url', data.url || url],
];
return fields.map(([field, value]) => ({ field, value }));
},
});