Files
av-parser/CLAUDE.md
T
Leonid PershinandClaude Opus 5 44fb0d3a5f Gate network parsers on a working proxy and remember what worked
The pool now warms up from what the previous run learned instead of starting
cold every launch. Startup probes the remembered proxies first, stops as soon
as ProxyMinimumLive of them answer, and writes the survivors to
proxies.state.json after the warm-up and again on shutdown. Only proxies that
ever answered are stored: the feed republishes a few thousand dead addresses
every five minutes, and "was dead an hour ago" says almost nothing.

Remembered state is a hint, not a verdict. A restored proxy sorts first in the
warm-up queue but is not counted live until it answers in this session -
otherwise a launch a week later would report live proxies it had never spoken
to, the warm-up would skip the very entries it exists to re-check, and the
parser gate would open on week-old evidence.

That gate is the other half: a parser declaring RequiresNetwork will not run
while the pool has nothing live. The Parse page disables the run button and
shows a banner that leads to the Proxies page. Parsers that work on pasted text
are never gated - they have nothing to route, and blocking them would make the
app useless whenever the public lists are down. Two new settings cover the
escape hatch and the target: "allow network parsers without a proxy" and how
many live proxies to find at startup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 19:11:51 +03:00

16 KiB
Raw Blame History

CLAUDE.md

Конвенции этого репозитория и грабли, на которые здесь уже наступили. Читать до правок.

Команды

Полный локальный гейт (формат → сборка → тесты) — то, что нужно прогнать перед коммитом:

./build.ps1

Отдельные шаги, если нужен только один из них:

dotnet build AvParser.slnx -c Release
dotnet test AvParser.slnx -c Release
dotnet csharpier check .
./run.ps1

На Linux/macOS — ./build.sh и ./run.sh с теми же шагами.

Слои

Core ← Infrastructure ← UI ← Desktop, строго в одну сторону.

  • AvParser.Core не ссылается на Avalonia. Это единственное ограничение, которое здесь по-настоящему несущее: домен должен запускаться из CLI, worker-сервиса или бенчмарка. Как только Avalonia станет доступна из домена, кто-нибудь потянется к Dispatcher.UIThread или IStorageProvider внутри парсера.
  • AvParser.UI — библиотека, а не exe. Headless-тесты собирают настоящие View, не подтягивая Program.cs, Serilog и контейнер.
  • AvParser.Desktop — тонкий composition root. Логика туда не переезжает.

Добавить парсер

  1. Реализовать ITextParser в Core/Parsing/.
  2. Одна строка в CoreServiceCollectionExtensions.AddAvParserCore().

Всё. IParserCatalog, страница Parse и выпадающий список подхватят его сами.

Добавить страницу

  1. Наследник PageViewModel в UI/ViewModels/XxxViewModel.cs (Title, IconKey).
  2. UI/Views/XxxView.axaml — имя обязано соответствовать конвенции ViewLocator: ...ViewModels.XxxViewModel...Views.XxxView.
  3. Регистрация в AddAvParserUI(): конкретным типом и как PageViewModel — порядок этих регистраций и есть порядок пунктов в рельсе навигации.

ReactiveUI 24 (дистрибутив Primitives)

Это не классический ReactiveUI. System.Reactive не используется:

Классика Здесь
Unit RxVoid
IScheduler ISequencer (ReactiveUI.Primitives.Concurrency)
Subject<T> / BehaviorSubject<T> Signal<T> / BehaviorSignal<T>
RxApp.MainThreadScheduler RxSchedulers.MainThreadScheduler
TestScheduler VirtualClock, ImmediateSequencer.Instance

Привычные имена операторов (Select, Where, Throttle, DistinctUntilChanged, CombineLatest) работают — Primitives отдаёт оба набора. using ReactiveUI.Primitives; нужен ради Subscribe(Action<T>).

ReactiveUI 24 не инициализируется сама. Первый WhenAnyValue бросит InvalidOperationException, пока не отработал builder. В приложении это делает AppBuilder.UseReactiveUI(...); в проекте VM-тестов — module initializer ReactiveUiBootstrap. Новый тестовый проект без Avalonia обязан сделать то же самое.

