Files
av-parser/README.md
T
Leonid PershinandClaude Opus 5 ceacec79e2 Show the collected content: thumbnails and a gallery
Until now a collected image was a row of text, and after a restart it was not
visible at all. The collect list gains a row thumbnail, and a Gallery page
browses the whole store with filters by source, format and address, paged at
120 tiles, with a built-in viewer showing the full size beside its provenance.

Thumbnails decode straight to the width they are drawn at. That is the whole
memory story: a 4000x3000 JPEG is about 48 MB once decoded, so decoding full
size and scaling afterwards runs out of memory long before the user finishes
scrolling. The cache is bounded and owns its bitmaps, which means its capacity
has to comfortably exceed a page - a bitmap evicted while still on screen would
be disposed out from under the renderer.

Paged rather than infinite-scrolled for the same reason: how much to decode is a
decision the page should make, not one the archive's size makes for it.

Video is not previewed and will not be. Extracting a first frame means FFmpeg,
which is a media stack in exchange for one picture per tile; those tiles show a
format badge instead. The refusal happens before touching the disk, because
attempting it would be an exception per tile.

Rendering the page caught the viewer overlay being see-through: it named a brush
that does not exist, and an unresolved DynamicResource fails silently - the
property just keeps its default. That is the second silent-reference bug to
reach a screenshot, so both kinds now have guards: one resolves every
{DynamicResource} in the XAML against both themes, the other checks every
{l:Loc} key exists. Both were confirmed to fail before being kept.

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

326 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<T>` — обычная
форма молча не сматчилась бы ни с чем. На это есть тест
(`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/<sha256>.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
<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).