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:
Leonid Pershin
2026-08-13 22:23:52 +03:00
co-authored by Claude Opus 5
parent fe62bcf53f
commit f8744c930a
33 changed files with 327 additions and 2109 deletions
+68 -20
View File
@@ -34,17 +34,28 @@ dotnet csharpier check .
- **`AvParser.Core` не ссылается на Avalonia.** Это единственное ограничение, которое здесь
по-настоящему несущее: домен должен запускаться из CLI, worker-сервиса или бенчмарка. Как
только Avalonia станет доступна из домена, кто-нибудь потянется к `Dispatcher.UIThread` или
`IStorageProvider` внутри парсера.
`IStorageProvider` внутри источника.
- **`AvParser.UI` — библиотека, а не exe.** Headless-тесты собирают настоящие View, не
подтягивая `Program.cs`, Serilog и контейнер.
- **`AvParser.Desktop` — тонкий composition root.** Логика туда не переезжает.
## Добавить парсер
## Добавить источник
1. Реализовать `ITextParser` в `Core/Parsing/`.
2. Одна строка в `CoreServiceCollectionExtensions.AddAvParserCore()`.
1. Реализовать `IMediaSource` — в `Core/Collecting/Sources/`, если сети не нужно, иначе в
`Infrastructure/Collecting/`.
2. Одна строка регистрации: `AddAvParserCore()` для доменного, `AddAvParserCollecting()` для
сетевого.
3. Два ключа в оба resx: `Source.{id}.Name` и `Source.{id}.Description` (без них источник
отрисуется английским текстом из самого класса, а не сломается).
Всё. `IParserCatalog`, страница Parse и выпадающий список подхватят его сами.
Всё. Каталог, страница «Сбор» и выпадающий список подхватят его сами.
**Источник ищет, а не качает.** Он отдаёт `MediaCandidate` — адрес плюс метаданные. Скачиванием,
редиректами, тайм-аутами, сниффингом и троттлингом занимается `MediaFetcher`, один на всех. Источник,
который сам лезет за байтами, дублирует всё это и почти наверняка неправильно.
**Сетевой источник обязан переопределить `RequiresNetwork => true`** — иначе он не попадёт под гейт
и поедет напрямую в обход настройки.
## Добавить страницу
@@ -106,7 +117,7 @@ dotnet csharpier check .
- **Лиза без вердикта нейтральна.** Отменённая операция — не вина прокси; считать это отказом
значит карантинить здоровые прокси на каждый Cancel.
- **`Select` и `Next` — зарезервированные слова для CA1716.** Метод стратегии называется `Pick`.
- **`LiveCount` считает только `Alive` и не в карантине.** На нём висит гейт парсера, поэтому
- **`LiveCount` считает только `Alive` и не в карантине.** На нём висит гейт сбора, поэтому
«доступна» (карантин истёк) и «живая» здесь намеренно расходятся: гейт не должен открываться
от одного лишь истечения окна.
- **Прогрев обрывается по достижении цели, а не проходит список до конца.** `WarmUpAsync` линкует
@@ -115,7 +126,7 @@ dotnet csharpier check .
- **`RestoreState` не выставляет `Health = Alive`.** «Работала вчера» живёт в отдельном
`WasAliveOnLastRun` и влияет только на порядок прогрева. Если восстанавливать как `Alive`, пул
отрапортует живыми тех, с кем не разговаривал: прогрев сочтёт цель достигнутой и не проверит
никого, а гейт парсера откроется по данным недельной давности. Ловит
никого, а гейт сбора откроется по данным недельной давности. Ловит
`A_remembered_proxy_is_not_reported_live_until_it_answers_again`.
- **`RestoreState` не восстанавливает карантин.** Окно — стенные часы, между запусками могли пройти
сутки; перенос окна сажал бы прокси за то, что давно истекло.
@@ -125,19 +136,48 @@ dotnet csharpier check .
большинство запомненных заканчивают сессию непроверенными — строгое `Health == Alive` стирало бы
накопленный список за пару запусков.
## Гейт парсера
## Гейт сбора
`IParser.RequiresNetwork` — дефолтная реализация возвращает `false`, поэтому добавление парсера
остаётся однострочным. Парсер, который куда-то ходит, **обязан** её переопределить, иначе поедет
напрямую в обход настройки.
`IParser.RequiresNetwork` — дефолтная реализация возвращает `false`, поэтому добавление источника
остаётся однострочным.
Гейт живёт в `ParseViewModel.RefreshProxyGate()` и складывается из трёх условий: парсер сетевой,
Гейт живёт в `CollectViewModel.RefreshProxyGate()` и складывается из трёх условий: источник сетевой,
`AllowDirectConnection` выключен, `LiveCount == 0`. Он пересчитывается по событию пула (с
throttle 250 мс — пул дёргается на каждый исход лизы), при смене парсера и при смене настроек.
throttle 250 мс — пул дёргается на каждый исход лизы), при смене источника и при смене настроек.
`ParseView.axaml` держит баннер под `x:Name="ProxyGateBanner"`; `ParseViewTests` рендерит его
Второй экземпляр того же правила — в `FetchOptions.RequireProxy`: гейт закрывает кнопку, а фетчер
бросает `ProxyUnavailableException` вместо тихого прямого запроса. Одного UI мало — запрос ушёл бы
с адреса пользователя ровно тогда, когда он просил этого не делать.
`CollectView.axaml` держит баннер под `x:Name="ProxyGateBanner"`; `CollectViewTests` рендерит его
по-настоящему, потому что мёртвый биндинг `IsVisible` не ломает ни одного VM-теста.
## Инварианты хранилища медиа
- **В `blobs/` попадает только дочитанное.** Загрузка идёт во временный файл в соседнем каталоге на
том же томе и продвигается переименованием. Обрыв оставляет `.part`, который подметает следующий
старт, а не обрезанную картинку, неотличимую от настоящей навсегда.
- **Тип — по сигнатуре, никогда по URL, расширению или `Content-Type`.** Два из трёх выбирает тот,
кто отдаёт файл, и расширение на диске у пользователя не должно зависеть от чужого сервера.
- **`GIF89a` не доказывает анимацию**, и APNG не определяется по фиксированному префиксу: нужен обход
блоков (второй Image Descriptor) и чанков (`acTL` раньше первого `IDAT`). Тихая ошибка, поэтому
обходчики изолированы за `internal static` швами и проверяются на массивах байтов.
- **`ref_count` денормализован и пересчитывается, а не инкрементится.** Апсерт `item` может заменить
строку, указывавшую на другой blob, и слепой `+1` уехал бы навсегда. Есть
`VerifyReferenceCountsAsync`, и он часть замысла, а не отладка.
- **Журнал `seen_url` переживает чистку.** Иначе следующий прогон скачает заново ровно то, что
пользователь только что удалил. Терминальные исходы отделены от повторяемых: отказ описывает
момент, а не ресурс, и считать его окончательным значит терять контент на каждой сетевой икоте.
- **Вердикт лизы — про транспорт, а не про ресурс.** 404 и 429 — это успех прокси. Иначе пул
карантинил бы рабочие адреса ровно с той частотой, с какой встречаются мёртвые ссылки, а на
лимит отвечал бы сменой прокси, то есть обходом лимита.
- **Троттл поднимается только сигналами хоста** (429/503 с `Retry-After`) и никогда не приводит к
ротации прокси. Флажка «повторить через другую прокси при 429» в настройках быть не должно.
- **Жёсткая ссылка — привилегия ФС, а не гарантия.** Откат на копию удваивает расход диска, поэтому
достигнутый режим пишется в `showcase_mode` и виден в UI.
- **Имя из `SuggestedName` враждебно.** Остаётся только последний сегмент, разделители не
переживают, устройства Windows отодвигаются, расширение берётся из типа.
## Добавить строку в UI
1. Ключ и оба перевода — в `Strings.resx` и `Strings.ru.resx` (**оба**, иначе упадёт
@@ -152,8 +192,8 @@ throttle 250 мс — пул дёргается на каждый исход л
подпись один раз и не заметил смены языка. Идентичность обёртки — значение перечисления, чтобы
выбор не слетал.
- **Текст из домена переводится по коду.** `Core` о языках не знает: `ParseError` несёт `Code` и
`Arguments`, UI ищет `Parse.Error.{Code}` с откатом на `Message`. Имена парсеров — так же:
`Parser.{id}.Name` с откатом на `DisplayName`, поэтому обещание «добавить парсер = одна строка»
`Arguments`, UI ищет `Collect.Error.{Code}` с откатом на `Message`. Имена источников — так же:
`Source.{id}.Name` с откатом на `DisplayName`, поэтому обещание «добавить источник = одна строка»
остаётся в силе.
- **VM, у которой есть производный от языка текст, переопределяет `OnLanguageChanged`** и зовёт
`base`. Без этого заголовок страницы останется на прежнем языке.
@@ -201,8 +241,16 @@ throttle 250 мс — пул дёргается на каждый исход л
свежая CVE не роняла сборку кода, который никто не трогал.
- Тестовые послабления анализаторов — в `tests/Directory.Build.props`, не в самих тестах.
## Что осталось абстрактным
## Границы, выбранные намеренно
Домен — заглушка. `IParser<TInput, TOutput>` + `DelimitedTextParser` + `KeyValueTextParser`
существуют, чтобы каркас проверялся end-to-end. Когда появится настоящая доменная логика,
демо-парсеры удаляются вместе с их тестами и `SampleFor`/`LargeSampleFor` в `ParseViewModel`.
- **robots.txt не читается.** Оба источника v1 либо принадлежат пользователю, либо введены им
вручную, поэтому спрашивать разрешения не у кого. Это решение, а не забывчивость: **с появлением
третьего источника, который ходит по чужому сайту, robots.txt становится обязательным.**
- **Перебора идентификаторов нет и не планируется.** Источник перечисляет то, что сайт сам
публикует; подбор адресов — это не «сбор опубликованного», и прокси-пул существует ради лимитов
и доступности, а не ради их обхода.
- **Возобновления по `Range` нет.** Оборванная загрузка выбрасывается целиком; частичный файл в
хранилище дороже, чем повторное скачивание.
- **SVG не поддерживается сознательно** — это текст, он умеет исполнять скрипты и несёт XXE.
BMP/ICO/HEIC/JPEG-XL просто отложены.
- **Перцептивных хешей нет.** Дедуп точный, по SHA-256; «похожие» картинки — отдельная задача.