Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
28 KiB
Живой движок шага 2 — ядро извлечения (Plan A) — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Продакшен-ядро движка шага 2 — чистые PHP-классы, которые из исходного кода страниц + отрендеренных номеров + данных справочников строят итоговый список источников конкурента (настоящий/подменный, тип, офис, «где нашли» список, число подтверждений), с дедупом, скрытием пула-свалки и сортировкой по подтверждениям.
Architecture: 4 чистых модуля без инфраструктуры (всё тестируется юнит-тестами на синтетических данных): PhoneType (тип по коду), HtmlPhoneScanner (номера из кода: tel/schema/microdata + тело/e-mail), CalltrackingDetector (детект трекера), SourceAggregator (свод кандидатов → классификация/дедуп/сортировка). Реальные fetch/render/2ГИС и сборка RealCompetitorAgent — отдельный Plan B; экраны — Plan D/E.
Tech Stack: PHP 8.3, Pest 4. Переиспользуем App\Services\Autopodbor\AutopodborNormalizer (телефон→7XXXXXXXXXX, голова домена).
Граница плана (scope): только ЧИСТОЕ ядро. НЕ входит: HTTP-загрузка (curl/Guzzle), рендер (Playwright), обход 2ГИС/Яндекс, правка БД/контракта DTO StudyCompetitorResult, фронт. Это Plan B–E (перечислены в конце). Ядро самодостаточно и полностью покрыто тестами.
Эталоны: макеты docs/superpowers/prototypes/2026-06-30-konkurentnoe-pole-istochniki-sbor1.html (первый сбор) и …-sbor2-povtor.html (повтор). R&D: docs/superpowers/findings/2026-06-30-real-engine-step2-research.md. Рабочий прототип логики: scratchpad/engine_spike.php, engine_spike2.php.
Структура файлов
| Файл | Ответственность |
|---|---|
app/app/Services/Autopodbor/Agent/Extract/PhoneType.php |
Тип номера (city/mobile/tollfree) по коду |
app/app/Services/Autopodbor/Agent/Extract/HtmlPhoneScanner.php |
Из сырого HTML → номера в коде (tel/schema/microdata), счётчик тела, e-mail-цифры |
app/app/Services/Autopodbor/Agent/Extract/CalltrackingDetector.php |
Из HTML → список систем коллтрекинга |
app/app/Services/Autopodbor/Agent/Dto/CollectedSource.php |
Итоговый источник: id, тип, kind, office, sources[] «где нашли» |
app/app/Services/Autopodbor/Agent/Extract/SourceAggregator.php |
Свод кандидатов → классификация/дедуп/скрытие пула/сортировка |
app/tests/Unit/Autopodbor/Extract/*Test.php |
Pest-юниты на каждый модуль |
Task 1: Тип телефона (PhoneType)
Files:
-
Create:
app/app/Services/Autopodbor/Agent/Extract/PhoneType.php -
Test:
app/tests/Unit/Autopodbor/Extract/PhoneTypeTest.php -
Step 1: Написать падающий тест
<?php
use App\Services\Autopodbor\Agent\Extract\PhoneType;
it('определяет тип номера по коду', function () {
expect(PhoneType::of('73912920000'))->toBe('city'); // 391 — городской
expect(PhoneType::of('79135396546'))->toBe('mobile'); // 9xx — мобильный
expect(PhoneType::of('78002001122'))->toBe('tollfree'); // 800 — бесплатный
});
- Step 2: Запустить — убедиться, что падает
Run: cd app && ./vendor/bin/pest tests/Unit/Autopodbor/Extract/PhoneTypeTest.php
Expected: FAIL — Class "App\Services\Autopodbor\Agent\Extract\PhoneType" not found.
- Step 3: Минимальная реализация
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Extract;
final class PhoneType
{
/** @param string $p номер в виде 7XXXXXXXXXX */
public static function of(string $p): string
{
$code = substr($p, 1, 3);
if ($code === '800') return 'tollfree';
if (isset($code[0]) && $code[0] === '9') return 'mobile';
return 'city';
}
}
- Step 4: Запустить — зелёный
Run: cd app && ./vendor/bin/pest tests/Unit/Autopodbor/Extract/PhoneTypeTest.php
Expected: PASS.
- Step 5: Коммит (попросить «эскейп» у владельца перед коммитом)
git add app/app/Services/Autopodbor/Agent/Extract/PhoneType.php app/tests/Unit/Autopodbor/Extract/PhoneTypeTest.php
git commit -m "feat(автоподбор): PhoneType — тип номера по коду"
Task 2: Сканер номеров из кода — tel:/schema/microdata
Files:
- Create:
app/app/Services/Autopodbor/Agent/Extract/HtmlPhoneScanner.php - Test:
app/tests/Unit/Autopodbor/Extract/HtmlPhoneScannerTest.php
HtmlPhoneScanner::scan(string $html): array возвращает:
['code' => array<string,list<string>>, 'body' => array<string,int>, 'emails' => list<string>]
где code[number] — список слотов кода ('tel'|'schema'|'microdata'), body[number] — число вхождений номера в тело, emails — цифровые локал-части e-mail.
- Step 1: Написать падающий тест (tel:)
<?php
use App\Services\Autopodbor\Agent\Extract\HtmlPhoneScanner;
it('берёт номера из tel:-ссылок', function () {
$html = '<a href="tel:+7 (843) 203-25-33">звонок</a><a href="tel:88432452533">2</a>';
$r = (new HtmlPhoneScanner())->scan($html);
expect($r['code'])->toHaveKey('78432032533')
->and($r['code']['78432032533'])->toContain('tel')
->and($r['code'])->toHaveKey('78432452533');
});
-
Step 2: Запустить — FAIL (
Class … HtmlPhoneScanner not found). Run:cd app && ./vendor/bin/pest tests/Unit/Autopodbor/Extract/HtmlPhoneScannerTest.php -
Step 3: Реализация (только tel:)
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Extract;
use App\Services\Autopodbor\AutopodborNormalizer;
final class HtmlPhoneScanner
{
public function __construct(private AutopodborNormalizer $norm = new AutopodborNormalizer()) {}
/** @return array{code: array<string,list<string>>, body: array<string,int>, emails: list<string>} */
public function scan(string $html): array
{
$code = [];
$add = function (string $raw, string $slot) use (&$code): void {
$n = $this->normalizeMaybe($raw);
if ($n === null) return;
$code[$n] = $code[$n] ?? [];
if (! in_array($slot, $code[$n], true)) $code[$n][] = $slot;
};
if (preg_match_all('/tel:([+0-9()\s-]{7,})/i', $html, $m)) {
foreach ($m[1] as $x) $add($x, 'tel');
}
return ['code' => $code, 'body' => [], 'emails' => []];
}
private function normalizeMaybe(string $raw): ?string
{
$digits = preg_replace('/\D+/', '', $raw) ?? '';
if (strlen($digits) === 11 && ($digits[0] === '8' || $digits[0] === '7')) return '7'.substr($digits, 1);
if (strlen($digits) === 10) return '7'.$digits;
return null;
}
}
-
Step 4: Запустить — PASS.
-
Step 5: Добавить тест schema/microdata (RED)
it('берёт номера из schema.org и microdata', function () {
$html = '<script type="application/ld+json">{"telephone":"+7(843)203-25-33"}</script>'
. '<span itemprop="telephone" content="+78432452533">x</span>';
$r = (new HtmlPhoneScanner())->scan($html);
expect($r['code']['78432032533'])->toContain('schema')
->and($r['code']['78432452533'])->toContain('microdata');
});
-
Step 6: Запустить — FAIL (schema/microdata ещё не парсятся).
-
Step 7: Дополнить scan() (перед return)
if (preg_match_all('/"telephone"\s*:\s*"([^"]+)"/i', $html, $m)) {
foreach ($m[1] as $x) $add($x, 'schema');
}
if (preg_match_all('/itemprop=["\']telephone["\'][^>]*content=["\']([^"\']+)/i', $html, $m)) {
foreach ($m[1] as $x) $add($x, 'microdata');
}
-
Step 8: Запустить — PASS.
-
Step 9: Тест тела и e-mail (RED)
it('считает вхождения в тело и берёт e-mail-цифры', function () {
$html = 'тел 8(843)203-25-33, ещё 8(843)203-25-33. почта 2032533@mail.ru';
$r = (new HtmlPhoneScanner())->scan($html);
expect($r['body']['78432032533'])->toBe(2)
->and($r['emails'])->toContain('2032533');
});
-
Step 10: Запустить — FAIL.
-
Step 11: Дополнить scan() (перед return) телом и e-mail
$body = [];
if (preg_match_all('/(?:\+7|8)[\s(\-]*\d{3}[\s)\-]*\d{3}[\s\-]*\d{2}[\s\-]*\d{2}/', $html, $m)) {
foreach ($m[0] as $x) {
$n = $this->normalizeMaybe($x);
if ($n !== null) $body[$n] = ($body[$n] ?? 0) + 1;
}
}
$emails = [];
if (preg_match_all('/([a-z0-9._%+-]+)@[a-z0-9.-]+\.[a-z]{2,}/i', $html, $m)) {
foreach ($m[1] as $local) {
$d = preg_replace('/\D+/', '', $local) ?? '';
if (strlen($d) >= 7) $emails[] = $d;
}
}
И заменить return ['code' => $code, 'body' => [], 'emails' => []]; на
return ['code' => $code, 'body' => $body, 'emails' => $emails];
-
Step 12: Запустить — PASS, и весь файл теста зелёный. Run:
cd app && ./vendor/bin/pest tests/Unit/Autopodbor/Extract/HtmlPhoneScannerTest.php -
Step 13: Коммит (после «эскейп»)
git add app/app/Services/Autopodbor/Agent/Extract/HtmlPhoneScanner.php app/tests/Unit/Autopodbor/Extract/HtmlPhoneScannerTest.php
git commit -m "feat(автоподбор): HtmlPhoneScanner — номера из кода (tel/schema/microdata/тело/email)"
Task 3: Детектор коллтрекинга
Files:
-
Create:
app/app/Services/Autopodbor/Agent/Extract/CalltrackingDetector.php -
Test:
app/tests/Unit/Autopodbor/Extract/CalltrackingDetectorTest.php -
Step 1: Падающий тест
<?php
use App\Services\Autopodbor\Agent\Extract\CalltrackingDetector;
it('находит системы коллтрекинга в коде', function () {
expect((new CalltrackingDetector())->detect('<script src="//cdn.roistat.com/x.js"></script>'))->toBe(['roistat']);
expect((new CalltrackingDetector())->detect('<div class="callibri_phone"></div>'))->toBe(['callibri']);
expect((new CalltrackingDetector())->detect('<html>чисто</html>'))->toBe([]);
});
-
Step 2: Запустить — FAIL.
-
Step 3: Реализация
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Extract;
final class CalltrackingDetector
{
private const PROVIDERS = ['roistat','calltouch','comagic','uiscom','mango-office','callibri','ringostat','phonet'];
/** @return list<string> */
public function detect(string $html): array
{
$found = [];
foreach (self::PROVIDERS as $p) {
if (stripos($html, $p) !== false) $found[] = $p;
}
return array_values(array_unique($found));
}
}
-
Step 4: Запустить — PASS.
-
Step 5: Коммит (после «эскейп»)
git add app/app/Services/Autopodbor/Agent/Extract/CalltrackingDetector.php app/tests/Unit/Autopodbor/Extract/CalltrackingDetectorTest.php
git commit -m "feat(автоподбор): CalltrackingDetector — детект систем подмены номера"
Task 4: DTO итогового источника (CollectedSource)
Files:
-
Create:
app/app/Services/Autopodbor/Agent/Dto/CollectedSource.php -
Test:
app/tests/Unit/Autopodbor/Extract/CollectedSourceTest.php -
Step 1: Падающий тест
<?php
use App\Services\Autopodbor\Agent\Dto\CollectedSource;
it('считает число подтверждений по списку источников', function () {
$s = new CollectedSource(
signalType: 'call', identifier: '73912920000', phoneKind: 'real', phoneType: 'city',
office: 'ул. Калинина, 185',
sources: [['label' => 'в коде сайта', 'url' => 'https://x.ru/'], ['label' => '2ГИС', 'url' => 'https://2gis.ru/1']],
);
expect($s->confirmations())->toBe(2)->and($s->phoneKind)->toBe('real');
});
-
Step 2: Запустить — FAIL.
-
Step 3: Реализация
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Dto;
final class CollectedSource
{
/** @param array<int,array{label:string,url:?string}> $sources */
public function __construct(
public readonly string $signalType, // call | site
public readonly string $identifier, // 7XXXXXXXXXX | домен
public readonly ?string $phoneKind, // real | substitute | null
public readonly ?string $phoneType, // city | mobile | tollfree | null
public readonly ?string $office, // подпись филиала | null
public readonly array $sources, // «где нашли»
) {}
public function confirmations(): int
{
return count($this->sources);
}
}
-
Step 4: Запустить — PASS.
-
Step 5: Коммит (после «эскейп»)
git add app/app/Services/Autopodbor/Agent/Dto/CollectedSource.php app/tests/Unit/Autopodbor/Extract/CollectedSourceTest.php
git commit -m "feat(автоподбор): DTO CollectedSource — источник с провенансом и подтверждениями"
Task 5: Агрегатор — дедуп + объединение провенанса
Files:
- Create:
app/app/Services/Autopodbor/Agent/Extract/SourceAggregator.php - Test:
app/tests/Unit/Autopodbor/Extract/SourceAggregatorTest.php
SourceAggregator::aggregate(array $candidates): list<CollectedSource>
Вход — список кандидатов (их готовит Plan B из сканера/рендера/справочников):
['number' => '7XXXXXXXXXX', 'kind' => 'code'|'contacts'|'directory'|'email'|'pool'|'displayed', 'label' => string, 'url' => ?string, 'office' => ?string, 'tracker' => bool]
Несколько кандидатов на один номер сливаются в один CollectedSource с объединённым sources[].
Правила классификации (как в утверждённом спайке):
-
pool— внутренняя ротация трекера: скрываем (в выход не идёт). -
displayedпри активном трекере и без «настоящих» источников →phoneKind='substitute'(🎭). -
иначе (есть хоть один источник kind ∈ {code, contacts, directory, email}) →
phoneKind='real'. -
officeберём из первого кандидата, где он задан. -
Step 1: Падающий тест (дедуп + объединение)
<?php
use App\Services\Autopodbor\Agent\Extract\SourceAggregator;
it('сливает один номер из разных мест в один источник с двумя где-нашли', function () {
$out = (new SourceAggregator())->aggregate([
['number' => '73912920000', 'kind' => 'code', 'label' => 'в коде сайта', 'url' => 'https://k.ru/', 'office' => null, 'tracker' => true],
['number' => '73912920000', 'kind' => 'contacts', 'label' => 'страница «Контакты»','url' => 'https://k.ru/c', 'office' => 'Единая справочная', 'tracker' => true],
]);
expect($out)->toHaveCount(1);
expect($out[0]->identifier)->toBe('73912920000');
expect($out[0]->confirmations())->toBe(2);
expect($out[0]->office)->toBe('Единая справочная');
expect($out[0]->phoneKind)->toBe('real');
expect($out[0]->phoneType)->toBe('city');
});
-
Step 2: Запустить — FAIL.
-
Step 3: Реализация (дедуп + классификация real)
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Extract;
use App\Services\Autopodbor\Agent\Dto\CollectedSource;
final class SourceAggregator
{
private const TRUSTED = ['code', 'contacts', 'directory', 'email'];
/**
* @param array<int,array{number:string,kind:string,label:string,url:?string,office:?string,tracker:bool}> $candidates
* @return list<CollectedSource>
*/
public function aggregate(array $candidates): array
{
/** @var array<string,array{sources:array<int,array{label:string,url:?string}>,kinds:list<string>,office:?string,tracker:bool}> $by */
$by = [];
foreach ($candidates as $c) {
$n = $c['number'];
$by[$n] ??= ['sources' => [], 'kinds' => [], 'office' => null, 'tracker' => false];
$by[$n]['kinds'][] = $c['kind'];
$by[$n]['tracker'] = $by[$n]['tracker'] || $c['tracker'];
$by[$n]['office'] ??= $c['office'];
if ($c['kind'] !== 'pool') {
$key = $c['label'];
if (! isset($by[$n]['sources'][$key])) {
$by[$n]['sources'][$key] = ['label' => $c['label'], 'url' => $c['url']];
}
}
}
$out = [];
foreach ($by as $n => $info) {
$kinds = $info['kinds'];
$hasTrusted = (bool) array_intersect(self::TRUSTED, $kinds);
$onlyHidden = ! $hasTrusted && ! in_array('displayed', $kinds, true); // только pool
if ($onlyHidden) {
continue; // пул-свалка — не выводим
}
$kind = $hasTrusted ? 'real' : 'substitute';
$out[] = new CollectedSource(
signalType: 'call',
identifier: $n,
phoneKind: $kind,
phoneType: PhoneType::of($n),
office: $info['office'],
sources: array_values($info['sources']),
);
}
return $out;
}
}
-
Step 4: Запустить — PASS.
-
Step 5: Тест скрытия пула + пометки подменного (RED)
it('скрывает пул ротации и помечает видимый подменный', function () {
$out = (new SourceAggregator())->aggregate([
['number' => '79012414545', 'kind' => 'displayed', 'label' => 'Callibri (на сайте)', 'url' => 'https://k.ru/', 'office' => null, 'tracker' => true],
['number' => '70489624563', 'kind' => 'pool', 'label' => 'пул', 'url' => null, 'office' => null, 'tracker' => true],
]);
expect($out)->toHaveCount(1); // пул скрыт
expect($out[0]->identifier)->toBe('79012414545');
expect($out[0]->phoneKind)->toBe('substitute');
});
-
Step 6: Запустить — PASS (логика уже реализована в Step 3; тест подтверждает).
-
Step 7: Коммит (после «эскейп»)
git add app/app/Services/Autopodbor/Agent/Extract/SourceAggregator.php app/tests/Unit/Autopodbor/Extract/SourceAggregatorTest.php
git commit -m "feat(автоподбор): SourceAggregator — дедуп, классификация, скрытие пула"
Task 6: Сортировка по числу подтверждений
Files:
-
Modify:
app/app/Services/Autopodbor/Agent/Extract/SourceAggregator.php -
Modify:
app/tests/Unit/Autopodbor/Extract/SourceAggregatorTest.php -
Step 1: Падающий тест (RED)
it('сортирует: больше подтверждений — выше, подменные — в самый низ', function () {
$out = (new SourceAggregator())->aggregate([
// 1 подтверждение
['number' => '73912500000', 'kind' => 'contacts', 'label' => 'контакты', 'url' => null, 'office' => null, 'tracker' => true],
// 2 подтверждения
['number' => '73912920000', 'kind' => 'code', 'label' => 'код', 'url' => null, 'office' => null, 'tracker' => true],
['number' => '73912920000', 'kind' => 'contacts', 'label' => 'контакты', 'url' => null, 'office' => null, 'tracker' => true],
// подменный
['number' => '79012414545', 'kind' => 'displayed','label' => 'Callibri', 'url' => null, 'office' => null, 'tracker' => true],
]);
expect(array_map(fn ($s) => $s->identifier, $out))
->toBe(['73912920000', '73912500000', '79012414545']);
});
-
Step 2: Запустить — FAIL (порядок не гарантирован). Run:
cd app && ./vendor/bin/pest tests/Unit/Autopodbor/Extract/SourceAggregatorTest.php -
Step 3: Добавить сортировку перед
return $out;
usort($out, function (CollectedSource $a, CollectedSource $b): int {
$sa = $a->phoneKind === 'substitute' ? 1 : 0;
$sb = $b->phoneKind === 'substitute' ? 1 : 0;
if ($sa !== $sb) return $sa <=> $sb; // подменные — вниз
if ($a->confirmations() !== $b->confirmations()) {
return $b->confirmations() <=> $a->confirmations(); // больше подтверждений — выше
}
return strcmp($a->identifier, $b->identifier);
});
-
Step 4: Запустить — PASS, и весь файл зелёный.
-
Step 5: Коммит (после «эскейп»)
git add app/app/Services/Autopodbor/Agent/Extract/SourceAggregator.php app/tests/Unit/Autopodbor/Extract/SourceAggregatorTest.php
git commit -m "feat(автоподбор): сортировка источников по числу подтверждений"
Task 7: Прогон ядра на живых данных (regression-фикстуры)
Files:
- Test:
app/tests/Unit/Autopodbor/Extract/SourceAggregatorLiveTest.php
Зафиксировать поведение на реальных данных КрасЛомбарда из R&D (чтобы ядро не регрессировало): из реальных кандидатов получаем 27 настоящих + 1 подменный, 292-00-00 — сверху (5 источников).
- Step 1: Тест на упрощённой выборке реальных данных (RED)
<?php
use App\Services\Autopodbor\Agent\Extract\SourceAggregator;
it('на данных КрасЛомбарда: справочная сверху, подменный внизу, пул скрыт', function () {
$cand = [
['number'=>'73912920000','kind'=>'code','label'=>'код','url'=>null,'office'=>'Единая справочная','tracker'=>true],
['number'=>'73912920000','kind'=>'contacts','label'=>'контакты','url'=>null,'office'=>null,'tracker'=>true],
['number'=>'73912920000','kind'=>'directory','label'=>'2ГИС','url'=>'https://2gis.ru/1','office'=>null,'tracker'=>true],
['number'=>'73912817070','kind'=>'code','label'=>'код','url'=>null,'office'=>'Кр. рабочий, 61','tracker'=>true],
['number'=>'73912500000','kind'=>'contacts','label'=>'контакты','url'=>null,'office'=>'Калинина, 185','tracker'=>true],
['number'=>'79012414545','kind'=>'displayed','label'=>'Callibri','url'=>null,'office'=>null,'tracker'=>true],
['number'=>'70489624563','kind'=>'pool','label'=>'пул','url'=>null,'office'=>null,'tracker'=>true],
];
$out = (new SourceAggregator())->aggregate($cand);
$ids = array_map(fn ($s) => $s->identifier, $out);
expect($ids)->not->toContain('70489624563'); // пул скрыт
expect($ids[0])->toBe('73912920000'); // 3 подтверждения — сверху
expect(end($out)->phoneKind)->toBe('substitute'); // подменный — внизу
});
-
Step 2: Запустить — PASS (логика готова; тест фиксирует поведение на живых данных). Run:
cd app && ./vendor/bin/pest tests/Unit/Autopodbor/Extract/SourceAggregatorLiveTest.php -
Step 3: Прогнать весь блок Extract Run:
cd app && ./vendor/bin/pest tests/Unit/Autopodbor/Extract/Expected: все зелёные. -
Step 4: Коммит (после «эскейп»)
git add app/tests/Unit/Autopodbor/Extract/SourceAggregatorLiveTest.php
git commit -m "test(автоподбор): regression-фикстура ядра на данных КрасЛомбарда"
Self-Review (выполнено)
Покрытие требований ядра:
- Номера из кода (tel/schema/microdata) — Task 2 ✓
- E-mail-подтверждение — Task 2 (emails) + Task 5 (kind=email как trusted) ✓
- Детект коллтрекинга — Task 3 ✓
- Тип номера — Task 1 ✓
- «Где нашли» список + дедуп провенанса — Task 5 ✓
- Скрытие пула-свалки + пометка подменного — Task 5 ✓
- Сортировка по подтверждениям, подменный вниз — Task 6 ✓
- Живой regression — Task 7 ✓
Плейсхолдеры: нет — каждый шаг с реальным кодом и командой.
Согласованность типов: scan() → {code,body,emails}; кандидат-форма {number,kind,label,url,office,tracker}; CollectedSource(signalType,identifier,phoneKind,phoneType,office,sources) — едина во всех тасках. PhoneType::of и confirmations() — имена совпадают везде.
Известный разрыв (намеренно вне Plan A): ядро классифицирует уже размеченных кандидатов. Превращение страниц/рендера/2ГИС в размеченных кандидатов (kind=code/contacts/directory/pool/displayed) — Plan B.
Дальнейшие планы (порядок сборки)
- Plan B — Fetch/Render/Directory → RealCompetitorAgent. curl сырого кода + рендер страниц (Playwright из PHP через Process/node) + обход филиалов 2ГИС/Яндекс → формирование размеченных кандидатов →
aggregate(). Биндинг вAutopodborServiceProvider(заменаFakeCompetitorAgentодной строкой). Тесты с фейковым fetcher'ом. - Plan C — Контракт/БД провенанса. Расширить
StudyCompetitorResult/autopodbor_sourcesпод список «где нашли» + число подтверждений + офис (миграция + CHANGELOG + rls-reviewer). Связать сRunAutopodborStudyJob. - Plan D — Экран «Первый сбор» (Vue) один-в-один с
…-sbor1.html: сортировка по подтверждениям, «где нашли»-плашки кликабельные, подписи офисов, многолинейные офисы отдельными строками, подменный «можно, но эффект слабее». - Plan E — Экран «Повторный сбор» (Vue) один-в-один с
…-sbor2-povtor.html: диф новые/не найдены/без изменений; backend-логика дифа повторного сбора.
После «ок» по Plan A — собираем его по TDD, затем переходим к Plan B.