Вести живой документ в Google Docs, который правится итеративно и его читает заказчик: "поправь ТЗ в доке", "обнови документ у клиента". Разовая сборка - `md-docx`/`md-pdf`.
---
name: gdocs
description: Вести живой документ в Google Docs, который правится итеративно и его читает заказчик: "поправь ТЗ в доке", "обнови документ у клиента". Разовая сборка - `md-docx`/`md-pdf`.
---
# gdocs
Ведение живого документа в Google Docs: `scripts/gdocs.py` с четырьмя подкомандами. Только stdlib, авторизация общая с `gsheets.py`.
## Зачем это поверх прямых вызовов API
Документ, который правится программно и его же читает заказчик, копит класс дефектов, **невидимых для API**: все вызовы возвращают успех, а поломку видит только человек, открывший документ (`rules/silent-failure.md`). Ни одна из граблей ниже не следует из документации Docs API - каждая стоила отдельного разбора на живом документе.
Пробел в каноне закрывается ровно этот: работа с Sheets есть (`scripts/gsheets.py`), сборка документа из markdown есть (`md-docx`, `md-pdf`), а ведения живого документа не было.
## Когда применять
- Документ ведется **итеративно**: правки идут сериями, между ними его читают и правят люди.
- Документ имеет **структуру с номерами** - разделы, подпункты, перекрестные ссылки "см. 10.7".
- Документ **уходит наружу**: заказчику, на согласование, в приложение к договору.
## Когда НЕ применять
- **Разовая генерация документа** из готового текста - это `md-docx` (или `md-pdf`): собрал и отдал, вести нечего.
- **Документ без нумерации и структуры** - заметка, черновик, произвольный текст: `normalize` и `check` тут проверяют то, чего нет, а `blanks` навяжет чужое правило.
- **Таблица данных** - это Sheets (`scripts/gsheets.py`, `rules/google-sheets-mcp.md`), не Docs.
- **Чужой документ, который мы только читаем** - хватит `snapshot`; `normalize` и `blanks` чужую разметку менять не должны.
## Грабли Docs API
**1. Вставленный текст наследует форматирование точки вставки.** `replaceAllText` и `insertText` своего стиля не несут: результат перенимает оформление места, куда попал. Три наблюдавшихся исхода: заменил абзац-заголовок на "заголовок + тело" - **все тело стало заголовком** и уехало в структуру документа; вставил список строк вплотную к заголовку - **каждая строка стала заголовком**; заменил текст внутри бывшего заголовка - абзац остался обычным, но **сохранил кегль 16pt вместо 11**. Снаружи API все три - успешный ответ.
Лечится не аккуратностью, а проходом-нормализатором после каждой правки. Инвариант проверяемый: заголовок - это строка вида `N. ЗАГЛАВНЫМИ` или `N.M. ЗАГЛАВНЫМИ`, все остальное - обычный текст без явного `fontSize`. **Опознается по тексту, а не по разметке** - разметка и есть то, что врет. Ловится в обе стороны: и текст, ставший заголовком, и заголовок, потерявший разметку.
**2. Удаления и вставки в одном `batchUpdate` ломают индексы.** Операции применяются последовательно, и индексы, посчитанные по исходному документу, после первого же удаления смещаются; сортировка по убыванию спасает только внутри однородного набора. Наблюдалось: смешанный батч разорвал заголовок пополам - `5.9. О КОМПАНИИ` превратился в `5.9. О` плюс отдельный абзац `КОМПАНИИ`. **Удаления и вставки - разными запросами, между ними документ перечитывается.** Дороже на один запрос, зато не портит документ.
**3. Оглавление API вставлять не умеет.** Запроса нет. Ручной список разделов текстом - плохая замена: он не обновляется и, вставленный рядом с заголовком, сам становится набором заголовков (грабля 1). Правильно: разметить заголовки стилями и сказать пользователю, что оглавление вставляется штатно (Вставка - Оглавление) и дальше собирается само.
**4. Нативные списки несовместимы с перекрестными ссылками.** В нативном списке Docs номер живет в разметке, а не в тексте: сослаться на него нельзя, а при вставке пункта все ссылки "см. 10.7" начинают врать. **В документе с перекрестными ссылками номера остаются частью текста.** Цена - ручная перенумерация, она же дает контроль. Визуальную вложенность дают отступы абзаца, а не списки.
**5. Удаление пункта оставляет три следа**, и проверять надо все три сплошным проходом: осиротевшую пустую строку, дыру в нумерации (`10.6` -> `10.8`) и перекрестные ссылки в никуда.
**6. Правило пустых строк зависит от типа раздела**, единого нет - и в этом ловушка: приведешь весь документ к одному виду, испортишь половину. Раздел, где подпункты это **заголовки блоков с телом**, - перед каждым ровно одна пустая строка. Раздел, где подпункты это **список коротких пунктов**, - пустых строк между ними нет. Тип определяется автоматически: по тому, есть ли под подпунктом непустое тело.
**7. Документ живет только в облаке.** Его правят несколько человек, а история версий Google неудобна для сравнения. Снимок в markdown рядом с проектом дает обычный diff: видно, что изменилось между правками и что внес заказчик.
## Порядок работы
1. **Правку вносить заменой по точному фрагменту текста.** Тогда чужая правка не будет затерта молча: замена просто не найдет фрагмент, и это видно по `occurrencesChanged: 0`.
**Проверять надо `occurrencesChanged == 1`, а не `> 0`.** Двойка означает, что фрагмент встретился дважды и вторую копию мы затерли не глядя; API при этом отвечает успехом. Отсюда же вторая ловушка - **цепочка замен, наступающая сама на себя**: если `new` одной пары окажется подстрокой `old` другой, вторая замена попадет в уже переписанный текст. Серию замен подавай так, чтобы фрагменты не пересекались, а после серии сверяй число затронутых мест с числом задуманных.
**Удалять пункт - `deleteContentRange` по границам абзаца, с конца документа к началу**, а не заменой на пустую строку: замена оставляет пустой абзац, который потом придется искать через `blanks`, а сортировка от конца нужна по грабле 2 - индексы смещаются.
2. **После каждой содержательной правки** - `normalize` и `blanks`, а не в конце работы: дефект, найденный через десять правок, уже неотличим от исходного текста.
3. **Перед отправкой наружу** - `check` (нумерация и ссылки) и `snapshot` (копия рядом с проектом).
4. Заголовки размечать стилями сразу; оглавление текстом не вставлять.
5. **Пользователь видит в документе не то, что вернул API, - верить пользователю** и смотреть фактическое состояние документа, а не свой предыдущий ответ. Именно так были найдены грабли 1 и 2.
## CLI
```bash
python3 scripts/gdocs.py normalize <doc_id> # предпросмотр
python3 scripts/gdocs.py normalize <doc_id> --send # записать
python3 scripts/gdocs.py blanks <doc_id> --send
python3 scripts/gdocs.py check <doc_id> # только отчет
python3 scripts/gdocs.py snapshot <doc_id> -o тз.md # только чтение
```
`doc_id` - из URL документа (`.../document/d/<doc_id>/edit`).
**Дефолт у `normalize` и `blanks` - dry-run**, как у остальных пишущих скриптов канона: показывает построчно, что изменится. Документ общий, и правка видна заказчику сразу - показывай вывод пользователю и получай "ок" перед `--send`. `check` и `snapshot` не пишут никогда.
**`check` возвращает ненулевой код**, когда что-то нашел: годится как гейт перед отправкой.
## Идемпотентность и границы правки
- **Повторный прогон `normalize` и `blanks` ничего не делает** - оба приводят документ к инварианту, а не накладывают правку. Проверено на живом документе: второй прогон дает "расхождений нет".
- **`normalize` не трогает пустые абзацы**, а `blanks` не трогает стили - иначе две команды дрались бы за один абзац.
- **Смена стиля индексы не двигает**, поэтому `normalize` идет одним батчем. `blanks` двумя с перечитыванием между ними - по грабле 2.
- **Абзацы внутри таблиц видны всем командам**: иначе `normalize` пропустил бы текст в таблицах, а `check` - ссылки в них.
- **Скрипт не переносит и не удаляет содержимое** - только стили и пустые строки. Перенумерация, удаление пунктов и правка текста остаются за агентом и человеком.
## Что скрипт не умеет, и это осознанно
Проверено состязательным ревью в два раунда; перечисленное ниже - остаточные ограничения, а не недосмотр.
- **Намеренный кегль 14/16/20pt неотличим от унаследованного.** Снимается размер, одинаковый во всем абзаце и совпадающий с кеглем заголовка Docs; цитата 12pt, подпись 9pt и цена 18pt переживают нормализацию, а намеренный 16pt - нет. Документ с перенастроенными named styles (унаследованный кегль не из этого набора) `normalize` не почистит.
- **Заголовок со строчной латинской буквой внутри слова не опознается** (`ПРОЦЕСС cДАЧИ` с латинской `c`). Промах безопасный: заголовок просто останется без разметки, а не текст уедет в структуру.
- **Перед первым подпунктом сразу после заголовка раздела пустая строка не ставится** - там уже есть граница раздела. Правило "ровно одна пустая" действует между подпунктами.
- **Абзацы до первого заголовка верхнего уровня `blanks` не трогает**: без раздела непонятно, какое правило применять.
- **`check` не различает контекст.** Ссылка "см. 9.9" внутри примера кода или цитаты будет показана как битая. Это осознанно: различать контекст можно только по стилям, а стили и есть то, что врет. Команда ничего не правит, поэтому ложная тревога стоит одной строки в отчете.
**Но доля имеет значение.** Первая редакция искала цели ссылок только среди размеченных заголовков, и на живом документе, где половина подпунктов разметку потеряла, получилось 16 ложных срабатываний из 16 - гейт красный всегда, то есть проверкой быть перестал (`rules/silent-failure.md`). Теперь цель ищется шире: заголовок **или** абзац, начинающийся с номера. Найденная не в заголовке цель отмечается в отчете отдельно - это не битая ссылка, а работа для `normalize`.
Обратная плата у этого своя, и ее надо знать: номер, живущий только в цитате или в тексте ("2.5. миллиона"), теперь засчитается как существующая цель, и ссылка на несуществующий пункт может проскочить. Меняли шило на мыло сознательно: сплошное ложное срабатывание обесценивает гейт целиком, единичный пропуск - нет.
- **`blanks` пишет двумя запросами и не является транзакцией.** Между ними документ перечитывается, и правка привязана к ревизии; но если чужая правка придет между запросами, удаления окажутся применены, а вставки нет. Скрипт скажет об этом прямо - повторить надо весь прогон, с предпросмотра.
- **`snapshot` выгружает тело первой вкладки.** Сноски, колонтитулы и остальные вкладки не попадают; оглавление, картинки и разрывы отмечаются комментарием, а многовкладочный документ получает пометку "НЕПОЛНЫЙ СНИМОК". Молча не теряется ничего.
## Авторизация
Общая с `gsheets.py`: `~/.config/gsheets/auth.json` (`client_id`, `client_secret`, `refresh_token`, `token_uri`, права 600). Модуль подгружается сбоку - два места, работающих с ключами, разошлись бы на первом же фиксе; нет `gsheets.py` рядом - скрипт скажет, что взять из канона.
Нужна область `documents` у того же OAuth-клиента. Ее нет - Docs API отвечает `403 insufficient`, и скрипт печатает, что делать. Токен - секрет: в синкаемые папки, заметки и артефакты не класть (`rules/secrets-handling.md`).
## Связанные файлы
- `scripts/gdocs.py` в корне проекта - движок (dry-run по умолчанию).
- `scripts/gsheets.py` - сосед по авторизации, он же для таблиц.
- `rules/google-sheets-mcp.md` - почему к Google ходим скриптами, а не только MCP.
- `skills/md-docx`, `skills/md-pdf` - разовая сборка документа из markdown, другой случай.