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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
fe62bcf53f
commit
f8744c930a
@@ -1,11 +1,15 @@
|
||||
# AvParser
|
||||
|
||||
Каркас desktop-приложения на **Avalonia 12** с ReactiveUI-MVVM, адаптивным layout поверх
|
||||
Semi.Avalonia, единым DI-контейнером и тремя уровнями тестов.
|
||||
Desktop-приложение на **Avalonia 12** с ReactiveUI-MVVM, адаптивным layout поверх Semi.Avalonia,
|
||||
единым DI-контейнером и тремя уровнями тестов.
|
||||
|
||||
Доменная часть пока намеренно абстрактная: ядро — это pluggable-контракт
|
||||
`IParser<TInput, TOutput>` и два демо-парсера, чтобы каркас был запускаемым и проверяемым
|
||||
end-to-end до появления настоящей логики.
|
||||
Собирает изображения и гифки в локальное хранилище: источник перечисляет адреса, загрузчик их
|
||||
скачивает и проверяет, хранилище дедуплицирует по содержимому и помнит, что уже видело. Запросы
|
||||
идут через пул прокси с ротацией и проверкой живости.
|
||||
|
||||
Источников два: **список ссылок**, который вы вставляете сами, и **свой сервис** — листинг-эндпоинт
|
||||
сервиса, который вы держите сами. Перебора идентификаторов чужих хостов нет; см.
|
||||
[«Границы, выбранные намеренно»](CLAUDE.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -54,15 +58,18 @@ dotnet csharpier check .
|
||||
|
||||
```
|
||||
src/
|
||||
AvParser.Core домен: IParser, IParserCatalog, модели, демо-парсеры
|
||||
ноль зависимостей кроме DI.Abstractions — ни Avalonia, ни IO
|
||||
AvParser.Infrastructure AppPaths, JSON-настройки с debounce, Serilog
|
||||
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 разбор фида прокси, локальный список, маппинг на WebProxy
|
||||
AvParser.Core.Tests источники, каталог, модели, пул прокси и стратегии
|
||||
AvParser.Infrastructure.Tests сигнатуры и анимация, загрузчик против «плохого» сервера,
|
||||
хранилище и дедуп, фид прокси, настройки
|
||||
AvParser.UI.Tests ViewModel'и без Avalonia
|
||||
AvParser.UI.HeadlessTests реальное дерево контролов через [AvaloniaFact]
|
||||
```
|
||||
@@ -129,7 +136,7 @@ Avalonia матчит **точный** тип, а `ShellView` наследует
|
||||
|
||||
Проверка живости — тоже настройка, два режима: **Pool** прогоняет весь список параллельно один
|
||||
раз, **Lazy** проверяет прокси в момент выдачи и перескакивает на следующую. У бесплатных списков
|
||||
рабочих обычно единицы процентов, поэтому без проверки парсер будет в основном ждать таймауты.
|
||||
рабочих обычно единицы процентов, поэтому без проверки сборщик будет в основном ждать таймауты.
|
||||
|
||||
Упавшая прокси уходит в карантин с экспоненциальным окном (30 с → 15 мин), но **не** удаляется
|
||||
навсегда: бесплатные прокси постоянно мигают, и жёсткий бан терял бы их безвозвратно.
|
||||
@@ -143,7 +150,7 @@ Avalonia матчит **точный** тип, а `ShellView` наследует
|
||||
|
||||
Запомненное — это подсказка, а не зачёт: восстановленная прокси идёт первой в очередь на проверку,
|
||||
но живой не считается, пока не ответит в этом запуске. Иначе запуск через неделю открывал бы гейт
|
||||
парсера по недельной давности данным, а прогрев пропускал бы ровно те прокси, ради которых он есть.
|
||||
сбора по недельной давности данным, а прогрев пропускал бы ровно те прокси, ради которых он есть.
|
||||
|
||||
Состояние лежит в `proxies.state.json` рядом с настройками и пишется после прогрева и на выходе.
|
||||
Сохраняются **только те прокси, что когда-либо отвечали**: мёртвых в фиде тысячи, они переиздаются
|
||||
@@ -152,11 +159,15 @@ Avalonia матчит **точный** тип, а `ShellView` наследует
|
||||
|
||||
### Гейт «без прокси не работаем»
|
||||
|
||||
Парсер, который объявил `RequiresNetwork`, не запустится, пока в пуле нет ни одной живой прокси:
|
||||
кнопка «Разобрать» гаснет, а на странице появляется баннер с переходом на страницу Proxies.
|
||||
Гейт снимается настройкой **«Разрешить сетевым парсерам работать без прокси»**.
|
||||
Источник, который объявил `RequiresNetwork`, не запустится, пока в пуле нет ни одной живой прокси:
|
||||
кнопка «Собрать» гаснет, а на странице появляется баннер с переходом на страницу Proxies.
|
||||
Гейт снимается настройкой **«Разрешить сетевым источникам работать без прокси»**.
|
||||
|
||||
Парсеры, читающие вставленный пользователем текст, не блокируются никогда — им нечего
|
||||
То же правило продублировано в загрузчике: он бросает `ProxyUnavailableException` вместо тихого
|
||||
прямого запроса. Одного UI мало — запрос ушёл бы с адреса пользователя ровно тогда, когда он просил
|
||||
этого не делать.
|
||||
|
||||
Источники, читающие вставленный пользователем текст, не блокируются никогда — им нечего
|
||||
маршрутизировать, и блокировка делала бы приложение бесполезным всякий раз, когда публичные списки
|
||||
лежат.
|
||||
|
||||
@@ -178,6 +189,48 @@ using (lease)
|
||||
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, сетевых шарах и между
|
||||
томами жёстких ссылок нет — тогда происходит откат на копию, расход диска удваивается, и
|
||||
действующий режим виден в настройках.
|
||||
|
||||
### Чистка
|
||||
|
||||
Кнопка на странице сбора удаляет то, что собрал выбранный источник. Файл, на который ссылается и
|
||||
другой источник, остаётся — ровно за этим в индексе счётчик ссылок. Журнал переживает чистку, иначе
|
||||
следующий прогон скачал бы заново только что удалённое; забыть и его — отдельный флажок.
|
||||
|
||||
## Локализация
|
||||
|
||||
Русский и английский, переключение **без перезапуска** — язык выбирается в настройках
|
||||
@@ -204,9 +257,9 @@ proxifly-запись с `"protocol": "https"` — это всё равно HTTP
|
||||
- **Значения перечислений.** Конвертер разрешил бы подпись один раз и не заметил смены языка,
|
||||
поэтому в списках лежат обёртки `LocalizedOption<T>`: идентичность — значение перечисления
|
||||
(выбор не слетает), подпись следует за локализатором.
|
||||
- **Текст из домена.** `AvParser.Core` о языках не знает. Парсеры отдают английское сообщение
|
||||
**и код**, а UI переводит `Parse.Error.{Code}` с откатом на сообщение. Так же и с именами
|
||||
парсеров: `Parser.{id}.Name` с откатом на `DisplayName`, поэтому новый парсер работает
|
||||
- **Текст из домена.** `AvParser.Core` о языках не знает. Домен отдаёт английское сообщение
|
||||
**и код**, а UI переводит `Collect.Error.{Code}` с откатом на сообщение. Так же и с именами
|
||||
источников: `Source.{id}.Name` с откатом на `DisplayName`, поэтому новый источник работает
|
||||
непереведённым, а не показывает `!ключ!`.
|
||||
|
||||
Оба `.resx` генерируются из одной таблицы, чтобы ключ не мог существовать в одном файле и
|
||||
@@ -242,8 +295,14 @@ Semi.Avalonia даёт темы контролов; токены — это се
|
||||
1. Потянуть окно по ширине — сайдбар проходит путь
|
||||
`полный → только иконки → выезжающий drawer`, без мигания на границах.
|
||||
2. Переключить тему кнопкой в заголовке и в Settings; перезапустить — выбор сохранился.
|
||||
3. На странице Parse нажать **50k rows**, затем **Parse** — виден прогресс; **Cancel**
|
||||
останавливает на середине и пишет, сколько успело разобраться.
|
||||
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` здесь ничего не
|
||||
|
||||
Reference in New Issue
Block a user