Конвенции ViewModel

  • Каждая VM принимает ISequencer? mainThread = null и использует его в outputScheduler: и ToProperty(..., scheduler). Именно это делает тесты синхронными: они передают ImmediateSequencer.Instance. Без этого пришлось бы гонять диспетчер.
  • У VM с необязательным ISequencer регистрация в DI — явная фабрика, а не по типу: иначе выбор конструктора контейнером зависит от порядка регистраций.
  • [Reactive] из ReactiveUI.SourceGenerators на partial-свойствах; класс — partial.

Добавить источник прокси

  1. Реализовать IProxySource (или IMutableProxySource, если список редактируемый).
  2. Зарегистрировать как IProxySource в AddAvParserProxies(). Порядок регистрации = порядок слияния; свой список идёт последним, чтобы пользовательский адрес перебивал фидовый.

ProxyPool при обновлении переиспользует существующие ProxyEntry по Endpoint.Key — иначе перезагрузка списка стирала бы всю накопленную статистику, а публичные фиды переиздаются каждые несколько минут.

Инварианты прокси-пула

  • Доступность определяется карантином, а не Health. Health — это «что видели в последний раз». Если исключать всё, что когда-либо падало, окно карантина становится бессмысленным, а прокси теряется навсегда после первой же осечки. Это уже был баг, его ловит A_failing_proxy_is_quarantined_and_comes_back_later.
  • Проба не трогает SuccessCount/FailureCount. Эти счётчики про реальные запросы; свип по паре тысяч прокси перезаписал бы всё, на чём держится взвешенный выбор. Провалившаяся проба выставляет карантин через RecordProbe(..., quarantineOnFailure:).
  • Лиза без вердикта нейтральна. Отменённая операция — не вина прокси; считать это отказом значит карантинить здоровые прокси на каждый Cancel.
  • Select и Next — зарезервированные слова для CA1716. Метод стратегии называется Pick.
  • LiveCount считает только Alive и не в карантине. На нём висит гейт парсера, поэтому «доступна» (карантин истёк) и «живая» здесь намеренно расходятся: гейт не должен открываться от одного лишь истечения окна.
  • Прогрев обрывается по достижении цели, а не проходит список до конца. WarmUpAsync линкует CTS и гасит остаток, как только набралось MinimumLiveProxies. Порядок кандидатов — WarmUpOrder(), он публичный ровно затем, чтобы порядок проверялся без прогона проб.
  • RestoreState не выставляет Health = Alive. «Работала вчера» живёт в отдельном WasAliveOnLastRun и влияет только на порядок прогрева. Если восстанавливать как Alive, пул отрапортует живыми тех, с кем не разговаривал: прогрев сочтёт цель достигнутой и не проверит никого, а гейт парсера откроется по данным недельной давности. Ловит A_remembered_proxy_is_not_reported_live_until_it_answers_again.
  • RestoreState не восстанавливает карантин. Окно — стенные часы, между запусками могли пройти сутки; перенос окна сажал бы прокси за то, что давно истекло.
  • Сохраняются только HasEverAnswered = SuccessCount > 0 || IsBelievedAlive, где «believed» = вердикт этой сессии, а без него — прошлой. Мёртвые в фиде исчисляются тысячами и переиздаются каждые пять минут. Важен именно перенос: прогрев обрывается рано, поэтому большинство запомненных заканчивают сессию непроверенными — строгое Health == Alive стирало бы накопленный список за пару запусков.

Гейт парсера

IParser.RequiresNetwork — дефолтная реализация возвращает false, поэтому добавление парсера остаётся однострочным. Парсер, который куда-то ходит, обязан её переопределить, иначе поедет напрямую в обход настройки.

Гейт живёт в ParseViewModel.RefreshProxyGate() и складывается из трёх условий: парсер сетевой, AllowDirectConnection выключен, LiveCount == 0. Он пересчитывается по событию пула (с throttle 250 мс — пул дёргается на каждый исход лизы), при смене парсера и при смене настроек.

ParseView.axaml держит баннер под x:Name="ProxyGateBanner"; ParseViewTests рендерит его по-настоящему, потому что мёртвый биндинг IsVisible не ломает ни одного VM-теста.

Добавить строку в UI

  1. Ключ и оба перевода — в Strings.resx и Strings.ru.resx (оба, иначе упадёт LocalizationTests.Russian_translates_every_english_key).
  2. В XAML — {l:Loc Ключ}, во ViewModel — Localizer.Instance[...] / .Format(...).

