Files
av-parser/README.md
T
Leonid PershinandClaude Opus 5 f8744c930a Remove the demo text-parsing domain
The scaffolding domain existed to prove the shell end to end before there was
anything real to put in it. There is now, so it goes - as CLAUDE.md promised it
would.

Gone: the two sample parsers, ITextParser, ParsedRecord, the parser catalog,
ParseViewModel and ParseView, their tests, and the settings key that remembered
which parser was last used. ParseError.LineNumber becomes Index, since for a
listing "line 42" was simply untrue, and the error keys move from Parse.Error.*
to Collect.Error.* now that parsing is not a concept here.

Kept: IParser<,>, ParseOutcome, ParseProgress and ParseError. The streaming
contract was always the general part - it was only ever the text-shaped closure
of it that was scaffolding.

Rendering the dashboard caught two keys that were referenced but never added
during the rename: the XAML was repointed and the resources were not. The parity
test could not see it, because it compares the two files against each other and
a key absent from both is consistent. That gap now has its own test, which reads
every {l:Loc} in the XAML and checks it resolves - a screenshot is too late and
too manual a way to find a missing string.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 22:23:52 +03:00

22 KiB
Raw Blame History

AvParser

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

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

Источников два: список ссылок, который вы вставляете сами, и свой сервис — листинг-эндпоинт сервиса, который вы держите сами. Перебора идентификаторов чужих хостов нет; см. «Границы, выбранные намеренно».


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

Нужен .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            домен: IMediaSource и каталог, модели медиа, контракт хранилища,
                           прокси-пул и стратегии. Ноль зависимостей кроме DI.Abstractions —
                           ни Avalonia, ни HTTP, ни SQLite
  AvParser.Infrastructure  загрузчик (сниффинг, редиректы, троттл), SQLite-индекс, blob-хранилище,
                           витрина, источники прокси, AppPaths, JSON-настройки, 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  сигнатуры и анимация, загрузчик против «плохого» сервера,
                                 хранилище и дедуп, фид прокси, настройки
  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. Гейт снимается настройкой «Разрешить сетевым источникам работать без прокси».

То же правило продублировано в загрузчике: он бросает ProxyUnavailableException вместо тихого прямого запроса. Одного UI мало — запрос ушёл бы с адреса пользователя ровно тогда, когда он просил этого не делать.

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

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

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://.

Сбор

Страница Сбор: выбрать источник, дать ему работу, запустить.

  • Список ссылок — вставьте адреса, по одному в строке. Пустые строки и строки с # игнорируются, непонятые называются в списке ошибок, а не проглатываются.
  • Свой сервис — адрес листинг-эндпоинта. Принимается либо {"items":[…],"next":"…"}, либо голый массив адресов; элемент может быть строкой или объектом с url, id, name, published, size, tags. Постранично, пока есть next.

Что происходит с каждым найденным адресом:

  1. Журнал. Если прошлый прогон уже закрыл этот адрес — пропуск без единого запроса. Отказы и тайм-ауты закрытыми не считаются: они описывают момент, а не ресурс. Флажок «Скачать всё заново» игнорирует журнал.
  2. Загрузка. Редиректы разбираются вручную (лимит прыжков, отлов петли, отказ на не-http). Три отдельных тайм-аута: соединение, заголовки и простой между чтениями — один общий был бы либо слишком мал для тридцати мегабайт, либо бесполезен как признак зависания.
  3. Проверка. Тип определяется по сигнатуре файла, а не по URL, расширению или Content-Type. Ловятся: страница-ошибка за кодом 200, тело короче заявленного, превышение лимита размера, трекинг-пиксели, известные заглушки мёртвых ссылок.
  4. Хранилище. Файл кладётся по SHA-256 содержимого — один и тот же снимок, перезалитый по десяти адресам, занимает место один раз. Провенанс (откуда, когда, каким прогоном, через какую прокси) пишется отдельно.

Витрина

blobs/ab/cd/<sha256>.png не годится для просмотра глазами, поэтому рядом строится showcase/<источник>/<год>/<месяц>/<день>/0001-имя.png — жёсткими ссылками, то есть без второй копии байтов.

Жёсткая ссылка — это второе имя того же файла: правка витрины меняет оригинал, а удаление из витрины ничего не освобождает, пока не исчезнет последнее имя. На FAT32, сетевых шарах и между томами жёстких ссылок нет — тогда происходит откат на копию, расход диска удваивается, и действующий режим виден в настройках.

Чистка

Кнопка на странице сбора удаляет то, что собрал выбранный источник. Файл, на который ссылается и другой источник, остаётся — ровно за этим в индексе счётчик ссылок. Журнал переживает чистку, иначе следующий прогон скачал бы заново только что удалённое; забыть и его — отдельный флажок.

Локализация

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

Строки лежат в 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 переводит Collect.Error.{Code} с откатом на сообщение. Так же и с именами источников: Source.{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. На странице Сбор вставить десяток адресов и нажать Собрать: список наполняется, Остановить обрывает на середине и пишет, сколько успело собраться.
  4. Запустить тот же список повторно — все строки должны прийти как «пропущено», без единого сетевого запроса. Это журнал.
  5. Заглянуть в media/showcase — файлы разложены по датам; сверить, что это жёсткие ссылки (fsutil hardlink list в Windows, ls -li в Linux), а не копии.
  6. Нажать чистку — файлы, на которые ссылается только этот источник, исчезают; общие остаются.

В Avalonia 12 инспектора «из коробки» больше нет: Avalonia.Diagnostics остановился на 11.3.x, а DevTools вынесли в отдельный инструмент со своей установкой (AvaloniaUI.DiagnosticsSupport + .WithDeveloperTools()). Поэтому F12 здесь ничего не открывает — зависимость намеренно не добавлена.

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