Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
name: CI

on:
push:
branches: [main]
pull_request:

jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
- name: ruff check
run: uvx ruff check .
88 changes: 86 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ wordstat collect "ремонт квартир" "натяжные потолки"
# --cdp-url / WORDSTAT_CDP_URL, --timeout (сек, по умолчанию 45)
# --keep-raw — оставить скачанные CSV как <view>.csv рядом с parquet

# дозаписать недостающие виды в уже существующий run-каталог (ровно одна фраза,
# фраза/регион должны совпасть с manifest.json в этом каталоге)
wordstat collect "ремонт квартир" --region "Москва" \
--resume-dir ./wordstat-output/runs/20260821T090000Z-ремонт-квартир

# тесты
pytest
pytest tests/test_csv_io.py::test_parse_wordstat_csv_rejects_duplicate_headers # один тест
Expand Down Expand Up @@ -121,6 +126,52 @@ ruff check .
инвариант держится и для сбоя `write_dataset`/`finalize_raw`) скачанный
CSV переносится из временного каталога загрузок в `run_directory` фразы
и остаётся на диске для разбора, прежде чем исключение уйдёт выше.
- **Манифест пишется инкрементально**, а не одним куском в конце: один раз
до цикла видов (`exports=[]`, честно отражает «прогон начался, ничего ещё
не собрано») и заново после каждого успешно собранного вида
(`merge_export` + `write_manifest`, обе — `storage.py`). Так обрыв
посреди фразы (Ctrl-C, упавший CDP, сбой `write_dataset` на третьем виде)
оставляет на диске манифест, который честно описывает, что реально
собрано, а не либо ничего, либо (что опаснее) манифест с четырьмя видами,
из которых реально записались не все. `status`/`missing_views` в
`CollectionManifest` — `computed_field`, выводятся из `exports`, а не
хранятся отдельно: сконструировать манифест, где они противоречат
`exports`, невозможно (детали — в докстринге модели, `models.py`).
`source_url` при первом прогоне снимается один раз, до цикла видов
(первая вкладка), а не после последнего вида (карта), как было в
однократной записи — это осознанная смена: URL из середины/конца цикла
было бы либо недоступно при инкрементальной записи первого манифеста
(ещё ни одна вкладка не выбрана), либо непостоянно от вида к виду. При
дозаписи `source_url` обновляется заново (см. следующий пункт) —
значение из первого прогона не остаётся висеть навсегда.
- **Дозапись** (`--resume-dir`) — отдельный явный путь, не ослабление
`create_run_directory`: тот каталог создаётся заново, как и раньше;
резюм принимает уже существующий каталог, где `manifest.json` хранит
`phrase`/`region`, и `prepare_resume_directory` (`storage.py`) жёстко
отклоняет (`ResumeMismatchError`) каталог с несовпадающей (после
`.strip()`) фразой или регионом, отсутствующим/битым манифестом или
путём, который не является каталогом — иначе опечатка в `--resume-dir`
молча смешала бы данные двух разных фраз в одном каталоге. Вид считается
уже собранным (`views_to_collect`), только если он одновременно есть в
`manifest.exports` **и** его `<view>.parquet` реально лежит на диске —
удалённый вручную parquet при живой записи в манифесте не должен
восприниматься как «уже собрано». `collect_many`/CLI отклоняют
`--resume-dir` при более чем одной фразе: один run-каталог = одна фраза,
дозапись на батч размазала бы чужие exports по одному манифесту. CLI
дополнительно вызывает `prepare_resume_directory` до старта Chrome
(fail-fast на опечатку), но `_collect_one` всё равно перепроверяет то же
самое перед стартом — CLI-проверка экономит время, а не заменяет гейт.
- **При дозаписи `_collect_one` сразу после `_set_phrase`** (не раньше —
до `_set_phrase` страница ещё показывает предыдущую фразу/вкладку)
перезаписывает манифест с обновлёнными `source_url` и `updated_at`,
оставляя `created_at` нетронутым. Иначе манифест после резюма продолжал
бы утверждать, что все виды собраны в момент первого, прерванного
прогона, и `source_url` указывал бы на устаревшую вкладку/фразу —
ловушка, которую пропустили все три независимых решения issue #2 при
первом проходе. Ранний `return` резюма уже полностью собранного каталога
(нечего досчитывать) этот шаг не выполняет — там реально ничего не
изменилось, бампать `updated_at` было бы ложью. Семантика всех трёх
полей подробно описана в докстринге `CollectionManifest` (`models.py`).
- Все клики/проверки идут через `page.evaluate` с CSS-селекторами и строгой
проверкой «найден ровно один элемент» — если 0 или >1, кидается
`InterfaceChangedError`. Это защита от того, что Wordstat незаметно
Expand Down Expand Up @@ -193,6 +244,38 @@ ruff check .
при `keep_raw` переименовывает в `<view>.csv`.
`write_manifest` пишет `manifest.json` в UTF-8 без экранирования кириллицы.
После прогона в каталоге лежат четыре `<view>.parquet` и `manifest.json`.
- **`write_manifest` атомарна**: временный файл создаётся в том же
каталоге (`tempfile.mkstemp(dir=path.parent, ...)`), а не в системном
temp — иначе `os.replace` через границу файловой системы падает с
`OSError` вместо атомарного переименования — и заменяет цель через
`os.replace`. Это не деталь реализации, а предпосылка для инкрементальной
записи манифеста (см. `collector.py`): раз манифест теперь переписывается
после каждого вида, а не один раз в конце, окно между усечением файла и
записью нового содержимого стало реальным, и обычный `Path.write_text`
(усечение + запись) оставлял бы пустой/битый `manifest.json` при обрыве
ровно в этот момент. Временный файл всегда убирается в `finally`, даже
если `os.replace` упал — не остаётся мусора вида `.manifest-*.json.tmp`.
Перед `os.replace` — `handle.flush()` + `os.fsync(handle.fileno())`:
`os.replace` гарантирует только порядок операций (переименование), но не
то, что байты временного файла физически дошли до диска — без `fsync`
потеря питания между переименованием и сбросом page cache могла бы
оставить пустой/мусорный `manifest.json` там, где был валидный. Порядок
важен — `fsync`, потом `replace`, не наоборот; тест проверяет именно
порядок вызовов, а не сам факт вызова `fsync`.
- `load_manifest` читает `manifest.json` обратно в `CollectionManifest`;
отсутствующий или невалидный файл — `ResumeMismatchError`, а не голый
`FileNotFoundError`/pydantic `ValidationError`, чтобы вызывающий код
(резюм) получал понятную доменную ошибку. `CollectionManifest` отклоняет
(через `model_validator`) манифест с двумя `exports` на один и тот же
`WordstatView` — штатная запись (`merge_export`) такое произвести не
может, это защита именно для `load_manifest`, то есть от вручную
отредактированного или битого `manifest.json` на диске, который иначе
молча собьёт `views_to_collect` (возьмёт первую попавшуюся дублирующую
запись).
- `prepare_resume_directory`/`views_to_collect`/`merge_export` — чистая
логика дозаписи существующего run-каталога; подробности и обоснование
главного риска порчи данных (смешение двух разных фраз в одном
каталоге) — в блоке про `collector.py` выше.