Никакого хардкода в Views/ кроме имени продукта и примеров адресов.

  • Счётчики — только через Localizer.Plural с ключами .One / .Few / .Many. У русского три формы; «{0} records» с приклеенным окончанием непереводимо.
  • Перечисления в списках — LocalizedOption<T>, не сырые значения. Конвертер разрешил бы подпись один раз и не заметил смены языка. Идентичность обёртки — значение перечисления, чтобы выбор не слетал.
  • Текст из домена переводится по коду. Core о языках не знает: ParseError несёт Code и Arguments, UI ищет Parse.Error.{Code} с откатом на Message. Имена парсеров — так же: Parser.{id}.Name с откатом на DisplayName, поэтому обещание «добавить парсер = одна строка» остаётся в силе.
  • VM, у которой есть производный от языка текст, переопределяет OnLanguageChanged и зовёт base. Без этого заголовок страницы останется на прежнем языке.

Грабли, уже оплаченные

  • Селектор типа в Avalonia матчит точный тип. UserControl.shell не матчит ShellView (наследник ReactiveUserControl<T>) и молча не делает ничего. Использовать :is(UserControl).shell. Голый .shell тоже матчит, но тогда XAML-компилятор не может вывести тип для Setter и падает с AVLN2200.
  • Style-сеттер навсегда проигрывает локальному значению. Не стилизовать IsPaneOpen и DisplayMode — они биндятся во ViewModel.
  • IPseudoClasses.Set требует ведущего :.
  • InvariantGlobalization обязан быть false: Semi.Avalonia строит CultureInfo в статическом конструкторе и падает целиком.
  • Превьюер рефлексирует безпараметровый статический BuildAvaloniaApp(). Необязательный параметр ломает его вызов, вторая перегрузка — AmbiguousMatchException.
  • Compiled bindings включены по умолчанию (Avalonia 12): x:DataType нужен на каждом UserControl и каждом DataTemplate.
  • Avalonia.Headless.XUnit 12.x — это xUnit v3, а не v2. Тестовые проекты — Exe.
  • Headless: ручной Measure/Arrange внутри окна бесполезен — следующий проход layout окна вернёт свой размер. Задавать ширину самому Window. Но и без окна нельзя: у открепленного контрола не строится визуальное дерево.
  • csharpier — единственный владелец форматирования (включая .axaml и .csproj). IDE0055 понижен до suggestion: два форматтера с TreatWarningsAsErrors дерутся насмерть.
  • Condition="'$(Configuration)' == 'Debug'" на PackageReference — ловушка. dotnet restore вычисляется с конфигурацией по умолчанию, поэтому пакет обязан резолвиться, даже если эту конфигурацию никто не собирает. Так тут проехал мёртвый Avalonia.Diagnostics (его нет под Avalonia 12): dotnet build -c Release работал, а голый dotnet restore падал.
  • Execute() завершился ≠ IsExecuting уже false. Второе публикуется на выходном планировщике. Тест, который сразу после await дёргает команду, закрытую по чужому IsExecuting, будет мигать под нагрузкой — ждите CanExecute, а не предполагайте.
  • Проект VM-тестов не параллелится. ReactiveUiBootstrap ставит глобальные планировщики ReactiveUI, то есть тесты делят изменяемое состояние независимо от их желания.
  • Инспектора в Avalonia 12 нет из коробки. Avalonia.Diagnostics закончился на 11.3.x; DevTools живут отдельно (AvaloniaUI.DiagnosticsSupport + .WithDeveloperTools()), со своей установкой. Зависимость намеренно не добавлена.

Качество

  • Центральные версии пакетов — Directory.Packages.props. Никаких Version= в csproj (иначе NU1008 на restore).
  • TreatWarningsAsErrors включён; NuGet-advisory (NU19xx) выведены из ошибок, чтобы свежая CVE не роняла сборку кода, который никто не трогал.
  • Тестовые послабления анализаторов — в tests/Directory.Build.props, не в самих тестах.

Что осталось абстрактным

Домен — заглушка. IParser<TInput, TOutput> + DelimitedTextParser + KeyValueTextParser существуют, чтобы каркас проверялся end-to-end. Когда появится настоящая доменная логика, демо-парсеры удаляются вместе с их тестами и SampleFor/LargeSampleFor в ParseViewModel.