diff --git a/app/scripts/_shot.cjs b/app/scripts/_shot.cjs new file mode 100644 index 00000000..b2db2274 --- /dev/null +++ b/app/scripts/_shot.cjs @@ -0,0 +1,9 @@ +const { chromium } = require('playwright'); +const { pathToFileURL } = require('url'); +(async () => { + const b = await chromium.launch({headless:true}); + const p = await b.newPage({viewport:{width:1240,height:900}}); + await p.goto(pathToFileURL(process.argv[2]).href, {waitUntil:'networkidle'}); + await p.screenshot({path:process.argv[3], fullPage:true}); + await b.close(); +})(); diff --git a/app/scripts/sandbox-step2.php b/app/scripts/sandbox-step2.php new file mode 100644 index 00000000..afa15ec4 --- /dev/null +++ b/app/scripts/sandbox-step2.php @@ -0,0 +1,584 @@ +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"); +} diff --git a/bylo-stanet/385-avtozaim124.html b/bylo-stanet/385-avtozaim124.html new file mode 100644 index 00000000..9963f779 --- /dev/null +++ b/bylo-stanet/385-avtozaim124.html @@ -0,0 +1 @@ +Автозайм 124 — было/станет

Автозайм 124региональный

Красноярск · похожесть 58% · сравнение источников «Предложения»: как было в портале и как станет после нового шага 2 (факт прогона песочницы 03.07.2026)

БЫЛО (3)

📞
+7 (391) 294-88-88 городской предложение
🏢 компания автозайма под залог автомобилей, спецтехники и мототехники, Октябрьская улица, 10а
где нашли: 2ГИС
📞
+7 (953) 584-88-88 мобильный предложение
🏢 автоломбард, Красноярск, Ярыгинская наб.
где нашли: Яндекс.Карты

СТАНЕТ (5)

\ No newline at end of file diff --git a/bylo-stanet/385-avtozaim124.png b/bylo-stanet/385-avtozaim124.png new file mode 100644 index 00000000..cd592f71 Binary files /dev/null and b/bylo-stanet/385-avtozaim124.png differ diff --git a/bylo-stanet/412-drive-zaim.html b/bylo-stanet/412-drive-zaim.html new file mode 100644 index 00000000..6c37ee77 --- /dev/null +++ b/bylo-stanet/412-drive-zaim.html @@ -0,0 +1 @@ +Драйв займ — было/станет

Драйв займрегиональный

Красноярск · похожесть 41% · сравнение источников «Предложения»: как было в портале и как станет после нового шага 2 (факт прогона песочницы 03.07.2026)

БЫЛО (4)

📞
+7 (391) 216-83-41 городской предложение
🏢 микрофинансовая организация, Красноярск, Взлётная ул.
где нашли: Яндекс.Карты

СТАНЕТ (4)

\ No newline at end of file diff --git a/bylo-stanet/412-drive-zaim.png b/bylo-stanet/412-drive-zaim.png new file mode 100644 index 00000000..43e7114b Binary files /dev/null and b/bylo-stanet/412-drive-zaim.png differ diff --git a/bylo-stanet/414-dom-rf.html b/bylo-stanet/414-dom-rf.html new file mode 100644 index 00000000..be927187 --- /dev/null +++ b/bylo-stanet/414-dom-rf.html @@ -0,0 +1 @@ +Банк ДОМ.РФ — было/станет

Банк ДОМ.РФрегиональный

Красноярск · похожесть 40% · сравнение источников «Предложения»: как было в портале и как станет после нового шага 2 (факт прогона песочницы 03.07.2026)

БЫЛО (6)

СТАНЕТ (7)

\ No newline at end of file diff --git a/bylo-stanet/414-dom-rf.png b/bylo-stanet/414-dom-rf.png new file mode 100644 index 00000000..75917bc8 Binary files /dev/null and b/bylo-stanet/414-dom-rf.png differ diff --git a/bylo-stanet/416-vash-investor.html b/bylo-stanet/416-vash-investor.html new file mode 100644 index 00000000..6c1e473e --- /dev/null +++ b/bylo-stanet/416-vash-investor.html @@ -0,0 +1 @@ +Ваш Инвестор — было/станет

Ваш Инвесторрегиональный

Красноярск · похожесть 40% · сравнение источников «Предложения»: как было в портале и как станет после нового шага 2 (факт прогона песочницы 03.07.2026)

БЫЛО (9)

📞
+7 (495) 023-66-52 городской предложение
🏢 микрофинансовая организация, Красноярск, ул. Молокова
где нашли: в коде сайта Яндекс.Карты ✓2 подтвержд.
📞
+7 (495) 023-68-13 городской предложение
где нашли: в коде сайта
📞
+7 (495) 258-97-05 городской предложение
где нашли: в коде сайта
📞
+7 (499) 322-46-77 городской предложение
где нашли: в коде сайта
📞
+7 (923) 100-76-53 мобильный предложение
где нашли: в коде сайта
📞
+7 (961) 846-82-71 мобильный предложение
где нашли: в коде сайта

