# AvParser Desktop-приложение на **Avalonia 12** с ReactiveUI-MVVM, адаптивным layout поверх Semi.Avalonia, единым DI-контейнером и тремя уровнями тестов. Собирает изображения и гифки в локальное хранилище: источник перечисляет адреса, загрузчик их скачивает и проверяет, хранилище дедуплицирует по содержимому и помнит, что уже видело. Запросы идут через пул прокси с ротацией и проверкой живости. Источников два: **список ссылок**, который вы вставляете сами, и **свой сервис** — листинг-эндпоинт сервиса, который вы держите сами. Перебора идентификаторов чужих хостов нет; см. [«Границы, выбранные намеренно»](CLAUDE.md). --- ## Быстрый старт Нужен .NET SDK **10.0.100** (закреплён в `global.json`). Полный локальный гейт — восстановление, проверка форматирования, сборка, тесты: ```bash ./build.ps1 ``` Запуск приложения (по умолчанию Debug): ```bash ./run.ps1 ``` На Linux и macOS — `./build.sh` и `./run.sh`, аргументы те же. Полезные флаги: ```bash ./build.ps1 -Fix -Configuration Debug ``` `-Fix` переформатирует код вместо того, чтобы падать на непрошедшей проверке; `-SkipTests` собирает без прогона тестов. То же в bash: `--fix`, `-c Debug`, `--skip-tests`. Если нужны отдельные шаги, скрипты ничего не прячут: ```bash dotnet build AvParser.slnx -c Release ``` ```bash dotnet test AvParser.slnx -c Release ``` ```bash 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` — обычная форма молча не сматчилась бы ни с чем. На это есть тест (`ShellViewTests.The_shell_stylesheet_is_actually_applied`). --- ## Прокси Пул прокси с ротацией — `AvParser.Core/Proxies`, источники и сетевая часть — `AvParser.Infrastructure/Proxies`, управление — страница **Proxies**. Источники: - **[proxifly/free-proxy-list](https://github.com/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 мало — запрос ушёл бы с адреса пользователя ровно тогда, когда он просил этого не делать. Источники, читающие вставленный пользователем текст, не блокируются никогда — им нечего маршрутизировать, и блокировка делала бы приложение бесполезным всякий раз, когда публичные списки лежат. Использование из кода: ```csharp 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/.png` не годится для просмотра глазами, поэтому рядом строится `showcase/<источник>/<год>/<месяц>/<день>/0001-имя.png` — жёсткими ссылками, то есть без второй копии байтов. Жёсткая ссылка — это **второе имя того же файла**: правка витрины меняет оригинал, а удаление из витрины ничего не освобождает, пока не исчезнет последнее имя. На FAT32, сетевых шарах и между томами жёстких ссылок нет — тогда происходит откат на копию, расход диска удваивается, и действующий режим виден в настройках. ### Галерея Страница **Галерея** показывает то, что уже лежит в хранилище, — с фильтрами по источнику, формату и подстроке адреса, постранично по 120 плиток. Клик открывает встроенный просмотр: полный размер плюс откуда, когда, каким форматом и с каким хешем. Миниатюры декодируются сразу в нужную ширину и кэшируются: полноразмерный JPEG 4000×3000 занимает в памяти около 48 МБ, и пары сотен таких хватило бы, чтобы приложение кончилось раньше, чем пользователь долистает. **Видео не превьюится.** Показать первый кадр mp4 или webm нечем без FFmpeg, тащить который в десктопное приложение ради превью несоразмерно; плитка получает значок формата. Гифки показываются первым кадром — Avalonia не анимирует GIF без стороннего пакета. ### Чистка Кнопка на странице сбора удаляет то, что собрал выбранный источник. Файл, на который ссылается и другой источник, остаётся — ровно за этим в индексе счётчик ссылок. Журнал переживает чистку, иначе следующий прогон скачал бы заново только что удалённое; забыть и его — отдельный флажок. ## Локализация Русский и английский, переключение **без перезапуска** — язык выбирается в настройках («Системный» берёт язык ОС, если для него есть перевод, иначе английский). Строки лежат в `UI/Localization/Strings.resx` и `Strings.ru.resx`; русский собирается в сателлитную сборку `ru/AvParser.UI.resources.dll`. В XAML — разметочное расширение: ```xml ``` Оно возвращает **биндинг** через индексатор `Localizer`, а не готовую строку: смена языка поднимает `PropertyChanged` для индексатора, и все такие биндинги перечитываются разом. Строка, разрешённая один раз при загрузке, потребовала бы перезапуска. Три места, где локализация упирается в грамматику или в слои: - **Множественные числа.** У русского три формы, поэтому счётчики собираются не из «{0} records» с приклеенным окончанием, а из ключей `.One` / `.Few` / `.Many` через `Localizer.Plural`. «1 запись», «3 записи», «7 записей». - **Значения перечислений.** Конвертер разрешил бы подпись один раз и не заметил смены языка, поэтому в списках лежат обёртки `LocalizedOption`: идентичность — значение перечисления (выбор не слетает), подпись следует за локализатором. - **Текст из домена.** `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).