Leonid PershinandClaude Opus 5 1742c094e9 Add the download pipeline: sniffing, redirects, throttling, verdicts
Second half of the collector foundation. Still nothing in the app references
it; the pipeline is tested end to end against a deliberately badly behaved
loopback server before anything depends on it.

Types come from the bytes, never from the URL, the extension or Content-Type -
two of those three are chosen by whoever serves the file, and a host must not
get to pick the extension of a file written to the user's disk. Animation is a
separate question from kind: GIF89a proves nothing without a second image
descriptor, and a PNG is an APNG only if acTL precedes the first IDAT, so both
are walked properly rather than guessed.

Timeouts are split three ways because HttpClient.Timeout covers the whole
response: any value large enough for a 30 MB file is also large enough for a
dead connection to hang on. Connect, headers and a per-read idle deadline let
both be strict. Redirects are followed by hand since the shared proxy handler
disables them, which is what allows a hop cap, loop detection and refusing a
jump to a data: URL.

The lease verdict is a pure function, because ProxyLease's constructor is
internal to the domain and no test can fabricate one. Its rule is that the
verdict describes the transport, not the resource: a 404 is a working proxy,
and so is a 429 - blaming the proxy for an origin's rate limit would make the
pool rotate away from a good address in response to being asked to slow down.
Cancellation reports nothing at all.

Throttling exists to be obeyed. It is raised only by the host's own 429 and 503,
and never by rotating to another proxy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 21:06:15 +03:00

AvParser

Каркас desktop-приложения на Avalonia 12 с ReactiveUI-MVVM, адаптивным layout поверх Semi.Avalonia, единым DI-контейнером и тремя уровнями тестов.

Доменная часть пока намеренно абстрактная: ядро — это pluggable-контракт IParser<TInput, TOutput> и два демо-парсера, чтобы каркас был запускаемым и проверяемым end-to-end до появления настоящей логики.


Быстрый старт

Нужен .NET SDK 10.0.100 (закреплён в global.json).

Полный локальный гейт — восстановление, проверка форматирования, сборка, тесты:

./build.ps1

Запуск приложения (по умолчанию Debug):

./run.ps1

На Linux и macOS — ./build.sh и ./run.sh, аргументы те же.

Полезные флаги:

./build.ps1 -Fix -Configuration Debug

-Fix переформатирует код вместо того, чтобы падать на непрошедшей проверке; -SkipTests собирает без прогона тестов. То же в bash: --fix, -c Debug, --skip-tests.

Если нужны отдельные шаги, скрипты ничего не прячут:

dotnet build AvParser.slnx -c Release
dotnet test AvParser.slnx -c Release
dotnet csharpier check .

Структура

src/
  AvParser.Core            домен: IParser, IParserCatalog, модели, демо-парсеры
                           ноль зависимостей кроме DI.Abstractions — ни Avalonia, ни IO
  AvParser.Infrastructure  AppPaths, JSON-настройки с debounce, Serilog
  AvParser.UI              Avalonia class library: App-независимые View, ViewModel,
                           ResponsiveLayout, дизайн-токены, навигация
  AvParser.Desktop         WinExe-хост: Program.cs, App.axaml, composition root
tests/
  AvParser.Core.Tests            парсеры, реестр, отмена, прогресс, пул прокси и стратегии
  AvParser.Infrastructure.Tests  разбор фида прокси, локальный список, маппинг на WebProxy
  AvParser.UI.Tests              ViewModel'и без Avalonia
  AvParser.UI.HeadlessTests      реальное дерево контролов через [AvaloniaFact]

Ссылки идут строго в одну сторону: Core ← Infrastructure ← UI ← Desktop. UI — библиотека, а не exe, именно чтобы headless-тесты собирали настоящие View, не подтягивая Program.cs, Serilog и контейнер.


Адаптивный layout

В Avalonia нет AdaptiveTrigger, VisualStateManager и media-queries. Есть три примитива: наблюдаемый Visual.Bounds, псевдоклассы и SplitView. ResponsiveLayout связывает первое со вторым — получается CSS-подобная реакция на ширину.

Брейкпоинт Ширина окна Навигация
Compact < 720 px выезжающий drawer поверх контента
Medium 720 1100 px рельс из одних иконок (56 px)
Expanded ≥ 1100 px полный сайдбар с подписями (248 px)

