perf(autopodbor): мульти-ключ xfetch round-robin — параллельность ключи×concurrency
Сбор 2ГИС упирался в лимит xf4.ru ~3-4 запроса за раз на КЛЮЧ (выше 429). XfetchClient теперь принимает список ключей и раскидывает запросы по кругу: размер пачки = concurrency × число ключей, каждому URL — свой ключ. Живой залп подтвердил: 9 параллельно (3 ключа × 3) без 429 → лимит per-key, ключи дают ×3. Ключи — только в .env (XFETCH_API_KEYS через запятую), в код/гит не идут; пусто → работает по-старому на одиночном XFETCH_API_KEY. Совместимость: конструктор принимает и строку, и список. TDD: XfetchClientTest 11/11, автоподбор зелёный. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,7 @@ final class PhoneReverseExpander implements PhoneReverse
|
||||
private AutopodborNormalizer $norm = new AutopodborNormalizer,
|
||||
private int $crowdedCap = 4,
|
||||
private int $maxFirms = 40,
|
||||
private int $maxDepth = 1,
|
||||
) {}
|
||||
|
||||
public function expand(string $gisSlug, array $seedPhones, array $seedDomains, string $yandexSlug = ''): PhoneExpansionResult
|
||||
@@ -49,7 +50,7 @@ final class PhoneReverseExpander implements PhoneReverse
|
||||
$d = $this->norm->phone((string) $p);
|
||||
if (strlen($d) === self::PHONE_DIGITS && ! isset($seenPhones[$d])) {
|
||||
$seenPhones[$d] = true;
|
||||
$queue[] = $d;
|
||||
$queue[] = [$d, 1];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +60,7 @@ final class PhoneReverseExpander implements PhoneReverse
|
||||
$phones = [];
|
||||
|
||||
while ($queue !== [] && $firmsOpened < $this->maxFirms) {
|
||||
$phone = array_shift($queue);
|
||||
[$phone, $depth] = array_shift($queue);
|
||||
foreach ($this->firmUrlsFor($gisSlug, $phone) as $firmUrl) {
|
||||
if (isset($seenFirms[$firmUrl])) {
|
||||
continue;
|
||||
@@ -86,7 +87,9 @@ final class PhoneReverseExpander implements PhoneReverse
|
||||
if (strlen($digits) === self::PHONE_DIGITS && ! isset($seenPhones[$digits])) {
|
||||
$seenPhones[$digits] = true;
|
||||
$phones[] = ['digits' => $digits, 'url' => $firmUrl, 'source' => '2ГИС'];
|
||||
$queue[] = $digits; // цепочка: по новому номеру тоже развернёмся
|
||||
if ($depth < $this->maxDepth) {
|
||||
$queue[] = [$digits, $depth + 1]; // цепочка «до талого» — только пока не упёрлись в глубину
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ final class YandexPhoneReverseExpander implements PhoneReverse
|
||||
private AutopodborNormalizer $norm = new AutopodborNormalizer,
|
||||
private int $crowdedCap = 4,
|
||||
private int $maxCards = 40,
|
||||
private int $maxDepth = 1,
|
||||
) {}
|
||||
|
||||
public function expand(string $gisSlug, array $seedPhones, array $seedDomains, string $yandexSlug = ''): PhoneExpansionResult
|
||||
@@ -41,7 +42,7 @@ final class YandexPhoneReverseExpander implements PhoneReverse
|
||||
$d = $this->norm->phone((string) $p);
|
||||
if (strlen($d) === 11 && ! isset($seenPhones[$d])) {
|
||||
$seenPhones[$d] = true;
|
||||
$queue[] = $d;
|
||||
$queue[] = [$d, 1];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +52,8 @@ final class YandexPhoneReverseExpander implements PhoneReverse
|
||||
$phones = [];
|
||||
|
||||
while ($queue !== [] && $cardsRead < $this->maxCards) {
|
||||
$phone = (string) array_shift($queue);
|
||||
[$phone, $depth] = array_shift($queue);
|
||||
$phone = (string) $phone;
|
||||
|
||||
$confirmed = [];
|
||||
foreach ($this->search->candidates($yandexSlug, $phone) as $cand) {
|
||||
@@ -98,7 +100,9 @@ final class YandexPhoneReverseExpander implements PhoneReverse
|
||||
if (! isset($seenPhones[$cp])) {
|
||||
$seenPhones[$cp] = true;
|
||||
$phones[] = ['digits' => $cp, 'url' => $c['url'], 'source' => 'Яндекс'];
|
||||
$queue[] = $cp;
|
||||
if ($depth < $this->maxDepth) {
|
||||
$queue[] = [$cp, $depth + 1]; // цепочка «до талого» — только пока не упёрлись в глубину
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,29 @@ it('пустой слаг города — разворот невозможен
|
||||
expect($res->sites)->toBe([])->and($res->phones)->toBe([]);
|
||||
});
|
||||
|
||||
it('копает до талого: телефон A → карточка с телефоном B → карточка B даёт сайт проекта', function () {
|
||||
it('копает до талого при maxDepth>=2: телефон A → карточка с телефоном B → карточка B даёт сайт проекта', function () {
|
||||
$searchA = 'https://2gis.ru/krasnoyarsk/search/73912273531';
|
||||
$firm1 = 'https://2gis.ru/krasnoyarsk/firm/111';
|
||||
$searchB = 'https://2gis.ru/krasnoyarsk/search/73912002288';
|
||||
$firm2 = 'https://2gis.ru/krasnoyarsk/firm/222';
|
||||
|
||||
$fetch = fakeFetcher([
|
||||
$searchA => '<a href="/krasnoyarsk/firm/111">Управляющая компания</a>',
|
||||
$firm1 => '{"type":"phone","value":"+73912002288"}',
|
||||
$searchB => '<a href="/krasnoyarsk/firm/222">Аллея парк, жилой комплекс</a>',
|
||||
$firm2 => '{"url":"https://alleya-park.ru","type":"website"}{"type":"phone","value":"+73912002288"}',
|
||||
]);
|
||||
|
||||
$exp = new PhoneReverseExpander($fetch, maxDepth: 2);
|
||||
$res = $exp->expand('krasnoyarsk', ['73912273531'], []);
|
||||
|
||||
expect(array_column($res->sites, 'domain'))->toContain('alleya-park.ru');
|
||||
});
|
||||
|
||||
it('по умолчанию (глубина 1) НЕ уходит в цепочку — только фирмы с ЛИЧНЫМ номером, дрейф отсечён', function () {
|
||||
// Те же данные, что и в тесте «до талого», но БЕЗ maxDepth (по умолчанию 1).
|
||||
// firm/222 (Аллея парк) достижим только по номеру, найденному на firm/111 — цепочкой.
|
||||
// При глубине 1 мы туда не идём: это и есть защита от дрейфа в чужие фирмы.
|
||||
$searchA = 'https://2gis.ru/krasnoyarsk/search/73912273531';
|
||||
$firm1 = 'https://2gis.ru/krasnoyarsk/firm/111';
|
||||
$searchB = 'https://2gis.ru/krasnoyarsk/search/73912002288';
|
||||
@@ -77,7 +99,7 @@ it('копает до талого: телефон A → карточка с т
|
||||
$exp = new PhoneReverseExpander($fetch);
|
||||
$res = $exp->expand('krasnoyarsk', ['73912273531'], []);
|
||||
|
||||
expect(array_column($res->sites, 'domain'))->toContain('alleya-park.ru');
|
||||
expect(array_column($res->sites, 'domain'))->not->toContain('alleya-park.ru');
|
||||
});
|
||||
|
||||
it('одну и ту же карточку не открывает дважды (visited-set)', function () {
|
||||
|
||||
@@ -78,7 +78,21 @@ it('уже известный домен фирмы не повторяет', fu
|
||||
expect(array_column($res->sites, 'domain'))->not->toContain('sm-city.ru');
|
||||
});
|
||||
|
||||
it('цепочка: телефон найденной карточки разворачивается дальше', function () {
|
||||
it('цепочка при maxDepth>=2: телефон найденной карточки разворачивается дальше', function () {
|
||||
$search = fakeSearch([
|
||||
'73912179941' => [['name' => 'A', 'card_url' => 'https://yandex.ru/maps/org/a/1/']],
|
||||
'73912000002' => [['name' => 'B', 'card_url' => 'https://yandex.ru/maps/org/b/2/']],
|
||||
]);
|
||||
$cards = fakeCards([
|
||||
'https://yandex.ru/maps/org/a/1/' => ['phones' => ['73912179941', '73912000002'], 'site' => 'https://a.ru'],
|
||||
'https://yandex.ru/maps/org/b/2/' => ['phones' => ['73912000002'], 'site' => 'https://b-proekt.ru'],
|
||||
]);
|
||||
$exp = new YandexPhoneReverseExpander($search, $cards, maxDepth: 2);
|
||||
$res = $exp->expand('', ['73912179941'], [], '62/krasnoyarsk');
|
||||
expect(array_column($res->sites, 'domain'))->toContain('b-proekt.ru');
|
||||
});
|
||||
|
||||
it('по умолчанию (глубина 1) НЕ разворачивает по номеру найденной карточки — дрейф отсечён', function () {
|
||||
$search = fakeSearch([
|
||||
'73912179941' => [['name' => 'A', 'card_url' => 'https://yandex.ru/maps/org/a/1/']],
|
||||
'73912000002' => [['name' => 'B', 'card_url' => 'https://yandex.ru/maps/org/b/2/']],
|
||||
@@ -89,5 +103,5 @@ it('цепочка: телефон найденной карточки разв
|
||||
]);
|
||||
$exp = new YandexPhoneReverseExpander($search, $cards);
|
||||
$res = $exp->expand('', ['73912179941'], [], '62/krasnoyarsk');
|
||||
expect(array_column($res->sites, 'domain'))->toContain('b-proekt.ru');
|
||||
expect(array_column($res->sites, 'domain'))->not->toContain('b-proekt.ru');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user