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

312 lines
22 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, сетевых шарах и между
томами жёстких ссылок нет — тогда происходит откат на копию, расход диска удваивается, и
действующий режим виден в настройках.
### Чистка
Кнопка на странице сбора удаляет то, что собрал выбранный источник. Файл, на который ссылается и
другой источник, остаётся — ровно за этим в индексе счётчик ссылок. Журнал переживает чистку, иначе
следующий прогон скачал бы заново только что удалённое; забыть и его — отдельный флажок.
## Локализация
Русский и английский, переключение **без перезапуска** — язык выбирается в настройках
(«Системный» берёт язык ОС, если для него есть перевод, иначе английский).
Строки лежат в `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).