Переключение с гистерезисом в 24 px: без неё перетаскивание края окна заставляет layout мигать между двумя состояниями на каждом пикселе дрожания.

Разделение обязанностей, которое важно не сломать:

  • SplitView.DisplayMode и IsPaneOpen биндятся во ViewModel. Style-сеттер навсегда проигрывает локальному значению, поэтому первый же клик по гамбургеру заморозил бы любой стиль, который тоже пишет в эти свойства.
  • Всё чисто визуальное — ширины панели, видимость подписей, паддинги — живёт в Styles/Shell.axaml.

Селекторы там написаны как :is(UserControl).shell, а не UserControl.shell: селектор типа в Avalonia матчит точный тип, а ShellView наследуется от ReactiveUserControl<T> — обычная форма молча не сматчилась бы ни с чем. На это есть тест (ShellViewTests.The_shell_stylesheet_is_actually_applied).


Прокси

Пул прокси с ротацией — AvParser.Core/Proxies, источники и сетевая часть — AvParser.Infrastructure/Proxies, управление — страница Proxies.

Источники:

  • proxifly/free-proxy-list — публичный список, обновляется каждые 5 минут. Тянем сводный all/data.json через jsDelivr и фильтруем локально: один условный запрос за весь список надёжнее четырёх по протоколам, которые могут разъехаться между собой в момент публикации. Ответ кэшируется на 5 минут, недоступность фида не роняет приложение — остаётся прошлый список.
  • Свой списокproxies.custom.json рядом с настройками. Вставляется пачкой, по одной на строку; поддерживаются scheme://host:port, голый host:port и user:pass@. Непонятые строки не проглатываются молча, а называются в статусе.

Ротация выбирается в настройках:

Стратегия Поведение Когда
Sticky одна прокси, смена только по отказу по умолчанию: не рвёт сессии и cookie
RoundRobin новая на каждый запрос размазывает рейт-лимиты, но ломает сессии
WeightedRandom случайно, с весом по score и доле успехов при сильном разбросе качества

Проверка живости — тоже настройка, два режима: Pool прогоняет весь список параллельно один раз, Lazy проверяет прокси в момент выдачи и перескакивает на следующую. У бесплатных списков рабочих обычно единицы процентов, поэтому без проверки парсер будет в основном ждать таймауты.

Упавшая прокси уходит в карантин с экспоненциальным окном (30 с → 15 мин), но не удаляется навсегда: бесплатные прокси постоянно мигают, и жёсткий бан терял бы их безвозвратно.

Что запоминается между запусками

Пул грузится и прогревается сам при старте, нажимать «Обновить» не нужно. Прогрев идёт от известного хорошего: сначала пробуются те, что отвечали в прошлый раз, затем самые быстрые из них, и проверка обрывается, как только набралось ProxyMinimumLive живых (по умолчанию 10). Иначе каждый запуск был бы полным свипом по паре тысяч адресов ради десятка рабочих.

Запомненное — это подсказка, а не зачёт: восстановленная прокси идёт первой в очередь на проверку, но живой не считается, пока не ответит в этом запуске. Иначе запуск через неделю открывал бы гейт парсера по недельной давности данным, а прогрев пропускал бы ровно те прокси, ради которых он есть.

Состояние лежит в proxies.state.json рядом с настройками и пишется после прогрева и на выходе. Сохраняются только те прокси, что когда-либо отвечали: мёртвых в фиде тысячи, они переиздаются каждые пять минут, и «было мертво час назад» не говорит почти ничего. Карантин не восстанавливается — окно отсчитывается по стенным часам, а между запусками могли пройти сутки.

Гейт «без прокси не работаем»

Парсер, который объявил RequiresNetwork, не запустится, пока в пуле нет ни одной живой прокси: кнопка «Разобрать» гаснет, а на странице появляется баннер с переходом на страницу Proxies. Гейт снимается настройкой «Разрешить сетевым парсерам работать без прокси».

Парсеры, читающие вставленный пользователем текст, не блокируются никогда — им нечего маршрутизировать, и блокировка делала бы приложение бесполезным всякий раз, когда публичные списки лежат.

Использование из кода:

var (http, lease) = await clientFactory.CreateFromPoolAsync();
using (http)
using (lease)
{
    try   { var response = await http.GetAsync(url); lease?.ReportSuccess(); }
    catch { lease?.ReportFailure("request failed"); throw; }
}

