Files
portal/app/scripts/sandbox-step2.php
T
Дмитрий ec4a2b178c feat(автоподбор): песочница «глобального шага 2» + артефакты было/станет
Прототип нового шага 2 «Собрать источники» — отдельный скрипт scripts/sandbox-step2.php,
боевой шаг 2 НЕ тронут. Отпечаток фирмы (домен+имя+тип+город) → поиск по отпечатку через EXA
(гео-скоуп по типу фед/рег) → фильтр «точно она» по якорному телефону → глубокое вскрытие
сайтов (код/скрытые/подменный коллтрекинг/Контакты) + 2ГИС(поиск по имени, JSON) + Яндекс(Playwright)
→ все номера с пометками. Генерит HTML «было/станет» на конкурента (было из БД, станет из прогона),
ссылки кликабельные. scripts/_shot.cjs — скриншот HTML в PNG.

Артефакты bylo-stanet/ на 5 конкурентов с «Предложениями» (tenant 36). Свежие со всеми фиксами —
КрасЛомбард и Банк ДОМ.РФ; остальным нужен финальный перегон. Детали, дизайн и 4 фикса
(схема сайта, Яндекс→Playwright, 2ГИС по имени/JSON/ретрай, близнецы кир/лат) — в файле находок.

НЕ в портале, НЕ на проде. Прототип для оценки.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 21:22:13 +03:00

