From 01a6cc0d1d96cf2c3b4fa7c78d52237d96128111 Mon Sep 17 00:00:00 2001 From: Ilya utov Date: Wed, 7 Oct 2026 12:23:00 +0300 Subject: [PATCH] fix: preserve Windows paths and report conversion failures --- .github/workflows/ci.yml | 28 +++++-- README.md | 12 +-- skills/doc2md/SKILL.md | 16 ++-- skills/doc2md/scripts/convert.js | 74 +++++++++++++------ skills/doc2md/scripts/convert.sh | 121 +------------------------------ 5 files changed, 93 insertions(+), 158 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61208c6..c8d8ce0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,10 +1,7 @@ name: CI -# Бесплатный детерминированный гейт: синтаксис обеих версий обёртки + smoke-тесты -# арг-парсинга и graceful-поведения. Реальную конвертацию форматов CI НЕ гоняет: -# это требовало бы скачивания бинарника anydoc из сети на каждый прогон и -# зависело бы от внешнего пакета — недетерминированно. Живые проверки форматов -# делаются вручную (см. skills/doc2md/SKILL.md, раздел «Протестировано»). +# Быстрый офлайн smoke-гейт и отдельная проверка Windows с закреплённой +# версией anydoc. Полную матрицу форматов CI не гоняет; см. SKILL.md. on: pull_request: @@ -38,3 +35,24 @@ jobs: gemini-extension.json; do node -e "JSON.parse(require('fs').readFileSync('$f','utf8')); console.log('ok: $f')" done + + windows-paths: + runs-on: windows-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: "20" + - name: Install anydoc for the Windows path test + run: npm install -g @firecrawl/anydoc@0.2.4 + - name: Spaces, percent signs, and output collisions + shell: pwsh + run: | + New-Item -ItemType Directory -Force 'test-input/a', 'test-input/b', 'test-output' | Out-Null + Set-Content -LiteralPath 'test-input/a/a %TEMP% b.csv' -Value "name,value`na,1" -NoNewline + Set-Content -LiteralPath 'test-input/a/dup.csv' -Value "name,value`na,2" -NoNewline + Set-Content -LiteralPath 'test-input/b/dup.csv' -Value "name,value`nb,3" -NoNewline + node skills/doc2md/scripts/convert.js test-input/a test-input/b -o test-output + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + if (-not (Test-Path -LiteralPath 'test-output/a %TEMP% b.csv.md')) { throw 'Special-character path was lost' } + if ((Get-ChildItem -LiteralPath test-output -Filter 'dup.csv.*.md').Count -ne 2) { throw 'Output collision lost a file' } diff --git a/README.md b/README.md index b89317b..034079c 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ doc2md/ │ ├── SKILL.md — сам скилл (описание + инструкция для агента) │ └── scripts/ │ ├── convert.js — пакетный конвертер, кросс-платформенный (Windows/macOS/Linux) — основной -│ └── convert.sh — тот же конвертер, bash-версия для Unix +│ └── convert.sh — совместимый bash-вход, вызывает convert.js ├── commands/convert.md — слэш-команда /convert ├── .claude-plugin/ — манифесты для Claude Code + marketplace ├── .codex-plugin/ — манифест для Codex CLI @@ -94,13 +94,13 @@ node skills/doc2md/scripts/convert.js ~/Downloads/акты/ -o ~/Downloads/ак node skills/doc2md/scripts/convert.js a.pdf b.xlsx "отчёт за март.pptx" ``` -На Unix (macOS/Linux/WSL/Git Bash) — bash-версия, поведение идентично: +На Unix (macOS/Linux/WSL/Git Bash) можно вызвать тот же конвертер через bash: ```bash bash skills/doc2md/scripts/convert.sh договор.docx ``` -Без `-o` каждый файл `<имя>.<расширение>.md` пишется рядом с исходником — расширение сохраняется в имени намеренно, иначе `отчёт.docx` и `отчёт.csv` в одной папке лягут в один `отчёт.md` и один перезапишет другой. С `-o папка/` все результаты собираются в одном месте. +Без `-o` каждый файл `<имя>.<расширение>.md` пишется рядом с исходником — расширение сохраняется в имени намеренно. С `-o папка/` все результаты собираются в одном месте; тёзки из разных подпапок получают суффикс пути, чтобы не перезаписать друг друга. Триггеры для агента: «сконвертируй эти файлы», «разбери пачку документов», «не могу прочитать этот файл», «переведи в markdown», «подготовь документы для агента». @@ -119,10 +119,10 @@ bash skills/doc2md/scripts/convert.sh договор.docx ## Что скрипт делает, а что нет - Конвертирует файл(ы), печатает построчный статус и итоговую сводку: сколько `OK`, сколько `ПРОПУСК`, сколько `ОШИБКА`. -- **`OK`** — anydoc вернул код `0`. **`ОШИБКА`** — не удалось даже запустить `npx`/anydoc (нет в PATH, код 126/127): отказ окружения, а не «файл нечитаем», OCR тут не поможет. **`ПРОПУСК`** — anydoc запустился, но файл не сконвертировался: почти всегда скан/шифр/битый файл. +- **`OK`** — anydoc вернул код `0` и результат записан. **`ПРОПУСК`** — код `3`: PDF нужны OCR-страницы. **`ОШИБКА`** — прочий сбой чтения, конвертации, записи или вызова; пакет завершится с ненулевым кодом. - Не удаляет и не трогает исходники. -> Контракт кодов возврата anydoc формально не задокументирован — скрипт трактует их консервативно (см. `SKILL.md`). При первом боевом прогоне на битом/зашифрованном файле стоит перепроверить. +> Скрипт передаёт содержимое файла через stdin, чтобы путь с пробелами и `%VAR%` не проходил через `cmd.exe` на Windows. Коды возврата соответствуют справке `anydoc --help` версии 0.2.4. ## Протестировано (06.08.2026, песочница Cowork, Node 22, Linux/macOS) @@ -132,7 +132,7 @@ bash skills/doc2md/scripts/convert.sh договор.docx - Несуществующий путь в аргументах — пропускается с понятным сообщением, batch не падает. - `convert.js` — та же батарея тестов повторена и прошла (рекурсивная папка, коллизия имён, кириллица, вложенная подпапка, missing path). -**Не тестировано вживую:** `.pptx`, `.xlsx`, `.pdf`, `.epub`, боевая пачка документов клиента. **Живой запуск на Windows не делал** — в песочнице нет Windows-окружения; `convert.js` написан без shell-специфичного синтаксиса и с `shell:true` для `npx` на win32, но это обоснованное рассуждение, а не проверка. Проверил формат или ОС — [заведи issue](https://github.com/ilyautov/doc2md/issues), обновлю раздел. +**Не тестировано вживую:** `.pptx`, `.xlsx`, `.pdf`, `.epub`, боевая пачка документов клиента. **Живой запуск новой версии на Windows пока не сделан** — на macOS проверены имена с пробелом и `%TEMP%`, коллизии в `-o`, повреждённый `.docx` и отсутствие значения `-o`. Windows-подтверждение из issue относится к прежнему коду. Проверил новую версию на Windows — [сообщи результат](https://github.com/ilyautov/doc2md/issues). ## Чем отличается от upstream-скилла anydoc diff --git a/skills/doc2md/SKILL.md b/skills/doc2md/SKILL.md index a47348a..59760a9 100644 --- a/skills/doc2md/SKILL.md +++ b/skills/doc2md/SKILL.md @@ -32,15 +32,13 @@ node <папка скилла>/scripts/convert.js ~/Downloads/акты/ -o ~/Dow node <папка скилла>/scripts/convert.js a.pdf b.xlsx "отчёт за март.pptx" ``` -На Unix (macOS/Linux/WSL/Git Bash) можно и через bash-версию — поведение идентично: +На Unix (macOS/Linux/WSL/Git Bash) можно вызвать тот же конвертер через bash: ```bash bash <папка скилла>/scripts/convert.sh договор.docx ``` -Без `-o` каждый `<имя>.<расширение>.md` пишется рядом с исходником (например, `отчёт.docx` → `отчёт.docx.md` — расширение сохраняется в имени намеренно, иначе `отчёт.docx` и `отчёт.csv` в одной папке лягут в один и тот же `отчёт.md` и один перезапишет другой). С `-o папка/` все результаты собираются в одном месте. - -> ⚠️ При `-o папка/` вывод плоский: два файла с одинаковым именем из разных подпапок (`a/отчёт.docx` и `b/отчёт.docx`) дадут один `отчёт.docx.md`, второй перезапишет первый. Если в дереве возможны тёзки — конвертируй без `-o` (рядом с исходниками) либо по подпапкам отдельными вызовами. +Без `-o` каждый `<имя>.<расширение>.md` пишется рядом с исходником (например, `отчёт.docx` → `отчёт.docx.md`). С `-o папка/` результаты собираются в одном месте. Тёзки из разных подпапок получают суффикс пути, чтобы один результат не перезаписал другой. Для одного файла и разового вызова можно и напрямую, без скрипта: @@ -63,12 +61,12 @@ npx -y @firecrawl/anydoc report.docx -o report.docx.md ## Что скрипт делает, а что нет - Конвертирует файл(ы), печатает построчный статус (`OK` / `ПРОПУСК` / `ОШИБКА`) и итоговую сводку: сколько успешно, сколько пропущено, сколько ошибок вызова. -- **Различие ПРОПУСК vs ОШИБКА.** `OK` — код возврата anydoc `0`. `ОШИБКА` — не удалось даже запустить `npx`/anydoc (нет в PATH, код `126`/`127`): это отказ окружения, а не «файл нечитаем», и OCR тут не поможет. `ПРОПУСК` — anydoc запустился, но файл не сконвертировался (любой другой ненулевой код): почти всегда скан/шифр/битый файл, в строку выводится stderr от anydoc как есть. +- **Различие ПРОПУСК vs ОШИБКА.** `OK` — код anydoc `0` и результат записан. `ПРОПУСК` — код `3`, PDF нужны OCR-страницы. Любой другой сбой чтения, конвертации, записи или вызова — `ОШИБКА` и ненулевой код всего пакета. В строку выводится stderr от anydoc. - **Сканы и PDF-картинки без текстового слоя anydoc не читает** — это не баг, а ограничение движка (нет OCR внутри). Такие файлы попадают в «ПРОПУСК», batch на этом не падает. Для них: сначала OCR через скилл `pdf` (шаг «сделать PDF searchable»), потом повторный прогон. -- Зашифрованные/защищённые паролем файлы — туда же, в «ПРОПУСК», с текстом ошибки от anydoc. -- Не удаляет и не трогает исходники. Не перезаписывает существующие `.md`, если явно не совпало имя (тогда перезапишет — как обычная запись файла). +- Зашифрованные/защищённые паролем файлы — `ОШИБКА` с объяснением от anydoc; не отправляй их на OCR. +- Не удаляет и не трогает исходники. Повторный запуск может обновить ранее созданный `.md`. -> Контракт кодов возврата anydoc формально не задокументирован. Скрипт держится консервативной трактовки выше (`0`=OK, `126`/`127`=ошибка окружения, прочее=пропуск). При первом боевом прогоне на заведомо битом/зашифрованном файле стоит перепроверить, что anydoc действительно отдаёт на них ненулевой код, и при необходимости уточнить трактовку. +> Содержимое файла передаётся через stdin, чтобы путь с пробелами и `%VAR%` не попадал в командную строку `cmd.exe` на Windows. Коды возврата взяты из `anydoc --help` версии 0.2.4. ## После конвертации @@ -88,7 +86,7 @@ npx -y @firecrawl/anydoc report.docx -o report.docx.md - `scripts/convert.js` (кросс-платформенная Node-версия): та же батарея тестов повторена и прошла — рекурсивный обход папки, коллизия имён, кириллица в `.docx`/`.csv`, вложенная подпапка, несуществующий путь. - Синтаксис-гейт обеих версий (`node --check`, `bash -n`) и smoke-тесты (`--help`, несуществующий путь) гоняются в CI на каждый PR. -**Не тестировал:** `.pptx`, `.xlsx`, `.pdf`, `.epub`, реальную пачку документов клиента, файлы за пределами тестовой песочницы. **Живой запуск на Windows не делал** — в песочнице нет Windows-окружения. `convert.js` написан без shell-специфичного синтаксиса и с `shell:true` для `npx` на win32 (известная особенность Node — без этого `npx` на Windows не резолвится), но это рассуждение о корректности, а не проверка. Файлы со спецсимволами `& ^ %` в имени на Windows не проверялись. При первом боевом использовании на Windows — перепроверить и дополнить эту секцию, не выдавать непроверенное за проверенное. +**Не тестировал новую версию на Windows:** нет Windows-окружения. На macOS проверены имена с пробелом и `%TEMP%`, коллизии в `-o`, повреждённый `.docx` и отсутствие значения `-o`. Windows-подтверждение из issue относится к прежнему коду. При первом боевом использовании на Windows — перепроверить и дополнить эту секцию. Также не тестировались `.pptx`, `.xlsx`, `.pdf`, `.epub` и боевая пачка документов клиента. ## Источник diff --git a/skills/doc2md/scripts/convert.js b/skills/doc2md/scripts/convert.js index 412be57..bb51a3f 100755 --- a/skills/doc2md/scripts/convert.js +++ b/skills/doc2md/scripts/convert.js @@ -7,6 +7,7 @@ const fs = require('fs'); const path = require('path'); +const crypto = require('crypto'); const { spawnSync } = require('child_process'); const SUPPORTED_EXT = new Set([ @@ -72,6 +73,10 @@ function main() { for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a === '-o' || a === '--output') { + if (!argv[i + 1] || argv[i + 1].startsWith('-')) { + console.error('doc2md: после -o нужна выходная папка'); + process.exit(2); + } outDir = argv[++i]; } else if (a === '-h' || a === '--help') { usage(); @@ -115,46 +120,73 @@ function main() { let skip = 0; let fail = 0; const isWin = process.platform === 'win32'; + const outputNames = new Map(); + for (const f of files) { + const name = `${path.basename(f)}.md`; + const sources = outputNames.get(name) || new Set(); + sources.add(path.resolve(f)); + outputNames.set(name, sources); + } for (const f of files) { // Полное имя файла + .md (не срезаем расширение): "отчёт.csv" и "отчёт.docx" // иначе оба лягут в "отчёт.md" и второй перезапишет первый. const base = path.basename(f); - const out = outDir ? path.join(outDir, `${base}.md`) : path.join(path.dirname(f), `${base}.md`); + let outputName = `${base}.md`; + if (outDir && outputNames.get(outputName).size > 1) { + const suffix = crypto.createHash('sha256').update(path.resolve(f)).digest('hex').slice(0, 12); + outputName = `${base}.${suffix}.md`; + } + const out = outDir ? path.join(outDir, outputName) : path.join(path.dirname(f), outputName); - // shell:true на Windows обязателен — npx там резолвится как npx.cmd, - // без shell Node его не находит через PATH. - const res = spawnSync('npx', ['-y', '@firecrawl/anydoc', f, '-o', out], { + // На Windows npx.cmd запускается через cmd.exe. Пользовательский путь + // никогда не попадает в командную строку: документ передаётся через stdin. + // Формат указан явно, потому что CSV без имени файла не определяется. + let input; + try { + input = fs.readFileSync(f); + } catch (error) { + console.error(`ОШИБКА ${f} — не удалось прочитать файл: ${error.message}`); + fail++; + continue; + } + const format = path.extname(f).slice(1).toLowerCase(); + if (!SUPPORTED_EXT.has(format)) { + console.error(`ОШИБКА ${f} — неподдерживаемое расширение`); + fail++; + continue; + } + const res = spawnSync('npx', ['-y', '@firecrawl/anydoc', '-', '--format', format], { shell: isWin, + input, encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, }); const errOutput = (res.stderr || res.error?.message || '').trim(); - // Категоризация исхода. Контракт кодов возврата anydoc формально не - // задокументирован, поэтому различаем консервативно: - // • не удалось ДАЖE запустить npx (ENOENT / нет в PATH / 126/127) — это - // ОШИБКА окружения, а НЕ «файл — скан». Иначе полный отказ npx выглядел - // бы как «все ваши документы нечитаемы» и увёл бы на бессмысленный OCR. - // • код 0 — OK. - // • прочий ненулевой код (файл дошёл до anydoc, но не сконвертировался) — - // ПРОПУСК: скорее всего скан/шифр/битый файл; печатаем stderr как есть. - if (res.error || res.status === 127 || res.status === 126) { - console.error(`ОШИБКА ${f} — не удалось запустить anydoc: ${errOutput || 'npx недоступен'}`); - fail++; - } else if (res.status === 0) { - console.log(`OK ${f} -> ${out}`); - ok++; - } else { + if (res.status === 3) { console.error(`ПРОПУСК ${f} — ${errOutput}`); skip++; + } else if (res.status !== 0 || res.error) { + console.error(`ОШИБКА ${f} — ${errOutput || `anydoc завершился с кодом ${res.status}`}`); + fail++; + } else { + try { + fs.writeFileSync(out, res.stdout, { flag: 'w' }); + console.log(`OK ${f} -> ${out}`); + ok++; + } catch (error) { + console.error(`ОШИБКА ${f} — не удалось записать результат: ${error.message}`); + fail++; + } } } console.log(''); - console.log(`Готово: ${ok} конвертировано, ${skip} пропущено (не читается/зашифровано/скан), ${fail} ошибок вызова`); + console.log(`Готово: конвертировано ${ok}, нужен OCR ${skip}, ошибок ${fail}`); if (skip > 0) { - console.log('Пропущенные, скорее всего, сканы или PDF-картинки без текстового слоя — прогони их через OCR (скилл pdf, шаг «сделать PDF searchable») и повтори.'); + console.log('Пропущенным PDF нужен OCR: сделайте текстовый слой и повторите конвертацию.'); } process.exit(fail > 0 ? 1 : 0); diff --git a/skills/doc2md/scripts/convert.sh b/skills/doc2md/scripts/convert.sh index d8ecb68..4a2fbda 100755 --- a/skills/doc2md/scripts/convert.sh +++ b/skills/doc2md/scripts/convert.sh @@ -1,118 +1,5 @@ #!/usr/bin/env bash -# doc2md — пакетная конвертация документов в Markdown. -# Обёртка над firecrawl/anydoc (npx -y @firecrawl/anydoc). -# Поведение категоризации держится идентичным convert.js — при правке одного -# скрипта синхронизируй второй. -set -uo pipefail - -SUPPORTED_EXT="doc docx docm odt rtf epub pdf ppt pps pot pptx pptm ppsx ppsm odp xls xlsx xlsm xlsb ods csv" - -usage() { - cat <<'EOF' -doc2md — конвертирует документы в Markdown перед тем, как их читает агент. - -Использование: - convert.sh <файл-или-папка> [ещё файлы/папки...] [-o ВЫХОДНАЯ_ПАПКА] - -Примеры: - convert.sh договор.docx - convert.sh ~/Downloads/акты/ -o ~/Downloads/акты-md/ - convert.sh a.pdf b.xlsx "отчёт за март.pptx" - -Без -o каждый файл.md пишется рядом с исходником. -Папка сканируется рекурсивно по поддерживаемым расширениям. -EOF -} - -OUT_DIR="" -ARGS=() -while [[ $# -gt 0 ]]; do - case "$1" in - -o|--output) OUT_DIR="$2"; shift 2 ;; - -h|--help) usage; exit 0 ;; - *) ARGS+=("$1"); shift ;; - esac -done - -if [[ ${#ARGS[@]} -eq 0 ]]; then - usage - exit 2 -fi - -if [[ -n "$OUT_DIR" ]]; then - mkdir -p "$OUT_DIR" -fi - -# Собираем find-предикат по расширениям безопасно, через массив (не через eval). -FIND_PREDICATE=() -first=true -for ext in $SUPPORTED_EXT; do - if $first; then - FIND_PREDICATE+=( -iname "*.$ext" ) - first=false - else - FIND_PREDICATE+=( -o -iname "*.$ext" ) - fi -done - -FILES=() -for arg in "${ARGS[@]}"; do - if [[ -d "$arg" ]]; then - while IFS= read -r -d '' f; do - FILES+=("$f") - done < <(find "$arg" -type f \( "${FIND_PREDICATE[@]}" \) -print0) - elif [[ -f "$arg" ]]; then - FILES+=("$arg") - else - echo "doc2md: пропускаю, не найден: $arg" >&2 - fi -done - -if [[ ${#FILES[@]} -eq 0 ]]; then - echo "doc2md: подходящих файлов не найдено" >&2 - exit 1 -fi - -OK=0 -SKIP=0 -FAIL=0 - -for f in "${FILES[@]}"; do - base="$(basename "$f")" - # Полное имя файла + .md (не срезаем расширение): "отчёт.csv" и "отчёт.docx" - # иначе оба лягут в "отчёт.md" и второй перезапишет первый. - if [[ -n "$OUT_DIR" ]]; then - out="$OUT_DIR/$base.md" - else - out="$(dirname "$f")/$base.md" - fi - - err_output=$(npx -y @firecrawl/anydoc "$f" -o "$out" 2>&1 >/dev/null) - code=$? - - # Категоризация исхода — идентична convert.js: - # 0 → OK - # 126/127 → ОШИБКА (не удалось запустить npx/anydoc, а не «файл — скан») - # иначе → ПРОПУСК (скан/шифр/битый файл; печатаем stderr как есть) - if [[ $code -eq 0 ]]; then - echo "OK $f -> $out" - OK=$((OK + 1)) - elif [[ $code -eq 127 || $code -eq 126 ]]; then - echo "ОШИБКА $f — не удалось запустить anydoc: ${err_output:-npx недоступен}" >&2 - FAIL=$((FAIL + 1)) - else - echo "ПРОПУСК $f — $err_output" >&2 - SKIP=$((SKIP + 1)) - fi -done - -echo "" -echo "Готово: $OK конвертировано, $SKIP пропущено (не читается/зашифровано/скан), $FAIL ошибок вызова" -if [[ $SKIP -gt 0 ]]; then - echo "Пропущенные, скорее всего, сканы или PDF-картинки без текстового слоя — прогони их через OCR (скилл pdf, шаг «сделать PDF searchable») и повтори." -fi - -if [[ $FAIL -gt 0 ]]; then - exit 1 -fi -exit 0 +# Совместимый вход для пользователей shell-версии; логика одна для всех ОС. +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +exec node "$SCRIPT_DIR/convert.js" "$@"