Files
portal/docs/superpowers/plans/2026-06-30-real-engine-step2-core.md
T
2026-06-30 10:42:47 +03:00

28 KiB
Raw Blame History

Живой движок шага 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.