
Yandex Wordstat
- 167 installs
- 177 repo stars
- Updated May 10, 2026
- artwist-polyakov/polyakov-claude-skills
Fetch Russian-market search volumes and related keywords from Yandex Wordstat to plan SEO pages, ad copy, and localized content at launch.
About
Integrates Yandex Wordstat keyword research into agent workflows for Russian-language SEO: fetching search volumes, related queries, seasonal trends, and competitive terms to inform content strategy and landing-page architecture.
- Yandex search volume lookup
- Russian keyword discovery
- Seasonality and trend signals
- Related-query clustering
Yandex Wordstat by the numbers
- 167 all-time installs (skills.sh)
- Ranked #1,013 of 1,879 Marketing & SEO skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/artwist-polyakov/polyakov-claude-skills --skill yandex-wordstatAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 167 |
|---|---|
| repo stars | ★ 177 |
| Last updated | May 10, 2026 |
| Repository | artwist-polyakov/polyakov-claude-skills ↗ |
What it does
Fetch Russian-market search volumes and related keywords from Yandex Wordstat to plan SEO pages, ad copy, and localized content at launch.
Files
yandex-wordstat
Analyze search demand and keyword statistics using Yandex Wordstat API.
Config
Скилл поддерживает два бэкенда:
- `cloud` (рекомендуется) — Yandex Cloud Search API v2. Нужен
config/config.json+ service account key. Авторизация через IAM token (JWT с SA-ключа). - `legacy` (deprecated) — старый Wordstat OAuth API. Нужен
YANDEX_WORDSTAT_TOKENвconfig/.env. Яндекс больше не подключает новых пользователей, но старые токены работают.
Auto-selection (cloud-first): cloud выигрывает на tie. Чтобы остаться на legacy явно — YANDEX_WORDSTAT_BACKEND=legacy в config/.env.
Полная инструкция по настройке и troubleshooting: config/README.md.
Миграция в облако: когда Яндекс окончательно отключит legacy — удалите YANDEX_WORDSTAT_TOKEN, заполните config/config.json и config/service_account_key.json. Команды и аргументы скриптов не меняются.
⚠️ Cloud `dynamics` operator caveat: при --period weekly|monthly cloud-бэкенд поддерживает только оператор +. Минус-слова, кавычки, группировки и точные формы работают только при --period daily. Скилл делает preflight-проверку и падает с понятной ошибкой до запроса. Подробнее — в README.
Philosophy
1. Skepticism to non-target demand — high numbers don't mean quality traffic 2. Creative semantic expansion — think like a customer 3. Always clarify region — ask user for target region before analysis 4. Show operators in reports — include Wordstat operators for verification 5. VERIFY INTENT via web search — always check what people actually want to buy
CRITICAL: Intent Verification
Before marking ANY query as "target", verify intent via WebSearch!
The Problem
Query "каолиновая вата для дымохода" looks relevant for chimney seller, but:
- People search this to BUY COTTON WOOL, not chimneys
- They already HAVE a chimney and need insulation material
- This is NOT a target query for chimney sales!
Verification Process
For every promising query, ASK YOURSELF: 1. What does the person want to BUY? (not just "what are they interested in") 2. Will they buy OUR product from this search? 3. Or are they looking for something adjacent/complementary?
MANDATORY: Use WebSearch
Always run WebSearch to check:
WebSearch: "каолиновая вата для дымохода" что ищут покупателиLook at search results:
- What products are shown?
- What questions do people ask?
- Is this informational or transactional intent?
Red Flags (likely NOT target)
- Query contains "для [вашего продукта]" — they need ACCESSORY, not your product
- Query about materials/components — they DIY, not buy finished product
- Query has "своими руками", "как сделать" — informational, not buying
- Query about repair/maintenance — they already own it
Examples
| Query | Looks like | Actually | Target? |
|---|---|---|---|
| каолиновая вата для дымохода | chimney buyer | cotton wool buyer | ❌ NO |
| дымоход купить | chimney buyer | chimney buyer | ✅ YES |
| утепление дымохода | chimney buyer | insulation DIYer | ❌ NO |
| дымоход сэндвич цена | chimney buyer | chimney buyer | ✅ YES |
| потерпевший дтп | lawyer client | news reader | ❌ NO |
| юрист после дтп | lawyer client | lawyer client | ✅ YES |
Workflow Update
1. Find queries in Wordstat 2. WebSearch each promising query to verify intent 3. Mark as target ONLY if intent matches the sale 4. Report both target AND rejected queries with reasoning
Workflow
STOP! Before any analysis:
1. ASK user about region and WAIT for answer:
"Для какого региона анализировать спрос?
- Вся Россия (по умолчанию)
- Москва и область
- Конкретный город (какой?)"НЕ ПРОДОЛЖАЙ пока пользователь не ответит!
2. ASK about business goal:
"Что именно вы продаёте/рекламируете?
Это важно для фильтрации нецелевых запросов."After getting answers:
3. Check connection: bash scripts/quota.sh 4. Run analysis using appropriate script 5. Verify intent via WebSearch for each promising query 6. Present results with target/non-target separation
Scripts
quota.sh
Check API connection.
bash scripts/quota.shtop_requests.sh
Get top search phrases. Supports up to 2000 results and CSV export.
bash scripts/top_requests.sh \
--phrase "юрист дтп" \
--regions "213" \
--devices "all"
# Extended: 500 results exported to CSV
bash scripts/top_requests.sh \
--phrase "юрист дтп" \
--limit 500 \
--csv report.csv
# Max results with comma separator
bash scripts/top_requests.sh \
--phrase "юрист дтп" \
--limit 2000 \
--csv full_report.csv \
--sep ","| Param | Required | Default | Values |
|---|---|---|---|
--phrase | yes | - | text with operators |
--regions | no | all | comma-separated IDs |
--devices | no | all | all, desktop, phone, tablet |
--limit | no | API default (50) | 1-2000 (maps to API numPhrases) |
--csv | no | - | path to output CSV file |
--sep | no | ; | CSV separator (; for RU Excel) |
Result types: Top Requests vs Associations
The output contains two sections (both in stdout and CSV):
- top (
topRequests) — queries that contain the words from your phrase, sorted by frequency. These are direct variations of the search query. Example: phrase "юрист дтп" → "юрист по дтп", "консультация юриста по дтп". - assoc (
associations) — queries similar by meaning but not necessarily containing the same words, sorted by similarity. These are semantically related searches. Example: phrase "юрист дтп" → "юридическая ответственность", "адвокат аварии".
For analysis: top results are your primary keyword pool. assoc results are useful for semantic expansion but often contain noise — always verify intent before including them.
CSV export details
CSV format: UTF-8 with BOM, columns: n;phrase;impressions;type. When --csv is set, stdout shows first 20 rows per section; full data goes to file.
Working with large CSV exports
When --limit is set to a high value (e.g. 500-2000), use CSV export and read the file in chunks:
# Export 2000 results
bash scripts/top_requests.sh --phrase "query" --limit 2000 --csv data.csv
# Read first 50 rows (header + data)
head -n 51 data.csv
# Read rows 51-100
tail -n +52 data.csv | head -50
# Count total rows
wc -l < data.csv
# Filter only associations
grep ";assoc$" data.csvThis approach lets the agent process large datasets without flooding stdout.
dynamics.sh
Get search volume trends over time.
bash scripts/dynamics.sh \
--phrase "юрист дтп" \
--period "monthly" \
--from-date "2025-01-01"| Param | Required | Default | Values |
|---|---|---|---|
--phrase | yes | - | text |
--period | no | monthly | daily, weekly, monthly |
--from-date | yes | - | YYYY-MM-DD |
--to-date | no | today | YYYY-MM-DD |
--regions | no | all | region IDs |
--devices | no | all | all, desktop, phone, tablet |
regions_stats.sh
Get regional distribution.
bash scripts/regions_stats.sh \
--phrase "юрист дтп" \
--region-type "cities"| Param | Required | Default | Values |
|---|---|---|---|
--phrase | yes | - | text |
--region-type | no | all | cities, regions, all |
--devices | no | all | all, desktop, phone, tablet |
regions_tree.sh
Show common region IDs.
bash scripts/regions_tree.shsearch_region.sh
Find region ID by name.
bash scripts/search_region.sh --name "Москва"Wordstat Operators
Quotes "query"
Shows demand ONLY for this exact phrase (no additional words).
"юрист дтп" → "юрист дтп", "юристы дтп"
but NOT "юрист по дтп"Exclamation !word
Fixes exact word form.
!юрист → "юрист по дтп", "юрист москва"
but NOT "юристы", "юриста"Combination "!word !word"
Exact phrase + exact forms.
"!юрист !по !дтп" → only "юрист по дтп"Minus -word
Exclude queries with this word.
юрист дтп -бесплатно -консультацияGrouping (a|b|c)
Multiple variants in one query.
(юрист|адвокат) дтп → combined demandStop words
Always fix prepositions with `!`:
юрист !по дтп ← correct
юрист по дтп ← "по" ignored!Analysis Strategy
1. Broad query: юрист дтп — see total volume 2. Narrow with quotes: "юрист дтп" — exact phrase only 3. Fix forms: "!юрист !по !дтп" — exact match 4. Clean with minus: юрист дтп -бесплатно -онлайн 5. Expand: synonyms, related terms, client problems
Popular Region IDs
| Region | ID |
|---|---|
| Россия | 225 |
| Москва | 213 |
| Москва и область | 1 |
| Санкт-Петербург | 2 |
| Екатеринбург | 54 |
| Новосибирск | 65 |
| Казань | 43 |
Run bash scripts/regions_tree.sh for full list.
Limits
- 10 requests/second
- 1000 requests/day
Example Session
User: Найди запросы для рекламы дымоходов
Claude: Для какого региона анализировать спрос?
И уточните — вы продаёте готовые дымоходы или
материалы для их изготовления/утепления?
User: Москва, продаём готовые сэндвич-дымоходы
Claude: [Запускает анализ для региона 213]
Нашёл запросы. Проверяю интент через веб-поиск...
✅ ЦЕЛЕВЫЕ (покупают дымоходы):
- "дымоход сэндвич купить" — 450 показов
- "дымоход для бани цена" — 380 показов
❌ НЕ ЦЕЛЕВЫЕ (покупают другое):
- "каолиновая вата для дымохода" — ищут утеплитель, не дымоход
- "монтаж дымохода своими руками" — DIY, не покупатели
- "чистка дымохода" — уже владеют, сервисный запросKey Points
1. ВСЕГДА спрашивай регион и жди ответа 2. ВСЕГДА уточняй что именно продаёт клиент 3. ВСЕГДА проверяй интент через WebSearch 4. Разделяй отчёт на целевые/нецелевые с объяснением
Расширенные сценарии
Поиск упущенного спроса
Анализ рекламной кампании Яндекс Директ для нахождения фраз, не покрытых текущей семантикой. Требования: XLSX-выгрузка из Яндекс Директ (лист «Тексты»). Подробнее: MISSED_DEMAND.md
# Environment with secrets
config/.env
# Cloud mode credentials
config/config.json
config/service_account_key.json
# Cache files (can be regenerated)
cache/regions.json
cache/iam_token.json
# macOS
.DS_Store
# =====================================================================
# Yandex Wordstat skill — credentials
# =====================================================================
# The skill supports two backends. See config/README.md for full setup.
#
# Backend auto-selection (priority):
# 1. YANDEX_WORDSTAT_BACKEND=legacy|cloud (explicit override)
# 2. config/config.json + service_account_key.json present → cloud
# 3. YANDEX_WORDSTAT_TOKEN set → legacy
# 4. Nothing → error
# Cloud wins on tie. Pin legacy explicitly with the override below.
# --- Legacy mode (DEPRECATED — Yandex no longer onboards new users) ---
# OAuth token from https://oauth.yandex.ru/authorize?response_type=token&client_id=...
# Get it via: bash scripts/get_token.sh --client-id YOUR_CLIENT_ID
# Tokens expire after 1 year.
YANDEX_WORDSTAT_TOKEN=your_token_here
# Optional: pin backend explicitly even when both credential sets are present.
# Values: legacy | cloud
# YANDEX_WORDSTAT_BACKEND=legacy
# --- Cloud mode ---
# For cloud setup, copy config/config.example.json → config/config.json
# and place your service account key file at config/service_account_key.json
# (or any path you set in auth.service_account_key_file).
# See config/README.md → "Cloud mode" for the full 6-step flow.
{
"yandex_cloud_folder_id": "b1g...",
"auth": {
"service_account_key_file": "config/service_account_key.json",
"openssl_bin": "openssl"
}
}
Настройка скилла Yandex Wordstat
Скилл поддерживает два бэкенда:
| Бэкенд | Endpoint | Авторизация | Статус |
|---|---|---|---|
cloud | searchapi.api.cloud.yandex.net/v2/wordstat/* | IAM token (Service Account JWT) | Preview, актуальный |
legacy | api.wordstat.yandex.net/v1/* | OAuth Bearer | Deprecated, новых пользователей не подключают |
С 2026 года Яндекс перестал выдавать новые токены для legacy. Старые токены всё ещё работают (бесплатно), но новые установки скилла должны использовать cloud.
---
Cloud mode (рекомендуется для новых установок)
Нужен сервисный аккаунт в Яндекс.Облаке.
Шаг 1: Создайте каталог в Яндекс.Облаке
1. Откройте https://console.yandex.cloud/ 2. Зарегистрируйтесь (нужен Яндекс ID), если ещё нет аккаунта 3. Создайте каталог или используйте существующий 4. Скопируйте ID каталога (b1g...) — он понадобится дальше
Шаг 2: Создайте сервисный аккаунт
1. В консоли откройте ваш каталог 2. Слева выберите Сервисные аккаунты (раздел IAM) 3. Нажмите Создать сервисный аккаунт 4. Имя: wordstat-sa (или любое) 5. Нажмите Создать
Шаг 3: Назначьте роль
1. Откройте созданный сервисный аккаунт 2. Назначьте роль search-api.webSearch.user (та же роль используется для Yandex Search API — см. yandex-search-api скилл)
Шаг 4: Создайте ключ авторизации
1. В сервисном аккаунте → Авторизованные ключи → Создать 2. Скачайте JSON-файл 3. Переименуйте в service_account_key.json 4. Положите в config/ (рядом с этим README)
Файл секретный — он уже в .gitignore.Шаг 5: Создайте config.json
cp config/config.example.json config/config.jsonОткройте config.json и подставьте ваш yandex_cloud_folder_id:
{
"yandex_cloud_folder_id": "b1g_ваш_id_каталога",
"auth": {
"service_account_key_file": "config/service_account_key.json",
"openssl_bin": "openssl"
}
}auth.service_account_key_file — путь к ключу. Относительные пути резолвятся от корня скилла, не от config/. Можно указать абсолютный путь.
Шаг 6: Проверьте
sh scripts/quota.shДолжно вывести Backend: cloud (auto: config.json present) и список endpoints.
Если используете уже настроенный yandex-search-api
Если у вас уже есть service_account_key.json для скилла yandex-search-api, схема config.json идентична — можно скопировать оба файла:
cp ../yandex-search-api/skills/yandex-search-api/config/service_account_key.json config/
cp ../yandex-search-api/skills/yandex-search-api/config/config.json config/(Скиллы независимы — никакой filesystem-зависимости между ними нет, просто схема одинаковая.)
---
Legacy mode (deprecated, free)
Работает только для тех, кто получил OAuth-токен ДО депрекейта. Новые пользователи перейти не могут.
Шаг 1: Получите OAuth токен
Если у вас уже есть зарегистрированный OAuth client_id:
bash scripts/get_token.sh --client-id ВАШ_CLIENT_IDСкрипт выведет URL для авторизации в браузере, попросит вставить токен из URL после редиректа, и сохранит его в config/.env.
Альтернативно — вручную:
cp config/.env.example config/.env
# отредактируйте .env, вставьте YANDEX_WORDSTAT_TOKENШаг 2: Проверьте
sh scripts/quota.shДолжно вывести Backend: legacy (...) и список v1 endpoints + лимиты (1000/день, 10/сек).
Срок жизни токена
Токен действует 1 год. После истечения нужно получить новый.
---
Как скилл выбирает бэкенд
Логика в load_config (scripts/common.sh):
1. Явный override — YANDEX_WORDSTAT_BACKEND=legacy|cloud в config/.env или env shell. 2. Cloud structurally configured → cloud. Это значит:
config/config.jsonсуществует и валидно парситсяyandex_cloud_folder_idнепустойauth.service_account_key_fileрезолвится в существующий читаемый файл
3. `YANDEX_WORDSTAT_TOKEN` задан → legacy 4. Ничего не задано → ошибка с ссылкой на этот README
Cloud wins on tie: если заданы и legacy, и cloud — используется cloud. Чтобы остаться на legacy явно, добавьте в config/.env:
YANDEX_WORDSTAT_BACKEND=legacySelector делает только структурную проверку — никаких сетевых запросов, никакой проверки IAM. Если SA-ключ битый или роль не назначена, ошибка появится при первом реальном API-запросе.
---
Dynamics: ограничение оператора в cloud режиме
В cloud-бэкенде метод dynamics (scripts/dynamics.sh) поддерживает все операторы поиска Wordstat только при детализации `daily`. При weekly и monthly доступен только оператор `+`.
Это ограничение на стороне Yandex Cloud Search API — задокументировано в официальной документации.
Скилл делает preflight-проверку: если вы запустите dynamics.sh --period weekly --phrase "юрист -бесплатно", скрипт упадёт с понятной ошибкой ДО запроса, чтобы вы не тратили запрос впустую.
| Phrase | period=daily | period=weekly/monthly |
|---|---|---|
юрист дтп | ✓ | ✓ |
юрист +по дтп | ✓ | ✓ (+ разрешён) |
юрист -бесплатно | ✓ | ✗ (минус-слово) |
"юрист дтп" | ✓ | ✗ (кавычки) |
| `(юрист\ | адвокат) дтп` | ✓ |
!юрист | ✓ | ✗ (точная форма) |
санкт-петербург | ✓ | ✓ (внутрисловный дефис) |
б/у дымоход | ✓ | ✓ (слэш) |
Legacy-бэкенд исторически принимает все операторы; preflight в legacy режиме не срабатывает.
---
Troubleshooting
Wordstat API: Error / wordstat 401
- Cloud: SA-ключ битый или истёк → пересоздайте ключ; либо роль
search-api.webSearch.userне назначена → назначьте. - Legacy: токен истёк (срок жизни 1 год) → получите новый через
get_token.sh.
Wordstat 403 Forbidden
- Роль
search-api.webSearch.userне назначена сервисному аккаунту, либо вы пытаетесь обратиться не к тому каталогу. - Проверьте
yandex_cloud_folder_idвconfig.json.
LibreSSL detected (macOS)
macOS по умолчанию использует LibreSSL, который не поддерживает PS256 для подписи JWT.
brew install openssl@3И в config.json:
{
"auth": {
"openssl_bin": "/opt/homebrew/bin/openssl"
}
}Узнать точный путь: brew --prefix openssl.
config/config.json present but invalid
yandex_cloud_folder_idпустой → заполните- Файл ключа не найден по пути из
auth.service_account_key_file→ проверьте, что файл есть и читается. Помните: относительные пути резолвятся от корня скилла, а не отconfig/.
Хочу переключиться на другой бэкенд
В config/.env добавьте:
YANDEX_WORDSTAT_BACKEND=cloud # или legacyХочу удалить cloud конфиг и вернуться на legacy
rm config/config.json
# legacy подхватится автоматически (если YANDEX_WORDSTAT_TOKEN задан)---
Дополнительно
- Wordstat Cloud API docs: https://aistudio.yandex.ru/docs/ru/search-api/concepts/wordstat.html
- Pricing: https://yandex.cloud/ru/docs/search-api/pricing
- Operators: https://yandex.ru/support/direct/keywords/symbols-and-operators.html
- Альтернативная настройка SA через CLI:
yc iam service-account create --name wordstat-sa
yc resource-manager folder add-access-binding <FOLDER_ID> \
--role search-api.webSearch.user \
--subject serviceAccount:<SA_ID>
yc iam key create --service-account-name wordstat-sa \
--output config/service_account_key.jsonПоиск упущенного спроса
Анализ существующей рекламной кампании Яндекс Директ для нахождения ключевых фраз, которые не покрыты текущей семантикой.
Требования
- XLSX-выгрузка из Яндекс Директ (лист должен называться «Тексты» — стандартный формат экспорта)
- Формат файла:
.xlsx(не.xls) - Настроенный
YANDEX_WORDSTAT_TOKENвconfig/.env
Ссылки на редактирование групп
В начале работы спроси у пользователя логин Яндекс Директ (ulogin). Это нужно для формирования прямых ссылок на редактирование групп.
ID кампании = имя файла без расширения (например, 54939351.xlsx → 54939351).
Формат ссылки:
https://direct.yandex.ru/dna/groups-edit?ulogin={login}&campaigns-ids={campaign_id}&groups-ids={group_id}В отчёте по каждой группе добавляй ссылку, чтобы пользователь мог сразу перейти и добавить найденные фразы.
Режим работы: последовательный vs параллельный
Анализ всех групп кампании занимает время (1 API-запрос ~ 1-3 сек, 2 запроса на группу).
Если Tasks (субагенты) доступны (например, Claude Code с Task tool):
- Можно запустить анализ всех групп параллельно, по 10-15 групп на субагента
- Сначала покажи пользователю все группы, дай выбрать scope
- Запускай 3-5 субагентов параллельно, каждый обрабатывает свой батч
- В конце — объединённый отчёт
Если Tasks НЕ доступны (Claude Web, API без субагентов):
- Работай последовательно, по 1-2 группы за раз
- После каждой группы обсуждай результаты с пользователем
- Спрашивай: «Продолжить со следующей группой или остановимся?»
- Это экономит контекст и даёт пользователю контроль
Типы запросов и стратегии расширения
Перед расширением определи тип группы. Разные типы запросов расширяются по-разному.
Транзакционные запросы
Группы с коммерческим интентом (купить, заказать, цена). Расширение:
- Действия: купить -> заказать, цена, стоимость, где купить, сколько стоит
- Объекты: синонимы, разговорные формы (проигрыватель -> вертушка, музыкальный центр -> муз центр)
- Модификаторы: стилевые (ретро -> винтажный, в стиле ретро, под ретро, старинный)
- Дополнительные: география (в москве -> в интернет магазине, недорого, с доставкой)
Брендированные запросы
Группы по названию бренда (Crosley, Victrola, Muse). Расширение ограничено:
- Транслит: crosley -> кросли, victrola -> виктрола
- Пробелы/дефисы: playbox -> play box, roadstar -> road star
- Расшифровка аббревиатур: если бренд — аббревиатура
- НЕ расширяй брендовую компоненту на другие бренды или generic-слова
- Добавлять действия/модификаторы можно, но бренд — only transliteration
Навигационные запросы
Группы с географическим интентом (адрес, как проехать, где находится). Расширение:
- Метро/улицы: автосалон белорусская, магазин беломорская
- Районы: автосалон юзао, магазин центр москвы
- Проверь флаг: навигационные запросы релевантны только для offline-точек. Для чисто онлайн-магазинов — не ищи адреса
- Спроси пользователя: «У вас есть офлайн-точка/шоурум? Стоит ли искать навигационные запросы (метро, улицы)?»
Информационные запросы
Группы с информационным интентом (как выбрать, какой лучше, обзор, отзывы). Расширение:
- Перефразировка задачи: как выбрать проигрыватель -> какой проигрыватель лучше, рейтинг проигрывателей
- Смежные задачи: обзор проигрывателей -> сравнение проигрывателей, топ проигрывателей
- НЕ добавляй транзакционные слова к информационным группам (и наоборот)
Workflow
Шаг 1: Проверка подключения
bash scripts/quota.shШаг 2: Парсинг XLSX
uv run --script scripts/missed_demand.py parse-xlsx /path/to/export.xlsxВыход — JSON со всеми группами. Покажи пользователю таблицу:
| ID группы | Название | Фраз |
|---|---|---|
| 4302852986 | Ретро-телефоны | 52 |
| ... | ... | ... |
Спроси пользователя, какую группу (или все) анализировать.
Шаг 3: Получение фраз группы
uv run --script scripts/missed_demand.py parse-xlsx /path/to/export.xlsx --group <group_id>Шаг 4: LLM-сегментация
Разбей фразы группы на слоты. Используй plain слова без кавычек и операторов. Операторы (+ для стоп-слов) добавит build-query автоматически.
Не используй знаки препинания в слотах. Пиши муз центр, а не муз. центр. Скрипт автоматически удаляет точки, запятые и прочую пунктуацию, но лучше не давать её изначально.
Слоты:
- objects — объект рекламирования (телефон, проигрыватель, приёмник). Это то, ЧТО продают. Не используй абстрактные слова вроде «аппарат» — проверяй, ищут ли так реально.
- actions — действия (купить, заказать, продажа, ремонт)
- modifiers — свойства/определения (ретро, винтажный, старинный, дисковый, телефонный, настенный). Сюда идут прилагательные и характеристики объекта.
- additional — дополнительные (в москве, недорого, срочно)
Пример для группы «Ретро-телефоны»:
{
"objects": ["телефон"],
"actions": ["купить", "заказать"],
"modifiers": ["ретро", "винтажный", "старинный", "телефонный"],
"additional": ["в москве"]
}Правила:
objectsобязателен (если не ясен — фолбэк на частотное ядро фраз)- Остальные слоты опциональны
- Дефис внутри слова допустим (санкт-петербург, б/у)
- НЕ ставь
+,-,!,",|,(,)— build-query сделает сам - Синонимы объекта должны быть самостоятельными поисковыми словами. «Аппарат» без контекста — не ищут. «Телефонный аппарат» — ищут, но «телефонный» лучше в modifiers.
- Определи тип группы (транзакционная/брендированная/навигационная/информационная) и применяй соответствующую стратегию расширения
- Убирай избыточные варианты в OR-группе. Если есть однословный вариант «ретро», то многословные «под ретро», «в стиле ретро» избыточны — они буквально содержат слово «ретро» и уже покрыты им. Общее правило: многословный вариант, содержащий слово из однословного варианта в той же OR-группе, всегда является его подмножеством и не добавляет спроса. Но семантические синонимы с другими словами — всегда добавляют: «состаренный», «под старину», «старинный» — это другие леммы, не покрытые словом «ретро», и их надо включать
Шаг 4.1: Батчинг для больших групп (20+ фраз)
Если в группе больше 15-20 фраз, сегментируй батчами, чтобы не потерять редкие модификаторы и дополнительные конструкции (например, «до 5000», «из кожи»):
1. Раздели фразы на батчи по 10-15 штук 2. Для каждого батча выполни сегментацию (Шаг 4) и получи JSON слотов 3. Объедини слоты из всех батчей:
- Добавь все уникальные варианты (дедупликация по нормализованной форме)
- Убери подмножества (как в правилах Шага 4)
- Проверь покрытие: все ли исходные фразы представлены хотя бы одним токеном в слотах?
Для автоматизации объединения используй merge-slots:
echo '<input_json>' | uv run --script scripts/missed_demand.py merge-slotsФормат входа (stdin JSON):
{
"phrases": ["купить телефон ретро", "заказать трубку винтажную", "..."],
"batches": [
{
"phrase_indexes": [0, 1],
"slots": {"objects": ["телефон"], "actions": ["купить", "заказать"], "modifiers": ["ретро"], "additional": []}
},
{
"phrase_indexes": [2],
"slots": {"objects": ["телефон", "трубка"], "actions": [], "modifiers": ["винтажный"], "additional": []}
}
]
}Выход включает:
slots_pre_trim/slots_post_trim— слоты до и после обрезки по лимитамquery— собранный OR-запросcoverage— отчёт:uncovered_phrases,uncovered_tokens,additional_patternsdebug— какие батчи породили каждый вариант
Если в coverage.uncovered_phrases есть фразы — сегментация возможно их потеряла (coverage эвристический, без лемматизации возможны ложные срабатывания). Проверь и добавь пропущенные токены в слоты при необходимости.
Шаг 4.2: Валидация сегментации с пользователем
ОБЯЗАТЕЛЬНО покажи результат сегментации пользователю перед запросом Wordstat:
Группа «Ретро-телефоны» (транзакционная):
objects: телефон
actions: купить, заказать
modifiers: ретро, винтажный, старинный, телефонный
additional: в москве
Всё верно? Что добавить/убрать?Это экономит API-квоту и предотвращает ошибки сегментации (например, «аппарат» вместо «телефонный»).
Шаг 5: Сборка текущего OR-запроса
uv run --script scripts/missed_demand.py build-query '<slots_json>'Шаг 6: Запрос текущего спроса (X)
Сформируй full_phrase: 1. Возьми query из build-query 2. Добавь group_minus из parse-xlsx 3. Best-effort для campaign_minus: если len(query + group_minus + campaign_minus) <= 4096 — добавь campaign_minus; иначе — пропусти с дисклеймером
bash scripts/query_total.sh --phrase "<full_phrase>" --regions "<region_id>"Если API вернёт ошибку с campaign_minus — повтори без него, добавь дисклеймер в отчёт.
Шаг 7: LLM-расширение
ВАЖНО: Сохраняй структуру запроса. Если в оригинале нет actions (действий) — не добавляй их как обязательный слот. Добавление обязательного (купить|заказать) к запросу без actions сужает запрос, а не расширяет.
Правило: расширяй только те слоты, которые уже есть в оригинальном запросе. Новые слоты (например, actions для запроса без действий) предлагай как отдельные дополнительные запросы, не смешивая с основной OR-схемой.
Пример:
- Оригинал:
(муз центр|муз центры) ретро— нет actions, нет additional - Расширение основного:
(муз центр|музыкальный центр|музцентр|аудиоцентр) (ретро|винтажный|+в стиле ретро)— расширяем objects + modifiers - Дополнительный запрос (опционально):
(купить|заказать) (муз центр|музыкальный центр) ретро— отдельно оценить транзакционный спрос
Предложи новые варианты для каждого слота в зависимости от типа группы (см. раздел «Типы запросов»):
- Синонимы (телефон -> трубка, телефонный аппарат)
- Написание по-разному (volkswagen -> фольксваген, фольцваген)
- Опечатки (тигуан -> тигуанн)
- Жаргон и разговорные формы (проигрыватель -> вертушка)
- Сокращения без пунктуации (муз центр, а не муз. центр)
- Новые действия (купить -> заказать, цена, стоимость) — только если actions уже есть
- Новые модификаторы (ретро -> антикварный, раритетный)
Post-фильтр: проверь предложенные термины на совпадение с campaign_minus. Совпадающие — исключи (они заминусованы не просто так).
Шаг 8: Запрос расширенного спроса (Y)
Повтори шаги 5-6 с расширенными слотами -> получи Y.
Шаг 8.1: Проверка мусора при большой дельте
Если дельта > 200% И Y > 100, запроси развёрнутый ответ Wordstat (topRequests) по расширенному OR-запросу. Посмотри фразы в выдаче:
- Есть ли нерелевантный мусор? (например, добавили «вертушка» и получили «вертушка рыболовная»)
- Если мусор найден — предложи изолировать с помощью
!(принудительная словоформа) или дополнительных минус-слов - Покажи пользователю топ-10 фраз из выдачи с комментарием
Пример:
Дельта большая (+478%). Проверяю, что стоит за расширенным запросом...
Топ-фразы в Wordstat:
1. вертушка для пластинок купить — OK
2. вертушка рыболовная — МУСОР (нужен минус: -рыболовная)
3. граммофон ретро купить — OK
...
Рекомендация: добавить минус-слова: -рыболовная -рыбалкаШаг 9: Отчёт
Для каждой группы выведи:
Группа «Ретро-телефоны» (транзакционная):
Редактировать: https://direct.yandex.ru/dna/groups-edit?ulogin={login}&campaigns-ids={campaign_id}&groups-ids={group_id}
OR-схема ДО:
(купить|цена) (телефон|телефоны|трубка) (ретро|старинный|винтаж) +в москве
OR-схема ПОСЛЕ:
(купить|цена|заказать|стоимость) (телефон|телефоны|трубка|телефонный аппарат) (ретро|старинный|винтаж|винтажный|антикварный) +в москве
Новые термины:
actions: +заказать, +стоимость
objects: +телефонный аппарат
modifiers: +винтажный, +антикварный
Спрос: 651 -> 679 (+4.3%)
[!] Без campaign_minus — реальный спрос может быть ниже
[!] Дельта < 10% — расширение минимальное, можно пропуститьГде {login} — логин Яндекс Директ (спросить в начале сессии), {campaign_id} — имя файла без .xlsx, {group_id} — ID группы из parse-xlsx.
Спроси пользователя:
- Принять расширение?
- Доработать (убрать/добавить термины)?
- Перейти к следующей группе?
Шаг 10: Следующая группа
Повтори с шага 3 для другой группы. В последовательном режиме — спроси перед каждой группой. В параллельном режиме — обработай все группы и покажи сводный отчёт.
Стоп-слова
Предлоги на, в, к, за, с, по, из, от, до, для, без, при, под, над, между, через, об, перед являются стоп-словами в Wordstat — они игнорируются без оператора +.
Скрипт build-query автоматически добавляет + к стоп-словам в слотах. Ставить + вручную не нужно.
Ограничения
- Лист «Тексты»: скрипт работает только с XLSX-экспортом, где лист называется «Тексты». При другом названии — ошибка с перечислением доступных листов.
- Campaign_minus: кампанейные минус-фразы часто превышают 4096 символов и не помещаются в один Wordstat-запрос. В этом случае метрика X/Y учитывает только group_minus.
- OR-операторы: если Wordstat API неожиданно не поддерживает
(a|b)синтаксис — fallback: запросить каждый вариант отдельно и суммировать (upper bound — пересечения дадут оверкаунт). - Квота API: 10 запросов/сек, 1000 запросов/день. При анализе множества групп следить за расходом.
- Пунктуация: скрипт автоматически удаляет точки, запятые и прочие знаки из слотов (
муз. центр->муз центр). Это безопасно — Wordstat всё равно игнорирует пунктуацию.
#!/bin/sh
# Common functions for Yandex Wordstat skill — dual backend (legacy + cloud)
#
# Public API (sourced by other scripts):
# load_config — picks backend, exports WORDSTAT_BACKEND, _DETECTED_VIA, _CLOUD_*
# wordstat_request M P — request to Wordstat API, always returns LEGACY-shaped JSON
# print_backend_info — backend-aware diagnostic block (used by quota.sh)
# die_with_help MSG — structured error pointing user at config README
# json_escape, format_number, json_value, json_string — legacy helpers (unchanged)
#
# Backend dispatch:
# - WORDSTAT_BACKEND=legacy → POST api.wordstat.yandex.net/v1/{method} (Bearer OAuth)
# - WORDSTAT_BACKEND=cloud → POST searchapi.api.cloud.yandex.net/v2/wordstat/{method}
# with IAM Bearer + folderId, response normalized back to legacy
# shape so existing parsers in callers don't change.
#
# Selection in load_config is STRUCTURAL ONLY — no network, no IAM preflight.
# IAM/network errors surface on the first wordstat_request call.
# Resolve directories. Use $0 because we're sourced from many shells (sh + bash).
# Tests can pre-set WORDSTAT_SCRIPT_DIR / WORDSTAT_SKILL_DIR / WORDSTAT_CONFIG_DIR
# to override the auto-resolution (POSIX sh has no portable way to get the path
# of a sourced script when $0 isn't reliable).
if [ -z "${WORDSTAT_SCRIPT_DIR:-}" ]; then
WORDSTAT_SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
fi
if [ -z "${WORDSTAT_SKILL_DIR:-}" ]; then
WORDSTAT_SKILL_DIR="$(cd "$WORDSTAT_SCRIPT_DIR/.." && pwd)"
fi
WORDSTAT_CONFIG_DIR="${WORDSTAT_CONFIG_DIR:-$WORDSTAT_SKILL_DIR/config}"
WORDSTAT_CACHE_DIR="${WORDSTAT_CACHE_DIR:-$WORDSTAT_SKILL_DIR/cache}"
WORDSTAT_LEGACY_API="https://api.wordstat.yandex.net/v1"
WORDSTAT_CLOUD_API="https://searchapi.api.cloud.yandex.net/v2/wordstat"
WORDSTAT_IAM_API="https://iam.api.cloud.yandex.net/iam/v1/tokens"
WORDSTAT_README_URL="https://github.com/artwist-polyakov/polyakov-claude-skills/blob/main/plugins/yandex-wordstat/skills/yandex-wordstat/config/README.md"
# Exported by load_config so callers and die_with_help can read them
WORDSTAT_BACKEND=""
WORDSTAT_BACKEND_DETECTED_VIA=""
WORDSTAT_CLOUD_FOLDER_ID=""
WORDSTAT_CLOUD_SA_KEY_PATH=""
WORDSTAT_CLOUD_OPENSSL_BIN=""
# ---------------------------------------------------------------------
# Error helper
# ---------------------------------------------------------------------
die_with_help() {
_msg="$1"
_extra="${2:-}"
{
printf '[wordstat] %s\n' "$_msg"
if [ -n "$WORDSTAT_BACKEND" ]; then
printf 'Backend: %s' "$WORDSTAT_BACKEND"
[ -n "$WORDSTAT_BACKEND_DETECTED_VIA" ] && \
printf ' (%s)' "$WORDSTAT_BACKEND_DETECTED_VIA"
printf '\n'
fi
[ -n "$_extra" ] && printf '%s\n' "$_extra"
printf '\n'
printf 'Likely the plugin config needs updating. See:\n'
printf ' %s\n\n' "$WORDSTAT_README_URL"
printf 'Quick checks:\n'
printf ' - cloud mode: config/config.json has yandex_cloud_folder_id?\n'
if [ -n "$WORDSTAT_CLOUD_SA_KEY_PATH" ]; then
printf ' SA key file: %s\n' "$WORDSTAT_CLOUD_SA_KEY_PATH"
printf ' (resolved from auth.service_account_key_file) — present and readable?\n'
else
printf ' SA key file from auth.service_account_key_file — present and readable?\n'
fi
printf " SA has role 'search-api.webSearch.user'?\n"
printf ' - legacy mode: YANDEX_WORDSTAT_TOKEN still valid? (tokens expire after 1 year)\n'
printf ' - to switch: set YANDEX_WORDSTAT_BACKEND=legacy|cloud in config/.env\n'
} >&2
exit 1
}
# ---------------------------------------------------------------------
# Legacy helpers (kept for compatibility with bash callers)
# ---------------------------------------------------------------------
json_escape() {
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/ /\\t/g'
}
format_number() {
printf "%'d" "$1" 2>/dev/null || echo "$1"
}
# Extract a numeric/literal JSON value (no string quoting)
json_value() {
_jv_json="$1"; _jv_key="$2"
printf '%s' "$_jv_json" | grep -o "\"$_jv_key\":[^,}]*" | head -1 | sed 's/.*://' | tr -d '"[:space:]'
}
# Extract a JSON string value
json_string() {
_js_json="$1"; _js_key="$2"
printf '%s' "$_js_json" | grep -o "\"$_js_key\":\"[^\"]*\"" | head -1 | sed 's/.*:"//' | tr -d '"'
}
# ---------------------------------------------------------------------
# Backend selection — load_config
# ---------------------------------------------------------------------
# Read .env if present (legacy creds + override). Sourced into current shell.
_load_env_file() {
_env_file="$WORDSTAT_CONFIG_DIR/.env"
if [ -f "$_env_file" ]; then
# shellcheck disable=SC1090
. "$_env_file"
fi
}
# Read a value from config.json. Usage: _cfg_get "key" or "auth.openssl_bin"
# Returns empty string on missing key, missing file, or parse error.
_cfg_get() {
_cfg_file="$WORDSTAT_CONFIG_DIR/config.json"
[ -f "$_cfg_file" ] || { echo ""; return 0; }
_CFG_FILE="$_cfg_file" _CFG_KEY="$1" python3 - <<'PYEOF' 2>/dev/null
import json, os, sys
try:
with open(os.environ["_CFG_FILE"]) as f:
cfg = json.load(f)
except Exception:
print("")
sys.exit(0)
v = cfg
for part in os.environ["_CFG_KEY"].split("."):
if isinstance(v, dict) and part in v:
v = v[part]
else:
v = None
break
print("" if v is None else v)
PYEOF
}
# Resolve a path: absolute as-is, relative resolved against skill dir
_resolve_path() {
_rp="$1"
case "$_rp" in
/*) printf '%s\n' "$_rp" ;;
*) printf '%s/%s\n' "$WORDSTAT_SKILL_DIR" "$_rp" ;;
esac
}
# Detect cloud structural config. Sets WORDSTAT_CLOUD_* variables on success.
# Returns 0 if cloud is structurally configured, 1 if not, 2 if config.json is
# present but malformed (caller should die loudly).
_detect_cloud_config() {
_cfg_file="$WORDSTAT_CONFIG_DIR/config.json"
[ -f "$_cfg_file" ] || return 1
_folder=$(_cfg_get yandex_cloud_folder_id)
_sa_rel=$(_cfg_get auth.service_account_key_file)
_ossl=$(_cfg_get auth.openssl_bin)
if [ -z "$_folder" ]; then
WORDSTAT_BACKEND_DETECTED_VIA="cloud (config.json present but yandex_cloud_folder_id missing)"
return 2
fi
if [ -z "$_sa_rel" ]; then
WORDSTAT_BACKEND_DETECTED_VIA="cloud (config.json present but auth.service_account_key_file missing)"
return 2
fi
_sa_resolved=$(_resolve_path "$_sa_rel")
if [ ! -r "$_sa_resolved" ]; then
WORDSTAT_CLOUD_SA_KEY_PATH="$_sa_resolved"
WORDSTAT_BACKEND_DETECTED_VIA="cloud (SA key file not found at resolved path)"
return 2
fi
WORDSTAT_CLOUD_FOLDER_ID="$_folder"
WORDSTAT_CLOUD_SA_KEY_PATH="$_sa_resolved"
WORDSTAT_CLOUD_OPENSSL_BIN="${_ossl:-openssl}"
return 0
}
load_config() {
_load_env_file
# 1. Explicit override
if [ -n "${YANDEX_WORDSTAT_BACKEND:-}" ]; then
case "$YANDEX_WORDSTAT_BACKEND" in
cloud)
_rc=0
_detect_cloud_config || _rc=$?
if [ "$_rc" = "2" ]; then
WORDSTAT_BACKEND="cloud"
die_with_help "YANDEX_WORDSTAT_BACKEND=cloud but config is incomplete: $WORDSTAT_BACKEND_DETECTED_VIA"
fi
if [ "$_rc" = "1" ]; then
WORDSTAT_BACKEND="cloud"
die_with_help "YANDEX_WORDSTAT_BACKEND=cloud but config/config.json is missing"
fi
WORDSTAT_BACKEND="cloud"
WORDSTAT_BACKEND_DETECTED_VIA="explicit override"
return 0
;;
legacy)
if [ -z "${YANDEX_WORDSTAT_TOKEN:-}" ]; then
WORDSTAT_BACKEND="legacy"
WORDSTAT_BACKEND_DETECTED_VIA="explicit override"
die_with_help "YANDEX_WORDSTAT_BACKEND=legacy but YANDEX_WORDSTAT_TOKEN is not set"
fi
WORDSTAT_BACKEND="legacy"
WORDSTAT_BACKEND_DETECTED_VIA="explicit override"
return 0
;;
*)
die_with_help "Invalid YANDEX_WORDSTAT_BACKEND='$YANDEX_WORDSTAT_BACKEND' (expected 'legacy' or 'cloud')"
;;
esac
fi
# 2. Cloud structurally configured → cloud (cloud wins on tie)
_rc=0
_detect_cloud_config || _rc=$?
if [ "$_rc" = "0" ]; then
WORDSTAT_BACKEND="cloud"
WORDSTAT_BACKEND_DETECTED_VIA="auto: config.json present"
return 0
fi
if [ "$_rc" = "2" ]; then
# Malformed cloud config → fail loudly, do NOT silently fall back
WORDSTAT_BACKEND="cloud"
die_with_help "config/config.json present but invalid: $WORDSTAT_BACKEND_DETECTED_VIA"
fi
# 3. Legacy creds present → legacy
if [ -n "${YANDEX_WORDSTAT_TOKEN:-}" ]; then
WORDSTAT_BACKEND="legacy"
WORDSTAT_BACKEND_DETECTED_VIA="auto: YANDEX_WORDSTAT_TOKEN set"
return 0
fi
# 4. Nothing
die_with_help "No Wordstat credentials found"
}
# ---------------------------------------------------------------------
# Backend info — used by quota.sh
# ---------------------------------------------------------------------
print_backend_info() {
case "$WORDSTAT_BACKEND" in
legacy)
echo "Backend: legacy ($WORDSTAT_BACKEND_DETECTED_VIA)"
echo ""
echo "=== Endpoints ==="
echo " POST $WORDSTAT_LEGACY_API/topRequests"
echo " POST $WORDSTAT_LEGACY_API/dynamics"
echo " POST $WORDSTAT_LEGACY_API/regions"
echo ""
echo "=== API Limits ==="
echo " - Rate limit: 10 requests/second"
echo " - Daily quota: 1000 requests"
echo ""
echo "Note: This API is deprecated for new users. Existing tokens still work."
;;
cloud)
echo "Backend: cloud ($WORDSTAT_BACKEND_DETECTED_VIA)"
echo " folder_id: $WORDSTAT_CLOUD_FOLDER_ID"
echo " SA key: $WORDSTAT_CLOUD_SA_KEY_PATH"
echo ""
echo "=== Endpoints ==="
echo " POST $WORDSTAT_CLOUD_API/topRequests"
echo " POST $WORDSTAT_CLOUD_API/dynamics"
echo " POST $WORDSTAT_CLOUD_API/regions"
echo ""
echo "=== API Limits ==="
echo " Wordstat in Search API is currently in Preview."
echo " See https://yandex.cloud/ru/docs/search-api/pricing for current limits and billing."
;;
*)
echo "Backend: (not configured)"
;;
esac
}
# ---------------------------------------------------------------------
# IAM token — JWT PS256 with SA key (inline-copied from yandex-search-api)
# ---------------------------------------------------------------------
_make_secure_tmpdir() {
_old_umask=$(umask)
umask 077
_td=$(mktemp -d "${TMPDIR:-/tmp}/wordstat_XXXXXX")
umask "$_old_umask"
echo "$_td"
}
_check_openssl() {
_ossl="$1"
if ! command -v "$_ossl" >/dev/null 2>&1; then
die_with_help "openssl not found at '$_ossl'" \
"Install OpenSSL 1.1.1+ or set auth.openssl_bin in config/config.json"
fi
_ossl_ver=$("$_ossl" version 2>/dev/null || true)
case "$_ossl_ver" in
LibreSSL*)
die_with_help "LibreSSL detected ($_ossl_ver) — OpenSSL 1.1.1+ required for PS256" \
"macOS users: brew install openssl@3 and set auth.openssl_bin to the homebrew path"
;;
"OpenSSL 0."*|"OpenSSL 1.0."*)
die_with_help "OpenSSL too old ($_ossl_ver), need 1.1.1+"
;;
esac
}
_get_cached_iam_token() {
_cf="$WORDSTAT_CACHE_DIR/iam_token.json"
[ -f "$_cf" ] || return 0
_CACHE_FILE="$_cf" python3 - <<'PYEOF' 2>/dev/null
import json, os, time
cf = os.environ["_CACHE_FILE"]
try:
with open(cf) as f:
d = json.load(f)
exp = d.get("expires_at", 0)
if exp - time.time() > 300:
print(d["iam_token"])
except Exception:
pass
PYEOF
}
_save_iam_token() {
_tok="$1"
_exp="$2"
mkdir -p "$WORDSTAT_CACHE_DIR"
_cf="$WORDSTAT_CACHE_DIR/iam_token.json"
_old_umask=$(umask)
umask 077
_tmp="$WORDSTAT_CACHE_DIR/.iam_token_tmp_$$.json"
_SAVE_TOKEN="$_tok" _SAVE_EXP="$_exp" _TMP_FILE="$_tmp" python3 - <<'PYEOF'
import json, os
d = {"iam_token": os.environ["_SAVE_TOKEN"], "expires_at": int(os.environ["_SAVE_EXP"])}
with open(os.environ["_TMP_FILE"], "w") as f:
json.dump(d, f)
PYEOF
mv "$_tmp" "$_cf"
umask "$_old_umask"
}
# Issue a fresh IAM token from the SA key. Echoes token on stdout.
_iam_token_issue() {
_check_openssl "$WORDSTAT_CLOUD_OPENSSL_BIN"
if [ ! -r "$WORDSTAT_CLOUD_SA_KEY_PATH" ]; then
die_with_help "Service account key file not readable: $WORDSTAT_CLOUD_SA_KEY_PATH"
fi
_tmp=$(_make_secure_tmpdir)
# shellcheck disable=SC2064
trap "rm -rf '$_tmp'" EXIT INT TERM
# Build JWT header + payload, write key.pem and signing_input.txt
_SA_KEY="$WORDSTAT_CLOUD_SA_KEY_PATH" _TMP="$_tmp" python3 - <<'PYEOF' || die_with_help "Failed to build JWT from SA key"
import json, base64, time, os, sys
sa_key_file = os.environ["_SA_KEY"]
tmp_dir = os.environ["_TMP"]
try:
with open(sa_key_file) as f:
sa = json.load(f)
sa_id = sa["service_account_id"]
key_id = sa["id"]
private_key = sa["private_key"]
except Exception as e:
print(f"SA key parse error: {e}", file=sys.stderr)
sys.exit(1)
with open(os.path.join(tmp_dir, "key.pem"), "w") as f:
f.write(private_key)
header = json.dumps({"typ": "JWT", "alg": "PS256", "kid": key_id}, separators=(",", ":"))
header_b64 = base64.urlsafe_b64encode(header.encode()).rstrip(b"=").decode()
now = int(time.time())
payload = json.dumps({
"iss": sa_id,
"aud": "https://iam.api.cloud.yandex.net/iam/v1/tokens",
"iat": now,
"exp": now + 3600,
}, separators=(",", ":"))
payload_b64 = base64.urlsafe_b64encode(payload.encode()).rstrip(b"=").decode()
signing_input = f"{header_b64}.{payload_b64}"
with open(os.path.join(tmp_dir, "signing_input.txt"), "w") as f:
f.write(signing_input)
with open(os.path.join(tmp_dir, "header_payload.txt"), "w") as f:
f.write(signing_input)
PYEOF
"$WORDSTAT_CLOUD_OPENSSL_BIN" dgst -sha256 \
-sigopt rsa_padding_mode:pss \
-sigopt rsa_pss_saltlen:-1 \
-sign "$_tmp/key.pem" \
-out "$_tmp/signature.bin" \
"$_tmp/signing_input.txt" 2>/dev/null \
|| die_with_help "openssl PS256 signing failed"
_sig=$(python3 -c "
import base64, sys
with open('$_tmp/signature.bin', 'rb') as f:
print(base64.urlsafe_b64encode(f.read()).rstrip(b'=').decode())
")
_hp=$(cat "$_tmp/header_payload.txt")
_jwt="${_hp}.${_sig}"
_resp=$(curl -s -X POST "$WORDSTAT_IAM_API" \
-H "Content-Type: application/json" \
-d "{\"jwt\":\"$_jwt\"}")
if [ -z "$_resp" ]; then
die_with_help "Empty response from IAM API"
fi
# Parse token + expiry
_result=$(printf '%s' "$_resp" | python3 -c "
import json, sys
from datetime import datetime
try:
d = json.load(sys.stdin)
except Exception as e:
print('PARSE_ERROR:' + str(e))
sys.exit(0)
tok = d.get('iamToken', '')
exp_s = d.get('expiresAt', '')
if not tok:
print('NO_TOKEN:' + json.dumps(d)[:300])
sys.exit(0)
if exp_s:
try:
ts = datetime.fromisoformat(exp_s.replace('Z', '+00:00')).timestamp()
exp = int(ts)
except Exception:
import time
exp = int(time.time()) + 43200
else:
import time
exp = int(time.time()) + 43200
print(f'{tok}|{exp}')
")
case "$_result" in
PARSE_ERROR:*) die_with_help "IAM response parse error: ${_result#PARSE_ERROR:}" "Raw: $_resp" ;;
NO_TOKEN:*) die_with_help "IAM response missing iamToken" "${_result#NO_TOKEN:}" ;;
esac
_tok=$(printf '%s' "$_result" | cut -d'|' -f1)
_exp=$(printf '%s' "$_result" | cut -d'|' -f2)
_save_iam_token "$_tok" "$_exp"
rm -rf "$_tmp"
trap - EXIT INT TERM
printf '%s' "$_tok"
}
_iam_token_get() {
_cached=$(_get_cached_iam_token)
if [ -n "$_cached" ]; then
printf '%s' "$_cached"
return 0
fi
_iam_token_issue
}
# ---------------------------------------------------------------------
# Request translation + response normalization (cloud ↔ legacy)
# ---------------------------------------------------------------------
# Translate legacy-shape params JSON → cloud request body JSON.
# Args: $1 = method (topRequests|dynamics|regions), $2 = legacy params JSON
# Output: cloud-shape JSON on stdout.
# Exits 1 with die_with_help on dynamics preflight failure.
_xlate_request() {
_method="$1"
_params="$2"
_METHOD="$_method" _PARAMS="$_params" _FOLDER="$WORDSTAT_CLOUD_FOLDER_ID" \
python3 - <<'PYEOF'
import json, os, re, sys
method = os.environ["_METHOD"]
params = json.loads(os.environ["_PARAMS"])
folder = os.environ["_FOLDER"]
DEVICE_MAP = {
"all": "DEVICE_ALL",
"desktop": "DEVICE_DESKTOP",
"phone": "DEVICE_PHONE",
"tablet": "DEVICE_TABLET",
}
PERIOD_MAP = {
"monthly": "PERIOD_MONTHLY",
"weekly": "PERIOD_WEEKLY",
"daily": "PERIOD_DAILY",
}
REGION_TYPE_MAP = {
"all": "REGION_ALL",
"cities": "REGION_CITIES",
"regions": "REGION_REGIONS",
}
def map_devices(d):
if d is None:
return ["DEVICE_ALL"]
if isinstance(d, list):
return [DEVICE_MAP.get(x, x) if isinstance(x, str) and not x.startswith("DEVICE_") else x for x in d]
return [DEVICE_MAP.get(d, "DEVICE_ALL")]
def map_regions(r):
if r is None:
return None
return [str(x) for x in r]
def to_rfc3339(d):
# Accept either YYYY-MM-DD or already-RFC3339
if not d:
return d
if "T" in d:
return d
return d + "T00:00:00Z"
if method == "topRequests":
body = {"phrase": params["phrase"]}
if "numPhrases" in params:
body["numPhrases"] = str(params["numPhrases"])
if "regions" in params:
body["regions"] = map_regions(params["regions"])
if "devices" in params:
body["devices"] = map_devices(params["devices"])
body["folderId"] = folder
elif method == "dynamics":
# ---- Preflight: cloud only allows '+' operator at weekly/monthly ----
period = params.get("period", "monthly")
phrase = params.get("phrase", "")
if period != "daily":
# Token-boundary detection of operators that cloud rejects at non-daily.
# Hyphen inside word (санкт-петербург, премиум-класс) MUST pass.
# Token-leading -, !, or any of " ( | ) trigger the failure. + is allowed.
bad_ops = []
if re.search(r'(^|\s)-\S', phrase):
bad_ops.append("- (minus-word)")
if re.search(r'(^|\s)!\S', phrase):
bad_ops.append("! (exact form)")
if '"' in phrase:
bad_ops.append('" (exact phrase)')
if "(" in phrase or ")" in phrase or "|" in phrase:
bad_ops.append("( | ) (grouping)")
if bad_ops:
print("PREFLIGHT_FAIL:" + ", ".join(bad_ops), file=sys.stderr)
sys.exit(2)
body = {
"phrase": phrase,
"period": PERIOD_MAP.get(period, period),
"fromDate": to_rfc3339(params["fromDate"]),
}
if "toDate" in params and params["toDate"]:
body["toDate"] = to_rfc3339(params["toDate"])
if "regions" in params:
body["regions"] = map_regions(params["regions"])
if "devices" in params:
body["devices"] = map_devices(params["devices"])
body["folderId"] = folder
elif method == "regions":
body = {"phrase": params["phrase"]}
if "regionType" in params:
body["region"] = REGION_TYPE_MAP.get(params["regionType"], params["regionType"])
if "devices" in params:
body["devices"] = map_devices(params["devices"])
body["folderId"] = folder
else:
print(f"UNKNOWN_METHOD:{method}", file=sys.stderr)
sys.exit(2)
print(json.dumps(body, ensure_ascii=False))
PYEOF
}
# Normalize cloud response JSON → legacy shape JSON.
# Args: $1 = method, $2 = path to cloud response file (optional; if missing, spool stdin)
# Output: legacy-shape JSON on stdout
#
# Implementation note: cloud responses for topRequests --limit 2000 can be
# multi-MB. Passing through env var is unsafe (ARG_MAX / E2BIG). We use a file
# path. If the caller already has the response in a file (e.g. _cloud_request),
# pass it as $2 to skip the spool step.
_normalize_response() {
_method="$1"
_nr_owns_tmp=0
if [ -n "${2:-}" ]; then
_nr_tmp="$2"
else
_nr_tmp="${TMPDIR:-/tmp}/wordstat_norm_$$.json"
cat > "$_nr_tmp"
_nr_owns_tmp=1
fi
_METHOD="$_method" _RESP_FILE="$_nr_tmp" python3 - <<'PYEOF'
import json, os, sys
method = os.environ["_METHOD"]
try:
with open(os.environ["_RESP_FILE"], "r", encoding="utf-8") as f:
d = json.load(f)
except Exception as e:
print(json.dumps({"error": f"Cloud response parse error: {e}"}))
sys.exit(0)
# Translate cloud error JSON to legacy {"error": ...}
if "code" in d and "message" in d and "results" not in d and "topRequests" not in d:
print(json.dumps({"error": d.get("message", "cloud error"), "code": d.get("code")}))
sys.exit(0)
def to_int(v):
if v is None:
return 0
try:
return int(v)
except (TypeError, ValueError):
return v
if method == "topRequests":
out = {}
if "totalCount" in d:
out["totalCount"] = to_int(d["totalCount"])
out["topRequests"] = [
{"phrase": r.get("phrase", ""), "count": to_int(r.get("count", 0))}
for r in d.get("results", [])
]
out["associations"] = [
{"phrase": r.get("phrase", ""), "count": to_int(r.get("count", 0))}
for r in d.get("associations", [])
]
elif method == "dynamics":
out = {
"data": [
{
"date": r.get("date", ""),
"count": to_int(r.get("count", 0)),
"share": r.get("share", 0),
}
for r in d.get("results", [])
]
}
elif method == "regions":
out = {
"regions": [
{
"regionId": to_int(r.get("region", 0)),
"count": to_int(r.get("count", 0)),
"share": r.get("share", 0),
"affinity": to_int(r.get("affinityIndex", r.get("affinity", 0))),
}
for r in d.get("results", [])
]
}
else:
out = d
# Compact separators — no spaces. Matches the legacy API JSON shape that
# existing grep/sed parsers in top_requests.sh, dynamics.sh, regions_stats.sh expect.
# E.g. "topRequests":[{"phrase":"...","count":123}] not "topRequests": [{"phrase": "...", "count": 123}]
print(json.dumps(out, ensure_ascii=False, separators=(",", ":")))
PYEOF
[ "$_nr_owns_tmp" = "1" ] && rm -f "$_nr_tmp"
return 0
}
# ---------------------------------------------------------------------
# wordstat_request — public dispatcher
# ---------------------------------------------------------------------
# Legacy backend: direct curl to api.wordstat.yandex.net/v1
_legacy_request() {
_method="$1"
_params="$2"
curl -s -X POST "$WORDSTAT_LEGACY_API/$_method" \
-H "Authorization: Bearer $YANDEX_WORDSTAT_TOKEN" \
-H "Content-Type: application/json; charset=utf-8" \
-H "Accept-Language: ru" \
-d "$_params"
}
# Cloud backend: translate, sign, POST, normalize
_cloud_request() {
_method="$1"
_params="$2"
# 1. Translate request
_xlate_out=$(_xlate_request "$_method" "$_params" 2>&1)
_xlate_rc=$?
if [ "$_xlate_rc" != "0" ]; then
case "$_xlate_out" in
*PREFLIGHT_FAIL:*)
_ops=${_xlate_out#*PREFLIGHT_FAIL:}
die_with_help \
"Cloud Wordstat dynamics: at weekly/monthly granularity, only '+' operator is allowed. Found: $_ops" \
"Either switch --period to daily, or remove these operators from --phrase. See: https://aistudio.yandex.ru/docs/ru/search-api/operations/wordstat-getdynamics.html"
;;
*UNKNOWN_METHOD:*)
die_with_help "Unknown wordstat method: ${_xlate_out#*UNKNOWN_METHOD:}"
;;
*)
die_with_help "Request translation failed" "$_xlate_out"
;;
esac
fi
_cloud_body="$_xlate_out"
# 2. Get IAM token (uses cache, falls back to issue)
_tok=$(_iam_token_get)
if [ -z "$_tok" ]; then
die_with_help "Failed to obtain IAM token"
fi
# 3. POST with retry on 5xx and refresh on 401
_attempt=0
_max_attempts=3
_backoff=2
while [ "$_attempt" -lt "$_max_attempts" ]; do
_attempt=$((_attempt + 1))
_tmp=$(_make_secure_tmpdir)
_resp_file="$_tmp/resp"
_status=$(curl -s -o "$_resp_file" -w '%{http_code}' \
-X POST "$WORDSTAT_CLOUD_API/$_method" \
-H "Authorization: Bearer $_tok" \
-H "Content-Type: application/json" \
-d "$_cloud_body")
case "$_status" in
2[0-9][0-9])
_normalize_response "$_method" "$_resp_file"
rm -rf "$_tmp"
return 0
;;
401)
# Refresh once and retry
if [ "$_attempt" = "1" ]; then
rm -f "$WORDSTAT_CACHE_DIR/iam_token.json"
_tok=$(_iam_token_issue)
rm -rf "$_tmp"
continue
fi
_err=$(cat "$_resp_file" 2>/dev/null)
rm -rf "$_tmp"
die_with_help "Cloud Wordstat 401 Unauthorized after token refresh" "$_err"
;;
403)
_err=$(cat "$_resp_file" 2>/dev/null)
rm -rf "$_tmp"
die_with_help "Cloud Wordstat 403 Forbidden" \
"Check that your service account has the role 'search-api.webSearch.user' on folder $WORDSTAT_CLOUD_FOLDER_ID. Raw: $_err"
;;
5[0-9][0-9]|000)
if [ "$_attempt" -lt "$_max_attempts" ]; then
rm -rf "$_tmp"
sleep "$_backoff"
_backoff=$((_backoff * 2))
continue
fi
_err=$(cat "$_resp_file" 2>/dev/null)
rm -rf "$_tmp"
die_with_help "Cloud Wordstat $_status after $_max_attempts retries" "$_err"
;;
*)
_err=$(cat "$_resp_file" 2>/dev/null)
rm -rf "$_tmp"
die_with_help "Cloud Wordstat HTTP $_status" "$_err"
;;
esac
done
}
# Public entry point
wordstat_request() {
_method="$1"
_params="$2"
if [ -z "$WORDSTAT_BACKEND" ]; then
die_with_help "wordstat_request called before load_config"
fi
case "$WORDSTAT_BACKEND" in
legacy) _legacy_request "$_method" "$_params" ;;
cloud) _cloud_request "$_method" "$_params" ;;
*) die_with_help "Unknown backend: $WORDSTAT_BACKEND" ;;
esac
}
#!/bin/bash
# Get search volume dynamics from Yandex Wordstat
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# common.sh is POSIX sh; bash sources it without issue.
. "$SCRIPT_DIR/common.sh"
# Defaults
PHRASE=""
PERIOD="monthly"
FROM_DATE=""
TO_DATE=""
REGIONS=""
DEVICES="all"
# Parse args
while [[ $# -gt 0 ]]; do
case $1 in
--phrase|-p) PHRASE="$2"; shift 2 ;;
--period) PERIOD="$2"; shift 2 ;;
--from-date|-f) FROM_DATE="$2"; shift 2 ;;
--to-date|-t) TO_DATE="$2"; shift 2 ;;
--regions|-r) REGIONS="$2"; shift 2 ;;
--devices|-d) DEVICES="$2"; shift 2 ;;
*) echo "Unknown option: $1"; exit 1 ;;
esac
done
if [[ -z "$PHRASE" ]]; then
echo "Usage: dynamics.sh --phrase \"search query\" [options]"
echo ""
echo "Options:"
echo " --phrase, -p Search phrase (required)"
echo " --period Grouping: daily, weekly, monthly (default: monthly)"
echo " --from-date, -f Start date YYYY-MM-DD (required)"
echo " --to-date, -t End date YYYY-MM-DD (default: today)"
echo " --regions, -r Region IDs, comma-separated (optional)"
echo " --devices, -d Device filter: all, desktop, phone, tablet (default: all)"
echo ""
echo "Examples:"
echo " bash scripts/dynamics.sh --phrase \"юрист дтп\" --from-date 2025-01-01"
echo " bash scripts/dynamics.sh --phrase \"юрист\" --period weekly --from-date 2025-06-01"
exit 1
fi
# Set default from_date if not provided
if [[ -z "$FROM_DATE" ]]; then
FROM_DATE=$(date -v-1y +%Y-%m-%d 2>/dev/null || date -d "1 year ago" +%Y-%m-%d 2>/dev/null || echo "2025-01-01")
fi
load_config
# Escape phrase for JSON
PHRASE_ESCAPED=$(json_escape "$PHRASE")
# Build JSON params
PARAMS="{\"phrase\":\"$PHRASE_ESCAPED\",\"period\":\"$PERIOD\",\"fromDate\":\"$FROM_DATE\""
if [[ -n "$TO_DATE" ]]; then
PARAMS="$PARAMS,\"toDate\":\"$TO_DATE\""
fi
if [[ -n "$REGIONS" ]]; then
PARAMS="$PARAMS,\"regions\":[$REGIONS]"
fi
if [[ "$DEVICES" != "all" ]]; then
PARAMS="$PARAMS,\"devices\":\"$DEVICES\""
fi
PARAMS="$PARAMS}"
echo "=== Yandex Wordstat: Dynamics ==="
echo "Phrase: $PHRASE"
echo "Period: $PERIOD"
echo "From: $FROM_DATE"
[[ -n "$TO_DATE" ]] && echo "To: $TO_DATE"
[[ -n "$REGIONS" ]] && echo "Regions: $REGIONS"
echo "Devices: $DEVICES"
echo ""
echo "Fetching data..."
result=$(wordstat_request "dynamics" "$PARAMS")
# Check for error
if echo "$result" | grep -q '"error"'; then
echo "Error:"
echo "$result"
exit 1
fi
echo ""
echo "=== Results ==="
echo ""
echo "| Date | Count |"
echo "|------|-------|"
# Extract dynamics data
echo "$result" | grep -o '{"date":"[^"]*","count":[0-9]*,"share":[^}]*}' | while IFS= read -r entry; do
dt=$(echo "$entry" | grep -o '"date":"[^"]*"' | sed 's/"date":"//' | tr -d '"')
cnt=$(echo "$entry" | grep -o '"count":[0-9]*' | sed 's/"count"://')
echo "| $dt | $(format_number "$cnt") |"
done
echo ""
echo "=== Raw JSON ==="
echo "$result" | head -c 2000
echo ""
echo "[truncated if > 2000 chars]"
#!/bin/bash
# Get Yandex OAuth token for Wordstat API
#
# DEPRECATED — Yandex no longer onboards new users to the legacy Wordstat OAuth API.
# This script only works for users who already have a Yandex OAuth client_id from
# before the deprecation. For new setups, use the cloud backend instead — see
# config/README.md → "Cloud mode (recommended)".
set -e
echo "[NOTICE] Legacy mode only. Yandex stopped onboarding new Wordstat OAuth users."
echo "[NOTICE] For cloud setup see config/README.md → 'Cloud mode'."
echo ""
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONFIG_DIR="$SCRIPT_DIR/../config"
ENV_FILE="$CONFIG_DIR/.env"
CLIENT_ID=""
CLIENT_SECRET=""
# Parse args
while [[ $# -gt 0 ]]; do
case $1 in
--client-id|-i) CLIENT_ID="$2"; shift 2 ;;
--client-secret|-s) CLIENT_SECRET="$2"; shift 2 ;;
*) echo "Unknown option: $1"; exit 1 ;;
esac
done
if [[ -z "$CLIENT_ID" ]]; then
echo "Usage: get_token.sh --client-id YOUR_CLIENT_ID [--client-secret YOUR_SECRET]"
echo ""
echo "Options:"
echo " --client-id, -i OAuth client ID (required)"
echo " --client-secret, -s OAuth client secret (optional, for code exchange)"
echo ""
echo "Get credentials at: https://oauth.yandex.ru/client/new"
echo ""
echo "Two modes:"
echo " 1. Without secret: Opens browser URL, you copy token manually"
echo " 2. With secret: Exchange authorization code for token"
exit 1
fi
echo "=== Yandex OAuth Token Setup ==="
echo ""
if [[ -z "$CLIENT_SECRET" ]]; then
# Simple mode: token in URL fragment
echo "Step 1: Open this URL in your browser:"
echo ""
echo " https://oauth.yandex.ru/authorize?response_type=token&client_id=$CLIENT_ID"
echo ""
echo "Step 2: Authorize the application"
echo ""
echo "Step 3: Copy the token from the redirect URL:"
echo " https://oauth.yandex.ru/#access_token=YOUR_TOKEN_HERE&..."
echo ""
echo -n "Paste your token here: "
read -r TOKEN
if [[ -z "$TOKEN" ]]; then
echo "Error: No token provided"
exit 1
fi
else
# Code exchange mode
echo "Step 1: Open this URL in your browser:"
echo ""
echo " https://oauth.yandex.ru/authorize?response_type=code&client_id=$CLIENT_ID"
echo ""
echo "Step 2: Authorize the application"
echo ""
echo "Step 3: Copy the code from the redirect URL or page"
echo ""
echo -n "Paste the authorization code here: "
read -r AUTH_CODE
if [[ -z "$AUTH_CODE" ]]; then
echo "Error: No code provided"
exit 1
fi
echo ""
echo "Exchanging code for token..."
RESPONSE=$(curl -s -X POST "https://oauth.yandex.ru/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "code=$AUTH_CODE" \
-d "client_id=$CLIENT_ID" \
-d "client_secret=$CLIENT_SECRET")
# Extract token
TOKEN=$(echo "$RESPONSE" | grep -o '"access_token":"[^"]*"' | sed 's/"access_token":"//' | tr -d '"')
if [[ -z "$TOKEN" ]]; then
echo "Error: Failed to get token"
echo "$RESPONSE"
exit 1
fi
# Show expiration
EXPIRES=$(echo "$RESPONSE" | grep -o '"expires_in":[0-9]*' | sed 's/"expires_in"://')
if [[ -n "$EXPIRES" ]]; then
DAYS=$((EXPIRES / 86400))
echo "Token expires in: $DAYS days"
fi
fi
echo ""
echo "Token received!"
echo ""
# Save to .env
if [[ -f "$ENV_FILE" ]]; then
# Update existing file
if grep -q "^YANDEX_WORDSTAT_TOKEN=" "$ENV_FILE"; then
# Replace existing token
sed -i.bak "s/^YANDEX_WORDSTAT_TOKEN=.*/YANDEX_WORDSTAT_TOKEN=$TOKEN/" "$ENV_FILE"
rm -f "$ENV_FILE.bak"
echo "Updated token in: $ENV_FILE"
else
# Append
echo "YANDEX_WORDSTAT_TOKEN=$TOKEN" >> "$ENV_FILE"
echo "Added token to: $ENV_FILE"
fi
else
# Create new file
echo "YANDEX_WORDSTAT_TOKEN=$TOKEN" > "$ENV_FILE"
echo "Created: $ENV_FILE"
fi
echo ""
echo "Verifying token..."
echo ""
# Test the token
bash "$SCRIPT_DIR/quota.sh"
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.10"
# dependencies = ["openpyxl"]
# ///
"""Missed demand analysis for Yandex Direct campaigns.
Subcommands:
parse-xlsx Parse Yandex Direct XLSX export into groups/phrases/minus
build-query Build OR-query from slot structure
merge-slots Merge batch segmentation results into unified slots
query-total Extract totalCount from a pre-fetched Wordstat response file
NOTE: query-total is transport-agnostic. It does NOT make HTTP calls.
The caller (query_total.sh) routes the request through common.sh:wordstat_request,
which handles backend selection (legacy vs cloud) and writes the legacy-shape
JSON response to a file. This file is what query-total reads.
"""
import argparse
import json
import sys
import re
from collections import OrderedDict
# --- Stop words that need + prefix in Wordstat queries ---
STOP_WORDS = frozenset([
"на", "в", "к", "за", "с", "по", "из", "от", "до", "для",
"без", "при", "под", "над", "между", "через", "об", "перед",
])
# Characters forbidden in slot variants (OR-syntax operators and leading modifiers)
# Hyphen inside words is OK (б/у, санкт-петербург)
FORBIDDEN_CHARS_RE = re.compile(r'[|()"]')
LEADING_OPERATOR_RE = re.compile(r'^[+\-!]')
# Punctuation to strip from slot variants (dots, commas, semicolons, etc.)
# Keeps hyphens (б/у, санкт-петербург), slashes, and ! (Wordstat exact form operator)
PUNCTUATION_RE = re.compile(r'[.,;:?…·•]')
def cmd_parse_xlsx(args):
"""Parse Yandex Direct XLSX export."""
import openpyxl
wb = openpyxl.load_workbook(args.file, data_only=True)
if "Тексты" not in wb.sheetnames:
sys.exit(
f"Error: лист 'Тексты' не найден. Доступные: {wb.sheetnames}"
)
ws = wb["Тексты"]
# Find campaign minus-phrases (scan rows 1-20, cols A-F)
campaign_minus = ""
found_minus = False
for row in ws.iter_rows(min_row=1, max_row=20, min_col=1, max_col=6, values_only=False):
if found_minus:
break
for cell in row:
if cell.value and "Минус-фразы на кампанию" in str(cell.value):
next_cell = ws.cell(row=cell.row, column=cell.column + 1)
if next_cell.value:
campaign_minus = str(next_cell.value).strip()
found_minus = True
break
# Find first data row: col A == "-" and col G non-empty
data_start = None
for row in ws.iter_rows(min_row=1, max_row=ws.max_row, min_col=1, max_col=1, values_only=False):
cell = row[0]
if cell.value and str(cell.value).strip() == "-":
# Check col G
g_cell = ws.cell(row=cell.row, column=7)
if g_cell.value and str(g_cell.value).strip():
data_start = cell.row
break
if data_start is None:
sys.exit("Error: не найдены строки данных (col A='-' с непустой col G)")
# Collect groups
groups = OrderedDict() # group_id -> {name, group_minus_set, phrases_list}
for row_idx in range(data_start, ws.max_row + 1):
col_a = ws.cell(row=row_idx, column=1).value
if col_a is None or str(col_a).strip() != "-":
continue
col_g = ws.cell(row=row_idx, column=7).value
if col_g is None or not str(col_g).strip():
continue
col_c = ws.cell(row=row_idx, column=3).value
if col_c is None or not str(col_c).strip():
continue # skip rows with empty group_id
group_id = str(col_c).strip()
group_name = str(ws.cell(row=row_idx, column=4).value or "").strip()
phrase = str(col_g).strip()
# Col AG (33) - group minus
col_ag = ws.cell(row=row_idx, column=33).value
group_minus_val = str(col_ag).strip() if col_ag else ""
if group_id not in groups:
groups[group_id] = {
"name": group_name,
"group_minus_set": set(),
"phrases_ordered": OrderedDict(),
}
g = groups[group_id]
# Deduplicate phrases preserving order
g["phrases_ordered"][phrase] = None
if group_minus_val:
g["group_minus_set"].add(group_minus_val)
wb.close()
# Build output
result_groups = []
for gid, g in groups.items():
result_groups.append({
"id": gid,
"name": g["name"],
"group_minus": " ".join(sorted(g["group_minus_set"])),
"phrases": list(g["phrases_ordered"].keys()),
})
# Filter by --group if specified
if args.group:
result_groups = [g for g in result_groups if g["id"] == args.group]
result = {
"campaign_minus": campaign_minus,
"campaign_minus_length": len(campaign_minus),
"groups": result_groups,
}
print(json.dumps(result, ensure_ascii=False, indent=2))
def sanitize_variant(variant, slot_name):
"""Sanitize a slot variant. Returns (cleaned, warnings)."""
warnings = []
cleaned = variant
# Strip punctuation (муз. центр -> муз центр)
if PUNCTUATION_RE.search(cleaned):
cleaned = PUNCTUATION_RE.sub("", cleaned)
cleaned = re.sub(r'\s+', ' ', cleaned).strip()
# Remove forbidden OR-syntax characters (replace with space to avoid merging tokens)
if FORBIDDEN_CHARS_RE.search(cleaned):
warnings.append(
f"slot '{slot_name}': вариант '{variant}' содержит OR-синтаксис, деградирован в многословный"
)
cleaned = FORBIDDEN_CHARS_RE.sub(" ", cleaned)
# Collapse multiple spaces
cleaned = re.sub(r'\s+', ' ', cleaned).strip()
# Remove leading operators (+ - !) from each token
tokens = cleaned.split()
new_tokens = []
for t in tokens:
if LEADING_OPERATOR_RE.match(t):
stripped = LEADING_OPERATOR_RE.sub("", t)
if stripped:
warnings.append(
f"slot '{slot_name}': убран ведущий оператор из '{t}'"
)
new_tokens.append(stripped)
else:
new_tokens.append(t)
cleaned = " ".join(new_tokens)
return cleaned.strip(), warnings
def add_stop_word_plus(variant):
"""Add + prefix to stop words in a variant string."""
tokens = variant.split()
result = []
for t in tokens:
# Don't touch tokens already starting with operator
if t[0] in ("+", "-", "!"):
result.append(t)
elif t.lower() in STOP_WORDS:
result.append(f"+{t}")
else:
result.append(t)
return " ".join(result)
def normalize_token(token):
"""Normalize a token for comparison: lowercase, strip punctuation."""
t = token.lower().strip()
t = PUNCTUATION_RE.sub("", t)
t = LEADING_OPERATOR_RE.sub("", t)
t = FORBIDDEN_CHARS_RE.sub("", t)
return t.strip()
class SlotMerger:
"""Merges slot segmentation results from multiple LLM batches.
Does NOT perform LLM segmentation itself — only merges
already-segmented results and provides coverage analysis.
"""
SLOT_NAMES = ["objects", "actions", "modifiers", "additional"]
ADDITIONAL_PATTERN_RE = re.compile(
r'\b(' + '|'.join(STOP_WORDS) + r')\s+(\S+)',
re.IGNORECASE,
)
def __init__(self, phrases):
self._phrases = list(phrases)
self._slots = {
name: OrderedDict() for name in self.SLOT_NAMES
}
def merge_batch(self, batch_index, phrase_indexes, slots):
"""Merge slots from one batch into accumulated state.
Args:
batch_index: sequential index of this batch
phrase_indexes: indices into self._phrases covered by batch
slots: dict with keys from SLOT_NAMES, values are variant lists
Raises:
ValueError: if phrase_indexes are out of range
"""
n = len(self._phrases)
for pi in phrase_indexes:
if not isinstance(pi, int) or pi < 0 or pi >= n:
raise ValueError(
f"phrase_index {pi} вне диапазона [0..{n - 1}]"
)
for slot_name in self.SLOT_NAMES:
variants = slots.get(slot_name, [])
if not isinstance(variants, list):
continue
for v in variants:
v_str = str(v).strip()
if not v_str:
continue
cleaned, _ = sanitize_variant(v_str, slot_name)
if not cleaned:
continue
canon = re.sub(r'\s+', ' ', cleaned.lower()).strip()
slot_dict = self._slots[slot_name]
if canon not in slot_dict:
slot_dict[canon] = {
"display": cleaned,
"batch_indexes": set(),
}
slot_dict[canon]["batch_indexes"].add(batch_index)
def _remove_subsets(self):
"""Remove subset variants within each slot (heuristic).
If normalized tokens of A are a strict subset of tokens of B,
B is redundant in Wordstat OR (A already covers wider queries).
Comparison uses normalized tokens (lower, no punctuation).
"""
for slot_name in self.SLOT_NAMES:
slot_dict = self._slots[slot_name]
keys = list(slot_dict.keys())
token_sets = {}
for k in keys:
tokens = set(
normalize_token(t)
for t in k.split()
if normalize_token(t)
)
token_sets[k] = tokens
to_remove = set()
for i, k_short in enumerate(keys):
for j, k_long in enumerate(keys):
if i == j or k_long in to_remove:
continue
ts_short = token_sets[k_short]
ts_long = token_sets[k_long]
if ts_short and ts_short < ts_long:
to_remove.add(k_long)
for k in to_remove:
del slot_dict[k]
def _compute_coverage(self):
"""Compute coverage of original phrases by merged slot variants."""
all_variant_tokens = set()
for slot_name in self.SLOT_NAMES:
for canon in self._slots[slot_name]:
for t in canon.split():
nt = normalize_token(t)
if nt:
all_variant_tokens.add(nt)
uncovered_phrases = []
all_phrase_tokens = set()
for idx, phrase in enumerate(self._phrases):
phrase_tokens = set()
for t in phrase.split():
nt = normalize_token(t)
if nt and nt not in STOP_WORDS:
phrase_tokens.add(nt)
all_phrase_tokens.add(nt)
if phrase_tokens and not (phrase_tokens & all_variant_tokens):
uncovered_phrases.append({"index": idx, "phrase": phrase})
uncovered_tokens = sorted(
all_phrase_tokens - all_variant_tokens - STOP_WORDS
)
additional_variants_lower = set(
self._slots.get("additional", OrderedDict()).keys()
)
additional_patterns = []
seen_patterns = set()
for phrase in self._phrases:
normalized_phrase = PUNCTUATION_RE.sub("", phrase.lower())
normalized_phrase = re.sub(r'\s+', ' ', normalized_phrase).strip()
for m in self.ADDITIONAL_PATTERN_RE.finditer(normalized_phrase):
pattern = re.sub(r'\s+', ' ', m.group(0)).strip()
if pattern not in seen_patterns:
seen_patterns.add(pattern)
if pattern not in additional_variants_lower:
additional_patterns.append(pattern)
return {
"uncovered_phrases": uncovered_phrases,
"uncovered_tokens": uncovered_tokens,
"additional_patterns": additional_patterns,
}
def finalize(self, max_query_length=4096):
"""Finalize merged slots: deduplicate, remove subsets, build query."""
self._remove_subsets()
slots_pre_trim = {}
for slot_name in self.SLOT_NAMES:
slots_pre_trim[slot_name] = [
entry["display"]
for entry in self._slots[slot_name].values()
]
coverage = self._compute_coverage()
debug = {}
for slot_name in self.SLOT_NAMES:
debug[slot_name] = {
entry["display"]: sorted(entry["batch_indexes"])
for entry in self._slots[slot_name].values()
}
slot_order = self.SLOT_NAMES
warnings = []
trimmed = []
sanitized_slots = {}
for slot_name in slot_order:
clean_variants = []
for entry in self._slots[slot_name].values():
clean_variants.append(add_stop_word_plus(entry["display"]))
sanitized_slots[slot_name] = clean_variants
def build_or_string(slot_variants):
if not slot_variants:
return ""
if len(slot_variants) == 1:
return slot_variants[0]
return "(" + "|".join(slot_variants) + ")"
def assemble_query(s_slots):
parts = []
for name in slot_order:
or_str = build_or_string(s_slots[name])
if or_str:
parts.append(or_str)
return " ".join(parts)
def est_phrases(s_slots):
product = 1
for name in slot_order:
n = len(s_slots[name])
if n > 0:
product *= n
return product
trim_order = ["additional", "modifiers", "actions"]
while est_phrases(sanitized_slots) > 200:
trimmed_any = False
for trim_slot in trim_order:
if len(sanitized_slots[trim_slot]) > 1:
removed = sanitized_slots[trim_slot].pop()
trimmed.append({
"slot": trim_slot, "removed": removed,
"reason": "estimated_phrases > 200",
})
trimmed_any = True
break
if not trimmed_any:
break
query = assemble_query(sanitized_slots)
while len(query) > max_query_length:
trimmed_any = False
for trim_slot in trim_order:
if len(sanitized_slots[trim_slot]) > 1:
removed = sanitized_slots[trim_slot].pop()
trimmed.append({
"slot": trim_slot, "removed": removed,
"reason": f"query_length > {max_query_length}",
})
trimmed_any = True
query = assemble_query(sanitized_slots)
break
if not trimmed_any:
warnings.append(
f"query_length {len(query)} > {max_query_length}"
" после максимальной обрезки"
)
break
slots_post_trim = {}
for slot_name in slot_order:
slots_post_trim[slot_name] = list(sanitized_slots[slot_name])
return {
"slots_pre_trim": slots_pre_trim,
"slots_post_trim": slots_post_trim,
"query": query,
"estimated_phrases": est_phrases(sanitized_slots),
"query_length": len(query),
"trimmed": trimmed,
"warnings": warnings,
"coverage": coverage,
"debug": debug,
}
def cmd_merge_slots(args):
"""Merge batch segmentation results from stdin JSON."""
raw = sys.stdin.read()
try:
data = json.loads(raw)
except json.JSONDecodeError as e:
sys.exit(f"Error: невалидный JSON на stdin: {e}")
phrases = data.get("phrases", [])
batches = data.get("batches", [])
if not isinstance(phrases, list) or not phrases:
sys.exit("Error: phrases должен быть непустым списком строк")
if not isinstance(batches, list) or not batches:
sys.exit("Error: batches должен быть непустым списком объектов")
for p in phrases:
if not isinstance(p, str):
sys.exit(f"Error: элемент phrases не строка: {type(p).__name__}")
merger = SlotMerger(phrases)
for i, batch in enumerate(batches):
if not isinstance(batch, dict):
sys.exit(f"Error: batch[{i}] не объект")
phrase_indexes = batch.get("phrase_indexes", [])
if not isinstance(phrase_indexes, list):
sys.exit(f"Error: batch[{i}].phrase_indexes не список")
slots = batch.get("slots", {})
if not isinstance(slots, dict):
sys.exit(f"Error: batch[{i}].slots не объект")
try:
merger.merge_batch(i, phrase_indexes, slots)
except ValueError as e:
sys.exit(f"Error: batch[{i}]: {e}")
result = merger.finalize(max_query_length=args.max_query_length)
print(json.dumps(result, ensure_ascii=False, indent=2))
def cmd_build_query(args):
"""Build OR-query from slots JSON."""
try:
slots = json.loads(args.slots_json)
except json.JSONDecodeError as e:
sys.exit(f"Error: невалидный JSON слотов: {e}")
max_len = args.max_query_length
warnings = []
trimmed = []
# Slot order: actions, objects, modifiers, additional
slot_order = ["actions", "objects", "modifiers", "additional"]
# Sanitize all variants
sanitized_slots = {}
for slot_name in slot_order:
raw_variants = slots.get(slot_name, [])
if not isinstance(raw_variants, list):
raw_variants = []
clean_variants = []
for v in raw_variants:
v_str = str(v).strip()
if not v_str:
continue
cleaned, san_warnings = sanitize_variant(v_str, slot_name)
warnings.extend(san_warnings)
if cleaned:
# Apply stop-word plus
cleaned = add_stop_word_plus(cleaned)
clean_variants.append(cleaned)
sanitized_slots[slot_name] = clean_variants
def build_or_string(slot_variants):
"""Build OR part: (a|b|c) or a."""
if len(slot_variants) == 0:
return ""
if len(slot_variants) == 1:
return slot_variants[0]
return "(" + "|".join(slot_variants) + ")"
def assemble_query(s_slots):
"""Assemble full query from slots dict."""
parts = []
for name in slot_order:
or_str = build_or_string(s_slots[name])
if or_str:
parts.append(or_str)
return " ".join(parts)
def estimated_phrases(s_slots):
"""Calculate estimated phrase count (product of slot sizes)."""
product = 1
for name in slot_order:
n = len(s_slots[name])
if n > 0:
product *= n
return product
# Trim priority: additional -> modifiers -> actions (objects untouched)
trim_order = ["additional", "modifiers", "actions"]
# Check estimated phrases limit
while estimated_phrases(sanitized_slots) > 200:
trimmed_any = False
for trim_slot in trim_order:
if len(sanitized_slots[trim_slot]) > 1:
removed = sanitized_slots[trim_slot].pop()
trimmed.append({"slot": trim_slot, "removed": removed, "reason": "estimated_phrases > 200"})
trimmed_any = True
break
if not trimmed_any:
break
# Check query length limit
query = assemble_query(sanitized_slots)
while len(query) > max_len:
trimmed_any = False
for trim_slot in trim_order:
if len(sanitized_slots[trim_slot]) > 1:
removed = sanitized_slots[trim_slot].pop()
trimmed.append({"slot": trim_slot, "removed": removed, "reason": f"query_length > {max_len}"})
trimmed_any = True
query = assemble_query(sanitized_slots)
break
if not trimmed_any:
warnings.append(
f"query_length {len(query)} всё ещё > {max_len} после максимальной обрезки"
)
break
result = {
"query": query,
"estimated_phrases": estimated_phrases(sanitized_slots),
"query_length": len(query),
"trimmed": trimmed,
"warnings": warnings,
}
print(json.dumps(result, ensure_ascii=False, indent=2))
def cmd_query_total(args):
"""Extract totalCount from a pre-fetched Wordstat response file.
Transport-agnostic: caller (query_total.sh) is responsible for the HTTP call
via common.sh:wordstat_request, which handles legacy vs cloud backend
selection. This function only parses the resulting legacy-shape JSON.
"""
try:
with open(args.json_file, "r", encoding="utf-8") as f:
resp_body = f.read()
except OSError as e:
result = {"error": f"Cannot read response file: {e}", "query": args.phrase}
print(json.dumps(result, ensure_ascii=False))
sys.exit(1)
try:
obj = json.loads(resp_body)
except json.JSONDecodeError:
result = {"error": "Invalid JSON response", "query": args.phrase, "raw": resp_body[:500]}
print(json.dumps(result, ensure_ascii=False))
sys.exit(1)
# Check for errors (multiple possible formats)
if "error" in obj:
err_val = obj["error"]
result = {"error": str(err_val), "query": args.phrase, "raw": resp_body[:500]}
print(json.dumps(result, ensure_ascii=False))
sys.exit(1)
if "error_code" in obj:
err_msg = f"{obj.get('error_str', '')}: {obj.get('error_detail', '')}"
result = {
"error": err_msg.strip(": "),
"error_code": obj["error_code"],
"query": args.phrase,
"raw": resp_body[:500],
}
print(json.dumps(result, ensure_ascii=False))
sys.exit(1)
# Extract totalCount with fallbacks (top-level or nested under "result")
total_count = obj.get("totalCount")
if total_count is None:
total_count = (obj.get("result") or {}).get("totalCount")
if total_count is None:
result = {
"error": "totalCount not found in response",
"query": args.phrase,
"raw": resp_body[:500],
}
print(json.dumps(result, ensure_ascii=False))
sys.exit(1)
result = {"total_count": int(total_count), "query": args.phrase}
print(json.dumps(result, ensure_ascii=False))
def main():
parser = argparse.ArgumentParser(description="Missed demand analysis tools")
sub = parser.add_subparsers(dest="command", required=True)
# parse-xlsx
p_parse = sub.add_parser("parse-xlsx", help="Parse Yandex Direct XLSX export")
p_parse.add_argument("file", help="Path to XLSX file")
p_parse.add_argument("--group", default=None, help="Filter by group ID")
# build-query
p_build = sub.add_parser("build-query", help="Build OR-query from slots")
p_build.add_argument("slots_json", help="JSON string with slots")
p_build.add_argument("--max-query-length", type=int, default=4096, help="Max query length (default: 4096)")
# merge-slots
p_merge = sub.add_parser(
"merge-slots",
help="Merge batch segmentation results (stdin JSON)",
)
p_merge.add_argument(
"--max-query-length", type=int, default=4096,
help="Max query length (default: 4096)",
)
# query-total
p_query = sub.add_parser(
"query-total",
help="Extract totalCount from a pre-fetched Wordstat response file (transport-agnostic)",
)
p_query.add_argument(
"--json-file",
required=True,
help="Path to JSON file containing legacy-shape Wordstat response (written by common.sh:wordstat_request)",
)
p_query.add_argument("--phrase", required=True, help="Original search phrase (for output formatting)")
args = parser.parse_args()
if args.command == "parse-xlsx":
cmd_parse_xlsx(args)
elif args.command == "build-query":
cmd_build_query(args)
elif args.command == "merge-slots":
cmd_merge_slots(args)
elif args.command == "query-total":
cmd_query_total(args)
if __name__ == "__main__":
main()
#!/bin/sh
# Get totalCount from Yandex Wordstat for an OR-query.
# Backend-aware: routes through common.sh wordstat_request.
#
# Usage:
# sh scripts/query_total.sh --phrase "(купить|заказать) телефон ретро" [--regions "213"]
#
# Output: JSON {"total_count": N, "query": "..."} or {"error": "...", "query": "..."}
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/common.sh"
# Parse arguments
PHRASE=""
REGIONS=""
while [ $# -gt 0 ]; do
case $1 in
--phrase|-p) PHRASE="$2"; shift 2 ;;
--regions|-r) REGIONS="$2"; shift 2 ;;
*) echo "Unknown option: $1"; exit 1 ;;
esac
done
if [ -z "$PHRASE" ]; then
echo "Usage: query_total.sh --phrase \"(a|b) query\" [--regions \"213\"]"
echo ""
echo "Options:"
echo " --phrase, -p Search phrase with operators and minus-words (required)"
echo " --regions, -r Region IDs, comma-separated (optional)"
echo ""
echo "Output: JSON with total_count"
exit 1
fi
load_config
# Build legacy-shape JSON params (same shape as top_requests.sh).
PHRASE_ESCAPED=$(json_escape "$PHRASE")
PARAMS="{\"phrase\":\"$PHRASE_ESCAPED\""
if [ -n "$REGIONS" ]; then
PARAMS="$PARAMS,\"regions\":[$REGIONS]"
fi
PARAMS="$PARAMS}"
TMPFILE="${TMPDIR:-/tmp}/ws_query_total_$$.json"
cleanup() { rm -f "$TMPFILE"; }
trap cleanup EXIT
# Backend-aware request — common.sh writes legacy-shape JSON to stdout
wordstat_request "topRequests" "$PARAMS" > "$TMPFILE"
# Delegate totalCount extraction + JSON formatting to Python helper.
# Python is transport-agnostic; future legacy removal touches zero Python code.
uv run --script "$SCRIPT_DIR/missed_demand.py" query-total \
--json-file "$TMPFILE" \
--phrase "$PHRASE"
#!/bin/sh
# Check Yandex Wordstat API connection (backend-aware)
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/common.sh"
load_config
echo "Checking Wordstat API connection..."
echo ""
# Test with a simple regions request
response=$(wordstat_request "regions" '{"phrase":"тест"}')
if echo "$response" | grep -q '"regions"'; then
echo "Wordstat API: OK"
echo ""
# Count regions in response
region_count=$(echo "$response" | grep -o '"regionId"' | wc -l | tr -d ' ')
echo "Test query 'тест' returned data for $region_count regions"
else
echo "Wordstat API: Error"
echo "$response"
exit 1
fi
echo ""
print_backend_info
echo ""
echo "Token/credentials are valid and API is accessible."
#!/bin/bash
# Get regional search statistics from Yandex Wordstat
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# common.sh is POSIX sh; bash sources it without issue.
. "$SCRIPT_DIR/common.sh"
# Defaults
PHRASE=""
REGION_TYPE="all"
DEVICES="all"
# Parse args
while [[ $# -gt 0 ]]; do
case $1 in
--phrase|-p) PHRASE="$2"; shift 2 ;;
--region-type|-t) REGION_TYPE="$2"; shift 2 ;;
--devices|-d) DEVICES="$2"; shift 2 ;;
*) echo "Unknown option: $1"; exit 1 ;;
esac
done
if [[ -z "$PHRASE" ]]; then
echo "Usage: regions_stats.sh --phrase \"search query\" [options]"
echo ""
echo "Options:"
echo " --phrase, -p Search phrase (required)"
echo " --region-type, -t Filter: cities, regions, all (default: all)"
echo " --devices, -d Device filter: all, desktop, phone, tablet (default: all)"
echo ""
echo "Examples:"
echo " bash scripts/regions_stats.sh --phrase \"юрист дтп\""
echo " bash scripts/regions_stats.sh --phrase \"юрист\" --region-type cities"
exit 1
fi
load_config
# Escape phrase for JSON
PHRASE_ESCAPED=$(json_escape "$PHRASE")
# Build JSON params
PARAMS="{\"phrase\":\"$PHRASE_ESCAPED\""
if [[ "$REGION_TYPE" != "all" ]]; then
PARAMS="$PARAMS,\"regionType\":\"$REGION_TYPE\""
fi
if [[ "$DEVICES" != "all" ]]; then
PARAMS="$PARAMS,\"devices\":\"$DEVICES\""
fi
PARAMS="$PARAMS}"
echo "=== Yandex Wordstat: Regional Statistics ==="
echo "Phrase: $PHRASE"
echo "Region type: $REGION_TYPE"
echo "Devices: $DEVICES"
echo ""
echo "Fetching data..."
result=$(wordstat_request "regions" "$PARAMS")
# Check for error
if echo "$result" | grep -q '"error"'; then
echo "Error:"
echo "$result"
exit 1
fi
echo ""
echo "=== Top 30 Regions ==="
echo ""
echo "| Region ID | Count | Affinity |"
echo "|-----------|-------|----------|"
# Extract regions data (top 30) - sort by count descending
count=0
echo "$result" | grep -o '"regionId":[0-9]*,"count":[0-9]*' | sort -t: -k3 -rn | head -30 | while IFS= read -r entry; do
count=$((count + 1))
region_id=$(echo "$entry" | grep -o '"regionId":[0-9]*' | sed 's/"regionId"://')
cnt=$(echo "$entry" | grep -o '"count":[0-9]*' | sed 's/"count"://')
echo "| $region_id | $(format_number "$cnt") |"
done
echo ""
echo "Note: Use search_region.sh --name \"City\" to find region names"
echo ""
echo "=== Raw JSON (first 2000 chars) ==="
echo "$result" | head -c 2000
echo ""
echo "[truncated]"
#!/bin/bash
# Show common Yandex regions
echo "=== Common Region IDs ==="
echo ""
echo "Countries:"
echo " 225 - Россия"
echo " 159 - Казахстан"
echo " 187 - Украина"
echo " 149 - Беларусь"
echo ""
echo "Federal Districts (Russia):"
echo " 3 - Центральный ФО"
echo " 17 - Северо-Западный ФО"
echo " 40 - Приволжский ФО"
echo " 52 - Уральский ФО"
echo " 59 - Сибирский ФО"
echo " 73 - Южный ФО"
echo " 26 - Дальневосточный ФО"
echo ""
echo "Major Cities:"
echo " 213 - Москва"
echo " 2 - Санкт-Петербург"
echo " 54 - Екатеринбург"
echo " 65 - Новосибирск"
echo " 43 - Казань"
echo " 35 - Краснодар"
echo " 47 - Нижний Новгород"
echo " 39 - Ростов-на-Дону"
echo " 51 - Самара"
echo " 172 - Уфа"
echo ""
echo "Moscow Region:"
echo " 1 - Москва и область"
echo " 213 - Москва (город)"
echo " 10716 - Московская область"
echo ""
echo "Use these IDs with --regions parameter in other scripts."
echo "Example: bash scripts/top_requests.sh --phrase \"test\" --regions 213"
#!/bin/bash
# Search for region by name
SEARCH=""
while [[ $# -gt 0 ]]; do
case $1 in
--name|-n) SEARCH="$2"; shift 2 ;;
*) echo "Unknown option: $1"; exit 1 ;;
esac
done
if [[ -z "$SEARCH" ]]; then
echo "Usage: search_region.sh --name \"city name\""
echo ""
echo "Examples:"
echo " bash scripts/search_region.sh --name \"Москва\""
echo " bash scripts/search_region.sh --name \"Казань\""
exit 1
fi
echo "Searching for: $SEARCH"
echo ""
# Hardcoded common regions
REGIONS="
225|Россия
159|Казахстан
187|Украина
149|Беларусь
3|Центральный ФО
17|Северо-Западный ФО
40|Приволжский ФО
52|Уральский ФО
59|Сибирский ФО
73|Южный ФО
26|Дальневосточный ФО
1|Москва и область
213|Москва
10716|Московская область
2|Санкт-Петербург
54|Екатеринбург
65|Новосибирск
43|Казань
35|Краснодар
47|Нижний Новгород
39|Ростов-на-Дону
51|Самара
172|Уфа
56|Челябинск
66|Омск
11|Пермь
14|Воронеж
38|Волгоград
37|Саратов
195|Тюмень
"
# Search (case-insensitive)
matches=$(echo "$REGIONS" | grep -i "$SEARCH" || true)
if [[ -z "$matches" ]]; then
echo "No regions found matching \"$SEARCH\""
echo ""
echo "Try running regions_tree.sh to see all common regions"
else
echo "Found:"
echo ""
echo "| ID | Name |"
echo "|----|------|"
echo "$matches" | while IFS='|' read -r id name; do
[[ -n "$id" ]] && echo "| $id | $name |"
done
fi
{"phrase": "юрист", "period": "PERIOD_WEEKLY", "fromDate": "2025-01-01T00:00:00Z", "toDate": "2025-12-31T00:00:00Z", "regions": ["213"], "devices": ["DEVICE_ALL"], "folderId": "b1g-test-folder"}
{
"results": [
{"date": "2025-12-01T00:00:00Z", "count": "1999", "share": 0.00201},
{"date": "2025-12-08T00:00:00Z", "count": "3095", "share": 0.00316}
]
}
{"phrase": "юрист дтп", "region": "REGION_CITIES", "devices": ["DEVICE_DESKTOP"], "folderId": "b1g-test-folder"}
{
"results": [
{"region": "213", "count": "5000", "share": 0.5, "affinityIndex": "120"},
{"region": "2", "count": "2000", "share": 0.2, "affinityIndex": "110"}
]
}
{"phrase": "юрист дтп", "numPhrases": "50", "regions": ["213", "2"], "devices": ["DEVICE_PHONE"], "folderId": "b1g-test-folder"}
{
"totalCount": "12345",
"results": [
{"phrase": "юрист дтп", "count": "2500"},
{"phrase": "юрист по дтп", "count": "1800"}
],
"associations": [
{"phrase": "адвокат авария", "count": "900"}
]
}
{"data":[{"date":"2025-12-01T00:00:00Z","count":1999,"share":0.00201},{"date":"2025-12-08T00:00:00Z","count":3095,"share":0.00316}]}
{"phrase":"юрист","period":"weekly","fromDate":"2025-01-01","toDate":"2025-12-31","regions":[213],"devices":"all"}
{"regions":[{"regionId":213,"count":5000,"share":0.5,"affinity":120},{"regionId":2,"count":2000,"share":0.2,"affinity":110}]}
{"phrase":"юрист дтп","regionType":"cities","devices":"desktop"}
{"totalCount":12345,"topRequests":[{"phrase":"юрист дтп","count":2500},{"phrase":"юрист по дтп","count":1800}],"associations":[{"phrase":"адвокат авария","count":900}]}
{"phrase":"юрист дтп","numPhrases":50,"regions":[213,2],"devices":"phone"}
#!/bin/sh
# Test runner for wordstat skill — POSIX sh, no network.
set -e
TESTS_DIR="$(cd "$(dirname "$0")" && pwd)"
PASS=0
FAIL=0
FAILED_TESTS=""
for t in "$TESTS_DIR"/test_*.sh; do
[ -f "$t" ] || continue
name=$(basename "$t" .sh)
printf '%s ... ' "$name"
if sh "$t" >/dev/null 2>&1; then
printf 'PASS\n'
PASS=$((PASS + 1))
else
printf 'FAIL\n'
FAIL=$((FAIL + 1))
FAILED_TESTS="$FAILED_TESTS $name"
# Re-run with output for diagnosis
echo "--- output of $name ---"
sh "$t" 2>&1 || true
echo "--- end ---"
fi
done
echo ""
echo "Results: $PASS passed, $FAIL failed"
if [ "$FAIL" -gt 0 ]; then
echo "Failed: $FAILED_TESTS"
exit 1
fi
exit 0
#!/bin/sh
# Test the dynamics operator preflight.
#
# Cloud Wordstat getDynamics: at weekly/monthly granularity, ONLY '+' operator is allowed.
# At daily granularity, all operators are allowed.
set -e
TESTS_DIR="$(cd "$(dirname "$0")" && pwd)"
SCRIPTS_DIR="$(cd "$TESTS_DIR/.." && pwd)"
SKILL_DIR="$(cd "$SCRIPTS_DIR/.." && pwd)"
WORDSTAT_SCRIPT_DIR="$SCRIPTS_DIR"
WORDSTAT_SKILL_DIR="$SKILL_DIR"
export WORDSTAT_SCRIPT_DIR WORDSTAT_SKILL_DIR
# shellcheck disable=SC1091
. "$SCRIPTS_DIR/common.sh"
WORDSTAT_CLOUD_FOLDER_ID="b1g-test-folder"
# Run _xlate_request and capture both stdout and exit code.
# Returns "PASS" if exit 0, "FAIL:<msg>" if exit 2 (preflight reject).
preflight_check() {
_method="$1"
_params="$2"
_err_file="${TMPDIR:-/tmp}/preflight_err_$$"
if _xlate_request "$_method" "$_params" >/dev/null 2>"$_err_file"; then
rm -f "$_err_file"
echo "PASS"
else
_msg=$(cat "$_err_file")
rm -f "$_err_file"
echo "FAIL:$_msg"
fi
}
assert_pass() {
_name="$1"; _result="$2"
case "$_result" in
PASS) echo " ok (pass): $_name" ;;
*) echo " FAIL (expected pass): $_name → $_result"; exit 1 ;;
esac
}
assert_fail() {
_name="$1"; _result="$2"
case "$_result" in
FAIL:*PREFLIGHT_FAIL*) echo " ok (fail): $_name" ;;
*) echo " FAIL (expected preflight fail): $_name → $_result"; exit 1 ;;
esac
}
# --- Must PASS at weekly: intra-word hyphens, slashes, + operator ---
assert_pass "weekly + санкт-петербург" \
"$(preflight_check dynamics '{"phrase":"санкт-петербург","period":"weekly","fromDate":"2025-01-01"}')"
assert_pass "weekly + б/у дымоход" \
"$(preflight_check dynamics '{"phrase":"б/у дымоход","period":"weekly","fromDate":"2025-01-01"}')"
assert_pass "weekly + премиум-класс" \
"$(preflight_check dynamics '{"phrase":"премиум-класс","period":"weekly","fromDate":"2025-01-01"}')"
assert_pass "weekly + юрист +по дтп (+ allowed)" \
"$(preflight_check dynamics '{"phrase":"юрист +по дтп","period":"weekly","fromDate":"2025-01-01"}')"
assert_pass "monthly + plain phrase" \
"$(preflight_check dynamics '{"phrase":"юрист дтп","period":"monthly","fromDate":"2025-01-01"}')"
# --- Must FAIL at weekly/monthly: token-leading -, !, " ( | ) ---
assert_fail "weekly + юрист -бесплатно (minus-word)" \
"$(preflight_check dynamics '{"phrase":"юрист -бесплатно","period":"weekly","fromDate":"2025-01-01"}')"
assert_fail "weekly + \"юрист дтп\" (quotes)" \
"$(preflight_check dynamics '{"phrase":"\"юрист дтп\"","period":"weekly","fromDate":"2025-01-01"}')"
assert_fail "weekly + (юрист|адвокат) (grouping)" \
"$(preflight_check dynamics '{"phrase":"(юрист|адвокат) дтп","period":"weekly","fromDate":"2025-01-01"}')"
assert_fail "monthly + !юрист (exact form)" \
"$(preflight_check dynamics '{"phrase":"!юрист","period":"monthly","fromDate":"2025-01-01"}')"
# --- Must PASS at daily: all operators allowed ---
assert_pass "daily + юрист -бесплатно" \
"$(preflight_check dynamics '{"phrase":"юрист -бесплатно","period":"daily","fromDate":"2025-01-01"}')"
assert_pass "daily + \"юрист дтп\"" \
"$(preflight_check dynamics '{"phrase":"\"юрист дтп\"","period":"daily","fromDate":"2025-01-01"}')"
assert_pass "daily + (a|b) грouping" \
"$(preflight_check dynamics '{"phrase":"(a|b) test","period":"daily","fromDate":"2025-01-01"}')"
echo "test_dynamics_preflight: all passed"
#!/bin/sh
# Test that _normalize_response correctly transforms cloud responses to legacy shape.
set -e
TESTS_DIR="$(cd "$(dirname "$0")" && pwd)"
SCRIPTS_DIR="$(cd "$TESTS_DIR/.." && pwd)"
SKILL_DIR="$(cd "$SCRIPTS_DIR/.." && pwd)"
FIXTURES="$TESTS_DIR/fixtures"
# Pre-set dir vars so common.sh doesn't try to resolve them via $0
# (POSIX sh has no portable way to get the path to a sourced script)
WORDSTAT_SCRIPT_DIR="$SCRIPTS_DIR"
WORDSTAT_SKILL_DIR="$SKILL_DIR"
export WORDSTAT_SCRIPT_DIR WORDSTAT_SKILL_DIR
# shellcheck disable=SC1091
. "$SCRIPTS_DIR/common.sh"
assert_eq() {
_name="$1"; _actual="$2"; _expected="$3"
if [ "$_actual" = "$_expected" ]; then
echo " ok: $_name"
return 0
fi
echo " FAIL: $_name"
echo " actual: $_actual"
echo " expected: $_expected"
return 1
}
# Compare two JSON strings semantically (key order independent)
json_eq() {
_a="$1"; _b="$2"
_A="$_a" _B="$_b" python3 -c "
import json, os, sys
try:
a = json.loads(os.environ['_A'])
b = json.loads(os.environ['_B'])
except Exception as e:
print(f'PARSE: {e}')
sys.exit(1)
if a == b:
sys.exit(0)
print(f'NEQ: actual={json.dumps(a, ensure_ascii=False)} expected={json.dumps(b, ensure_ascii=False)}')
sys.exit(1)
"
}
run_normalize_test() {
_method="$1"
_cloud_file="$FIXTURES/cloud-${_method}-response.json"
_expected_file="$FIXTURES/legacy-${_method}-expected.json"
actual=$(_normalize_response "$_method" < "$_cloud_file")
expected=$(cat "$_expected_file")
if json_eq "$actual" "$expected"; then
echo " ok: normalize $_method"
else
echo " FAIL: normalize $_method"
echo " actual: $actual"
echo " expected: $expected"
return 1
fi
}
run_normalize_test topRequests
run_normalize_test dynamics
run_normalize_test regions
echo "test_normalize: all passed"
#!/bin/sh
# Test load_config backend selection. Structural-only — no IAM, no network.
set -e
TESTS_DIR="$(cd "$(dirname "$0")" && pwd)"
SCRIPTS_DIR="$(cd "$TESTS_DIR/.." && pwd)"
# Helper: run load_config in a subshell with given setup; capture WORDSTAT_BACKEND.
# Each call uses a fresh transient skill dir under $TMPDIR.
run_selector() {
_label="$1"; _config_setup="$2"; _override="$3"; _legacy_token="$4"
_td="${TMPDIR:-/tmp}/wordstat_test_$$_$(printf '%s' "$_label" | tr ' /+' '___')"
rm -rf "$_td"
mkdir -p "$_td/config" "$_td/scripts" "$_td/cache"
case "$_config_setup" in
legacy_only)
# No config.json; legacy creds come via env in subshell
;;
cloud_only)
cat > "$_td/config/config.json" <<EOF
{"yandex_cloud_folder_id":"b1g-test","auth":{"service_account_key_file":"config/sa_key.json"}}
EOF
: > "$_td/config/sa_key.json"
;;
both)
cat > "$_td/config/config.json" <<EOF
{"yandex_cloud_folder_id":"b1g-test","auth":{"service_account_key_file":"config/sa_key.json"}}
EOF
: > "$_td/config/sa_key.json"
;;
malformed_cloud)
# config.json present but key file missing
cat > "$_td/config/config.json" <<EOF
{"yandex_cloud_folder_id":"b1g-test","auth":{"service_account_key_file":"config/missing.json"}}
EOF
;;
empty)
;;
esac
_result=$(
# Pre-set dir vars so common.sh doesn't try to resolve them via $0
WORDSTAT_SCRIPT_DIR="$_td/scripts"
WORDSTAT_SKILL_DIR="$_td"
WORDSTAT_CONFIG_DIR="$_td/config"
WORDSTAT_CACHE_DIR="$_td/cache"
export WORDSTAT_SCRIPT_DIR WORDSTAT_SKILL_DIR WORDSTAT_CONFIG_DIR WORDSTAT_CACHE_DIR
if [ -n "$_override" ]; then
export YANDEX_WORDSTAT_BACKEND="$_override"
else
unset YANDEX_WORDSTAT_BACKEND 2>/dev/null || true
fi
if [ -n "$_legacy_token" ]; then
export YANDEX_WORDSTAT_TOKEN="$_legacy_token"
else
unset YANDEX_WORDSTAT_TOKEN 2>/dev/null || true
fi
# shellcheck disable=SC1091
. "$SCRIPTS_DIR/common.sh"
# Run load_config in nested subshell so die_with_help's exit stays scoped.
# Suppress set -e via `if` so we can read the exit code without aborting.
if ( load_config ) >/dev/null 2>&1; then
# Re-run in current shell to capture the exported WORDSTAT_BACKEND
load_config 2>/dev/null
printf 'BACKEND=%s\n' "$WORDSTAT_BACKEND"
else
printf 'DIE\n'
fi
)
rm -rf "$_td"
printf '%s' "$_result"
}
assert_backend() {
_label="$1"; _result="$2"; _expected="$3"
_backend=$(printf '%s' "$_result" | sed -n 's/^BACKEND=//p' | tail -n 1)
if [ "$_backend" = "$_expected" ]; then
echo " ok: $_label → $_expected"
else
echo " FAIL: $_label expected '$_expected' got '$_backend'"
echo " full output: $_result"
exit 1
fi
}
assert_die() {
_label="$1"; _result="$2"
case "$_result" in
*DIE*) echo " ok: $_label → DIE (expected)" ;;
*) echo " FAIL: $_label expected DIE got: $_result"; exit 1 ;;
esac
}
# 1. Legacy only → legacy
out=$(run_selector "legacy_only" "legacy_only" "" "test_token")
assert_backend "legacy only" "$out" "legacy"
# 2. Cloud only → cloud
out=$(run_selector "cloud_only" "cloud_only" "" "")
assert_backend "cloud only" "$out" "cloud"
# 3. Both → cloud (cloud wins on tie)
out=$(run_selector "both" "both" "" "test_token")
assert_backend "both → cloud wins" "$out" "cloud"
# 4. Both + override=legacy → legacy
out=$(run_selector "both_pin_legacy" "both" "legacy" "test_token")
assert_backend "both + override=legacy" "$out" "legacy"
# 5. Cloud only + override=legacy without token → DIE
out=$(run_selector "cloud_pin_legacy_no_token" "cloud_only" "legacy" "")
assert_die "override=legacy without token" "$out"
# 6. Empty → DIE
out=$(run_selector "empty" "empty" "" "")
assert_die "empty config" "$out"
# 7. Malformed cloud (config.json present, key file missing) → DIE
out=$(run_selector "malformed_cloud" "malformed_cloud" "" "")
assert_die "malformed cloud config" "$out"
# 8. Malformed cloud + legacy token → DIE (cloud config error takes precedence)
out=$(run_selector "malformed_cloud_with_legacy" "malformed_cloud" "" "test_token")
assert_die "malformed cloud with legacy token" "$out"
echo "test_selector: all passed"
#!/bin/sh
# Test that _xlate_request correctly transforms legacy params to cloud body.
set -e
TESTS_DIR="$(cd "$(dirname "$0")" && pwd)"
SCRIPTS_DIR="$(cd "$TESTS_DIR/.." && pwd)"
SKILL_DIR="$(cd "$SCRIPTS_DIR/.." && pwd)"
FIXTURES="$TESTS_DIR/fixtures"
WORDSTAT_SCRIPT_DIR="$SCRIPTS_DIR"
WORDSTAT_SKILL_DIR="$SKILL_DIR"
export WORDSTAT_SCRIPT_DIR WORDSTAT_SKILL_DIR
# shellcheck disable=SC1091
. "$SCRIPTS_DIR/common.sh"
# Set folder id for translation injection
WORDSTAT_CLOUD_FOLDER_ID="b1g-test-folder"
json_eq() {
_a="$1"; _b="$2"
_A="$_a" _B="$_b" python3 -c "
import json, os, sys
a = json.loads(os.environ['_A'])
b = json.loads(os.environ['_B'])
if a == b:
sys.exit(0)
print(f'NEQ: actual={json.dumps(a, ensure_ascii=False)} expected={json.dumps(b, ensure_ascii=False)}')
sys.exit(1)
"
}
run_translate_test() {
_method="$1"
_params=$(cat "$FIXTURES/legacy-${_method}-params.json")
_expected=$(cat "$FIXTURES/cloud-${_method}-request-expected.json")
actual=$(_xlate_request "$_method" "$_params")
if json_eq "$actual" "$_expected"; then
echo " ok: translate $_method"
else
echo " FAIL: translate $_method"
echo " actual: $actual"
echo " expected: $_expected"
return 1
fi
}
run_translate_test topRequests
run_translate_test dynamics
run_translate_test regions
echo "test_translate: all passed"