- **`csv_io.py`** — парсинг только что скачанных CSV. Кодировка
автоопределяется перебором (`utf-8-sig`, `utf-8`, `cp1251` — Wordstat
Expand Down Expand Up @@ -232,8 +315,9 @@ ruff check .

- **`errors.py`** — плоская иерархия от `WordstatError`; каждый тип ошибки
соответствует конкретной причине сбоя (нет авторизации, изменился UI,
не скачалось вовремя, не распарсился CSV). `cli.py` ловит
`WordstatError | ValueError` и оборачивает в `click.ClickException`.
не скачалось вовремя, не распарсился CSV, `--resume-dir` не подходит —
`ResumeMismatchError`). `cli.py` ловит `WordstatError | ValueError` и
оборачивает в `click.ClickException`.

## Testing notes

Expand Down
29 changes: 28 additions & 1 deletion src/wordstat/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from wordstat.collector import WordstatCollector
from wordstat.config import load_config
from wordstat.errors import WordstatError
from wordstat.storage import prepare_resume_directory

_CONFIG = load_config()
_DEFAULT_CDP_URL = _CONFIG.get("cdp_url", "http://127.0.0.1:9222")
Expand Down Expand Up @@ -74,6 +75,17 @@ def _read_phrases_file(path: Path) -> str:
default=False,
help="Keep each downloaded CSV as <view>.csv instead of discarding it after conversion.",
)
@click.option(
"--resume-dir",
"resume_dir",
type=click.Path(path_type=Path, exists=True, file_okay=False),
default=None,
help=(
"Append missing views to an existing run directory instead of starting a new one. "
"Requires exactly one PHRASE, matching the phrase/region recorded in that "
"directory's manifest.json."
),
)
@click.pass_context
def collect(
ctx: click.Context,
Expand All @@ -84,6 +96,7 @@ def collect(
cdp_url: str,
timeout_seconds: float,
keep_raw: bool,
resume_dir: Path | None,
) -> None:
"""Collect all MVP Wordstat reports for one or more PHRASE as Parquet datasets.

Expand All @@ -95,6 +108,20 @@ def collect(
phrases = resolve_phrases(phrase, phrases_file)
if not phrases:
raise click.ClickException("At least one search phrase is required")
if resume_dir is not None:
if len(phrases) != 1:
raise click.ClickException("--resume-dir requires exactly one phrase")
# Pre-flight check: prepare_resume_directory is pure filesystem logic
# (see storage.py), so a bad --resume-dir (wrong phrase/region, no
# manifest.json, not a directory) can be rejected here before Chrome
# is even touched, instead of surfacing later as a batch failure.
# collect_many/​_collect_one still re-validate the same way right
# before use — this call is a fail-fast convenience, not the only
# guard against a mismatched directory.
try:
prepare_resume_directory(resume_dir, phrases[0], region)
except WordstatError as error:
raise click.ClickException(str(error)) from error

collector = WordstatCollector(
cdp_url=cdp_url,
Expand All @@ -103,7 +130,7 @@ def collect(
keep_raw=keep_raw,
)
try:
batch = asyncio.run(collector.collect_many(phrases, region=region))
batch = asyncio.run(collector.collect_many(phrases, region=region, resume_directory=resume_dir))
# Only domain errors become friendly messages; an unexpected ValueError
# from a dependency should keep its traceback instead of being reworded.
except WordstatError as error:
Expand Down
Loading
Loading