Skip to content

Скачанный CSV уходит в ~/Downloads мимо каталога прогона; сбой на одном виде обнуляет весь результат #27

Description

@axisrow

Симптом

Живой прогон 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 продолжал отдавать путь
внутрь него. То есть снапшот может вернуть путь к уже исчезнувшему
файлу.

Что нужно

  1. Работать только с файлами внутри своего каталога загрузок. Путь,
    лежащий вне downloads_path, — сигнал, что Chrome проигнорировал
    настройку, и его нельзя молча переносить/удалять.
  2. Никогда не трогать ~/Downloads. Ни replace, ни unlink по пути
    вне своего каталога — там файлы пользователя.
  3. Разобраться, почему настройка пути перестаёт действовать к
    четвёртому скачиванию — это корень. Возможные направления: настройка
    применяется к таргету/сессии и сбрасывается, накопление
    downloaded_files между видами, гонка с пересозданием каталога.
  4. Перенос между разными ФС 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 в репозитории нет.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions