/** * 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, })); }, });