СТАНЕТ (15)

\ No newline at end of file diff --git a/bylo-stanet/416-vash-investor.png b/bylo-stanet/416-vash-investor.png new file mode 100644 index 00000000..e704091c Binary files /dev/null and b/bylo-stanet/416-vash-investor.png differ diff --git a/bylo-stanet/420-kraslombard.html b/bylo-stanet/420-kraslombard.html new file mode 100644 index 00000000..fa609a97 --- /dev/null +++ b/bylo-stanet/420-kraslombard.html @@ -0,0 +1 @@ +КрасЛомбард — было/станет

КрасЛомбардрегиональный

Красноярск · похожесть 39% · сравнение источников «Предложения»: как было в портале и как станет после нового шага 2 (факт прогона песочницы 03.07.2026)

БЫЛО (34)

📞
+7 (391) 240-06-60 городской предложение
🏢 улица Александра Матросова, 3
где нашли: 2ГИС Яндекс.Карты ✓2 подтвержд.
📞
+7 (391) 279-70-00 городской предложение
🏢 автоломбард, Красноярск, Взлётная ул.
где нашли: в коде сайта Яндекс.Карты ✓2 подтвержд.
📞
+7 (391) 250-40-00 городской предложение
где нашли: в коде сайта
📞
+7 (391) 251-05-50 городской предложение
🏢 ломбард, Красноярск, ул. Академика Киренского
где нашли: Яндекс.Карты
📞
+7 (391) 252-00-00 городской предложение
где нашли: в коде сайта
📞
+7 (391) 255-00-00 городской предложение
где нашли: в коде сайта
📞
+7 (391) 271-33-33 городской предложение
где нашли: в коде сайта
📞
+7 (391) 278-40-40 городской предложение
где нашли: в коде сайта
📞
+7 (391) 281-00-00 городской предложение
где нашли: в коде сайта
📞
+7 (391) 281-70-70 городской предложение
где нашли: в коде сайта
📞
+7 (391) 281-80-00 городской предложение
где нашли: в коде сайта
📞
+7 (391) 282-85-85 городской предложение
где нашли: в коде сайта
📞
+7 (391) 282-88-28 городской предложение
где нашли: в коде сайта
📞
+7 (391) 284-33-33 городской предложение
где нашли: в коде сайта
📞
+7 (391) 284-55-55 городской предложение
где нашли: в коде сайта
📞
+7 (391) 286-40-00 городской предложение
где нашли: в коде сайта
📞
+7 (391) 288-85-85 городской предложение
где нашли: в коде сайта
📞
+7 (391) 288-92-88 городской предложение
где нашли: в коде сайта
📞
+7 (391) 289-33-33 городской предложение
где нашли: в коде сайта
📞
+7 (391) 289-80-00 городской предложение
где нашли: в коде сайта
📞
+7 (391) 292-55-55 городской предложение
где нашли: в коде сайта
📞
+7 (902) 962-32-32 мобильный предложение
где нашли: в коде сайта
📞
+7 (902) 982-85-85 мобильный предложение
где нашли: в коде сайта
📞
+7 (902) 982-88-28 мобильный предложение
где нашли: в коде сайта
📞
+7 (923) 300-22-22 мобильный предложение
где нашли: в коде сайта
📞
+7 (923) 309-00-90 мобильный предложение
где нашли: в коде сайта
📞
+7 (953) 586-40-00 мобильный предложение
где нашли: в коде сайта
📞
+7 (953) 588-85-85 мобильный предложение
где нашли: в коде сайта
📞
+7 (953) 841-70-70 мобильный предложение
где нашли: в коде сайта
📞
+7 (953) 841-80-00 мобильный предложение
где нашли: в коде сайта

СТАНЕТ (36)

