feat(автоподбор): DeepStudyCollector — движок глобального сбора (близнецы порознь)
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Study;
|
||||
|
||||
use App\Services\Autopodbor\Agent\Extract\DirectoryParser;
|
||||
use App\Services\Autopodbor\Agent\Extract\PhoneType;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Движок «глобального шага 2»: от отпечатка фирмы собирает сайты (оба близнеца порознь) и номера
|
||||
* с пометками — 1:1 с песочницей sandbox-step2.php. Возвращает строки контракта StudyCompetitorResult.
|
||||
*/
|
||||
final class DeepStudyCollector
|
||||
{
|
||||
private const SRC_CHIP = ['code' => 'в коде сайта', 'displayed' => 'коллтрекинг',
|
||||
'pool' => 'коллтрекинг (пул)', 'contacts' => 'страница «Контакты»', 'uncertain' => 'на сайте (код города?)'];
|
||||
|
||||
/** @var array<string,array{sub:bool,src:array<string,?string>}> */
|
||||
private array $phones = [];
|
||||
|
||||
public function __construct(
|
||||
private XfetchClient $xfetch,
|
||||
private SiteOpener $opener,
|
||||
private FootprintSearch $exa = new FootprintSearch,
|
||||
private FootprintQueryBuilder $queries = new FootprintQueryBuilder,
|
||||
private PlatformClassifier $classifier = new PlatformClassifier,
|
||||
private AnchorGate $gate = new AnchorGate(new DomainKinship),
|
||||
private DirectoryParser $dirParser = new DirectoryParser,
|
||||
private AutopodborNormalizer $norm = new AutopodborNormalizer,
|
||||
private ?string $nodeBin = null,
|
||||
) {}
|
||||
|
||||
/** @return array<int,array{signal_type:string,identifier:string,phone_kind:?string,phone_type:?string,provenance_url:?string,provenance_label:?string,where_found:array<int,array{label:string,url:?string}>,office:?string,confirmations:int}> */
|
||||
public function collect(Fingerprint $fp): array
|
||||
{
|
||||
$this->phones = [];
|
||||
$urls = $this->exa->search($this->queries->build($fp));
|
||||
$buckets = $this->classifier->classify($urls);
|
||||
|
||||
$seedAscii = $fp->seedDomain !== '' ? $this->idn($fp->seedDomain) : '';
|
||||
$order = array_keys($buckets['own']);
|
||||
if ($seedAscii !== '') {
|
||||
array_unshift($order, $seedAscii);
|
||||
$order = array_values(array_unique($order));
|
||||
}
|
||||
|
||||
// 1) сайты + якоря
|
||||
$anchorDigits = [];
|
||||
$siteRows = [];
|
||||
foreach (array_slice($order, 0, 8) as $domain) {
|
||||
$r = $this->opener->open($domain, $fp->areaCode);
|
||||
$verified = $this->gate->siteVerified($domain, $seedAscii, $fp->seedDomain, array_map('strval', array_keys($anchorDigits)), $r->htmlLower);
|
||||
if (! $verified) {
|
||||
continue; // чужой — не наша фирма
|
||||
}
|
||||
foreach ($r->codeDigits as $d) {
|
||||
$anchorDigits[$d] = true;
|
||||
}
|
||||
$siteUrl = 'https://'.$this->idn($domain).'/';
|
||||
foreach ($r->numbers as $c) {
|
||||
$sub = in_array($c['kind'], ['displayed', 'pool'], true);
|
||||
$this->addPhone($c['number'], [(self::SRC_CHIP[$c['kind']] ?? $c['kind']) => $siteUrl, $this->idnUtf($domain) => $siteUrl], $sub);
|
||||
}
|
||||
// отдельная строка-источник на КАЖДЫЙ сайт (близнецы — порознь)
|
||||
$siteRows[] = [
|
||||
'signal_type' => 'site', 'identifier' => $this->idnUtf($domain),
|
||||
'phone_kind' => null, 'phone_type' => null,
|
||||
'provenance_url' => $siteUrl, 'provenance_label' => 'сайт конкурента',
|
||||
'where_found' => [['label' => 'сайт конкурента', 'url' => $siteUrl]],
|
||||
'office' => null, 'confirmations' => 1,
|
||||
];
|
||||
}
|
||||
|
||||
// 2) 2ГИС по имени + EXA-ссылки
|
||||
$this->collect2gis($fp, $buckets['gis'], $anchorDigits);
|
||||
// 3) Яндекс через Playwright
|
||||
$this->collectYandex($fp, $buckets['yandex'], $anchorDigits);
|
||||
|
||||
// 4) маппинг номеров
|
||||
$callRows = [];
|
||||
foreach ($this->phones as $digits => $info) {
|
||||
$digits = (string) $digits;
|
||||
$src = [];
|
||||
foreach ($info['src'] as $label => $url) {
|
||||
$src[] = ['label' => (string) $label, 'url' => $url];
|
||||
}
|
||||
$callRows[] = [
|
||||
'signal_type' => 'call', 'identifier' => $digits,
|
||||
'phone_kind' => $info['sub'] ? 'substitute' : 'real',
|
||||
'phone_type' => PhoneType::of($digits),
|
||||
'provenance_url' => $src[0]['url'] ?? null,
|
||||
'provenance_label' => $src[0]['label'] ?? null,
|
||||
'where_found' => $src, 'office' => null, 'confirmations' => count($src),
|
||||
];
|
||||
}
|
||||
|
||||
return array_merge($siteRows, $callRows);
|
||||
}
|
||||
|
||||
/** @param list<string> $gisFromExa @param array<string,bool> $anchorDigits */
|
||||
private function collect2gis(Fingerprint $fp, array $gisFromExa, array $anchorDigits): void
|
||||
{
|
||||
$firmUrls = $gisFromExa;
|
||||
if ($fp->gisSlug !== '') {
|
||||
$searchUrl = 'https://2gis.ru/'.$fp->gisSlug.'/search/'.rawurlencode($fp->name);
|
||||
$found = [];
|
||||
for ($try = 1; $try <= 4 && $found === []; $try++) {
|
||||
$found = $this->dirParser->parseBranchList($this->xfetch->html($searchUrl));
|
||||
}
|
||||
foreach ($found as $f) {
|
||||
$firmUrls[] = str_starts_with($f, 'http') ? $f : 'https://2gis.ru'.$f;
|
||||
}
|
||||
}
|
||||
$firmUrls = array_slice(array_values(array_filter(array_unique($firmUrls), fn ($u) => str_contains((string) $u, '/firm/'))), 0, 12);
|
||||
foreach ($firmUrls as $furl) {
|
||||
$fh = $this->xfetch->html($furl);
|
||||
if ($fh === '') {
|
||||
continue;
|
||||
}
|
||||
foreach ($this->dirParser->parse2gisCard($fh, $furl) as $card) {
|
||||
$this->pushCard('2ГИС', $card->number ?: null, $card->siteUrl, $furl, $fp->seedDomain, $anchorDigits);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @param list<string> $yandexUrls @param array<string,bool> $anchorDigits */
|
||||
private function collectYandex(Fingerprint $fp, array $yandexUrls, array $anchorDigits): void
|
||||
{
|
||||
if ($yandexUrls === []) {
|
||||
return;
|
||||
}
|
||||
$node = $this->nodeBin ?? (string) config('autopodbor.node_bin', 'node');
|
||||
$pages = new RoutingBatchFetcher($this->xfetch, ['yandex' => new PlaywrightPageFetcher($node)]);
|
||||
$dir = new XfetchDirectoryFetcher($pages, $this->dirParser);
|
||||
foreach (array_slice($yandexUrls, 0, 5) as $yurl) {
|
||||
try {
|
||||
foreach ($dir->directory($yurl) as $card) {
|
||||
$this->pushCard($card->source, $card->number ?: null, $card->siteUrl, $card->url, $fp->seedDomain, $anchorDigits);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// одна карточка упала — не роняем сбор
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string,bool> $anchorDigits */
|
||||
private function pushCard(string $source, ?string $number, ?string $site, string $url, string $seedDomain, array $anchorDigits): void
|
||||
{
|
||||
$cd = $number ? $this->norm->phone($number) : '';
|
||||
if ($cd !== '' && strlen($cd) !== 11) {
|
||||
$cd = '';
|
||||
}
|
||||
if ($this->gate->cardKept($site, $cd, $seedDomain, $anchorDigits) && $cd !== '') {
|
||||
$this->addPhone($cd, [$source => $url]);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string,?string> $chips */
|
||||
private function addPhone(string $number, array $chips, bool $sub = false): void
|
||||
{
|
||||
$d = $this->norm->phone($number);
|
||||
if (strlen($d) !== 11) {
|
||||
return;
|
||||
}
|
||||
$this->phones[$d]['sub'] = ($this->phones[$d]['sub'] ?? false) || $sub;
|
||||
foreach ($chips as $label => $url) {
|
||||
if ((string) $label !== '') {
|
||||
$this->phones[$d]['src'][$label] = $url ?: ($this->phones[$d]['src'][$label] ?? null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function idn(string $d): string
|
||||
{
|
||||
return function_exists('idn_to_ascii') ? (idn_to_ascii($d, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46) ?: $d) : $d;
|
||||
}
|
||||
|
||||
private function idnUtf(string $h): string
|
||||
{
|
||||
return function_exists('idn_to_utf8') ? (idn_to_utf8($h, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46) ?: $h) : $h;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use App\Services\Autopodbor\Agent\Fetch\FetchedSite;
|
||||
use App\Services\Autopodbor\Agent\Fetch\Fetcher;
|
||||
use App\Services\Autopodbor\Agent\Fetch\XfetchClient;
|
||||
use App\Services\Autopodbor\Agent\Study\DeepStudyCollector;
|
||||
use App\Services\Autopodbor\Agent\Study\Fingerprint;
|
||||
use App\Services\Autopodbor\Agent\Study\SiteOpener;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
it('оба сайта-близнеца — две отдельные строки-источника (НЕ слиты)', function () {
|
||||
config()->set('services.exa', ['key' => 't', 'base_url' => 'https://api.exa.ai']);
|
||||
Http::fake([
|
||||
'api.exa.ai/*' => Http::response(['results' => [
|
||||
['url' => 'https://kraslombard24.ru/'],
|
||||
['url' => 'https://xn--80aacn0ahhppgf.xn--p1ai/'], // красломбард.рф в punycode
|
||||
]]),
|
||||
'*' => Http::response(['response_body_base64' => '']),
|
||||
]);
|
||||
$siteFetcher = new class implements Fetcher
|
||||
{
|
||||
public function site(string $url): FetchedSite
|
||||
{
|
||||
return new FetchedSite($url, '<a href="tel:+73912920000">'.$url.'</a>');
|
||||
}
|
||||
|
||||
public function directory(string $url): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
};
|
||||
$collector = new DeepStudyCollector(new XfetchClient(null), new SiteOpener($siteFetcher));
|
||||
$fp = new Fingerprint('КрасЛомбард', 'kraslombard24.ru', 'Красноярск', '391', 'krasnoyarsk', false);
|
||||
|
||||
$rows = $collector->collect($fp);
|
||||
$sites = array_values(array_filter($rows, fn ($r) => $r['signal_type'] === 'site'));
|
||||
$domains = array_map(fn ($r) => $r['identifier'], $sites);
|
||||
|
||||
expect(count($sites))->toBe(2)
|
||||
->and($domains)->toContain('kraslombard24.ru')
|
||||
->and($domains)->toContain('красломбард.рф');
|
||||
|
||||
$call = array_values(array_filter($rows, fn ($r) => $r['signal_type'] === 'call' && $r['identifier'] === '73912920000'))[0];
|
||||
expect(collect($call['where_found'])->pluck('label'))->toContain('в коде сайта')
|
||||
->and($call['phone_kind'])->toBe('real');
|
||||
});
|
||||
Reference in New Issue
Block a user