SECURITY REVIEW
Not yet assessed
Review the original instructions and requested permissions before installing.
No security review is available for this catalog entry yet.
Create and edit Yulia's travel articles from photo folders or existing drafts.
Статьи-путешествия Юли: из папки фото или правка живой статьи (дневник, впечатления, км/высоты, советы). Триггеры: «отредактируй статью», «путеводитель», «добавь впечатления», «сделай статьи из папки», «добавь фото и распиши».
Review the original instructions and requested permissions before installing.
No security review is available for this catalog entry yet.
How clearly the skill guides your agent, how complete its workflow is, and how you can check the outcome.
No quality assessment is available for this catalog entry yet.
Original instructions from the publisher’s SKILL.md
# metravel-travel-article
Движок и регламент для публикации путешествий на metravel.by.
## Принципы (ОБЯЗАТЕЛЬНО)
0a. **Автор — только Юля (`ignatieva_julia@tut.by`, user id 1).** Все статьи
ведутся от её имени. Перед созданием НОВОЙ статьи (`create-*-guide.js` или
`PUT /travels/upsert/` с `id:null`) взять её токен:
`METRAVEL_TOKEN=$(E2E_EMAIL=$E2E_EMAIL2 E2E_PASSWORD=$E2E_PASSWORD2 node scripts/get-quest-token.js)`
(аккаунт 2 в `.env.e2e` = Julia id 1). Дефолтный токен (`.secrets/metravel-token.json`,
`E2E_EMAIL`) = Сергей id 104 — под ним статьи создавать НЕЛЬЗЯ. Автор бэк ставит
по токену ПРИ СОЗДАНИи и не меняет при upsert/PATCH; вышло под чужим автором —
`DELETE /api/travels/<id>/` + пересоздать токеном Юли (slug переиспользуется).
Правка статей Юли через `seo-edit --desc-file` авторство сохраняет.
0b. **Фото — ТОЛЬКО из собственных статей metravel.by.** URL брать из галерей/
описаний уже опубликованных travel (`/gallery/…`, `/travel-description-image/…`,
`/address-image/…`), каждый проверять HEAD=200. Внешние/стоковые/Wikimedia
картинки в теле статьи запрещены. Нет своего фото под раздел — раздел без фото,
не выдумывать.
1. **Не дублировать.** Перед созданием получи список уже существующих путешествий
за этот год и пропусти совпадения:
`GET https://metravel.by/api/travels/?where={"publish":1,"moderation":1,"year":"<YEAR>"}&perPage=100`
Сопоставь по смыслу (одно реальное путешествие может быть разбито на несколько
статей — например Хорватия). Не создавай повтор.
2. **Цель текста — польза + память.** Статья должна (а) реально помочь тому, кто
собирается посетить место, и (б) сохранить личную память о поездке. Тёплый
живой тон + конкретика.
2a. **Пиши как человек, без шаблонных заголовков.** НЕ используй секции
«Вступление», «Что увидели», «Как прошло», «Итог», «Точки», «Описание» —
это выдаёт генерацию. Начинай сразу с живого абзаца-крючка (без заголовка),
заканчивай абзацем-выводом (без заголовка). Заголовки — только осмысленные и
естественные: по названиям мест («Вавельский замок», «Костёл Святой Троицы»,
«Прогулка по Казимежу») или по теме («Что посмотреть», «Как добраться»,
«Практическая информация», «Маршрут в цифрах»). Для путеводителей по городу
заголовки = названия достопримечательностей. Не дублируй заголовок статьи в теле.
2d. **Жанр — журнальный рассказ на личной истории, не путеводитель** (решение
владельца 03.09.2026, эталон — статья 582 после правки). Читатель идёт за
автором по дню: старт, подъём, обед в приюте, поляна, закат. Справка о месте
(год, история, кто построил) даётся одним-двумя предложениями внутри абзаца
рядом с фото этого места, а не отдельным справочным разделом. Запрещены
разделы «Сложность маршрута», «Что взять с собой», «Что посмотреть по
дороге», «Наши впечатления», строки с координатами под заголовками,
вложенные нумерованные списки и `<ol>` с пустыми `<span>` внутри `<li>`.
Вся практика — один короткий `<ul>` «Если соберётесь» в конце: старт и
координаты парковки, километры и набор высоты одной фразой, когда идти,
что проверить (часы приюта). Тон — первое лицо, конкретные детали, которые
видны на фото (стаканчик кукурузы на столе, афиша на двери). AI-обороты
(«не просто …, а …», «именно», «настоящий», «одна из самых», «отлично
подходит тем, кто», жирный на каждом названии) — дефект, вычищать.
2e. **Вёрстка журнальная и задаётся структурой HTML.** Фронт считает раскладку
сам при показе (`utils/richTextImageLayout.ts` → `applySmartImageLayout`,
вызов из `components/travel/stableContent/htmlTransform.ts`), поэтому
готовые обёртки `img-row-2`/`img-grid` в тело писать не нужно — достаточно
порядка `<p><img>`: одиночный портрет между абзацами → обтекаемый float с
чередованием сторон (сторону фиксирует `class="img-float-right|left"` на
`<p>`, ширина ≈45% колонки на desktop, на mobile одна колонка); одиночный
пейзаж → на всю ширину; два `<p><img>` подряд → ряд; три портрета → триптих;
четыре → сетка; пять и больше подряд → высокая колонка, которую надо разбить
абзацем. Кадры стоят в порядке дня, рядом с абзацем про них; закат — в конце.
После записи раскладку проверять по классам на странице, а не по HTML в API.
2c. **Опирайся на точки-достопримечательности `travelAddress[]`** из
`GET /api/travels/{id}/` (`address`/`categoryName`/`coord`/`travelImageThumbUrl`) —
это объекты, на которых сфокусирована статья (название, тип, координаты, фото).
По каждому значимому добавляй полезную и интересную достоверную инфо о месте
(история, легенды, топонимика, год/стиль постройки, чем известно, практика),
привязывая её к соответствующему объекту/фото в теле.
2b. **При исправлении/дописывании — вставляй ПО КОНТЕКСТУ, а не валом в конец.**
Сначала прочитай тело и сопоставь новый текст с существующими разделами/фото.
Справку об объекте (история, легенды, топонимика) ставь ИНЛАЙН рядом с его
заголовком и фото, а не отдельным дублирующим `<h2>` в хвосте — иначе текст
отрывается от своих фото и появляются два заголовка об одном объекте. В конец
выноси только финальное и не дублирующее: «Маршрут в цифрах», «Практическая
информация», «Что рядом». Для контекстной перестановки правь полное тело через
безопасный редактор `scripts/seo-edit.js --id <ID> --desc-file full.html`
(бэкап + верификация + авто-откат), а не сырым append.
3. **Города — это путеводитель.** Если поездка про город (Краков, Варшава, и т.п.),
пиши полноценный путеводитель: добавляй ВСЁ, что стоит посмотреть, опираясь на
рейтинги (TripAdvisor, Google Maps top sights), а не только на свои фото.
Сгруппируй по районам/темам, добавь практику по ключевым объектам.
4. **Правильные названия.** Определяй место точно (по GPS + содержимому фото +
веб-проверке). Не угадывай наобум (пример ошибки: озеро Сосина ≠ Гродек).
Если не уверен — пометь `[уточнить]` и проверь поиском.
5. **Практическая информация:**
- Замки/музеи/парки/аттракционы → часы работы, цены, офсайт. Бери из веб-поиска,
помечай «актуально на <год>, уточняйте на офсайте». Не выдумывай цифры.
- Тропы/походы → «Маршрут в цифрах» из GPS-трека фото: длина (≈, нижняя оценка
по точкам), перепад высот (мин–макс, надёжно), высшая точка, время в пути.
5b. **У каждой точки — категория (тип места).** Точка без категории недопустима.
Тип задаётся id из справочника `categoryTravelAddress` (Замок=43, Костёл=150,
Озеро=84, Гора=26, Музей=76, Площадь=187, Водопад=20, Город=184 и т.д. —
`facets`). Движок проставляет категорию автоматически по названию точки
(`point_categories`), но проверяй адекватность; для непонятных — Город(184).
5c. **Никогда не упоминай источник данных.** В тексте НЕ должно быть «по GPS»,
«из фото», «по EXIF», «по точкам съёмки», «по кластеру», «по аэрокадрам» —
это выдаёт генерацию. Цифры маршрута подавай просто как факт («около 5 км»,
«перепад ~460 м»). Движок (`strip_meta`) вычищает такие фразы на upsert.
6. **Обложку выбирай ВИЗУАЛЬНО — это обязательный шаг.** Авто-обложка движка — лишь
черновик, её почти всегда нужно заменить. После заливки фото просмотри глазами
(Read) 5–8 кадров-кандидатов из папки (равномерно по съёмке) и выбери самый
«открыточный»: достопримечательность, пейзаж, общий вид места, узнаваемый кадр.
Установи: `python scripts/metravel_publish.py cover <id> "<файл>"`
(здесь и ниже `scripts/` — каталог САМОГО СКИЛЛА,
`.claude/skills/metravel-travel-article/scripts/`, а не `scripts/` репозитория).
На обложку НИКОГДА не ставь: **еду/рестораны/кафе, селфи и крупные планы людей,
интерьеры/квартиры/хостелы/номера, машины, рекламные щиты и вывески, домашних
животных, таблички, случайные бытовые кадры.** Если в папке смешаны «домашние»
кадры (большая поездка с папкой ДОМ) — отбирай фото только по GPS-кластеру места.
Галерея — тоже без мусора; лишние кадры убирай `DELETE /api/gallery/{imageId}/`.
Фото к точкам подбираются по GPS-близости (движок делает это сам).
7. **Что пропускать:** папки «Дом/ДОМ», домашние события (Рождество, Новый год,
шашлыки), ТЦ, и чисто бытовые кадры. Однодневные поездки берём, если это место,
а не дом.
8. **Черновики, не публикация.** Создавай как `publish=false, moderation=false`.
Публикацию/модерацию включает пользователь.
## Авторизация
Скрипт берёт токен из `METRAVEL_TOKEN` (env) или `~/.metravel_token`. Токен — это
сессионный токен залогиненного пользователя metravel.by (DRF Token), используется
только к metravel.by. В код/гит токен не коммитить. Получение: пользователь даёт
токен, либо берётся из браузера (DevTools → Application → Local Storage → userToken).
## Процесс
1. **Обзор папки года** (внешний диск, напр. `/Volumes/.../НАШИ ФОТКИ/<YEAR>`):
перечисли месяцы → подпапки → число фото/видео. Отбрось «Дом»/события.
2. **Дедуп** против сайта (см. принцип 1).
3. **Точки из EXIF**: вытащи GPS из фото и кластеризуй в места скриптом самого скилла —
`python3 .claude/skills/metravel-travel-article/scripts/photo_survey.py <папка>
--out <куда> [--geocode] [--no-sheets]` (`read_exif()` читает GPS IFD `0x8825`,
`cluster()` собирает точки). Подкоманд у него нет — папка позиционным аргументом.
`metravel_publish.py` для этого не годится: его `_exif()` возвращает одну пару координат
и лишь привязывает загружаемое фото к УЖЕ существующей точке. Координаты идут в раздел «Точки для карты».
4. **Напиши markdown-черновик** (формат ниже). Храни вне гита, напр.
`~/metravel-content/<YEAR>/NN-slug.md`.
5. **Создай/обнови статью**:
`python scripts/metravel_publish.py upsert <draft.md> [--id <id>] --year <YEAR>`
(при `--id` точки сохраняют свои id — фото к точкам не теряются).
6. **Залей фото**:
`python scripts/metravel_publish.py photos <id> "<folder>" [...] [--gallery N]`
(обложка + галерея + фото к точкам по GPS). Для нестандартной обложки:
`python scripts/metravel_publish.py cover <id> "<file>"`.
7. **Вставь фото в текст описания** (не только галерея!):
`python scripts/metravel_publish.py descimg <id>` — вставляет в текст ФОТО К ТОЧКАМ
(релевантные месту, о котором речь; где заголовок = название точки, ставится её фото),
НЕ дублируя кадры из галереи. Идемпотентно. Поэтому у точек должны быть фото — сначала
`photos`, потом `descimg`. В описании не должно быть «только текст».
8. **Проверь** GET-ом: точки (с категориями), страны, галерея, обложка, фото в тексте.
## Формат markdown-черновика
```
---
title: "SEO-заголовок (≤200 симв., с ключевыми словами и местом)"
country: Польша # или "Польша, Чехия" — маппинг в id внутри скрипта
region: Регион / город
date: YYYY-MM-DD
categories: [Поход, Хайкинг, Тур выходного дня, Самостоятельное путешествие]
---
# Заголовок
(абзац-крючок без заголовка — см. принципы 2a/2d)
...
## <Место или эпизод дня> # заголовки = места и эпизоды, не «Вступление/Итог»
...
## Если соберётесь # единственный практический блок: короткий список
(абзац-вывод без заголовка)
---
## Фото для загрузки # заметки, не идёт в описание
## Точки для карты # таблица: | Название | lat, lng |
| Вавель | 50.04821, 19.93145 |
```
Всё, что после `---` перед `## Фото`, попадает в описание (HTML). Блоки «Фото» и
«Точки для карты» в описание НЕ идут (точки уходят как координаты в карту).
## Справочники
`python scripts/metravel_publish.py facets` — актуальные id категорий и стран.
Базовые: категории Поход=2, Хайкинг=21, Треккинг=22, Тур выходного дня=19,
Самостоятельное=20, Автопутешествие=6, Сплав=4, Веломаршрут=24, Велопоход=7.
Страны: Польша=160, Словакия=184, Чехия=215.
## API-справка (на случай ручной работы)
- Список: `GET /api/travels/?where={...}&perPage=100` (cookie или Token).
- Деталь: `GET /api/travels/<id>/`.
- Создать/обновить: `PUT /api/travels/upsert/` (Token). `id:null` = создать,
`id:<n>` = обновить. Обязательны многие поля (см. скрипт): transports/month/
complexity/companions/over_nights_stay/thumbs200ForCollectionArr/
travelImageThumbUrlArr/travelImageAddress = []; minus/plus/recommendation/
youtube_link = "__draft_placeholder__". Точки = `coordsMeTravel:[{id,lat,lng,
address,country,categories,image}]`.
- Фото: `POST /api/upload` (multipart) поля `file`,`collection`,`id`.
collection: `travelMainImage` (обложка, id=travel), `gallery` (id=travel),
`description` (в текст, id=travel), `travelImageAddress` (фото точки, id=точки).
- Кураторство галереи: `DELETE /api/gallery/{imageId}/` — удалить плохой кадр из
галереи (id картинки из ответа upload или из travel.gallery[].id);
`POST|PATCH /api/gallery/reorder/` — порядок. Используй, чтобы убрать неудачные
снимки (еда, ТЦ, случайное) из галереи.
- Полная OpenAPI-схема бэкенда (90 endpoints): `http://localhost:8000/api/schema/`
на поднятом локальном стеке (ReDoc: `/api/schema/redoc/`); дев `192.168.50.36`
и прод отдают её же, но подключаются только по явному запросу владельца. Создания категорий точек (`categoryTravelAddress`)
в API НЕТ — это фиксированный справочник (админка Django). Подбирай из
существующих 200+ типов; новый тип добавляется только в админке бэкенда.
---
# Обогащение уже опубликованной статьи
Отдельный регламент для запросов вида «отредактируй статью», «добавь впечатления»,
«путеводитель», «добавь фото и распиши», «посмотри эту статью, добавь нужную
информацию», URL + дневник/сторис/текст видео.
Здесь НЕ работает движок `metravel_publish.py` (он про создание черновиков) —
пишем в живую статью через `scripts/seo-edit.js` и `POST /api/upload`.
Именованная статья (id/URL/slug) плюс просьба вписать дневник, высоты, еду,
жильё или советы — это подтверждение на правку текста. Не переспрашивать.
## Режим: дневник владельца без папки фото
Когда в запросе есть ссылка на статью и заметки (дневник, сторис, текст к видео,
Garmin км/+/- , цены, гостиницы) — папку фото не требовать и шаг 1 (EXIF) не
запускать.
1. `GET /api/travels/{id}/`. `userId` ≠ 1 — стоп.
2. Выгрузить `description`, прочитать целиком, пройти шаг 0.
3. Сопоставить заметки с разделами/днями/фото. Вставлять ПО КОНТЕКСТУ (2b):
новый абзац рядом с днём и кадром, не валом в конец и не отдельным
«Дневник» / «Наши впечатления».
4. Личные факты (еда, крик в булочной, конверт с ключом, потерянная ветровка) —
только из заметок. Историю места — проверяемые факты рядом с её фото.
Не сочинять то, чего нет в заметках, статье или на кадре.
5. Цифры дней (км, набор/спуск, цены) — в абзаце дня и одним набором в
«Если соберётесь», без противоречий между вступлением и хвостом.
6. Существующие `<img>` и `<iframe>` не трогать, если не просили заменить.
7. Запись: `scripts/seo-edit.js --id <ID> --desc-file` (сначала `--dry-run`).
8. FAQ / «Что рядом» / квесты / комментарий редакции — шаги 4 и 7–8, без дублей.
## Шаг 0. Аудит статьи — ВСЕГДА до того, как что-то писать
`GET /api/travels/{id}/`, выгрузи `description` в файл и прочитай ЦЕЛИКОМ.
Проверь по списку — в старых статьях это находится почти всегда:
| Что искать | Как проявляется | Что делать |
|---|---|---|
| `alt="Изображение"` | так сохраняет визард, у всех картинок | заменить на реальные описания |
| Дубль практического блока | два раздела с ценами/часами и РАЗНЫМИ цифрами | оставить один, слить полезное |
| Расхождения чисел между разделами | «72 км» во вступлении и «55 км» в «Как добраться» | свести к одному набору |
| `__draft_placeholder__` | в `plus` / `minus` / `recommendation` / `youtube_link` | заполнить (см. шаг 5) |
| Два блока «Что рядом» | один в теле, второй приклеен снизу | оставить один |
| Автор в третьем лице | «Julia права», «Julia с мужем сделали» в FAQ | переписать от первого лица |
| Пустой `<h2><br /></h2>`, `<span></span>` в `<li>`, `<p><br /></p>` | мусор разметки | вычистить |
| Паттерны AI-мусора | `utm_source=chatgpt.com`, U+FFFD, одноэлементные `<ol>` подряд, `<strong> </strong>` | вычистить |
| Текст описывает то, чего нет на фото | упомянуты янтарь/трапезная/вид с берега, а снимков нет | это и есть главный повод «добавить фото» |
| Галерея без подписей | `gallery[].caption` пустые | проставить (шаг 6) |
| Нет FAQ и блока внутренних ссылок | | добавить (шаг 4) |
| Нет блока квестов | между «Что рядом» и FAQ пусто | добавить по эталону 619 (шаг 4) |
| Внутренняя ссылка отдаёт 301 | слаг переименовали после публикации | заменить на канонический слаг из `redirect_url` |
| Сырые имена точек | `travelAddress[].address` = улица/номер дома/код трассы («Krakowska 149», «964», «S7», «2301») или одно имя у нескольких точек («Rohacske plesa - Tri Kopy» ×5) | переименовать (шаг 4a) |
| Дата статьи ≠ дата поездки | `created_at` = день импорта/публикации, а `year`/`month` — про поездку; в каталоге статья стоит не на своём месте | определить дату поездки и записать её в `created_at` тем же PUT (шаг 5a) |
| Нет связанных статей | на сайте есть свои статьи про тот же массив/город/сезон, а в теле на них ни одной ссылки | найти и поставить в «Что рядом» (шаг 4) |
## Шаг 1. Разведка папки — EXIF (если есть папка фото)
В режиме дневника без папки этот шаг пропускается. Если папка есть — обязателен.
```bash
python3 .claude/skills/metravel-travel-article/scripts/photo_survey.py \
"/Volumes/.../<папка поездки>" --out /tmp/survey --geocode
```
Скрипт вытащит GPS и даты, соберёт кластеры мест, обратно геокодирует их через
OSM и нарежет контактные листы 6x6. Это НЕ опциональный шаг: именно так
восстанавливается фактическая канва поездки (какие места, в каком порядке, в
какие дни) — иначе география в тексте будет выдумана.
Пример: по папке «36 лет СС» EXIF показал, что база — дом на озере в Кшыне
(гмина Дембница-Кашубска), а не на самой косе; что «Словинский заповедник» —
это Чолпино, а не Леба; и что маяки объехали за один день 20 апреля. Ни одного
из этих фактов в исходной статье не было.
Названия мест из геокодера сверяй с содержимым фото (щиты, вывески, таблички —
они часто дают точное название парка/объекта) и, если нужно, веб-поиском.
## Шаг 2. Отбор фото — просмотреть ВСЕ листы глазами
Открой Read'ом каждый лист из `--out/sheets`. Выпиши номера кадров-кандидатов,
затем собери из них ОТДЕЛЬНЫЙ проверочный лист (плитка 4x3, ~470px) и посмотри
ещё раз — на превью 264px легко перепутать похожие объекты и потом подписать
кадр неправильно.
Ориентир по количеству: 5–8 фото на смысловой раздел/день, 35–50 на большую
статью. Приоритет — кадры, которых в статье НЕТ, но о которых есть текст.
### Подпись сверяется с координатами кадра — обязательно
Перед тем как написать в `alt` или подписи галереи «в заповеднике», «у замка»,
«в таком-то городе», возьми GPS ИМЕННО ЭТОГО файла из `survey.json` и посчитай
расстояние до объекта. В одной папке почти всегда лежит несколько мест, и на
глаз они путаются.
```python
import math
def dist(a, b): # метры между (lat, lon)
R = 6371000
p1, p2 = math.radians(a[0]), math.radians(b[0])
dp, dl = p2 - p1, math.radians(b[1] - a[1])
x = math.sin(dp/2)**2 + math.cos(p1)*math.cos(p2)*math.sin(dl/2)**2
return 2 * R * math.asin(math.sqrt(x))
```
Координаты объектов бери из `travelAddress[].coord` самой статьи — тогда
проверка идёт против того, что заявлено в её точках. Порог: до ~400 м у крупного
объекта считается «на месте», дальше — нет.
**Кадр, не попавший уверенно ни к одному объекту, подписывай нейтрально**, по
тому, что видно, без привязки к месту. Второй независимый признак — текст щитов
и вывесок в кадре: он часто прямо называет объект.
Пример из 634: три группы кадров — заповедник (7–240 м), Скансен в 600 м и
дорога; один отобранный кадр оказался в 444 м от заповедника и 328 м от
Скансена, и подпись «в заповеднике» была бы неверной.
### Если в папке только видео
Бывает, что фотоархива у поездки нет вообще — например `/Volumes/T7/GR21` это 900
файлов MP4 и 293 ГБ (дрон, DJI Pocket, ZV-E1) и ни одной фотографии. Тогда кадры
достаются из видео, и это часто выигрыш: аэросъёмка даёт то, чего в пешей статье
физически быть не может.
Порядок:
1. **Превью-проход** — 1–2 кадра на клип, сразу в размер плитки:
`ffmpeg -ss <t> -i clip.MP4 -frames:v 1 -vf "scale=260:195:…,pad=264:198:…" -q:v 3 out.jpg`
Момент брать долей от длительности (`-show_entries format=duration`), `-ss` ДО
`-i` — это быстрый seek. Веди `index.tsv`: номер кадра → файл → таймкод → день → камера.
2. Контактные листы 6×6 из превью, просмотр глазами (как в шаге 2).
3. **Переизвлечение выбранных в полном разрешении** из исходного клипа по
сохранённому таймкоду — без `-vf`, `-q:v 2`. Только после этого ресайз до 1600
и заливка.
Дни поездки сопоставляй с датами папок ПО СОДЕРЖИМОМУ кадров, а не по порядку:
в GR21 «день 5» статьи оказался 28 мая (день, который заканчивается Феканом),
а не пятой папкой подряд.
## Шаг 3. Заливка фото в тело
```bash
# ресайз: бэкенд всё равно ужимает до 1024 px по длинной стороне,
# больше 1600 слать бессмысленно
ffmpeg -y -i in.JPG -vf "scale=1600:1600:force_original_aspect_ratio=decrease" -q:v 4 out.jpg
```
`POST /api/upload` (multipart): `file`, `collection=description`, `id=<travelId>`,
заголовок `Authorization: Token <токен Юли>`. Ответ: `{"url": "...", "id": N}`.
URL приходит по `http://` и БЕЗ сегмента `/<id>/description/` — приводи к
`https://` и клади как есть.
В описание вставляй ПЛОСКИЕ абзацы:
```html
<p><img src="https://metravel.by/travel-description-image/<hash>.jpg.webp"
width="1600" height="1200" alt="осмысленное описание" /></p>
```
**В тело — ТОЛЬКО `travel-description-image/…`.** Ссылки вида
`address-image/<pointId>/conversions/<hash>.webp` (фото точки маршрута) в описании
умирают дважды: при пересоздании точек (`pointId` исчезает) и при перезаливке фото
точки (меняется `<hash>`). Скан 26.07 нашёл 7 таких мёртвых картинок в 4 статьях —
[[project_address_image_rot]]. Если встретил `address-image` в теле, перезалей это
фото в `collection=description` и замени ссылку.
Хранимые `<div class="img-jrow">` из старых сохранений можно смело выбрасывать:
рендер всё равно снимает их (`removeImageLayoutClasses`) и заново собирает ряды
(`applySmartImageLayout` в `components/travel/stableContent/htmlTransform.ts`).
Реальные `width`/`height` нужны — по ним считается раскладка рядов (2–3 в ряд).
Идущие подряд `<p><img>` склеиваются в один ряд; хочешь одиночное фото — ставь
его отдельно между абзацами текста.
**Видео с YouTube** вставляй так, `?v=` обязателен:
```html
<p><iframe src="https://www.youtube.com/embed/<ID>?v=<ID>" title="…"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen="true" frameborder="0" width="560" height="315"></iframe></p>
```
Без `?v=` `replaceYouTubeIframes` не извлечёт id и на web вместо лёгкого
превью-фасада загрузится живой плеер. Между `>` и `</iframe>` не должно быть
ничего — регулярка требует их вплотную.
## Шаг 4. Текст
Правила 2, 2a, 2b, 2c, 4, 5 сверху действуют полностью. Дополнительно:
- **Сохраняй авторские абзацы дословно.** Личные наблюдения и курсивные строки —
это голос Юли, их не переписывать. Новое добавляется ВОКРУГ них.
- **Не выдумывай личный опыт.** Можно описывать то, что видно на фото, и
добавлять проверяемые факты (даты, размеры, история, правила). Нельзя
придумывать, что автор ел, чувствовал или куда ещё заходил.
- **Раскладывай по местам/дням**, а не «всё подряд»: у каждого раздела свои фото
и своя фактура.
- **Связанные статьи на сайте — искать обязательно, а не «если вспомнится».**
Блок «Что рядом» — 3–4 ссылки на СВОИ статьи про тот же массив, город, долину
или тип маршрута. Соседей ищи двумя запросами с фильтром `userIds==1`: по
теме `GET /api/travels/?query=<массив|город|река>&perPage=100` и по сезону
`GET /api/travels/?year=<год>&perPage=100`. Статьи одной поездки или серии
(походы по Бескидам одной осени, два дня в Татрах, два веломаршрута из
Кракова) перелинковывай между собой — каждая ссылается на остальные. Самую
близкую по маршруту статью упоминай ещё и по контексту в теле («той же
дорогой, что и в …»), если для этого есть естественное место. КАЖДЫЙ слаг
перед вставкой проверяй `curl -o /dev/null -w "%{http_code}"
https://metravel.by/travels/<slug>` — битые внутренние ссылки уже были
отдельным багом. Ссылки давай без `?returnTo=`.
- **FAQ** — 5–6 вопросов из реальных запросов («сколько времени», «сколько
стоит», «как добраться», «можно ли с собакой», «когда ехать»). От ПЕРВОГО
лица. Разметка — строго по образцу ниже, иначе `FAQPage` не появится вовсе.
#### FAQ пишется только `<details>/<summary>` — иначе разметка молчит
`extractFaqEntries` (`scripts/generate-seo-pages.js`) читает пары **только** из
`<details>` внутри FAQ-секции: первой же строкой он выходит на
`if (!html.includes('<details')) return []`. FAQ, записанный привычным
`<h2>Частые вопросы</h2><p><strong>Вопрос?</strong>Ответ</p>` или
`<h3>Вопрос?</h3><p>Ответ</p>`, на странице **выглядит точно так же**, ничего не
падает — и `FAQPage` в прод-HTML не попадает совсем. Так вышли без разметки
восемь опубликованных статей (#1765), потому что три исходника
(`abandoned-hub`, `brest-guide`, `weekend-minsk-hub`) были написаны плоско.
Канонический вид, копировать дословно:
```html
<section class="seo-faq" data-faq="metravel-seo" itemscope itemtype="https://schema.org/FAQPage">
<h2>Частые вопросы: …</h2>
<details itemprop="mainEntity" itemscope itemtype="https://schema.org/Question">
<summary itemprop="name"><strong>Вопрос?</strong></summary>
<div itemprop="acceptedAnswer" itemscope itemtype="https://schema.org/Answer"><div itemprop="text">
<p>Ответ.</p>
</div></div>
</details>
</section>
```
Эталоны в репозитории: `scripts/vitebsk-guide-content.html`,
`scripts/grodno-guide-content.html`, `scripts/mogilev-guide-content.html`.
Проверка до публикации — на самом генераторе, а не глазами:
```bash
node -e 'const{buildTravelFaqJsonLd}=require("./scripts/generate-seo-pages");
const h=require("fs").readFileSync(process.argv[1],"utf8");
const j=buildTravelFaqJsonLd(h);console.log(j?j.mainEntity.length+" вопросов":"FAQPage НЕ БУДЕТ")' scripts/<файл>-content.html
```
Ноль вопросов при видимом на странице FAQ-блоке = статью публиковать нельзя.
По уже опубликованному корпусу то же самое ловит
`node scripts/seo-audit.js --user-id 1` строкой
`FAQ block without FAQPage markup:` — она обязана быть нулевой.
### Блок квестов — эталон статьи 619 (Бытом)
Ставится **между «Что рядом» и FAQ**, ровно один блок с этим заголовком на
статью. Автоблок «Квест по городу» его не заменяет: он матчит по названию
города, а не по координатам, и на большинстве статей не поднимается вовсе.
Тематические блоки вроде «Квесты для детей и подростков» (шесть путеводителей
обл. центров, `docs/QUEST_CONTENT_PLAN.md` §3.1) — другой блок, он не конкурирует
с этим и под счётчик «ровно один» не попадает.
Пути берутся ТОЛЬКО из `GET https://metravel.by/api/quests/?perPage=300`:
канонический вид — `/quests/<city_id>/<quest_id>`, где `quest_id` это строковый
слаг из ответа (`utils/questCityAlias.js` → `questRouteKey`). Slug не выдумывать
и не собирать из названия.
**Проверка curl'ом на HTTP 200 здесь НЕ работает** — `/quests/` отдаёт SPA-шелл
на любой путь, и `/quests/1/does-not-exist-xyz` тоже даёт 200. Валидируй иначе:
пара `(city_id, quest_id)` обязана присутствовать в ответе `/api/quests/`.
Оттуда же бери `points` и `duration_min` для текста пункта.
Структура блока — четыре элемента, все обязательны:
```html
<h2>Квесты по городам рядом</h2>
<p>Своего квеста по <место> у нас пока нет, но если формат «идёшь по городу и
ищешь ответы на месте» вам заходит, рядом есть подходящие маршруты — все
бесплатные и проходятся с телефона.</p>
<ul>
<li><a href="/quests/1/krakow-nowahuta">Квест по Нова-Хуте: соцгород Кракова</a>
— самая близкая по теме история: целый район, построенный вокруг
металлургического комбината. 8 точек, около 100 минут.</li>
</ul>
<p>Полный список — на странице <a href="/quests">квестов metravel</a>.</p>
```
1. `<h2>Квесты по городам рядом</h2>` — заголовок один и тот же во всех статьях.
2. Вводный абзац: честно сказать, что своего квеста нет (если так), объяснить
формат одной фразой и что это бесплатно и с телефона.
3. `<ul>` на 3–4 пункта: ссылка с полным названием квеста, тире, и **зачем он
именно этому читателю** — тематическая или географическая связь со статьёй
(расстояние, тот же регион, та же тема), затем число точек и время из
`points` / `duration_min`. Цифры брать из API, не на глаз.
4. Закрывающий абзац со ссылкой на `/quests`.
Порядок пунктов — от самого релевантного к самому дальнему. Если у места есть
собственный квест, он идёт первым, а вводный абзац переписывается под него.
## Шаг 4a. Имена точек маршрута
`travelAddress[].address` показывается на карте, в списке точек и под фото
точки, а движок создания берёт его из обратного геокодера — так в статьях
остаются «Krakowska 149», «964», «S7», «2301», «Aleja Jurajska» ×2 и
«Rohacske plesa - Tri Kopy» ×5. При любой правке статьи имена точек приводятся
в порядок:
- имя = что это за место, по-русски, с оригинальным названием в скобках, когда
оно нужно для поиска на карте: «Парковка у трассы S7 (Pcim)», «Второе
Рогачское озеро (Roháčske plesá)», «Бывший карьер известняка (Zabierzów)»;
- без улиц, номеров домов, кодов дорог и номеров парковок В КАЧЕСТВЕ имени
(«964», «S7», «Krakowska 149» — нельзя); код дороги допустим только как
ориентир внутри описательного имени, как в примере выше («Парковка у трассы
S7 (Pcim)»); без дублей в пределах статьи — у двух точек с одним именем читатель не поймёт, какая где;
- имя совпадает с тем, как место названо в заголовке/абзаце тела, и подходит
к фото точки (`travelImageThumbUrl` открыть Read'ом и убедиться, что это оно);
- категория проверяется тем же проходом (правило 5b).
Запись — тем же upsert, что и текст; ids точек сохраняются, значит фото точек
не теряются: `_process_travel_coordinates`
(`travels/services/upsert_travel_service.py`) обновляет `address` у
существующего id, а `image` без изменений не трогает.
```js
const { buildUpsertPayload } = require('./scripts/seo-edit.js');
const d = await (await fetch(`https://metravel.by/api/travels/${ID}/`)).json();
const payload = buildUpsertPayload(d, {});
const rename = { 16243: 'Парковка у трассы S7 (Pcim)' }; // id точки: новое имя
payload.coordsMeTravel = payload.coordsMeTravel.map(p => rename[p.id] ? { ...p, address: rename[p.id] } : p);
await fetch('https://metravel.by/api/travels/upsert/', { method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Token ${TOKEN}` },
body: JSON.stringify(payload) });
```
Проверка re-GET: тот же набор `travelAddress[].id`, у каждой точки прежний
`travelImageThumbUrl`, новые имена на месте. Точку с `id: null` в payload не
слать — это пересоздание строки без фото.
## Шаг 5. Запись
```bash
METRAVEL_TOKEN=$(cat ~/.metravel_token) \
node scripts/seo-edit.js --id <ID> --desc-file new.html --dry-run # сначала так
METRAVEL_TOKEN=… node scripts/seo-edit.js --id <ID> --desc-file new.html
```
`seo-edit.js` делает бэкап в `scripts/.seo-backups/`, PUT, re-GET и авто-откат
при регрессии. Откат вручную: `node scripts/seo-edit.js --restore <ID>`.
`--meta` работает (#1759): значение уходит тем же upsert и подтверждается
пере-чтением байт-в-байт. Отказ до записи стоял, пока `TravelUpsertSerializer`
поле `meta_description` не объявлял — DRF срезал его на валидации при HTTP 200
на PUT, а скрипт объявлял порчу текста и откатывал вместе с метой всё заново
собранное описание (#1716). #1737 объявил поле и научил `GET /api/travels/<id>/`
его отдавать, поэтому круговая сверка замкнулась.
Лимит поля — 255 символов (`Travel.meta_description`): более длинное значение
бэкенд отвергает целиком, HTTP 400, и тогда НЕ записывается и тело — правку
придётся повторить. `--dry-run` этого не ловит, он ничего не отправляет.
Что мета НЕ делает: сниппет в поиске от неё не меняется. SSG выводит
`description` страницы как `stripHtmlToSnippet(description, 160)`
(`scripts/generate-seo-pages.js`) и хранимое поле не читает вовсе — чинить
сниппет по-прежнему нужно ПЕРВЫМ абзацем тела.
`seo-edit` меняет ТОЛЬКО `description` (и `meta_description`, когда передан
`--meta`). Название/slug он не трогает.
`plus` / `minus` / `recommendation` он переносит как есть — если там
сентинел `__draft_placeholder__`, заполняй отдельным PUT поверх
`buildUpsertPayload` (он экспортируется из того же файла):
```js
const { buildUpsertPayload } = require('./scripts/seo-edit.js');
const d = await (await fetch(`https://metravel.by/api/travels/${ID}/`)).json();
const payload = buildUpsertPayload(d, {});
payload.plus = '<ul><li>…</li></ul>'; // minus, recommendation — так же
await fetch('https://metravel.by/api/travels/upsert/', {
method: 'PUT', headers: {'Content-Type':'application/json', Authorization:`Token ${TOKEN}`},
body: JSON.stringify(payload) });
```
## Шаг 5a. Дата статьи = дата поездки
Каталог и «Новые маршруты» на главной сортируют `newest`/`oldest` по
`Travel.created_at` с тай-брейком по `id` (#1995), и `created_at` пишется тем же
`PUT /api/travels/upsert/`. Дата статьи = дата поездки, а не день загрузки в
базу: иначе импортированная сегодня статья о поездке 2024 года встаёт выше
свежих поездок 2026-го. При каждой правке:
1. Определить дату поездки: день — из EXIF кадров или текста статьи; точного
дня нет — 1-е число месяца поездки из `year`/`month` в
`GET /api/travels/{id}/`, при нескольких месяцах — самого раннего (решение
владельца 23.09.2026, #2046). Пустой `month` восстановить по кадрам/тексту
и проставить тем же upsert (`payload.month`).
2. Если `created_at` уже внутри месяца поездки, а точного дня нет, — не
трогать.
3. Иначе в тот же PUT класть `payload.created_at = '<YYYY-MM-DD>T12:00:00Z'`
(полдень UTC — тот же день в поясах от UTC−12 до UTC+11) и подтверждать re-GET
байт-в-байт, как `--meta`. Будущая дата → 400; сдвинуть дату ПОЗЖЕ
сохранённой может только суперпользователь.
## Шаг 6. Подписи: alt в теле и подписи галереи
Подписи — обязательная часть ЛЮБОЙ правки статьи, даже точечной: тронул статью
— закрой обе группы, а не одну.
1. `alt` у каждого `<img>` в теле — конкретное описание кадра: что видно и где
(«Деревянные настилы Bobrowisko над старицей Попрада»), не «Изображение» и
не название статьи. Проверка: `alt="Изображение"` в описании = 0.
2. `gallery[].caption` у каждого кадра галереи —
`PATCH https://metravel.by/api/gallery/{imageId}/` c `{"caption": "…"}` (≤500).
Подписи показываются на hero-слайдере, поэтому пустая галерея — заметный
минус. Проверка: `gallery[].caption` без пустых.
3. Подписывать только то, что видно на 100% (кадр открыть Read'ом по `url`);
привязку к объекту сверять с координатами (см. «Подпись сверяется с
координатами кадра»); неуверенный кадр — нейтральная подпись «место + что
видно», а не выдуманная привязка.
4. **С точки зрения места, а не людей** (решение владельца 20.09.2026).
Подписывается объект и где он: «Карьер в Забежуве: известняковая стена над
водой», «Парковка у трассы S7 в Pcim», «Тропа к смотровой на Тронке»,
«Руины замка в Ойцове». Никаких «Юля», «муж», «мы вдвоём», «селфи»,
«человек с рюкзаком», «велосипедист», «идём/едем»: люди в кадре — не предмет
подписи, у портрета на фоне места подписывается фон. Проверка перед сдачей —
grep по `Юля|муж|мы |селфи|человек|велосипедист|идём|едем` в alt и
`gallery[].caption` = 0.
## Шаг 7. Комментарий редакции в блоке комментариев
Под каждой статьёй должен висеть комментарий от редакции. Не в теле статьи, а в
блоке комментариев (`components/travel/CommentsSection.tsx`, сущность
`/api/travel-comments/`). Задача — расшевелить обсуждение: человек, который был в
этом месте, должен понять, о чём именно его спрашивают, и захотеть ответить.
Сначала проверь, нет ли уже:
```bash
curl -s "https://metravel.by/api/travel-comments/tree/?travel_id=<ID>" # total_count
```
Есть комментарий редакции — не дублируй; правь существующий
`PATCH /api/travel-comments/<commentId>/` c `{"text": "…"}`.
Публикация (главный тред создаётся сам, если его ещё нет — `POST` с `travel_id`):
```bash
curl -X POST https://metravel.by/api/travel-comments/ \
-H "Content-Type: application/json" \
-H "Authorization: Token $METRAVEL_TOKEN" \
-d '{"travel_id": <ID>, "text": "…"}'
```
**Автор — аккаунт «Редакция metravel» (user id 120), не Юля** (решение владельца
21.09.2026: все комментарии редакции — от редакции). Автор берётся из токена,
поэтому для комментария нужен отдельный токен, не тот, которым правится статья:
```bash
set -a; . ./.env.e2e; set +a
EDITORIAL_TOKEN=$(E2E_EMAIL="$APP_REVIEW_DEMO_EMAIL" E2E_PASSWORD="$APP_REVIEW_DEMO_PASSWORD" \
node scripts/get-quest-token.js | tail -1)
```
Это постоянный reviewer-аккаунт Apple App Review (`docs/WORKFLOW_OPERATIONS.md`
§3.1): не удалять, логин и пароль не выводить ни в чат, ни в борд, ни в коммит.
Ответ на `POST` — `201` + `{id, thread, user: 120, user_name: "Редакция metravel"}`.
Пришёл другой `user` — комментарий ушёл не от того аккаунта: `DELETE` его и
повторить с верным токеном. Токен Юли (`E2E_EMAIL2`) и Сергея (`~/.metravel_token`)
для комментариев не годятся.
Как писать — это отдельный жанр, не пересказ статьи:
- **2–4 коротких абзаца**, живой голос редакции от первого лица («мы шли», «у нас
вышло»), без «я» — подпись под комментарием «Редакция metravel»;
- **конкретные вопросы вместо «делитесь мнением»**. Спрашивай то, что читатель
реально может знать: попал ли внутрь, в каком месяце ездил, что было открыто,
как оно днём и как вечером, каково с детьми или собакой;
- **зацепись за то, что в статье устаревает** — стройка, сезонное расписание,
ремонт музея, цены. Это даёт человеку повод написать именно сейчас;
- **обещание, которое выполняется**: «напишите — дополню статью». Факты из
комментариев потом реально вносятся в текст;
- если в теле статьи есть редакционная выноска, не повторяй её текст: в теле —
рамка к материалу, в комментариях — приглашение к разговору.
## Шаг 8. Проверка перед сдачей
```
GET /api/travels/{id}/ → publish/moderation не изменились, gallery и points на месте
counts: <img> == сколько вставил, все src уникальны, все отдают HTTP 200
alt="Изображение" == 0 ; U+FFFD == 0 ; chatgpt.com == 0 ; <span></span> == 0
одноэлементных <ol> == 0 ; остатков img-jrow == 0 ; «Что рядом» встречается 1 раз
«Квесты по городам рядом» == 1 блок (тематические блоки §3.1 не в счёт)
каждая пара (city_id, quest_id) из ссылок есть в GET /api/quests/ — НЕ curl 200,
/quests/ отдаёт шелл на любой путь и 200 ничего не доказывает
plus/minus/recommendation != __draft_placeholder__
подписи галереи проставлены
alt у каждого <img> осмысленный (не «Изображение», не название статьи), alt и подписи галереи — с точки зрения места, grep Юля|муж|мы |селфи|велосипедист == 0
имена точек: без улиц/номеров/кодов, без дублей; набор id точек и их фото прежние
«Что рядом» ссылается на найденные связанные статьи; статьи одной серии перелинкованы
created_at = дата поездки (шаг 5a; без точного дня — 1-е число месяца), re-GET байт-в-байт; month не пуст
GET /api/travel-comments/tree/?travel_id={id} → комментарий редакции есть, ровно один, user == 120
```
Каждый URL картинки реально дёргать curl'ом — заливка может пройти, а файл не
отдаваться.
## Что нельзя
- Менять slug (ломает индексацию) и трогать чужие статьи — см.
`feedback_guest_articles_untouchable`.
- Ставить в тело внешние/стоковые фото (принцип 0b).
- Публиковать/снимать с публикации — это решение владельца.
- Слать точку с `id: null` при переименовании — это пересоздание строки без
фото; оставлять точкам имена-адреса («Krakowska 149», «S7») и дубли.
- Сдавать правку с `alt="Изображение"`, пустыми `gallery[].caption` или без
найденных связанных статей — подписи и «Что рядом» входят в любую правку.