Files
portal/app/scripts/sandbox-step2.php
T

585 lines
28 KiB
PHP
Raw Normal View History

<?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");
}