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>
312 lines
22 KiB
Markdown
312 lines
22 KiB
Markdown
# 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).
|