\ No newline at end of file diff --git a/bylo-stanet/420-kraslombard.png b/bylo-stanet/420-kraslombard.png new file mode 100644 index 00000000..a403b102 Binary files /dev/null and b/bylo-stanet/420-kraslombard.png differ diff --git a/docs/superpowers/findings/2026-07-02-avtopodbor-field-test-bugs.md b/docs/superpowers/findings/2026-07-02-avtopodbor-field-test-bugs.md index 9db8b973..fbda1f9d 100644 --- a/docs/superpowers/findings/2026-07-02-avtopodbor-field-test-bugs.md +++ b/docs/superpowers/findings/2026-07-02-avtopodbor-field-test-bugs.md @@ -82,6 +82,34 @@ --- +## 🆕🧪 ПЕСОЧНИЦА «ГЛОБАЛЬНЫЙ ШАГ 2» (03.07.2026 вечер) — прототип, НЕ в портале + +**Что это.** Отдельный скрипт `app/scripts/sandbox-step2.php` — прототип нового шага 2 «Собрать источники». Боевой шаг 2 **НЕ тронут**. Владелец даёт конкурента → песочница гоняет → HTML «было/станет» на каждого. Если ок — переносим в портал (по TDD, по слову). + +**Идея (после brainstorm, дизайн v2).** Не «сайты», а **где конкурент рекламируется** (рекламный след): +1. Отпечаток фирмы: домен(ы) + имя + стабильный номер + тип (фед/рег) + город. +2. Поиск по отпечатку через **EXA** (гибрид: имя+город/бренд + домен) → все площадки. +3. Гео-скоуп по типу: **регионал** → город фирмы; **федерал** → город КЛИЕНТА + центральный бренд-сайт (не вся страна по 8-800). +4. Фильтр «точно она»: домен=seed / близнец seed / якорный телефон на странице (иначе чужой — cbr, carzaem и т.п. отсекаются). +5. Вскрытие: сайты — глубоко (код→скрытые/настоящие, видимые→подменный коллтрекинг, «Контакты», пул); 2ГИС/Яндекс — номера+сайт. +6. Итог: все номера **с пометками** (в коде сайта / коллтрекинг / из справочника / домен), сайты с флагом «реклама (коллтрекинг)». Оплата шага 2 — **как есть** (priceGate+chargeForRun, per-competitor; цену поднять в настройке под реальную стоимость). + +**Запуск.** `c:/tools/php83/php scripts/sandbox-step2.php --comp --html <путь.html>` (режим по конкуренту, tenant 36: имя/тип/сайт/регион берёт из БД, «было» — из `autopodbor_sources` box=proposal; «станет» — реальный прогон). Скрин: `node scripts/_shot.cjs `. Ручной режим: `... "Имя" "Город" reg|fed "ГородКлиента" "КодГорода" "seed-домен"`. + +**Конкуренты с «Предложениями» (tenant 36):** 385 Автозайм124(рег), 420 КрасЛомбард(рег), 416 Ваш Инвестор(рег), 414 Банк ДОМ.РФ(рег), 412 Драйв займ(рег). Все регион 29 (Красноярск, слаг `krasnoyarsk`, код 391). Артефакты — `bylo-stanet/-.html`+`.png`. + +**4 фикса этого захода (все проверены на живых прогонах, по коду не гадая):** +1. **Сайт без `http://`** (КрасЛомбард/Банк ДОМ.РФ в БД `kraslombard24.ru` без схемы) → `parse_url` не видел host → seed пустой → сайт не вскрывался, терялись все «в коде сайта»/«Контакты». Фикс: добавлять `https://` перед разбором. Банк ДОМ.РФ: было 6 → станет 7. +2. **Яндекс → локальный Playwright** (как в боевом шаге 1), 2ГИС → xfetch. В изоляции xfetch Яндекс отдавал норм, но под полной нагрузкой режет (429→пусто→теряется карточка) — привёл к проду. +3. **2ГИС: поиск по имени фирмы + ретрай + полный разбор `parse2gisCard` (JSON)** вместо `tel:`-only `parseFirmCard`. Было: открывалась случайная карточка из EXA (без нужного номера); стало: 2ГИС-поиск по имени (ретрай, т.к. xfetch флакует) даёт все карточки, читаем JSON. КрасЛомбард: номер 255-00-00 теперь с меткой «2ГИС» (было — только «в коде сайта»); номеров с 2ГИС стало 15 (было 3). +4. **Второй домен кир↔лат** (красломбард.рф ↔ kraslombard24.ru): «клей близнецов» с **транслитерацией** кириллицы в латиницу + «один корень содержит другой» → второй домен фирмы распознаётся своим (был в «отброшенных»). КрасЛомбард: оба сайта в «Сайтах фирмы». + +**Статус набора.** Свежие со ВСЕМИ фиксами: **420 КрасЛомбард, 414 Банк ДОМ.РФ**. Нужен финальный перегон: **385 Автозайм124, 416 Ваш Инвестор, 412 Драйв займ** (у них часть фиксов, но не 2ГИС-по-имени/близнецы). Команда перегона — цикл по id в `scratchpad/batch*.log` (см. историю). Открытые хвосты: рекламный текст 2ГИС; редирект-домены (curl не ходит редирект → близнец показываем, но номера с него не берём — можно добрать рендером). + +**НЕ в портале, НЕ на проде.** Песочница — только прототип для оценки. Перенос в боевой шаг 2 — отдельная работа по TDD после «ок» владельца. + +--- + ## Баг 1 — «Предложения»: нет групповых действий (недоделка) **Что видно:** во вкладке источников **«Предложения»** (по одному конкуренту) отмечаешь 2+ источника галочками — массовые действия НЕ появляются. При этом есть обещание-подпись «Отметьте 2 и более — появится перенос в работу».