Отчёт об исходе — не формальность: без него пул ничего не узнаёт о том, какие прокси работают. Освобождение лизы без вердикта нейтрально — отменённая операция не вина прокси.

SOCKS работает штатно: .NET понимает схемы socks4/socks4a/socks5 в WebProxy. Учтите, что proxifly-запись с "protocol": "https" — это всё равно HTTP-прокси с CONNECT, а не схема https://.

Локализация

Русский и английский, переключение без перезапуска — язык выбирается в настройках («Системный» берёт язык ОС, если для него есть перевод, иначе английский).

Строки лежат в UI/Localization/Strings.resx и Strings.ru.resx; русский собирается в сателлитную сборку ru/AvParser.UI.resources.dll.

В XAML — разметочное расширение:

<TextBlock Text="{l:Loc Parse.Run}" />

Оно возвращает биндинг через индексатор Localizer, а не готовую строку: смена языка поднимает PropertyChanged для индексатора, и все такие биндинги перечитываются разом. Строка, разрешённая один раз при загрузке, потребовала бы перезапуска.

Три места, где локализация упирается в грамматику или в слои:

  • Множественные числа. У русского три формы, поэтому счётчики собираются не из «{0} records» с приклеенным окончанием, а из ключей .One / .Few / .Many через Localizer.Plural. «1 запись», «3 записи», «7 записей».
  • Значения перечислений. Конвертер разрешил бы подпись один раз и не заметил смены языка, поэтому в списках лежат обёртки LocalizedOption<T>: идентичность — значение перечисления (выбор не слетает), подпись следует за локализатором.
  • Текст из домена. AvParser.Core о языках не знает. Парсеры отдают английское сообщение и код, а UI переводит Parse.Error.{Code} с откатом на сообщение. Так же и с именами парсеров: Parser.{id}.Name с откатом на DisplayName, поэтому новый парсер работает непереведённым, а не показывает !ключ!.

Оба .resx генерируются из одной таблицы, чтобы ключ не мог существовать в одном файле и отсутствовать в другом; тесты проверяют совпадение ключей, отсутствие пустых переводов и одинаковый набор плейсхолдеров {0}.

Дизайн-токены

Все цвета, отступы, радиусы и типографика — в Styles/Tokens.axaml, с отдельными словарями для Light и Dark. В остальном XAML нет ни одного литерального цвета и ни одного «магического» отступа, так что перекрасить тему или уплотнить интерфейс — это правка одного файла.

Semi.Avalonia даёт темы контролов; токены — это семантический слой приложения поверх них. Кнопки .primary / .destructive описаны своими стилями, а не классами Semi, чтобы акцентный цвет не разъезжался между двумя палитрами.


Стек

Пакет Версия Заметка
Avalonia 12.1.1 compiled bindings по умолчанию → x:DataType обязателен
ReactiveUI.Avalonia 12.1.1 Avalonia.ReactiveUI — deprecated, это его преемник
ReactiveUI 24.1.0 дистрибутив Primitives: RxVoid вместо Unit, ISequencer вместо IScheduler
Semi.Avalonia 12.1.0.1 темы контролов
xUnit v3 (3.2.2) Avalonia.Headless.XUnit 12.x требует именно v3

Что проверить руками

  1. Потянуть окно по ширине — сайдбар проходит путь полный → только иконки → выезжающий drawer, без мигания на границах.
  2. Переключить тему кнопкой в заголовке и в Settings; перезапустить — выбор сохранился.
  3. На странице Parse нажать 50k rows, затем Parse — виден прогресс; Cancel останавливает на середине и пишет, сколько успело разобраться. В Avalonia 12 инспектора «из коробки» больше нет: Avalonia.Diagnostics остановился на 11.3.x, а DevTools вынесли в отдельный инструмент со своей установкой (AvaloniaUI.DiagnosticsSupport + .WithDeveloperTools()). Поэтому F12 здесь ничего не открывает — зависимость намеренно не добавлена.

Настройки и логи лежат в %APPDATA%/AvParser (Windows) или ~/.config/AvParser (Linux/macOS).

S
Description
No description provided
Readme
662 KiB
Languages
C# 99%
PowerShell 0.5%
Shell 0.4%