Симптом
Живой прогон 21.08.2026, main на d3e6ed2 (после мержа #25):
wordstat collect "ремонт квартир" --granularity daily \
--date-from 2026-06-23 --date-to 2026-08-20 \
--cdp-url http://127.0.0.1:9223 --region "Москва" --keep-raw
ремонт квартир: [Errno 1] Operation not permitted:
'/Users/axisrow/Downloads/wordstat_regions.csv'
-> 'wordstat-output/runs/20260821T095537Z-ремонт-квартир/wordstat_regions.csv'
Собрано 0 из 1
Сообщение вводит в заблуждение: собрано 3 вида из 4. На диске лежат
top_popular.parquet, top_related.parquet, dynamics_daily.parquet (58 строк,
корректный ряд 23.06.2026 — 19.08.2026) и валидный manifest.json.
Дневная грануляция (#6/PR #21) работает — падение к ней отношения не имеет.
Манифест при этом честный:
status: incomplete | missing_views: ['regions'] | empty_views: ['top_popular','top_related']
granularity: daily
requested: {'from': '2026-06-23', 'to': '2026-08-20'}
actual: {'field': 'Дата', 'from': '23.06.2026', 'to': '19.08.2026'}
Дефект 1: скачанный файл попадает в ~/Downloads мимо каталога прогона
collect_many (collector.py:260) создаёт временный каталог загрузок
.downloads-* внутри output_root и передаёт его в BrowserSession.
Первые три вида скачались в него правильно; на четвёртом (regions)
Chrome положил файл в ~/Downloads.
_download_current_view (collector.py:1017) детектирует загрузку по
разнице снапшотов, а _resolved_file_snapshot (collector.py:1098) берёт
объединение двух источников:
paths = {path for path in directory.glob("*") if path.is_file()}
paths |= {Path(path) for path in session.downloaded_files}
Второй источник — session.downloaded_files — это абсолютные пути от
Chrome, накапливаемые за всю сессию (_downloaded_files.append(event.path),
browser-use session.py:1263). Поэтому файл, ушедший в ~/Downloads,
успешно детектируется как «новая загрузка», и код передаёт этот путь дальше
в finalize_raw.
finalize_raw (storage.py:35) с --keep-raw делает
source.replace(destination) — перенос через границу файловых систем и
через TCC-защищённый каталог ~/Downloads. macOS отвечает
Errno 1 Operation not permitted. Без --keep-raw был бы
source.unlink() — то есть удаление файла из пользовательского
~/Downloads, что само по себе нежелательно.
Воспроизводится по-разному в зависимости от --output-dir. При
--output-dir в /private/tmp/.../scratchpad тот же прогон упал раньше и
иначе:
[Errno 2] No such file or directory:
'.../daily-test/.downloads-xl4x05q2/wordstat_top_queries.csv'
-> '.../runs/20260821T095427Z-ремонт-квартир/top_popular.csv'
Каталог .downloads-* (TemporaryDirectory) на момент переноса уже не
существовал — при этом session.downloaded_files продолжал отдавать путь
внутрь него. То есть снапшот может вернуть путь к уже исчезнувшему
файлу.
Что нужно
- Работать только с файлами внутри своего каталога загрузок. Путь,
лежащий вне downloads_path, — сигнал, что Chrome проигнорировал
настройку, и его нельзя молча переносить/удалять.
- Никогда не трогать
~/Downloads. Ни replace, ни unlink по пути
вне своего каталога — там файлы пользователя.
- Разобраться, почему настройка пути перестаёт действовать к
четвёртому скачиванию — это корень. Возможные направления: настройка
применяется к таргету/сессии и сбрасывается, накопление
downloaded_files между видами, гонка с пересозданием каталога.
- Перенос между разными ФС
Path.replace не выдерживает by design
(os.rename) — если файл легитимно оказывается на другом томе, нужен
copy+unlink. Но это следствие; сначала пункт 3.
Дефект 2: сбой на одном виде обнуляет весь результат фразы
Три вида собраны, parquet и манифест записаны — но фраза попадает в
failures, и CLI печатает Собрано 0 из 1 с exit code 1.
Это тот же класс отказа, что чинили в #22: отказ части убивает целое и
врёт о фактическом результате. Пользователь видит «ничего не собрано»,
имея на диске рабочий дневной ряд.
Манифест уже умеет описывать частичный результат (status: incomplete,
missing_views) — а вывод CLI этой возможностью не пользуется.
Что нужно
Частично собранная фраза не должна отображаться как полностью
провалившаяся. Механизм на усмотрение исполнителя; важно, чтобы вывод CLI и
код возврата соответствовали тому, что реально лежит на диске. Учесть, что
--resume-dir существует именно для дособирания недостающих видов — то
есть частичный результат уже является поддерживаемым состоянием.
Проверка
collector.py юнит-тестами не покрывается by design — обязателен живой
прогон (CDP http://127.0.0.1:9223, авторизация пройдена, порт 9222 не
трогать).
- полный прогон с
--granularity daily собирает все четыре вида,
включая regions;
- то же без
--keep-raw — убедиться, что ничего не удаляется из
~/Downloads (проверить содержимое до и после);
- прогон с
--output-dir вне репозитория (например в /private/tmp/...) —
тот путь падал иначе, обе формы должны работать;
- батч из двух фраз — путь загрузки не деградирует к последним видам;
- сымитировать файл вне
downloads_path и убедиться, что код его не
переносит и не удаляет, а сообщает внятной доменной ошибкой;
- привести фактический
manifest.json и вывод CLI.
pytest и ruff check . — страховка от регрессий. На main сейчас
151 passed за ~0.8s; рост времени в разы считать регрессией (#23).
CI в репозитории нет.
Симптом
Живой прогон 21.08.2026,
mainнаd3e6ed2(после мержа #25):Сообщение вводит в заблуждение: собрано 3 вида из 4. На диске лежат
top_popular.parquet,top_related.parquet,dynamics_daily.parquet(58 строк,корректный ряд
23.06.2026 — 19.08.2026) и валидныйmanifest.json.Дневная грануляция (#6/PR #21) работает — падение к ней отношения не имеет.
Манифест при этом честный:
Дефект 1: скачанный файл попадает в
~/Downloadsмимо каталога прогонаcollect_many(collector.py:260) создаёт временный каталог загрузок.downloads-*внутриoutput_rootи передаёт его вBrowserSession.Первые три вида скачались в него правильно; на четвёртом (
regions)Chrome положил файл в
~/Downloads._download_current_view(collector.py:1017) детектирует загрузку поразнице снапшотов, а
_resolved_file_snapshot(collector.py:1098) берётобъединение двух источников:
Второй источник —
session.downloaded_files— это абсолютные пути отChrome, накапливаемые за всю сессию (
_downloaded_files.append(event.path),browser-use
session.py:1263). Поэтому файл, ушедший в~/Downloads,успешно детектируется как «новая загрузка», и код передаёт этот путь дальше
в
finalize_raw.finalize_raw(storage.py:35) с--keep-rawделаетsource.replace(destination)— перенос через границу файловых систем ичерез TCC-защищённый каталог
~/Downloads. macOS отвечаетErrno 1 Operation not permitted. Без--keep-rawбыл быsource.unlink()— то есть удаление файла из пользовательского~/Downloads, что само по себе нежелательно.Воспроизводится по-разному в зависимости от
--output-dir. При--output-dirв/private/tmp/.../scratchpadтот же прогон упал раньше ииначе:
Каталог
.downloads-*(TemporaryDirectory) на момент переноса уже несуществовал — при этом
session.downloaded_filesпродолжал отдавать путьвнутрь него. То есть снапшот может вернуть путь к уже исчезнувшему
файлу.
Что нужно
лежащий вне
downloads_path, — сигнал, что Chrome проигнорировалнастройку, и его нельзя молча переносить/удалять.
~/Downloads. Ниreplace, ниunlinkпо путивне своего каталога — там файлы пользователя.
четвёртому скачиванию — это корень. Возможные направления: настройка
применяется к таргету/сессии и сбрасывается, накопление
downloaded_filesмежду видами, гонка с пересозданием каталога.Path.replaceне выдерживает by design(
os.rename) — если файл легитимно оказывается на другом томе, нуженcopy+unlink. Но это следствие; сначала пункт 3.
Дефект 2: сбой на одном виде обнуляет весь результат фразы
Три вида собраны, parquet и манифест записаны — но фраза попадает в
failures, и CLI печатаетСобрано 0 из 1с exit code 1.Это тот же класс отказа, что чинили в #22: отказ части убивает целое и
врёт о фактическом результате. Пользователь видит «ничего не собрано»,
имея на диске рабочий дневной ряд.
Манифест уже умеет описывать частичный результат (
status: incomplete,missing_views) — а вывод CLI этой возможностью не пользуется.Что нужно
Частично собранная фраза не должна отображаться как полностью
провалившаяся. Механизм на усмотрение исполнителя; важно, чтобы вывод CLI и
код возврата соответствовали тому, что реально лежит на диске. Учесть, что
--resume-dirсуществует именно для дособирания недостающих видов — тоесть частичный результат уже является поддерживаемым состоянием.
Проверка
collector.pyюнит-тестами не покрывается by design — обязателен живойпрогон (CDP
http://127.0.0.1:9223, авторизация пройдена, порт 9222 нетрогать).
--granularity dailyсобирает все четыре вида,включая
regions;--keep-raw— убедиться, что ничего не удаляется из~/Downloads(проверить содержимое до и после);--output-dirвне репозитория (например в/private/tmp/...) —тот путь падал иначе, обе формы должны работать;
downloads_pathи убедиться, что код его непереносит и не удаляет, а сообщает внятной доменной ошибкой;
manifest.jsonи вывод CLI.pytestиruff check .— страховка от регрессий. Наmainсейчас151 passed за ~0.8s; рост времени в разы считать регрессией (#23).
CI в репозитории нет.