df83fa836c
Шаг 1 «Конкурентное поле» — чекпоинт: - Финальный проход «Найти и объединить дубли» на поле и предложениях; клиент решает по каждой группе, ничего не склеиваем молча. - Тихая склейка только по сайту и коду справочника; телефон и людный номер у более чем 4 фирм — на решение клиента. - Колонка phones jsonb; телефон-дубли видны уже на «Предложениях». - Sonar даёт только имя и тип, сайт всегда через EXA; вскрываем все карточки без гейта «есть сайт». - Яндекс-карточки через локальный Playwright параллельно; рубрика из заголовка идёт в описание. - Фикс рекламных номеров: 2ГИС-парсер разбирает телефоны пообъектно и выкидывает рекламные кнопки с platforms/caption чужих фирм. Тесты: автоподбор 224/224 unit, 105/105 feature; фронт-спеки зелёные. Времянки app/scripts/diag-*/probe-* в гит не входят. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
72 lines
3.4 KiB
JavaScript
72 lines
3.4 KiB
JavaScript
// Usage: node render-yandex-list.cjs <url> [maxScrolls]
|
|
// Рендер страницы категории Яндекс.Карт локальным Playwright (Firecrawl её не берёт, §12.2).
|
|
// Скроллит КОНТЕЙНЕР СПИСКА результатов (подгрузка ленивая) и печатает JSON:
|
|
// { orgs: [{ name, id, href }] } — имя + id + ссылка на карточку организации.
|
|
// Сайт в списке Яндекса НЕ отдаётся (только на карточке) — здесь не собираем (шаг 1 = имя+карточка).
|
|
// require('playwright') резолвится из node_modules корня репо (скрипт лежит под app/scripts).
|
|
const { chromium } = require('playwright');
|
|
|
|
(async () => {
|
|
const url = process.argv[2];
|
|
// Сколько раз прокрутить ленту. Меньше прокруток = быстрее (берём верх выдачи — самые релевантные),
|
|
// а охват добираем тем, что рубрик теперь много. По умолчанию 14 (совместимость при ручном запуске).
|
|
const maxScrolls = Math.max(1, parseInt(process.argv[3], 10) || 14);
|
|
let parsed;
|
|
try {
|
|
parsed = new URL(url);
|
|
} catch (e) {
|
|
console.error('bad url');
|
|
process.exit(2);
|
|
}
|
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
console.error('bad scheme');
|
|
process.exit(2);
|
|
}
|
|
|
|
const browser = await chromium.launch({ headless: true });
|
|
try {
|
|
const page = await browser.newPage({ locale: 'ru-RU' });
|
|
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45000 });
|
|
await page.waitForTimeout(4000);
|
|
|
|
// Ленивая лента результатов — скроллим её контейнер, пока подгружается.
|
|
for (let i = 0; i < maxScrolls; i++) {
|
|
await page.evaluate(() => {
|
|
const el = document.querySelector('.scroll__container, .search-list-view__list, [class*="search-list"]');
|
|
if (el) el.scrollBy(0, 2000);
|
|
});
|
|
await page.waitForTimeout(800);
|
|
}
|
|
|
|
const orgs = await page.$$eval('a[href*="/maps/org/"]', (els) => {
|
|
const seen = new Set();
|
|
const out = [];
|
|
for (const e of els) {
|
|
const href = e.getAttribute('href') || '';
|
|
const m = href.match(/\/maps\/org\/[a-z0-9_-]+\/(\d+)/i);
|
|
if (!m || seen.has(m[1])) continue;
|
|
seen.add(m[1]);
|
|
const name = (e.getAttribute('aria-label') || e.textContent || '').trim();
|
|
if (!name) continue;
|
|
// Рубрика из списка (без захода в карточку): поднимаемся к контейнеру-сниппету, берём категорию.
|
|
let box = e;
|
|
for (let up = 0; up < 6; up++) {
|
|
if (box.parentElement) box = box.parentElement;
|
|
if (box.className && /snippet|search-business|card/i.test(box.className)) break;
|
|
}
|
|
const catEl = box.querySelector('[class*="category"], [class*="rubric"]');
|
|
const category = catEl ? catEl.textContent.trim().slice(0, 80) : '';
|
|
out.push({ name, id: m[1], href: href.split('?')[0], category });
|
|
}
|
|
return out;
|
|
});
|
|
|
|
process.stdout.write(JSON.stringify({ orgs }));
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
})().catch((e) => {
|
|
console.error(String(e));
|
|
process.exit(1);
|
|
});
|