Files
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

158 lines
6.3 KiB
JavaScript

/**
* dianping search — search shops/restaurants by keyword on a given city.
*
* Targets www.dianping.com (PC site). The PC site renders search results
* server-side, so the func only needs to parse the SSR DOM after navigate.
* m.dianping.com (mobile) is intentionally crippled for non-mobile UAs and
* does not return data without app installation, so it's not used.
*
* 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).
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
import {
SEARCH_COLUMNS,
detectAuthOrPageFailure,
parsePrice,
parseReviewCount,
requireSearchLimit,
wrapDianpingStep,
} from './utils.js';
import { resolveCityIdAsync } from './cityResolver.js';
/**
* Pure DOM extractor for the dianping search-results page.
*
* Uses bare `document` / `location` so it runs identically in:
* - the live browser (injected via `${extractSearchRows.toString()}`)
* - JSDOM unit tests (which swap `globalThis.document` / `globalThis.location`)
*/
export function extractSearchRows() {
const items = document.querySelectorAll('#shop-all-list li');
if (items.length === 0) {
const body = document.body;
const sampleText = (body && (body.innerText || body.textContent)) || '';
return {
ok: false,
sample: sampleText.slice(0, 800),
url: location.href,
};
}
const rows = [];
items.forEach((el, i) => {
const resultShape = el.querySelector('.tit h4, .review-num, .mean-price, .tag-addr')
|| el.querySelector('.tit a, a[data-shopid], a[href*="/shop/"]');
if (!resultShape) return;
const link = el.querySelector('.tit a[href*="/shop/"]')
|| el.querySelector('a[data-shopid]')
|| el.querySelector('a[href*="/shop/"]');
const shopId = (link && link.getAttribute('data-shopid'))
|| (link && (link.getAttribute('href') || '').match(/\/shop\/([^?#/]+)/)?.[1])
|| '';
const titleEl = el.querySelector('.tit h4');
const name = (titleEl && titleEl.textContent && titleEl.textContent.trim())
|| (link && link.getAttribute('title'))
|| '';
const reviewEl = el.querySelector('.review-num b');
const reviewsRaw = (reviewEl && reviewEl.textContent && reviewEl.textContent.trim()) || '';
const priceEl = el.querySelector('.mean-price b');
const priceRaw = (priceEl && priceEl.textContent && priceEl.textContent.trim()) || '';
const tagAddr = Array.from(el.querySelectorAll('.tag-addr .tag'))
.map((t) => t.textContent.trim())
.filter(Boolean);
let starClass = '';
const starWrap = el.querySelector('.nebula_star');
if (starWrap) {
const m = starWrap.outerHTML.match(/star_(\d{2})/g);
if (m && m.length) {
const last = m[m.length - 1];
const lastDigits = last.match(/star_(\d{2})/)[1];
starClass = lastDigits;
}
}
rows.push({
rank: i + 1,
shop_id: shopId,
name,
starClass,
reviewsRaw,
priceRaw,
cuisine: tagAddr[0] || '',
district: tagAddr[1] || '',
url: shopId ? 'https://www.dianping.com/shop/' + shopId : '',
});
});
return { ok: true, rows };
}
cli({
site: 'dianping',
name: 'search',
access: 'read',
description: '大众点评店铺搜索(按关键词 + 城市)',
domain: 'www.dianping.com',
strategy: Strategy.COOKIE,
args: [
{ name: 'keyword', required: true, positional: true, help: '搜索关键词,例如 "火锅"' },
{ name: 'city', help: '城市名(北京/上海/汕头/beijing/shantou/...)或 cityId 数字。未在静态表中的城市会通过 dianping.com 在线解析。不传则使用 cookie 默认城市' },
{ name: 'limit', type: 'int', default: 15, help: '返回的店铺数量(最多 15,dianping 单页固定 15 条)' },
],
columns: SEARCH_COLUMNS,
func: async (page, kwargs) => {
const keyword = String(kwargs.keyword || '').trim();
if (!keyword) throw new ArgumentError('keyword', 'must be a non-empty string');
const limit = requireSearchLimit(kwargs.limit);
const cityId = await wrapDianpingStep(
'city resolve',
() => resolveCityIdAsync(page, kwargs.city),
);
const path = cityId
? `/search/keyword/${cityId}/0_${encodeURIComponent(keyword)}`
: `/search/keyword/0/0_${encodeURIComponent(keyword)}`;
const url = `https://www.dianping.com${path}`;
await wrapDianpingStep(`search "${keyword}" navigation`, async () => {
await page.goto(url);
await page.wait(2);
});
const result = await wrapDianpingStep(
`search "${keyword}" extraction`,
() => page.evaluate(`(${extractSearchRows.toString()})()`),
);
if (!result || !result.ok) {
detectAuthOrPageFailure(
{ text: String(result?.sample || ''), url: String(result?.url || url) },
`search "${keyword}"`,
{ emptyPatterns: [/没有找到|暂无结果|暂无商户|换个关键词|未找到相关/i] },
);
}
const rows = (result.rows || []).slice(0, limit);
if (rows.length === 0) {
throw new CommandExecutionError('dianping search parser found no result-shaped shop cards');
}
if (rows.some((row) => !row?.shop_id)) {
throw new CommandExecutionError('dianping search parser found result cards without shop_id values');
}
return rows.map((r) => ({
rank: r.rank,
shop_id: r.shop_id,
name: r.name,
rating: r.starClass ? Number((Number(r.starClass) / 10).toFixed(1)) : null,
reviews: parseReviewCount(r.reviewsRaw),
price: parsePrice(r.priceRaw),
cuisine: r.cuisine,
district: r.district,
url: r.url,
}));
},
});