585 lines
28 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
/**
* ПЕСОЧНИЦА нового «глобального шага 2» — НЕ трогает боевой шаг 2.
* Отпечаток конкурента → поиск по отпечатку (EXA) → классификация площадок →
* фильтр «точно эта фирма» (якорный телефон настоящего сайта) → вскрытие (боевые классы) →
* отчёт: сайты + все номера с пометками + справочники + рекламный след.
*
* Запуск (из app/):
* php scripts/sandbox-step2.php "Имя" "Город" "reg|fed" "ГородКлиента" "КодГорода" "seed-домен"
* Пример:
* php scripts/sandbox-step2.php "Автозайм 124" "Красноярск" reg "Красноярск" 391 "авто-займ-124.рф"
*
* Времянка: в гит НЕ идёт.
*/
require __DIR__.'/../vendor/autoload.php';
$app = require_once __DIR__.'/../bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
use App\Models\AutopodborCompetitor;
use App\Models\AutopodborSource;
use App\Services\Autopodbor\Agent\Extract\CandidateBuilder;
use App\Services\Autopodbor\Agent\Extract\DirectoryParser;
use App\Services\Autopodbor\Agent\Fetch\CurlPlaywrightFetcher;
use App\Services\Autopodbor\Agent\Fetch\PlaywrightPageFetcher;
use App\Services\Autopodbor\Agent\Fetch\RoutingBatchFetcher;
use App\Services\Autopodbor\Agent\Fetch\XfetchClient;
use App\Services\Autopodbor\Agent\Fetch\XfetchDirectoryFetcher;
use App\Services\Autopodbor\AutopodborNormalizer;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
// ---- разбор аргументов -----------------------------------------------------
// Режим А (по конкуренту, для было/станет HTML): --comp <id> [--tenant 36] [--html <path>]
// Режим Б (ручной, для отладки): "Имя" "Город" "reg|fed" "ГородКлиента" "КодГорода" "seed"
$flags = [];
foreach ($argv as $i => $a) {
if ($i > 0 && str_starts_with((string) $a, '--')) {
$flags[substr((string) $a, 2)] = $argv[$i + 1] ?? '';
}
}
// код региона → [город, тел.код города, слаг города в 2ГИС] (в tenant 36 — только Красноярск)
$REGION = [29 => ['Красноярск', '391', 'krasnoyarsk']];
$compId = isset($flags['comp']) ? (int) $flags['comp'] : 0;
$tenantId = isset($flags['tenant']) ? (int) $flags['tenant'] : 36;
$htmlPath = $flags['html'] ?? '';
$byloSources = [];
$comp = null;
if ($compId > 0) {
$comp = AutopodborCompetitor::where('tenant_id', $tenantId)->findOrFail($compId);
$regionCode = (int) ($comp->searchRun?->region_code ?? 0);
[$city, $areaCode, $gisSlug] = $REGION[$regionCode] ?? ['', null, ''];
$clientCity = $city;
$name = (string) $comp->name;
$isFederal = (bool) $comp->is_federal;
// seed-домен из site_url (без схемы/пути/utm). ВАЖНО: в БД адрес часто БЕЗ http:// —
// тогда parse_url не видит host, поэтому схему добавляем принудительно.
$rawSite = trim((string) $comp->site_url);
if ($rawSite !== '' && ! preg_match('#^https?://#i', $rawSite)) {
$rawSite = 'https://'.$rawSite;
}
$host = (string) (parse_url($rawSite, PHP_URL_HOST) ?: '');
$seedDomain = mb_strtolower(preg_replace('#^www\.#', '', $host) ?? $host, 'UTF-8');
$byloSources = AutopodborSource::where('competitor_id', $compId)
->where('box', 'proposal')->orderBy('id')->get()->all();
} else {
$name = $argv[1] ?? 'Автозайм 124';
$city = $argv[2] ?? 'Красноярск';
$type = strtolower($argv[3] ?? 'reg');
$clientCity = $argv[4] ?? $city;
$areaCode = ($argv[5] ?? '') !== '' ? (string) $argv[5] : null;
$seedDomain = mb_strtolower(trim($argv[6] ?? ''), 'UTF-8');
$isFederal = in_array($type, ['fed', 'federal', 'федерал', 'федеральный'], true);
$gisSlug = 'krasnoyarsk';
}
$idn = static function (string $d): string {
return function_exists('idn_to_ascii')
? (idn_to_ascii($d, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46) ?: $d)
: $d;
};
$idnUtf = static function (string $h): string {
return function_exists('idn_to_utf8')
? (idn_to_utf8($h, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46) ?: $h)
: $h;
};
// «Корень» домена: punycode→unicode, без TLD и разделителей. авто-займ-124.рф и
// автозайм-124.рф → оба «автозайм124» → это домены-близнецы ОДНОЙ фирмы.
$coreOf = static function (string $domain) use ($idnUtf): string {
$u = mb_strtolower($idnUtf($domain), 'UTF-8');
$u = preg_replace('/\.[a-zа-я]{2,}$/u', '', $u) ?? $u;
return preg_replace('/[^a-zа-я0-9]/u', '', $u) ?? $u;
};
// Корень с транслитерацией кириллицы → латиница: чтобы красломбард.рф и kraslombard24.ru
// (один бренд, разные алфавиты) считались близнецами одной фирмы.
$translitCore = static function (string $domain) use ($coreOf): string {
$map = ['а' => 'a', 'б' => 'b', 'в' => 'v', 'г' => 'g', 'д' => 'd', 'е' => 'e', 'ё' => 'e',
'ж' => 'zh', 'з' => 'z', 'и' => 'i', 'й' => 'y', 'к' => 'k', 'л' => 'l', 'м' => 'm',
'н' => 'n', 'о' => 'o', 'п' => 'p', 'р' => 'r', 'с' => 's', 'т' => 't', 'у' => 'u',
'ф' => 'f', 'х' => 'h', 'ц' => 'c', 'ч' => 'ch', 'ш' => 'sh', 'щ' => 'sch', 'ъ' => '',
'ы' => 'y', 'ь' => '', 'э' => 'e', 'ю' => 'yu', 'я' => 'ya'];
return strtr($coreOf($domain), $map);
};
// Близнецы: один корень содержит другой (мин. длина 5 — от ложных совпадений).
$twinMatch = static function (string $a, string $b): bool {
if ($a === '' || $b === '' || min(strlen($a), strlen($b)) < 5) {
return false;
}
return str_contains($a, $b) || str_contains($b, $a);
};
$seedAscii = $seedDomain !== '' ? $idn($seedDomain) : '';
$seedCore = $seedDomain !== '' ? $coreOf($seedDomain) : '';
$seedCoreT = $seedDomain !== '' ? $translitCore($seedDomain) : '';
fwrite(STDERR, "=== ПЕСОЧНИЦА ШАГ-2 (v2) ===\n{$name} | {$city} | ".($isFederal ? 'ФЕДЕРАЛ' : 'РЕГИОНАЛ')." | клиент: {$clientCity} | seed: {$seedDomain}\n\n");
// ---- отпечаток → запросы EXA ----------------------------------------------
$queries = $isFederal
? ["{$name} {$clientCity}", "{$name} официальный сайт", "{$name} {$clientCity} телефон"]
: ["{$name} {$city}", "{$name} официальный сайт", "{$name} {$city} телефон справочник"];
if ($seedDomain !== '') {
$queries[] = $seedDomain;
}
$exaSearch = function (string $query): array {
$cfg = (array) config('services.exa');
$key = (string) ($cfg['key'] ?? '');
if ($key === '') {
fwrite(STDERR, "EXA_API_KEY не задан!\n");
return [];
}
try {
$resp = Http::withHeaders(['x-api-key' => $key])
->timeout((int) ($cfg['timeout_sec'] ?? 90))
->post(rtrim((string) ($cfg['base_url'] ?? 'https://api.exa.ai'), '/').'/search', [
'query' => $query, 'numResults' => 10, 'type' => 'auto',
]);
if (! $resp->successful()) {
return [];
}
$urls = [];
foreach ((array) $resp->json('results') as $r) {
$u = is_array($r) ? (string) ($r['url'] ?? '') : '';
if ($u !== '') {
$urls[] = $u;
}
}
return $urls;
} catch (\Throwable $e) {
fwrite(STDERR, 'EXA сбой: '.$e->getMessage()."\n");
return [];
}
};
$allUrls = [];
foreach ($queries as $q) {
$found = $exaSearch($q);
fwrite(STDERR, "поиск «{$q}» → ".count($found)."\n");
foreach ($found as $u) {
$allUrls[$u] = true;
}
}
$allUrls = array_keys($allUrls);
fwrite(STDERR, 'уникальных ссылок: '.count($allUrls)."\n\n");
// ---- классификация ---------------------------------------------------------
$host = static function (string $url): string {
$h = (string) (parse_url($url, PHP_URL_HOST) ?? '');
return preg_replace('#^www\.#', '', mb_strtolower($h, 'UTF-8')) ?? $h;
};
$endsAny = static function (string $h, array $list): bool {
foreach ($list as $d) {
if ($h === $d || str_ends_with($h, '.'.$d)) {
return true;
}
}
return false;
};
$DIRECTORY = ['zoon.ru', 'yp.ru', 'rubrikator.org', 'vseadresa.org', 'kitabi.ru', 'naitiko.ru',
'orgpage.ru', 'spr.ru', 'flamp.ru', 'gde.ru', 'firmika.ru', 'spravmer.ru', 'bug.city'];
$SOCIAL = ['vk.com', 'ok.ru', 't.me', 'telegram.me', 'instagram.com', 'facebook.com', 'youtube.com'];
// регуляторы/реестры/агрегаторы/госсайты — НЕ фирма
$SKIP = ['rusprofile.ru', 'list-org.com', 'sbis.ru', 'spark-interfax.ru', 'avito.ru', 'youla.ru',
'banki.ru', 'sravni.ru', '1000bankov.ru', 'otzovik.com', 'irecommend.ru', 'wikipedia.org',
'dzen.ru', 'cbr.ru', 'nalog.ru', 'nalog.gov.ru', 'gosuslugi.ru', 'consultant.ru', 'garant.ru',
'minfin.ru', 'google.com'];
$ownDomains = [];
$gisUrls = [];
$yandexUrls = [];
$dirUrls = [];
$socialUrls = [];
foreach ($allUrls as $url) {
$h = $host($url);
if ($h === '') {
continue;
}
// 2ГИС — только карточки фирмы /firm/ (не /search — там соседние фирмы)
if (str_ends_with($h, '2gis.ru')) {
if (str_contains($url, '/firm/')) { $gisUrls[$url] = true; }
continue;
}
// Яндекс.Карты — только карточка организации /maps/org/
if (str_contains($h, 'yandex')) {
if (str_contains($url, '/maps/org/')) { $yandexUrls[$url] = true; }
continue;
}
if ($endsAny($h, $SOCIAL)) { $socialUrls[$h] = $url; continue; }
if ($endsAny($h, $DIRECTORY)) { $dirUrls[$h] = $url; continue; }
if ($endsAny($h, $SKIP)) { continue; }
$ownDomains[$h] ??= $url;
}
// ---- инструменты вскрытия --------------------------------------------------
$nodeBin = (string) config('autopodbor.node_bin', 'node');
$siteFetcher = new CurlPlaywrightFetcher($nodeBin);
$builder = new CandidateBuilder;
$norm = new AutopodborNormalizer;
$xfetch = new XfetchClient(
(string) config('services.xfetch.key'),
(string) config('services.xfetch.endpoint', 'https://xf4.ru/fetch'),
);
// Как в боевом шаге 1: Яндекс.Карты → локальный Playwright (xfetch их режет под нагрузкой),
// 2ГИС → xfetch. Иначе яндекс-карточки могут прийти пустыми и мы теряем номера/сайт.
$dirPages = new RoutingBatchFetcher($xfetch, ['yandex' => new PlaywrightPageFetcher($nodeBin)]);
$dirFetcher = new XfetchDirectoryFetcher($dirPages, new DirectoryParser);
$kindLabel = [
'code' => 'настоящий (в коде сайта)',
'displayed' => 'ПОДМЕННЫЙ (коллтрекинг, видимый)',
'pool' => 'подменный (пул ротации)',
'contacts' => 'страница «Контакты»',
'uncertain' => 'локальный, код города не определён',
];
// Номер → набор чипов «где нашли» вида метка=>ссылка(или null) + флаг подменного.
$phones = [];
$addPhone = function (string $number, array $chips, bool $sub = false) use (&$phones, $norm): void {
$d = $norm->phone($number);
if (strlen($d) !== 11) {
return;
}
$phones[$d]['sub'] = ($phones[$d]['sub'] ?? false) || $sub;
foreach ($chips as $label => $url) {
if ((string) $label !== '') {
// ссылку не затираем на null, если она уже была
$phones[$d]['src'][$label] = $url ?: ($phones[$d]['src'][$label] ?? null);
}
}
};
$srcChip = ['code' => 'в коде сайта', 'displayed' => 'коллтрекинг', 'pool' => 'коллтрекинг (пул)',
'contacts' => 'страница «Контакты»', 'uncertain' => 'на сайте (код города?)'];
/** Вскрыть один сайт → [numbers(kind), tracker, ok, rawHtml, codeDigits]. */
$openSite = function (string $domain) use ($siteFetcher, $builder, $areaCode, $idn, $norm, $kindLabel) {
$url = 'https://'.$idn($domain).'/';
try {
$site = $siteFetcher->site($url);
if ($site->rawHtml === '') { // ретрай на флак открытия
$site = $siteFetcher->site($url);
}
$cands = $builder->build([$site], [], $areaCode);
$nums = [];
$codeDigits = [];
$tracker = false;
foreach ($cands as $c) {
$d = $norm->phone($c['number']);
$lbl = $kindLabel[$c['kind']] ?? $c['kind'];
$nums[] = [$d, $lbl, $c['kind']];
if ($c['kind'] === 'code' || $c['kind'] === 'contacts') {
$codeDigits[$d] = true;
}
$tracker = $tracker || ($c['tracker'] ?? false);
}
return ['nums' => $nums, 'tracker' => $tracker, 'ok' => $site->rawHtml !== '',
'html' => mb_strtolower($site->rawHtml, 'UTF-8'), 'code' => array_keys($codeDigits)];
} catch (\Throwable $e) {
return ['nums' => [], 'tracker' => false, 'ok' => false, 'html' => '', 'code' => [], 'err' => $e->getMessage()];
}
};
// ---- 1) вскрыть seed-сайт первым → якорные телефоны ------------------------
$sitesReport = [];
$anchorDigits = []; // цифры настоящих номеров фирмы (для клея чужих/своих)
$ownOrder = array_keys($ownDomains);
// seed в начало
if ($seedAscii !== '') {
array_unshift($ownOrder, $seedAscii);
$ownOrder = array_values(array_unique($ownOrder));
}
foreach (array_slice($ownOrder, 0, 8) as $domain) {
fwrite(STDERR, "сайт: {$domain}\n");
$r = $openSite($domain);
$isSeed = ($domain === $seedAscii);
$isTwin = $twinMatch($translitCore($domain), $seedCoreT); // домен-близнец seed (с транслитом кир↔лат)
// «точно она»: seed или его близнец — да; иначе совпал якорный телефон на странице ИЛИ ссылка на seed
$verified = $isSeed || $isTwin;
if (! $verified && $anchorDigits !== []) {
foreach (array_keys($anchorDigits) as $ad) {
if ($r['html'] !== '' && str_contains($r['html'], (string) $ad)) { $verified = true; break; }
}
}
if (! $verified && $seedAscii !== '' && $r['html'] !== '' && str_contains($r['html'], $seedAscii)) {
$verified = true;
}
$r['verified'] = $verified;
$sitesReport[$domain] = $r;
// якоря берём ТОЛЬКО с подтверждённых сайтов
if ($verified) {
foreach ($r['code'] as $d) {
$anchorDigits[$d] = true;
}
$siteUrl = 'https://'.$idn($domain).'/';
foreach ($r['nums'] as [$d, $lbl, $kind]) {
$sub = in_array($kind, ['displayed', 'pool'], true);
$addPhone($d, [($srcChip[$kind] ?? $kind) => $siteUrl, $idnUtf($domain) => $siteUrl], $sub);
}
}
}
// ---- 2) справочники ---------------------------------------------------------
$dirCards = [];
$parser2 = new DirectoryParser;
// фильтр «точно она»: сайт карточки совпал с seed ИЛИ телефон = якорь (иначе — чужая фирма).
$keepCard = function (?string $cardSite, string $cd) use ($seedDomain, $anchorDigits): bool {
$siteHit = $cardSite && $seedDomain !== '' && str_contains(mb_strtolower($cardSite, 'UTF-8'), $seedDomain);
$phoneHit = $cd !== '' && isset($anchorDigits[$cd]);
return $siteHit || $phoneHit || $seedDomain === '';
};
$pushCard = function (string $source, ?string $number, ?string $site, ?string $office, string $url) use (&$dirCards, $norm, $keepCard, $addPhone): void {
$cd = $number ? $norm->phone($number) : '';
if ($cd !== '' && strlen($cd) !== 11) {
$cd = '';
}
$keep = $keepCard($site, $cd);
$dirCards[] = ['source' => $source, 'number' => $cd !== '' ? $cd : '—', 'site' => $site, 'office' => $office, 'keep' => $keep];
if ($keep && $cd !== '') {
$addPhone($cd, [$source => $url]);
}
};
// 2ГИС: карточки фирмы ищем ПО ИМЕНИ (2ГИС-поиск) + ссылки из EXA; читаем JSON (parse2gisCard — полнее tel:).
$gisFirmUrls = array_keys($gisUrls);
if ($gisSlug !== '') {
$searchUrl = 'https://2gis.ru/'.$gisSlug.'/search/'.rawurlencode($name);
fwrite(STDERR, "2ГИС-поиск: {$searchUrl}\n");
// 2ГИС-поиск через xfetch флакует (иногда страница без ссылок/капча) — ретраим, пока не появятся карточки.
$found = [];
for ($try = 1; $try <= 4 && $found === []; $try++) {
$sh = $xfetch->html($searchUrl);
$found = $parser2->parseBranchList($sh);
}
fwrite(STDERR, " 2ГИС-поиск дал карточек: ".count($found)."\n");
foreach ($found as $f) {
$gisFirmUrls[] = str_starts_with($f, 'http') ? $f : 'https://2gis.ru'.$f;
}
}
$gisFirmUrls = array_values(array_filter(array_unique($gisFirmUrls), fn ($u) => str_contains((string) $u, '/firm/')));
$gisFirmUrls = array_slice($gisFirmUrls, 0, 12);
foreach ($gisFirmUrls as $furl) {
fwrite(STDERR, "2ГИС-карточка: {$furl}\n");
$fh = $xfetch->html($furl);
if ($fh === '') {
continue;
}
foreach ($parser2->parse2gisCard($fh, $furl) as $card) {
$pushCard('2ГИС', $card->number ?: null, $card->siteUrl, $card->office, $furl);
}
}
// Яндекс.Карты: через боевой XfetchDirectoryFetcher (роутит на Playwright, parseYandexCard).
foreach (array_slice(array_keys($yandexUrls), 0, 5) as $yurl) {
fwrite(STDERR, "Яндекс-карточка: {$yurl}\n");
try {
foreach ($dirFetcher->directory($yurl) as $card) {
$pushCard($card->source, $card->number ?: null, $card->siteUrl, $card->office, $card->url);
}
} catch (\Throwable $e) {
fwrite(STDERR, " сбой: {$e->getMessage()}\n");
}
}
// ---- ОТЧЁТ -----------------------------------------------------------------
$L = [];
$L[] = str_repeat('=', 72);
$L[] = "РЕКЛАМНЫЙ СЛЕД: {$name} ({$city}), ".($isFederal ? 'ФЕДЕРАЛ' : 'РЕГИОНАЛ');
$L[] = 'Якорные (настоящие) телефоны фирмы: '.(implode(', ', array_keys($anchorDigits)) ?: '—');
$L[] = str_repeat('=', 72);
$L[] = "\n[1] САЙТЫ ФИРМЫ";
foreach ($sitesReport as $d => $r) {
if (! ($r['verified'] ?? false)) {
continue;
}
$trk = $r['tracker'] ? ' [коллтрекинг=РЕКЛАМА]' : '';
$ok = $r['ok'] ? '✓' : '✗не открылся';
$L[] = "{$d} {$ok}{$trk}";
foreach ($r['nums'] as [$dg, $lbl]) {
$L[] = " {$dg}{$lbl}";
}
}
$dropped = array_filter($sitesReport, fn ($r) => ! ($r['verified'] ?? false));
$L[] = "\n[1b] ОТБРОШЕНЫ как чужие/нерелевантные (не наша фирма): ".count($dropped);
foreach ($dropped as $d => $r) {
$L[] = "{$d}";
}
$kept = array_filter($dirCards, fn ($c) => $c['keep']);
$L[] = "\n[2] СПРАВОЧНИКИ — карточек нашей фирмы: ".count($kept).' (всего вскрыто '.count($dirCards).')';
foreach ($kept as $c) {
$site = $c['site'] ? ' сайт='.$c['site'] : '';
$off = $c['office'] ? ' | '.$c['office'] : '';
$L[] = "{$c['source']}: {$c['number']}{$site}{$off}";
}
$L[] = "\n[3] ПРОЧИЕ КАТАЛОГИ (для справки, не вскрывал): ".count($dirUrls);
foreach ($dirUrls as $h => $u) {
$L[] = "{$h}{$u}";
}
$L[] = "\n[4] СОЦСЕТИ: ".count($socialUrls);
foreach ($socialUrls as $h => $u) {
$L[] = "{$h}{$u}";
}
$L[] = "\n[5] ИТОГ — НОМЕРА ФИРМЫ (уникальных: ".count($phones).')';
foreach ($phones as $d => $info) {
$chips = implode(', ', array_keys($info['src'] ?? []));
$sub = ! empty($info['sub']) ? ' 🎭подмена' : '';
$L[] = "{$d}{$sub} — где нашли: {$chips}";
}
$L[] = "\n".str_repeat('=', 72);
echo implode("\n", $L)."\n";
// ---- HTML «было/станет» (образец для оценки; станет = факт этого прогона) ---
if ($htmlPath !== '') {
$esc = static fn ($s): string => htmlspecialchars((string) $s, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$fmt = static function (string $d) use ($norm): string {
$d = $norm->phone($d);
return strlen($d) === 11
? sprintf('+7 (%s) %s-%s-%s', substr($d, 1, 3), substr($d, 4, 3), substr($d, 7, 2), substr($d, 9, 2))
: $d;
};
// Чип «где нашли»: ссылка, если есть http(s)-URL; иначе обычный.
$chipHtml = static function (string $label, ?string $url) use ($esc): string {
$ok = $url !== null && $url !== '' && preg_match('#^https?://#i', $url);
return $ok
? '<a class="ld-fchip" href="'.$esc($url).'" target="_blank" rel="noopener">'.$esc($label).'</a> '
: '<span class="ld-fchip ld-fchip--plain">'.$esc($label).'</span> ';
};
// $chips: [метка => ссылка|null].
$card = static function (string $icon, string $title, array $chips, ?string $office, int $cnt, string $flag) use ($esc, $chipHtml): string {
$h = '<article class="ld-srccard ld-srccard--sug">';
$h .= '<div class="ld-srcicon">'.$icon.'</div><div class="ld-srcmain">';
$h .= '<div class="ld-srctitle">'.$title.'</div>';
if ($office) {
$h .= '<div class="ld-srcprov">🏢 '.$esc($office).'</div>';
}
$h .= '<div class="ld-found"><span class="ld-found__lbl">где нашли:</span> ';
foreach ($chips as $label => $url) {
$h .= $chipHtml((string) $label, is_string($url) ? $url : null);
}
if ($cnt > 1) {
$h .= '<span class="ld-found__cnt">✓'.$cnt.' подтвержд.</span>';
}
if ($flag !== '') {
$h .= '<span class="ld-found__cnt" style="color:#b23a48">'.$esc($flag).'</span>';
}
$h .= '</div></div></article>';
return $h;
};
// --- БЫЛО (из текущих источников портала) ---
$ptype = ['city' => 'городской', 'mobile' => 'мобильный', 'tollfree' => '8-800'];
$byloHtml = '';
foreach ($byloSources as $s) {
$isCall = $s->signal_type === 'call';
$icon = $isCall ? '📞' : '🌐';
$kindIco = ($s->phone_kind ?? '') === 'substitute' ? '🎭 ' : ($isCall ? '✓ ' : '');
$idText = $isCall ? $fmt((string) $s->identifier) : (string) $s->identifier;
$idHtml = $isCall
? $esc($idText)
: '<a class="ld-idlink" href="'.$esc('https://'.$s->identifier).'" target="_blank" rel="noopener">'.$esc($idText).'</a>';
$title = $kindIco.'<b>'.$idHtml.'</b>';
if ($isCall && isset($ptype[$s->phone_type ?? ''])) {
$title .= ' <span class="ld-bdg '.($s->phone_type === 'mobile' ? 'ld-bdg--fed' : 'ld-bdg--loc').'">'.$ptype[$s->phone_type].'</span>';
}
$title .= ' <span class="ld-bdg ld-bdg--sug">предложение</span>';
$chips = [];
foreach ((array) ($s->where_found ?? []) as $wf) {
if (is_array($wf) && ! empty($wf['label'])) {
$chips[(string) $wf['label']] = $wf['url'] ?? null;
}
}
if ($chips === [] && $s->provenance_label) {
$chips[(string) $s->provenance_label] = $s->provenance_url ?? null;
}
$byloHtml .= $card($icon, $title, $chips, $s->office, (int) ($s->confirmations ?? 1), '');
}
if ($byloHtml === '') {
$byloHtml = '<p class="empty">Источников не было.</p>';
}
// --- СТАНЕТ (факт прогона песочницы) ---
$stanetHtml = '';
// сайты фирмы (в т.ч. близнецы)
foreach ($sitesReport as $d => $r) {
if (! ($r['verified'] ?? false)) {
continue;
}
$dom = $idnUtf($d);
$u = 'https://'.$dom.'/';
$flag = $r['tracker'] ? 'реклама (коллтрекинг)' : ($r['ok'] ? '' : 'не открылась');
$domHtml = '<a class="ld-idlink" href="'.$esc($u).'" target="_blank" rel="noopener">'.$esc($dom).'</a>';
$stanetHtml .= $card('🌐', '<b>'.$domHtml.'</b> <span class="ld-bdg ld-bdg--sug">сайт фирмы</span>', ['сайт конкурента' => $u], null, 1, $flag);
}
// номера — чипы «где нашли» кликабельны (сайт/справочник); подменный помечен иконкой 🎭
foreach ($phones as $d => $info) {
$isSub = ! empty($info['sub']);
$title = ($isSub ? '🎭 ' : '✓ ').'<b>'.$esc($fmt((string) $d)).'</b> <span class="ld-bdg ld-bdg--sug">предложение</span>';
$stanetHtml .= $card('📞', $title, $info['src'] ?? [], null, 0, '');
}
if ($stanetHtml === '') {
$stanetHtml = '<p class="empty">Песочница ничего не собрала.</p>';
}
$rel = $comp?->relevance_pct !== null ? ' · похожесть '.$comp->relevance_pct.'%' : '';
$typeBadge = $isFederal ? 'федеральный' : 'региональный';
$doc = '<!doctype html><html lang="ru"><head><meta charset="utf-8">'
.'<title>'.$esc($name).' — было/станет</title><style>'
.'body{margin:0;background:#F6F3EC;color:#012019;font-family:Inter,system-ui,Arial,sans-serif}'
.'.wrap{max-width:1200px;margin:0 auto;padding:24px}'
.'h1{font-size:22px;margin:0 0 2px}.sub{color:#5b6b64;margin:0 0 18px;font-size:14px}'
.'.tag{display:inline-block;background:#e3ede9;color:#0F6E56;border-radius:6px;padding:2px 8px;font-size:12px;margin-left:6px}'
.'.cols{display:grid;grid-template-columns:1fr 1fr;gap:20px}'
.'.col h2{font-size:15px;text-transform:uppercase;letter-spacing:.04em;color:#5b6b64;margin:0 0 10px}'
.'.col--now h2{color:#0F6E56}'
.'.ld-srccard{display:flex;gap:10px;align-items:flex-start;background:#fff;border:1px solid #e4e0d6;border-radius:12px;padding:12px 14px;margin-bottom:10px}'
.'.ld-srcicon{font-size:18px}.ld-srcmain{flex:1;min-width:0}'
.'.ld-srctitle{font-family:"JetBrains Mono",ui-monospace,monospace;font-size:15px;line-height:1.5}'
.'.ld-srcprov{color:#5b6b64;font-size:13px;margin-top:2px}'
.'.ld-found{margin-top:6px;font-size:12px;color:#5b6b64;display:flex;flex-wrap:wrap;gap:6px;align-items:center}'
.'.ld-fchip{background:#eef4f1;color:#0F6E56;border-radius:999px;padding:2px 9px;font-size:12px}'
.'a.ld-fchip{text-decoration:none;cursor:pointer}a.ld-fchip:hover{text-decoration:underline;background:#dcece4}'
.'.ld-idlink{color:inherit;text-decoration:none}.ld-idlink:hover{text-decoration:underline;color:#0F6E56}'
.'.ld-found__cnt{color:#0F6E56;font-weight:600}'
.'.ld-bdg{font-size:11px;border-radius:6px;padding:1px 7px;vertical-align:middle}'
.'.ld-bdg--sug{background:#f3ead9;color:#8a6d1f}.ld-bdg--loc{background:#e6efe9;color:#0F6E56}'
.'.ld-bdg--fed{background:#e5e9f5;color:#3a4a8a}.empty{color:#8a948f}'
.'</style></head><body><div class="wrap">'
.'<h1>'.$esc($name).'<span class="tag">'.$typeBadge.'</span></h1>'
.'<p class="sub">'.$esc($city).$rel.' · сравнение источников «Предложения»: как было в портале и как станет после нового шага 2 (факт прогона песочницы '.date('d.m.Y').')</p>'
.'<div class="cols">'
.'<div class="col col--was"><h2>БЫЛО ('.count($byloSources).')</h2>'.$byloHtml.'</div>'
.'<div class="col col--now"><h2>СТАНЕТ ('.(count($phones) + count(array_filter($sitesReport, fn ($r) => $r['verified'] ?? false))).')</h2>'.$stanetHtml.'</div>'
.'</div></div></body></html>';
file_put_contents($htmlPath, $doc);
fwrite(STDERR, "HTML записан: {$htmlPath}\n");
}