First half of replacing the stub text domain with a media collector. Nothing references this yet - the store is standalone and fully tested before anything depends on it. Blobs are addressed by SHA-256 and sharded two levels deep, so the same picture re-uploaded at a dozen addresses costs one file. Downloads stage in a sibling temp directory on the same volume and are promoted by rename, which is what keeps blobs/ free of truncated files: a crash leaves a stray .part that the next startup sweeps, never a half-image indistinguishable from a real one. The SQLite index holds provenance separately from content, so purging one source leaves blobs another source still references - that is what ref_count buys, and it is recomputed rather than incremented because the item upsert can replace a row pointing at a different blob. The seen_url journal deliberately outlives a purge: without that, the next run downloads again exactly what the user just deleted. Terminal outcomes are split from retryable ones so a flaky network does not permanently lose content. The showcase gives every item a dated, named path via hard links - a second name for one file, not a second file. Hard links are a filesystem privilege rather than a guarantee, so it degrades to copying and records which it achieved; the UI has to be able to admit that. Names suggested by the origin are treated as hostile: only the last path segment survives, Windows device names are pushed aside, and the extension comes from the sniffed kind, never from the remote. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AvParser
Каркас desktop-приложения на Avalonia 12 с ReactiveUI-MVVM, адаптивным layout поверх Semi.Avalonia, единым DI-контейнером и тремя уровнями тестов.
Доменная часть пока намеренно абстрактная: ядро — это pluggable-контракт
IParser<TInput, TOutput> и два демо-парсера, чтобы каркас был запускаемым и проверяемым
end-to-end до появления настоящей логики.
Быстрый старт
Нужен .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 домен: IParser, IParserCatalog, модели, демо-парсеры
ноль зависимостей кроме DI.Abstractions — ни Avalonia, ни IO
AvParser.Infrastructure AppPaths, JSON-настройки с debounce, 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 разбор фида прокси, локальный список, маппинг на WebProxy
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.
Гейт снимается настройкой «Разрешить сетевым парсерам работать без прокси».
Парсеры, читающие вставленный пользователем текст, не блокируются никогда — им нечего маршрутизировать, и блокировка делала бы приложение бесполезным всякий раз, когда публичные списки лежат.
Использование из кода:
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://.
Локализация
Русский и английский, переключение без перезапуска — язык выбирается в настройках («Системный» берёт язык ОС, если для него есть перевод, иначе английский).
Строки лежат в 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 переводитParse.Error.{Code}с откатом на сообщение. Так же и с именами парсеров:Parser.{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 |
Что проверить руками
- Потянуть окно по ширине — сайдбар проходит путь
полный → только иконки → выезжающий drawer, без мигания на границах. - Переключить тему кнопкой в заголовке и в Settings; перезапустить — выбор сохранился.
- На странице Parse нажать 50k rows, затем Parse — виден прогресс; Cancel
останавливает на середине и пишет, сколько успело разобраться.
В Avalonia 12 инспектора «из коробки» больше нет:
Avalonia.Diagnosticsостановился на 11.3.x, а DevTools вынесли в отдельный инструмент со своей установкой (AvaloniaUI.DiagnosticsSupport+.WithDeveloperTools()). ПоэтомуF12здесь ничего не открывает — зависимость намеренно не добавлена.
Настройки и логи лежат в %APPDATA%/AvParser (Windows) или ~/.config/AvParser (Linux/macOS).