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
579 lines
24 KiB
JavaScript
579 lines
24 KiB
JavaScript
import { readFileSync } from 'node:fs';
|
|
import { dirname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { JSDOM } from 'jsdom';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
ArgumentError,
|
|
AuthRequiredError,
|
|
CommandExecutionError,
|
|
EmptyResultError,
|
|
} from '@jackwener/opencli/errors';
|
|
import { getRegistry } from '@jackwener/opencli/registry';
|
|
import {
|
|
SEARCH_COLUMNS,
|
|
SHOP_COLUMNS,
|
|
detectAuthOrPageFailure,
|
|
normalizeShopId,
|
|
parsePrice,
|
|
parseReviewCount,
|
|
requireSearchLimit,
|
|
resolveCityId,
|
|
} from './utils.js';
|
|
import { extractSearchRows } from './search.js';
|
|
import { extractShopFields } from './shop.js';
|
|
import {
|
|
buildCitylistMap,
|
|
clearCityResolverCache,
|
|
extractCityIdFromPage,
|
|
resolveCityIdAsync,
|
|
} from './cityResolver.js';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const SHOP_FIXTURE = readFileSync(join(__dirname, '__fixtures__/shop.html'), 'utf8');
|
|
const SEARCH_FIXTURE = readFileSync(join(__dirname, '__fixtures__/search.html'), 'utf8');
|
|
|
|
function createPageMock(evaluateResult, overrides = {}) {
|
|
const evaluate = typeof evaluateResult === 'function'
|
|
? vi.fn(evaluateResult)
|
|
: vi.fn().mockResolvedValue(evaluateResult);
|
|
return {
|
|
goto: vi.fn().mockResolvedValue(undefined),
|
|
wait: vi.fn().mockResolvedValue(undefined),
|
|
evaluate,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe('dianping adapter — registration', () => {
|
|
it('registers search and shop as cookie-browser read commands', () => {
|
|
const search = getRegistry().get('dianping/search');
|
|
const shop = getRegistry().get('dianping/shop');
|
|
const detail = getRegistry().get('dianping/detail');
|
|
|
|
expect(search).toBeDefined();
|
|
expect(search.browser).toBe(true);
|
|
expect(search.strategy).toBe('cookie');
|
|
expect(search.columns).toEqual(SEARCH_COLUMNS);
|
|
|
|
expect(shop).toBeDefined();
|
|
expect(shop.browser).toBe(true);
|
|
expect(shop.strategy).toBe('cookie');
|
|
expect(shop.columns).toEqual(SHOP_COLUMNS);
|
|
expect(detail).toBe(shop);
|
|
});
|
|
});
|
|
|
|
describe('dianping adapter — helpers', () => {
|
|
it('validates search limit for direct func callers', () => {
|
|
expect(requireSearchLimit(undefined)).toBe(15);
|
|
expect(requireSearchLimit('5')).toBe(5);
|
|
expect(requireSearchLimit(15)).toBe(15);
|
|
expect(() => requireSearchLimit('abc')).toThrow(ArgumentError);
|
|
expect(() => requireSearchLimit('1.5')).toThrow(ArgumentError);
|
|
expect(() => requireSearchLimit(0)).toThrow(ArgumentError);
|
|
expect(() => requireSearchLimit(16)).toThrow(ArgumentError);
|
|
});
|
|
|
|
it('normalizes city, shop id, review count, and price inputs', () => {
|
|
expect(resolveCityId('北京')).toBe(2);
|
|
expect(resolveCityId('shanghai')).toBe(1);
|
|
expect(resolveCityId('dalian')).toBe(19);
|
|
expect(resolveCityId('shenyang')).toBe(18);
|
|
expect(resolveCityId('123')).toBe(123);
|
|
expect(resolveCityId('')).toBeNull();
|
|
expect(() => resolveCityId('not-a-city')).toThrow(ArgumentError);
|
|
|
|
expect(normalizeShopId('https://www.dianping.com/shop/GxJZ4urc9TnKE3kY?foo=1')).toBe('GxJZ4urc9TnKE3kY');
|
|
expect(normalizeShopId('GxJZ4urc9TnKE3kY')).toBe('GxJZ4urc9TnKE3kY');
|
|
expect(() => normalizeShopId('bad/id')).toThrow(ArgumentError);
|
|
|
|
expect(parseReviewCount('1.2万条')).toBe(12000);
|
|
expect(parseReviewCount('213 条评价')).toBe(213);
|
|
expect(parseReviewCount('暂无')).toBeNull();
|
|
expect(parsePrice('人均¥109')).toBe(109);
|
|
expect(parsePrice('暂无')).toBeNull();
|
|
});
|
|
|
|
it('classifies captcha, login, explicit empty, and unknown no-data pages', () => {
|
|
expect(() => detectAuthOrPageFailure({ url: 'https://verify.meituan.com/captcha' }, 'search'))
|
|
.toThrow(AuthRequiredError);
|
|
expect(() => detectAuthOrPageFailure({ text: '请先登录后继续访问' }, 'search'))
|
|
.toThrow(AuthRequiredError);
|
|
expect(() => detectAuthOrPageFailure(
|
|
{ text: '没有找到相关商户' },
|
|
'search',
|
|
{ emptyPatterns: [/没有找到相关商户/] },
|
|
)).toThrow(EmptyResultError);
|
|
expect(() => detectAuthOrPageFailure({ text: '<html>unexpected shell</html>' }, 'search'))
|
|
.toThrow(CommandExecutionError);
|
|
});
|
|
});
|
|
|
|
describe('dianping adapter — async city resolver', () => {
|
|
beforeEach(() => {
|
|
clearCityResolverCache();
|
|
});
|
|
|
|
it('returns null/numeric/static-map ids without ever touching the page', async () => {
|
|
const page = createPageMock({});
|
|
|
|
expect(await resolveCityIdAsync(page, undefined)).toBeNull();
|
|
expect(await resolveCityIdAsync(page, '')).toBeNull();
|
|
expect(await resolveCityIdAsync(page, ' ')).toBeNull();
|
|
expect(await resolveCityIdAsync(page, 47)).toBe(47);
|
|
expect(await resolveCityIdAsync(page, '47')).toBe(47);
|
|
expect(await resolveCityIdAsync(page, '北京')).toBe(2);
|
|
expect(await resolveCityIdAsync(page, 'shanghai')).toBe(1);
|
|
|
|
expect(page.goto).not.toHaveBeenCalled();
|
|
expect(page.evaluate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('falls back to /<pinyin> for an unknown lowercase slug, then caches', async () => {
|
|
const goto = vi.fn().mockResolvedValue(undefined);
|
|
const evaluate = vi.fn().mockResolvedValue(207);
|
|
const page = { goto, evaluate, wait: vi.fn() };
|
|
|
|
expect(await resolveCityIdAsync(page, 'shantou')).toBe(207);
|
|
expect(goto).toHaveBeenCalledTimes(1);
|
|
expect(goto).toHaveBeenCalledWith('https://www.dianping.com/shantou');
|
|
|
|
// Second call hits the in-process cache, no extra navigation.
|
|
expect(await resolveCityIdAsync(page, 'shantou')).toBe(207);
|
|
expect(goto).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('resolves a Chinese name via /citylist + /<pinyin>, then caches both forms', async () => {
|
|
const goto = vi.fn().mockResolvedValue(undefined);
|
|
const evaluate = vi.fn()
|
|
.mockResolvedValueOnce({ '汕头': 'shantou', '佛山': 'foshan' })
|
|
.mockResolvedValueOnce(207);
|
|
const page = { goto, evaluate, wait: vi.fn() };
|
|
|
|
expect(await resolveCityIdAsync(page, '汕头')).toBe(207);
|
|
expect(goto).toHaveBeenNthCalledWith(1, 'https://www.dianping.com/citylist');
|
|
expect(goto).toHaveBeenNthCalledWith(2, 'https://www.dianping.com/shantou');
|
|
|
|
// Cached for the Chinese name AND the discovered pinyin.
|
|
expect(await resolveCityIdAsync(page, '汕头')).toBe(207);
|
|
expect(await resolveCityIdAsync(page, 'shantou')).toBe(207);
|
|
expect(goto).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('rejects mixed/garbage input with ArgumentError before any navigation', async () => {
|
|
const page = createPageMock({});
|
|
await expect(resolveCityIdAsync(page, 'not-a-city!')).rejects.toThrow(ArgumentError);
|
|
expect(page.goto).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects a Chinese name that is not on /citylist with ArgumentError', async () => {
|
|
const goto = vi.fn().mockResolvedValue(undefined);
|
|
const evaluate = vi.fn().mockResolvedValueOnce({ '北京': 'beijing' });
|
|
const page = { goto, evaluate, wait: vi.fn() };
|
|
|
|
await expect(resolveCityIdAsync(page, '某虚构城')).rejects.toThrow(ArgumentError);
|
|
expect(goto).toHaveBeenCalledTimes(1);
|
|
expect(goto).toHaveBeenCalledWith('https://www.dianping.com/citylist');
|
|
});
|
|
|
|
it('throws CommandExecutionError when citylist renders without city anchors', async () => {
|
|
const goto = vi.fn().mockResolvedValue(undefined);
|
|
const evaluate = vi.fn().mockResolvedValueOnce({});
|
|
const page = { goto, evaluate, wait: vi.fn() };
|
|
|
|
await expect(resolveCityIdAsync(page, '汕头')).rejects.toThrow(CommandExecutionError);
|
|
expect(goto).toHaveBeenCalledTimes(1);
|
|
expect(goto).toHaveBeenCalledWith('https://www.dianping.com/citylist');
|
|
});
|
|
|
|
it('throws CommandExecutionError when the per-city page lacks a /search/keyword/{id}/ link', async () => {
|
|
const goto = vi.fn().mockResolvedValue(undefined);
|
|
const evaluate = vi.fn().mockResolvedValueOnce(null);
|
|
const page = { goto, evaluate, wait: vi.fn() };
|
|
|
|
await expect(resolveCityIdAsync(page, 'newcity')).rejects.toThrow(CommandExecutionError);
|
|
});
|
|
|
|
it('buildCitylistMap keeps Chinese-labeled city slugs and drops non-city paths', () => {
|
|
const dom = new JSDOM(`
|
|
<html><body>
|
|
<a href="//www.dianping.com/shanghai">上海</a>
|
|
<a href="//www.dianping.com/shantou">汕头</a>
|
|
<a href="/beijing">北京</a>
|
|
<a href="//www.dianping.com/citylist">更多城市 ></a>
|
|
<a href="//www.dianping.com/promo">优惠</a>
|
|
<a href="//www.dianping.com/shanghai">上海</a>
|
|
<a href="https://www.dianping.com/member/123">可乐不加冰</a>
|
|
<a href="https://example.com/notacity">东京</a>
|
|
</body></html>
|
|
`);
|
|
globalThis.document = dom.window.document;
|
|
|
|
try {
|
|
const map = buildCitylistMap();
|
|
expect(map['上海']).toBe('shanghai');
|
|
expect(map['汕头']).toBe('shantou');
|
|
expect(map['北京']).toBe('beijing');
|
|
expect(map['更多城市 >']).toBeUndefined();
|
|
expect(map['优惠']).toBeUndefined();
|
|
expect(map['可乐不加冰']).toBeUndefined();
|
|
expect(map['东京']).toBeUndefined();
|
|
} finally {
|
|
delete globalThis.document;
|
|
}
|
|
});
|
|
|
|
it('extractCityIdFromPage pulls the cityId from the first /search/keyword/{id}/ link', () => {
|
|
const dom = new JSDOM(`
|
|
<html><body>
|
|
<script>window.bad = "/search/keyword/999/";</script>
|
|
<a href="https://example.com/search/keyword/888/0_x">wrong host</a>
|
|
<a href="https://www.dianping.com.evil.com/search/keyword/666/0_x">host suffix</a>
|
|
<a href="http://www.dianping.com/search/keyword/777/0_x">non-https</a>
|
|
<a href="/search/keyword/207/0_%E5%88%BA%E8%BA%AB">刺身</a>
|
|
<a href="/search/category/207/10">美食</a>
|
|
</body></html>
|
|
`, { url: 'https://www.dianping.com/shantou' });
|
|
globalThis.document = dom.window.document;
|
|
globalThis.location = dom.window.location;
|
|
|
|
try {
|
|
expect(extractCityIdFromPage()).toBe(207);
|
|
} finally {
|
|
delete globalThis.document;
|
|
delete globalThis.location;
|
|
}
|
|
});
|
|
|
|
it('extractCityIdFromPage returns null when no /search/keyword/{id}/ link exists', () => {
|
|
const dom = new JSDOM(`<html><body><main>blocked</main></body></html>`);
|
|
globalThis.document = dom.window.document;
|
|
|
|
try {
|
|
expect(extractCityIdFromPage()).toBeNull();
|
|
} finally {
|
|
delete globalThis.document;
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('dianping adapter — search runtime', () => {
|
|
const command = getRegistry().get('dianping/search');
|
|
|
|
it('fails fast on invalid args before browser work', async () => {
|
|
const page = createPageMock({ ok: true, rows: [] });
|
|
|
|
await expect(command.func(page, { keyword: ' ', limit: 5 })).rejects.toThrow(ArgumentError);
|
|
await expect(command.func(page, { keyword: '火锅', limit: 'abc' })).rejects.toThrow(ArgumentError);
|
|
expect(page.goto).not.toHaveBeenCalled();
|
|
expect(page.evaluate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns normalized rows for happy search results', async () => {
|
|
const page = createPageMock({
|
|
ok: true,
|
|
rows: [{
|
|
rank: 1,
|
|
shop_id: 'GxJZ4urc9TnKE3kY',
|
|
name: ' 火锅店 ',
|
|
starClass: '45',
|
|
reviewsRaw: '1.2万条',
|
|
priceRaw: '人均¥109',
|
|
cuisine: '重庆火锅',
|
|
district: '海淀区',
|
|
url: 'https://www.dianping.com/shop/GxJZ4urc9TnKE3kY',
|
|
}],
|
|
});
|
|
|
|
const rows = await command.func(page, { keyword: '火锅', city: '北京', limit: '1' });
|
|
|
|
expect(page.goto).toHaveBeenCalledWith('https://www.dianping.com/search/keyword/2/0_%E7%81%AB%E9%94%85');
|
|
expect(page.wait).toHaveBeenCalledWith(2);
|
|
expect(rows).toEqual([{
|
|
rank: 1,
|
|
shop_id: 'GxJZ4urc9TnKE3kY',
|
|
name: ' 火锅店 ',
|
|
rating: 4.5,
|
|
reviews: 12000,
|
|
price: 109,
|
|
cuisine: '重庆火锅',
|
|
district: '海淀区',
|
|
url: 'https://www.dianping.com/shop/GxJZ4urc9TnKE3kY',
|
|
}]);
|
|
});
|
|
|
|
it('maps explicit zero-result pages to EmptyResultError', async () => {
|
|
const page = createPageMock({
|
|
ok: false,
|
|
sample: '没有找到相关商户,换个关键词试试',
|
|
url: 'https://www.dianping.com/search/keyword/2/0_x',
|
|
});
|
|
|
|
await expect(command.func(page, { keyword: 'zzzxxyy', city: '北京' })).rejects.toThrow(EmptyResultError);
|
|
});
|
|
|
|
it('maps captcha pages to AuthRequiredError', async () => {
|
|
const page = createPageMock({
|
|
ok: false,
|
|
sample: '请依次点击图中图标完成身份核实',
|
|
url: 'https://verify.meituan.com/v2/web/general_page',
|
|
});
|
|
|
|
await expect(command.func(page, { keyword: '火锅' })).rejects.toThrow(AuthRequiredError);
|
|
});
|
|
|
|
it('maps selector drift and missing shop ids to CommandExecutionError', async () => {
|
|
await expect(command.func(createPageMock({
|
|
ok: false,
|
|
sample: '<main>unexpected dianping layout</main>',
|
|
url: 'https://www.dianping.com/search/keyword/2/0_x',
|
|
}), { keyword: '火锅', city: 2 })).rejects.toThrow(CommandExecutionError);
|
|
|
|
await expect(command.func(createPageMock({
|
|
ok: true,
|
|
rows: [{ rank: 1, shop_id: '', name: '火锅店' }],
|
|
}), { keyword: '火锅', city: 2 })).rejects.toThrow(CommandExecutionError);
|
|
|
|
await expect(command.func(createPageMock({
|
|
ok: true,
|
|
rows: [
|
|
{ rank: 1, shop_id: 'GxJZ4urc9TnKE3kY', name: '火锅店' },
|
|
{ rank: 2, shop_id: '', name: '缺 id 火锅店' },
|
|
],
|
|
}), { keyword: '火锅', city: 2, limit: 2 })).rejects.toThrow(CommandExecutionError);
|
|
});
|
|
|
|
it('wraps browser navigation and evaluate failures as CommandExecutionError', async () => {
|
|
await expect(command.func(createPageMock(
|
|
{ ok: true, rows: [] },
|
|
{ goto: vi.fn().mockRejectedValue(new Error('browser disconnected')) },
|
|
), { keyword: '火锅' })).rejects.toThrow(CommandExecutionError);
|
|
|
|
await expect(command.func(createPageMock(() => {
|
|
throw new Error('evaluate failed');
|
|
}), { keyword: '火锅' })).rejects.toThrow(CommandExecutionError);
|
|
});
|
|
});
|
|
|
|
describe('dianping adapter — shop runtime', () => {
|
|
const command = getRegistry().get('dianping/shop');
|
|
|
|
it('fails fast on invalid shop id before browser work', async () => {
|
|
const page = createPageMock({ ok: true });
|
|
|
|
await expect(command.func(page, { shop_id: '' })).rejects.toThrow(ArgumentError);
|
|
await expect(command.func(page, { shop_id: 'bad/id' })).rejects.toThrow(ArgumentError);
|
|
expect(page.goto).not.toHaveBeenCalled();
|
|
expect(page.evaluate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns a field/value sheet and accepts a full shop URL', async () => {
|
|
const page = createPageMock({
|
|
ok: true,
|
|
name: '火锅店',
|
|
rating: '4.8',
|
|
reviewsRaw: '321条',
|
|
priceRaw: '¥109',
|
|
breakdown: { '口味': 4.9, '环境': 4.7, '服务': 4.6 },
|
|
features: ['可停车', '有包间'],
|
|
address: '北京市海淀区',
|
|
subway: '距地铁步行300m',
|
|
hours: '营业中 11:00-22:00',
|
|
rank: '海淀区 口味榜 · 第1名',
|
|
url: 'https://www.dianping.com/shop/GxJZ4urc9TnKE3kY',
|
|
});
|
|
|
|
const rows = await command.func(page, {
|
|
shop_id: 'https://www.dianping.com/shop/GxJZ4urc9TnKE3kY?foo=1',
|
|
});
|
|
|
|
expect(page.goto).toHaveBeenCalledWith('https://www.dianping.com/shop/GxJZ4urc9TnKE3kY');
|
|
expect(rows).toContainEqual({ field: 'shop_id', value: 'GxJZ4urc9TnKE3kY' });
|
|
expect(rows).toContainEqual({ field: 'rating', value: 4.8 });
|
|
expect(rows).toContainEqual({ field: 'reviews', value: 321 });
|
|
expect(rows).toContainEqual({ field: 'price', value: 109 });
|
|
expect(rows).toContainEqual({ field: 'taste', value: 4.9 });
|
|
expect(rows).toContainEqual({ field: 'ingredients', value: null });
|
|
expect(rows).toContainEqual({ field: 'features', value: '可停车, 有包间' });
|
|
});
|
|
|
|
it('maps captcha, not-found, and selector drift to distinct typed errors', async () => {
|
|
await expect(command.func(createPageMock({
|
|
ok: false,
|
|
sample: '身份核实 请依次点击',
|
|
url: 'https://verify.meituan.com/v2/web/general_page',
|
|
}), { shop_id: 'GxJZ4urc9TnKE3kY' })).rejects.toThrow(AuthRequiredError);
|
|
|
|
await expect(command.func(createPageMock({
|
|
ok: false,
|
|
sample: '商户不存在或店铺已关闭',
|
|
url: 'https://www.dianping.com/shop/missing',
|
|
}), { shop_id: 'missing' })).rejects.toThrow(EmptyResultError);
|
|
|
|
await expect(command.func(createPageMock({
|
|
ok: false,
|
|
sample: '<main>new shop shell without expected selectors</main>',
|
|
url: 'https://www.dianping.com/shop/GxJZ4urc9TnKE3kY',
|
|
}), { shop_id: 'GxJZ4urc9TnKE3kY' })).rejects.toThrow(CommandExecutionError);
|
|
});
|
|
|
|
it('wraps browser navigation and evaluate failures as CommandExecutionError', async () => {
|
|
await expect(command.func(createPageMock(
|
|
{ ok: true },
|
|
{ goto: vi.fn().mockRejectedValue(new Error('browser disconnected')) },
|
|
), { shop_id: 'GxJZ4urc9TnKE3kY' })).rejects.toThrow(CommandExecutionError);
|
|
|
|
await expect(command.func(createPageMock(() => {
|
|
throw new Error('evaluate failed');
|
|
}), { shop_id: 'GxJZ4urc9TnKE3kY' })).rejects.toThrow(CommandExecutionError);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* In-browser DOM extractors against frozen sanitized HTML fixtures.
|
|
*
|
|
* The mocked-page.evaluate tests above can't catch silent bugs that live
|
|
* inside the IIFE — they feed pre-baked results to the func and the real
|
|
* DOM walk never runs. PR #1312 found two such bugs only on live verify:
|
|
*
|
|
* 1. shop title fallback split on ASCII `[]` while dianping renders
|
|
* full-width `【】`, so `name` was always empty.
|
|
* 2. headText `\s+` collapse fused rating "4.8" with "21241条", so a
|
|
* head-wide /\d+条/ regex captured "4.821241" → 5.
|
|
*
|
|
* These tests replay the real (sanitized) HTML through JSDOM so changes
|
|
* to the extractor logic that re-introduce either bug fail in CI.
|
|
*/
|
|
describe('dianping adapter — extractors against frozen HTML fixtures', () => {
|
|
let originalDocument;
|
|
let originalLocation;
|
|
|
|
beforeEach(() => {
|
|
originalDocument = globalThis.document;
|
|
originalLocation = globalThis.location;
|
|
});
|
|
|
|
afterEach(() => {
|
|
globalThis.document = originalDocument;
|
|
globalThis.location = originalLocation;
|
|
});
|
|
|
|
function loadFixture(html, url) {
|
|
const dom = new JSDOM(html, { url });
|
|
globalThis.document = dom.window.document;
|
|
globalThis.location = dom.window.location;
|
|
return dom;
|
|
}
|
|
|
|
it('extractShopFields recovers name from full-width 【】 title and avoids rating/reviews fusion', () => {
|
|
loadFixture(SHOP_FIXTURE, 'https://www.dianping.com/shop/H20FrTRI6kbXbgTu');
|
|
|
|
const data = extractShopFields();
|
|
|
|
expect(data.ok).toBe(true);
|
|
// Regression guard for #1312 bug #1: title is "【芈重山老火锅(五道口店)】..."
|
|
// (full-width brackets); ASCII `[]` split would leave name empty here.
|
|
expect(data.name).toBe('芈重山老火锅(五道口店)');
|
|
// Regression guard for #1312 bug #2: .reviews selector returns "21241条" cleanly,
|
|
// while a head-wide /\d+条/ on the whitespace-collapsed headText would catch
|
|
// "4.821241条" → 5. Asserting the raw string excludes both regressions.
|
|
expect(data.reviewsRaw).toBe('21241条');
|
|
expect(data.rating).toBe('4.8');
|
|
expect(data.priceRaw).toBe('¥109');
|
|
expect(data.breakdown).toEqual({
|
|
'口味': 4.8,
|
|
'环境': 4.8,
|
|
'服务': 4.8,
|
|
'食材': 4.9,
|
|
});
|
|
expect(data.hours).toBe('营业中11:00-次日02:00');
|
|
expect(data.rank).toBe('海淀区重庆火锅口味榜 · 第1名');
|
|
expect(data.subway).toBe('距地铁13号线五道口站A北口步行90m');
|
|
expect(data.url).toBe('https://www.dianping.com/shop/H20FrTRI6kbXbgTu');
|
|
});
|
|
|
|
it('extractSearchRows returns three result-shaped rows with shop_id, name, reviews, price, star, tags', () => {
|
|
loadFixture(SEARCH_FIXTURE, 'https://www.dianping.com/search/keyword/2/0_%E7%81%AB%E9%94%85');
|
|
|
|
const result = extractSearchRows();
|
|
|
|
expect(result.ok).toBe(true);
|
|
expect(result.rows).toHaveLength(3);
|
|
|
|
// First row carries the rating + review fusion bug for shop fixture too;
|
|
// here we lock in the per-card breakdown to prove every row has its own
|
|
// identity (no silent fall-through to row[0] data).
|
|
expect(result.rows[0]).toMatchObject({
|
|
rank: 1,
|
|
shop_id: 'H20FrTRI6kbXbgTu',
|
|
name: '芈重山老火锅(五道口店)',
|
|
starClass: '50',
|
|
reviewsRaw: '21231',
|
|
priceRaw: '¥109',
|
|
cuisine: '重庆火锅',
|
|
district: '五道口',
|
|
url: 'https://www.dianping.com/shop/H20FrTRI6kbXbgTu',
|
|
});
|
|
expect(result.rows[1]).toMatchObject({
|
|
rank: 2,
|
|
shop_id: 'H6uJDRHtNUreCoBa',
|
|
name: '百年前门铜锅涮肉(前门总店)',
|
|
starClass: '45',
|
|
reviewsRaw: '15649',
|
|
priceRaw: '¥81',
|
|
cuisine: '老北京火锅',
|
|
district: '前门/大栅栏',
|
|
});
|
|
expect(result.rows[2]).toMatchObject({
|
|
rank: 3,
|
|
shop_id: 'H7gQoQ1L5p7CoUEs',
|
|
name: '东来顺饭庄(前门大街店)',
|
|
starClass: '50',
|
|
reviewsRaw: '21537',
|
|
priceRaw: '¥124',
|
|
});
|
|
|
|
// Round-trip through the public adapter mappers (parseReviewCount /
|
|
// parsePrice / star → rating) — this is what the live func does after
|
|
// the extractor returns. Catches drift between extractor + post-process.
|
|
const finalRows = result.rows.map((r) => ({
|
|
shop_id: r.shop_id,
|
|
rating: r.starClass ? Number((Number(r.starClass) / 10).toFixed(1)) : null,
|
|
reviews: parseReviewCount(r.reviewsRaw),
|
|
price: parsePrice(r.priceRaw),
|
|
}));
|
|
expect(finalRows).toEqual([
|
|
{ shop_id: 'H20FrTRI6kbXbgTu', rating: 5.0, reviews: 21231, price: 109 },
|
|
{ shop_id: 'H6uJDRHtNUreCoBa', rating: 4.5, reviews: 15649, price: 81 },
|
|
{ shop_id: 'H7gQoQ1L5p7CoUEs', rating: 5.0, reviews: 21537, price: 124 },
|
|
]);
|
|
});
|
|
|
|
it('extractShopFields signals ok:false with a sample when shop-head is missing', () => {
|
|
loadFixture(
|
|
'<html><head><title>blocked</title></head><body><main>验证码</main></body></html>',
|
|
'https://verify.meituan.com/v2/web/general_page',
|
|
);
|
|
|
|
const data = extractShopFields();
|
|
|
|
expect(data.ok).toBe(false);
|
|
expect(data.url).toBe('https://verify.meituan.com/v2/web/general_page');
|
|
expect(data.sample).toContain('验证码');
|
|
});
|
|
|
|
it('extractSearchRows signals ok:false with a sample when shop-all-list is empty', () => {
|
|
loadFixture(
|
|
'<html><head><title>blocked</title></head><body><main>没有找到相关</main><ul id="shop-all-list"></ul></body></html>',
|
|
'https://www.dianping.com/search/keyword/2/0_zzz',
|
|
);
|
|
|
|
const result = extractSearchRows();
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(result.sample).toContain('没有找到相关');
|
|
expect(result.url).toBe('https://www.dianping.com/search/keyword/2/0_zzz');
|
|
});
|
|
});
|