ParsesUnix — фреймворк для надёжного и экономного сбора данных из веба.
Он не «качает страницы». Он выбирает самый дешёвый маршрут, который доказанно отдаёт валидные данные: сначала структурированные источники, потом обычный HTTP, и только при доказанной блокировке — браузер. Каждый ответ проверяется на содержимое, каждый URL получает финальный вердикт, а рабочие маршруты запоминаются между прогонами.
Обычный парсер ломается тихо. Он получает 200 OK, сохраняет пустую страницу,
и вы узнаёте об этом через неделю по кривым отчётам. ParsesUnix построен вокруг
трёх утверждений:
| Утверждение | Что это значит на практике |
|---|---|
200 OK — это не успех |
Ответ проходит проверку содержимого. Челлендж-заглушка с кодом 200 — это SOFT_BLOCK, а не данные. |
| Ни один URL не теряется | После прогона unaccounted == 0. Удалённая страница, упавший origin, стена авторизации — у каждого свой финальный вердикт, но никто не исчезает. |
| Деньги тратятся только по доказательству | Платный провайдер включается лишь по вердикту BLOCKED/SOFT_BLOCK. Ошибки 404, 5xx и сломанные селекторы деньгами не лечатся. |
- Cost-aware routing — лестница уровней от почти бесплатного к дорогому.
- Adaptive route memory — система помнит, какая «дверь» реально открывается.
- Site Profiles — декларативная настройка домена без правки кода.
- Retry по причине —
429ждётRetry-After,5xxоткладывается, блок ведёт к другому маршруту. - Browser reconnaissance — браузер ищет скрытый JSON API и уходит, оставляя дешёвый маршрут.
- Failure fingerprints — знакомая защита узнаётся даже на новом домене.
- Quorum извлечения — критичное поле сверяется из независимых источников.
- Atomic promote + LKG — полупрогона не существует; старые данные помечены как старые.
- URL accounting — сводимый до нуля реестр.
- Regression detection — «сайт изменился» видно до того, как испортятся данные.
- Strict typing + CI —
mypy --strict,ruff, более 1100 тестов.
URL
│
┌──────────────▼──────────────┐
│ L0 JSON API / RSS / карта │ почти бесплатно
│ L1 прямой HTTP-сеанс │ дёшево
│ L2 локальный браузер │ дорого по CPU
│ L3/L4 платный провайдер │ деньги
└──────────────┬──────────────┘
│ triage: вердикт после КАЖДОЙ попытки
▼
Валидация содержимого
│
▼
Извлечение (JSON-LD → app state → meta → CSS → эвристика)
│
▼
Staging → проверка целиком → атомарный promote
│ │
▼ ▼
Чистые данные Отказ: сохраняется LKG
Это не тупая лестница сверху вниз. Роутер помнит статистику по каждому
маршруту и может поставить вперёд тот, который на этом сайте реально работает —
но только среди бесплатных уровней. Платный уровень роутеру недоступен: решение
о деньгах принимает исключительно triage.
git clone https://github.com/Manacost-Labs/ParsesUnix.git
cd ParsesUnix
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -e .Опционально — уровень L2 (рендеринг JavaScript и поиск скрытых API):
pip install -e '.[browser]'
playwright install chromiumБез браузера всё работает: маршруты L2 просто помечаются как пропущенные.
ws-probe https://example.com/ --draft-profile draft.jsonws-probe безопасно смотрит на сайт и отвечает: SSR или CSR, есть ли RSS,
sitemap, JSON-LD, скрытые API-подсказки, что говорит robots.txt и с какого
уровня стоит начинать. Приватные и loopback-адреса заблокированы на каждом
редиректе.
Профиль описывает домен декларативно — как проверять ответ, какими маршрутами ходить, что извлекать:
site: example.com
authorization:
public_data_only: true
url_classes:
article:
match: "^https://example\\.com/articles/"
validation:
min_body_bytes: 500
canary: "<article" # без этой строки ответ не считается статьёй
required_fields: [title, published_at]
routes:
primary:
id: articles-api # стабильная идентичность для статистики
type: json_api
level: L0
url: "https://example.com/api/articles"
alternatives:
- {type: direct_http, level: L1}
- {type: dynamic, level: L2}
extractors:
- {kind: json_ld, schema_type: Article}
- {kind: css, fields: {title: "h1::text", published_at: "time::attr(datetime)"}}
quorum_fields: [title]Профиль проверяется до обращения к сети:
ws-profile validate profiles/example.yamlСекреты, cookies и токены в профиле запрещены — валидатор их отклоняет.
Если приложение уже само управляет очередями и публикацией, оно может использовать ParsesUnix только как строгий транспортный слой. В таком режиме ожидаемый формат и доказательство полезного содержимого задаются явно:
from web_scraper import ResponseContract, fetch_validated
from web_scraper.fetchers import UrllibTransport
contract = ResponseContract.json(
required_json_paths=("decks.0.archetypeId", "decks.0.games"),
)
result = fetch_validated(
UrllibTransport(),
"https://data.example/decks.json",
contract,
headers={"Accept": "application/json"},
)
if result.transport_validated:
candidate = result.response.body # затем прикладной parser + publication gate
else:
diagnostics = result.telemetry() # без URL query, headers и bodyДля SSR HTML вместо JSON path требуется канарейка, подтверждающая, что пришли именно данные страницы, а не общая оболочка:
contract = ResponseContract.html(
canaries=("data-meta-table",),
min_body_bytes=500,
)Контракты fail-closed: JSON без обязательного path, HTML/Text без canary,
несовпавший формат и усечённый ответ не могут получить OK. Клиентская
оболочка остаётся CSR_REQUIRED; это разрешает бесплатный браузерный маршрут,
но не платного провайдера.
transport_validated означает только «получен полный ответ нужного типа с
заданным доказательством». Число строк, обязательные поля, свежесть патча,
регрессия и атомарная публикация проверяются прикладным парсером отдельно. Это
не позволяет резервным или старым данным выглядеть как свежий успех.
На этой границе проверены формы ответов, характерные для наших источников:
| Семейство источника | Контракт ParsesUnix | Что остаётся приложению |
|---|---|---|
| HSReplay / Firestone API | JSON + обязательные schema paths | число сущностей, patch, completeness |
| HSGuru SSR | HTML + source-specific canary | deck-коды и семантика строк |
| CSR-страницы | HTML + canary → CSR_REQUIRED |
discovery JSON API или L2 rendering |
Детальная последовательность внедрения в Hearthstone pipeline описана в плане интеграции.
run.json:
{
"profile": "profiles/example.yaml",
"state_dir": "state",
"seed_urls": ["https://example.com/articles/first"],
"deadline_seconds": 14400,
"batch_size": 20,
"allowed_providers": []
}ws-run run.json --report state/last-report.jsonВ отчёте — покрытие, вердикты по каждому URL, здоровье маршрутов, свежесть
данных и нулевой unaccounted.
Платные провайдеры включаются только явным массивом allowed_providers в этом
же файле. Одних переменных окружения с ключами недостаточно. Порядок массива
задаёт разрешённый оператором порядок провайдеров; для бесплатного запуска он
остаётся пустым.
| Уровень | Что используется | Цена | Когда |
|---|---|---|---|
| L0 | JSON API, RSS/Atom, sitemap, JSON-LD | почти ноль | всегда пробовать первым |
| L1 | Прямой HTTP-сеанс с прогревом и куками | дёшево | обычный SSR-сайт |
| L2 | Локальный браузер (Playwright) | дорого по CPU/RAM | нужен JS-рендеринг или доказан челлендж |
| L3 | Платный провайдер | деньги | L2 устойчиво не проходит |
| L4 | Residential / Web Unlocker | больше денег | исчерпано всё дешёвое, причина задокументирована |
Важное наблюдение из реальной приёмки: L2 не «сильнее» L1. Headless-браузер легче распознаётся — на
hsreplay.netобычный HTTP отдаёт страницу, а браузер получает Turnstile. Поэтому лестница начинается снизу, а альтернативные маршруты на том же уровне важнее подъёма.
Triage — единственный источник решений. Любой ответ, включая 200, получает
вердикт до того, как принято решение о повторе:
| Вердикт | Что делает система | Платить? |
|---|---|---|
OK |
сохранить | не нужно |
DEAD_URL (404/410) |
карантин | нет |
ORIGIN_DOWN (5xx) |
отложенный повтор | нет |
RATE_LIMITED (429) |
ждать Retry-After, снизить темп |
нет |
BLOCKED / SOFT_BLOCK |
другой маршрут → браузер | да, в рамках бюджета |
CSR_REQUIRED |
пустая оболочка — рендерить браузером | нет |
THIN_CONTENT |
2xx короче порога — проблема качества | нет |
PARSE_FAIL |
чинить профиль, не сеть | нет |
ACCESS_DENIED / AUTH_REQUIRED |
остановиться, не обходить | нет |
URL accounting. Прогон завершается сведённым реестром: каждый входной URL
находится ровно в одной корзине — обработан, перенесён на следующий прогон,
в карантине или в мёртвой зоне. unaccounted != 0 означает дефект системы.
LKG. Если свежий прогон не удался, потребитель получает последние валидные
данные — но явно помеченными: STALE_LKG, возраст и вердикт, из-за которого
обновление не состоялось. Молча выдать старое за свежее нельзя.
Regression. ws-regress сравнивает сохранённый эталон с текущим ответом:
потерянные поля, переехавшие JSON-пути (с подсказкой замены), переход SSR→CSR,
дрейф источника извлечения. При критичной регрессии — ненулевой код возврата.
Простыми словами: система запоминает, какая дверь открывается.
Для каждой пары «класс страниц + маршрут» копится статистика: сколько было попыток, сколько закончилось валидированным успехом, какая задержка. Дальше маршруты переупорядочиваются по нижней границе доверительного интервала (Wilson), а не по сырой доле успехов — один удачный запрос из одного не обгонит двести из двухсот пяти.
Три правила не дают этому сломаться:
- Отказ origin — нейтрален. Если сайт лежит, маршрут в этом не виноват: такие попытки не портят его оценку и не толкают лестницу вверх.
- Гистерезис. Маршруты не меняются местами от шума.
- Shadow probes. Изредка перепроверяется дешёвый маршрут, который история считает мёртвым — так система возвращается вниз, когда сайт ослабил защиту.
Failure fingerprints дополняют это: нормализованная «форма» отказа (вердикт, код, размер, маркеры челленджа, заголовки защиты) не зависит от домена. Если знакомая защита встречается на новом сайте, система сразу пробует маршрут, который уже помогал против неё.
«Сложный» — это когда данных нет в первом HTML-ответе: страница рендерится в браузере, стоит защита от ботов, или контент приходит отдельным XHR.
ParsesUnix не «пробивает» такие сайты. Он ищет наиболее устойчивый разрешённый способ получить публичные данные:
обычный HTTP
↓ если пришла пустая оболочка — вердикт CSR_REQUIRED
браузер (L2)
↓ перехват сети: какой JSON запрашивает сама страница
кандидат маршрута L0
↓ проверка и решение оператора
дальше — дёшево, без браузера
Ключевые детали:
- пустая оболочка не считается успехом —
<div id="root"></div>с кодом 200 получаетCSR_REQUIRED, который разблокирует браузер, но никогда не разрешает платного провайдера: рендеринг — наша работа; - канарейка не ищется внутри
<script>— маркер, найденный только в JS-массиве, не доказывает, что страница отрендерилась; - браузер — разведчик: один Chromium на прогон с контекстом на домен (измерено 4.9× против запуска на каждую страницу), а найденный JSON-эндпоинт переводит домен на дешёвый уровень;
- если сайт запрещает такой доступ — это фиксируется в отчёте приёмки, и механика обхода не строится.
Подробности — в docs/concepts/hard-sites.md, измерения — в docs/acceptance/hard-sites/.
Раньше JSON обрабатывался как HTML, который случайно разбирается: тело декодировалось, из него строилось DOM-дерево, и в нём искался точечный путь. Это работало достаточно часто, чтобы скрыть, что форма работы неверна.
ContentKind решает, чем ответ является, а не чем он назвался:
HTML JSON TEXT BINARY UNKNOWN
Заголовок — первый сигнал, а не единственный. Ошибка здесь беззвучна: HTML-экстрактор на JSON не находит элементов и возвращает пустоту, JSON-парсер на HTML падает и падение перехватывается. В обоих случаях прогон завершается, поле отсутствует, и никто не знает почему.
application/json → JSON
text/plain + {"a": 1} → JSON (частый случай, заголовок врёт)
application/json + <html> → HTML (страница ошибки JSON-эндпоинта)
image/png → BINARY (экстрактору не отдаётся вовсе)
Порядок проверок намеренный: сначала бинарные сигнатуры, чтобы не декодировать видео ради выяснения, что это видео; потом собственное начало тела, которое не может врать о себе; и только потом заголовок.
extractors:
- kind: json
fields:
name: data.character.name
score: data.character.score
players: data.players[*].nameПоддерживается сознательно подмножество, а не JSONPath: вложенные ключи, индексы массивов, целые списки и один ограниченный wildcard. Фильтры и рекурсивный спуск — это способы записать в профиль выражение, стоимость и результат которого никто не предскажет.
Типы сохраняются: 93 остаётся int, false остаётся False, список остаётся
списком. Приведение к строке при извлечении — это то, как числовое поле тремя
слоями ниже начинают сравнивать как текст.
Для JSON не строится DOM. Это и есть практический смысл всего изменения, и
на это есть тест, который падает, если parse_html был вызван.
Страница, требующая браузера, остаётся дорогой навсегда — если ничего не изменить. Изменение почти всегда одно и то же: страница взяла данные из эндпоинта, и этот эндпоинт дешевле, быстрее и стабильнее, чем разметка вокруг.
CSR-страница
↓
BrowserWorker
↓
наблюдение XHR / fetch / GraphQL
↓
кандидаты: схемная сигнатура, пагинация, операция
↓
валидация на нескольких страницах
↓
черновик L0 JSON-маршрута → человек решает
Обнаружение — не разрешение. Запрос с Authorization, куки или CSRF-токеном
был авторизован сессией, которую нам дали для рендеринга, а не лицензией
вызывать его напрямую → REJECTED_AUTH.
Найденный URL — всё ещё вектор SSRF. Отрендеренная страница может попросить
браузер о чём угодно, включая metadata-сервис → REJECTED_PRIVATE.
Аналитика тоже отвечает JSON. Google Analytics, Segment, Sentry, пиксели →
REJECTED_NOISE.
Отказ записывается, а не выбрасывается: на вопрос «почему не нашёл API?» ответ «нашёл и отказался, потому что нужна кука» полезнее молчания.
Ничего не пишется в профиль автоматически. Эндпоинт, ответивший один раз при
одном рендере, — совпадение. VALIDATED требует, чтобы тот же эндпоинт отдал ту
же схему на нескольких разных страницах, а результат — черновик, который читает
оператор.
ws-probe https://example.com/ --discover-api --target-field scoreВнутри прогона обнаружение включено по умолчанию (discover_api) и ничего не
стоит: оно едет пассажиром на рендерах, которые и так происходят. Разница
принципиальная — probe рендерит одну страницу и может дать только PROMISING,
а прогон рендерит много, и порог, отделяющий совпадение от закономерности,
достижим только там.
Найденные маршруты попадают в отчёт прогона готовым черновиком:
{
"suggested_route": {"id": "stats-api", "type": "json_api", "level": "L0", "url": "..."},
"extractor": {"kind": "json", "fields": {"title": "data.player.title"}},
"evidence": {"observed_count": 8, "confidence": "HIGH", "pagination": {"strategy": "CURSOR"}}
}Пути в черновике взяты оттуда, где поля реально видели, а не подставлены по шаблону — поэтому черновик можно проверить против эндпоинта.
Экономия заявляется только там, где посчитана: валидированный эндпоинт покрывает рендеры, которые этот прогон уже сделал. Прогноз на будущие прогоны не выдаётся — они ещё не состоялись.
На разрешённой цели wowmeta.com (robots: User-agent: *, запретов нет):
| Классификация | CSR-оболочка — 0 видимого текста, 2 скрипта |
| Найдено эндпоинтов | 2 JSON |
| С искомым полем | data.wowmeta.com/rankings/top-specs/… |
| Вердикт | PROMISING, 0 validated |
Ноль validated — правильный результат: отрендерена одна страница, а валидация требует нескольких. Система отказалась продвигать маршрут по одному наблюдению.
hsguru.com — SKIPPED BY POLICY. Его robots.txt содержит
User-agent: ClaudeBot / Disallow: / и Content-Signal: ai-train=no. Секция *
разрешает, но использовать нейтральный агент, чтобы обойти адресный запрет для
AI, — это и есть обход.
Платить система может только по вердикту BLOCKED или SOFT_BLOCK. Всё
остальное — мёртвый URL, лежащий origin, непрошедший парсинг — не оплачивается
никогда. Это правило про деньги, а не про стиль: измерено, что 404 через
scrape.do всё равно стоит кредит.
| Провайдер | Роль | Стратегии | Проверено |
|---|---|---|---|
| Scrape.do | основной | normal / render / super / super_render |
живыми вызовами |
| Firecrawl | управляемый рендеринг | basic / cached / auto / enhanced |
по документации |
| Bright Data | резерв высокой надёжности | unlocker / unlocker_render / browser |
живыми вызовами |
| ZenRows | широкий диапазон цены за вызов | basic / js / premium / js_premium / auto |
по документации |
| Zyte API | чистое разделение статусов, обучение маршрутам | http / browser / browser_capture |
по документации |
Firecrawl и Bright Data проверены живыми вызовами — и проверка нашла в них
пять настоящих дефектов, невидимых документации и тестам. ZenRows и Zyte
написаны по сверенной документации (docs_verified_at в каждом файле), но
ключей нет, поэтому они помечены NOT LIVE VERIFIED.
provider A: 1 кредит/запрос, валидны 50% → 2 кредита за результат
provider B: 1.5 кредита/запрос, валидны 99% → ~1.52 за результат
По прайсу A дешевле на треть. На деле — дороже на треть. Роутер ранжирует по
cost_per_valid_result, поэтому выбирает B.
Нижняя граница Уилсона и точечная оценка делают разную работу: граница решает, допустима ли стратегия вообще, точечная оценка — во сколько она обходится.
Cost.free() — платного вызова не было. Измеренный ноль.
Cost.of("5") — провайдер назвал цену. EXACT
Cost.provisional("1") — не назвал, но есть документированный потолок. PROVISIONAL
Cost.unknown() — безопасный потолок установить нельзя. UNKNOWN
PROVISIONAL — единственный уровень, при котором батч продолжает работу, ничего
не зная о фактической цене. Поэтому он допустим только при опубликованном
тарифе, и решение принимается в одном месте — providers/pricing.py.
Применение к трём вендорам несимметрично, и в этом суть:
| Вендор | Молчащий ответ | Почему |
|---|---|---|
| Scrape.do | не бывает | стоимость приходит в каждом ответе |
Firecrawl basic/cached |
PROVISIONAL |
1 кредит/страница опубликован, множителей нет |
Firecrawl auto/enhanced |
UNKNOWN |
документирован повтор на другом пуле, цена — нет |
| Bright Data | UNKNOWN |
CPM + премиум-домены без опубликованного множителя |
Provisional расчитывается по потолку, а не по прайсу: запись меньшей цифры позволила бы серии молчащих вызовов выйти за лимит, пройдя каждую проверку.
Один кредит Scrape.do, один кредит Firecrawl и один запрос Bright Data — три разные вещи. Ранжирование «1 против 1» сравнивает ничто. Роутер сравнивает в USD через снимки тарифов, и неоценённая стратегия сортируется последней: «мы не знаем, сколько это стоит» не должно выигрывать ценовое сравнение.
ws-run run.json --estimate-costНи одного платного вызова. Роутер спрашивается ровно так же, как во время прогона, поэтому оценка не может разойтись с реальным решением:
unresolved URLs: 40
expected cost: 42.00
reserved (held): 120
budget remaining: 500 (fits)
Три числа, которые часто путают: expected — плановая цифра, reserved — то, что будет удержано (всегда больше), worst case — насколько плохо может быть. Влезает ли прогон в бюджет, решается по удержанию: именно на нём реестр откажет. Прогон, одобренный по expected, встаёт на 60% с бюджетом, съеденным холдами.
A free все URL, L0–L2, платный вызов недостижим
B free retry только то, что чинится само
C cheap paid остаток, дешёвые стратегии
D expensive paid только то, что дешевле не берётся
Фаза B — где основная экономия. 10 000 URL на пятиминутном сбое origin дают 800 отказов; поштучная эскалация купила бы 800 платных вызовов за страницы, которые бесплатный повтор через двадцать минут отдаёт даром.
Состояние фаз переживает рестарт: процесс, умерший в фазе C, продолжает с фазы C. Перезапуск платных фаз оплатил бы те же URL дважды.
Стратегия, безупречно работавшая год назад и с тех пор не проверявшаяся, — не то же самое, что работавшая вчера: у сайта был год на изменения. Наблюдения теряют половину веса за период полураспада (по умолчанию 30 дней). Масштабируются и успехи, и попытки, поэтому наблюдаемая доля не меняется, а уверенность падает — мы по-прежнему верим, что оно работало, просто менее уверены, что работает сейчас.
CLOSED → OPEN → (остывание) → HALF_OPEN → один пробный вызов → CLOSED или OPEN
HALF_OPEN пропускает ровно один вызов: иначе истёкшее остывание выпустило
бы весь ждущий батч по тарифам провайдера, и восстановление стало бы вторым
инцидентом. AUTH таймером не лечится и ждёт человека; QUOTA возвращается
по биллинговому окну. Состояние переживает рестарт — иначе новый процесс первым
делом платит за то, чтобы заново узнать про неверный ключ.
Перед большим платным прогоном 3–10 представительных URL проходят тот же путь,
что и батч. BLOCK_PAID_PHASE останавливает трату. Нейтральные вердикты
(мёртвый URL, лежащий origin) из подсчёта исключаются: остановить платную
фазу из-за чужого сбоя значило бы добавить к нему свой.
Разбор инцидентов — docs/operations/provider-incidents.md.
- SSRF-защита на каждом хопе: приватные, loopback, link-local, CGNAT и metadata-адреса заблокированы; адрес пиннится, чтобы исключить DNS-rebinding.
- Заголовки не утекают:
AuthorizationиCookieвырезаются при редиректе на другой хост. - Никакого обхода авторизации и контроля доступа — это терминальные вердикты.
- Редакция секретов: тело снапшота, query-строка и заголовки чистятся перед записью на диск.
- Бюджет — обязательный шлюз для любого платного запроса.
- Профили без секретов — валидатор отклоняет ключи, cookies и токены.
make checkКоманда использует .venv, если окружение создано, и запускает тот же набор
Ruff, mypy и stdlib unittest, что используется в CI.
Python 3.11–3.13. Рантайм — только стандартная библиотека; браузер, PyYAML и Scrapling остаются опциональными зависимостями. Тесты, требующие браузера, корректно пропускаются, если Playwright не установлен.
src/web_scraper/
├── contracts.py единые типы: Verdict, Level, Route, Attempt, Result
├── triage.py классификатор ответов (единственный источник вердиктов)
├── profiles/ модель и валидатор Site Profile
├── probe/ разведка: статическая + браузерная, SSRF-защита
├── fetchers/ шлюз L0–L2, транспорты, сессии, пейсинг, circuit breaker
├── routing/ статистика маршрутов и адаптивный роутер
├── fingerprints/ нормализованные сигнатуры отказов и их восстановление
├── queue/ SQLite-очередь: дедуп, checkpoint/resume, карантин
├── extract/ цепочка экстракторов, кворум, нормализация
├── publish/ staging → атомарный promote → LKG, статусы свежести
├── freshness/ условные запросы и адаптивные интервалы
├── observability/ метрики, учёт URL, отчёты, алерты
├── regression/ диффы «эталон vs сейчас»
├── diagnose/ группировка отказов и корректные средства
└── run/ раннер прогона и CLI
Целевая среда — один сервер Debian с systemd-таймером. Юниты и инструкция —
в deploy/:
git pull && pip install --user -e .
systemctl --user start web-scraper.service
journalctl --user -u web-scraper.service -n 100Состояние (очередь, датасет, свежесть, статистика маршрутов) переживает деплой; прогон, прерванный обновлением, продолжается с очереди без дублей.
| Команда | Назначение |
|---|---|
ws-probe |
разведка нового домена, черновик профиля |
ws-probe --discover-api |
найти внутренние JSON/GraphQL-эндпоинты страницы |
ws-profile |
валидация профиля (без сети) |
ws-triage |
классификация сохранённого ответа |
ws-run |
прогон по расписанию |
ws-run --estimate-cost |
сколько будет стоить платная часть (без единого платного вызова) |
ws-regress |
«сайт изменился?» — гейт для CI |
ws-diagnose |
«почему прогон недобирает?» |
ws-budget |
учёт платных запросов |
Реально незавершённое:
- источники Reddit и X — контракт и нормализованная модель не написаны;
- живая проверка Firecrawl и Bright Data: адаптеры написаны по документации, ключей нет, поэтому разбор реального ответа не измерен;
- подключение
PhaseControllerкRunner: контроллер фаз готов и покрыт тестами, ноRunnerпока проходит очередь одним проходом; - бенчмарки под нагрузкой до решения о Rust-воркере.
Подробности — в docs/IMPLEMENTATION_PLAN.md.
MIT.