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 [--tenant 36] [--html ] // Режим Б (ручной, для отладки): "Имя" "Город" "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 ? ''.$esc($label).' ' : ''.$esc($label).' '; }; // $chips: [метка => ссылка|null]. $card = static function (string $icon, string $title, array $chips, ?string $office, int $cnt, string $flag) use ($esc, $chipHtml): string { $h = '
'; $h .= '
'.$icon.'
'; $h .= '
'.$title.'
'; if ($office) { $h .= '
🏢 '.$esc($office).'
'; } $h .= '
где нашли: '; foreach ($chips as $label => $url) { $h .= $chipHtml((string) $label, is_string($url) ? $url : null); } if ($cnt > 1) { $h .= '✓'.$cnt.' подтвержд.'; } if ($flag !== '') { $h .= ''.$esc($flag).''; } $h .= '
'; 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) : ''.$esc($idText).''; $title = $kindIco.''.$idHtml.''; if ($isCall && isset($ptype[$s->phone_type ?? ''])) { $title .= ' '.$ptype[$s->phone_type].''; } $title .= ' предложение'; $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 = '

Источников не было.

'; } // --- СТАНЕТ (факт прогона песочницы) --- $stanetHtml = ''; // сайты фирмы (в т.ч. близнецы) foreach ($sitesReport as $d => $r) { if (! ($r['verified'] ?? false)) { continue; } $dom = $idnUtf($d); $u = 'https://'.$dom.'/'; $flag = $r['tracker'] ? 'реклама (коллтрекинг)' : ($r['ok'] ? '' : 'не открылась'); $domHtml = ''.$esc($dom).''; $stanetHtml .= $card('🌐', ''.$domHtml.' сайт фирмы', ['сайт конкурента' => $u], null, 1, $flag); } // номера — чипы «где нашли» кликабельны (сайт/справочник); подменный помечен иконкой 🎭 foreach ($phones as $d => $info) { $isSub = ! empty($info['sub']); $title = ($isSub ? '🎭 ' : '✓ ').''.$esc($fmt((string) $d)).' предложение'; $stanetHtml .= $card('📞', $title, $info['src'] ?? [], null, 0, ''); } if ($stanetHtml === '') { $stanetHtml = '

Песочница ничего не собрала.

'; } $rel = $comp?->relevance_pct !== null ? ' · похожесть '.$comp->relevance_pct.'%' : ''; $typeBadge = $isFederal ? 'федеральный' : 'региональный'; $doc = '' .''.$esc($name).' — было/станет
' .'

'.$esc($name).''.$typeBadge.'

' .'

'.$esc($city).$rel.' · сравнение источников «Предложения»: как было в портале и как станет после нового шага 2 (факт прогона песочницы '.date('d.m.Y').')

' .'
' .'

БЫЛО ('.count($byloSources).')

'.$byloHtml.'
' .'

СТАНЕТ ('.(count($phones) + count(array_filter($sitesReport, fn ($r) => $r['verified'] ?? false))).')

'.$stanetHtml.'
' .'
'; file_put_contents($htmlPath, $doc); fwrite(STDERR, "HTML записан: {$htmlPath}\n"); }