У нас небольшой сайт на WordPress в финансовой нише, SEO делаем своими руками. Позиции раньше снимал платный сервис, частотность я смотрел в браузерном Вордстате, по одной фразе. В октябре перевёл и то и другое на Search API Яндекс Облака. Получилась горстка PHP-скриптов, которые запускаются через wp eval-file на том же сервере, где живёт сайт.
Дальше будет схема, упрощённый код и грабли. Код обезличен: ни ключей, ни путей, ни идентификаторов облака. Поля API у Яндекса меняются, так что перед копированием сверьтесь с документацией.
Зачем WP-CLI
Сайт и так на WordPress. wp eval-file script.php аргументы запускает PHP-файл с уже загруженным ядром, и половина обвязки достаётся бесплатно:
get_option()иupdate_option()хранят ключ, настройки и учёт расхода;wp_remote_request()работает как HTTP-клиент, с таймаутами иWP_Errorвместо исключений;для вывода есть
WP_CLI::error()иWP_CLI::success();позиционные аргументы приходят в
$args.
Ни отдельного приложения, ни деплоя. И ключ не валяется на рабочем ноутбуке.
Сколько это стоит
Цены на октябрь 2026, за 1 000 запросов. Вордстат (топ и динамика) — 20 ₽, разбивка по регионам — 50 ₽. Поиск в отложенном режиме: 30,5 ₽ днём, 25,41 ₽ ночью. Синхронный — 488 ₽.

Разница между режимами простая. Синхронный отвечает сразу. Отложенный принимает задание, отдаёт id операции, а результат вы забираете потом. Позициям спешить некуда, так что у меня основной режим отложенный, а синхронный остался для разовой проверки одного запроса.
Если переводить в привычные цифры: сотня запросов Вордстата обходится примерно в 2 ₽, сотня позиций отложенным поиском — в 3 ₽. Первый прогон (61 маркер Вордстата, позиции по 106 запросам, топ-10 конкурентов по 25 запросам и динамика по 10 маркерам) стоил около 6 ₽. Я сначала подумал, что где-то ошибся в подсчётах.
Доступ и ключ
В консоли облака заводим сервисный аккаунт с ролью search-api.webSearch.user, её хватает и на Вордстат, и на поиск. Аккаунту выпускаем API-ключ.
Ключ живёт в опции WordPress. Хотелось, чтобы он не светился ни в истории shell, ни в списке процессов, и тут помогает мелочь: если значение не передать аргументом, WP-CLI прочитает его из STDIN.
tr -d '\r\n ' < key.txt | wp option update yc_api_key --autoload=no
--autoload=no нужен, чтобы ключ не грузился в память на каждый запрос к сайту.
Минус у такого хранения есть: опции читает любой плагин. Константа в wp-config.php надёжнее, зато опцию проще поменять без доступа к файлам. У нас плагинов немного, меня это устраивает. На сайте с парой десятков сторонних плагинов я бы подумал ещё раз.
Как всё устроено
yc_lib.php ключ, id каталога, HTTP с повторами, учёт расхода │ ├── ws.php фраза → топ Вордстата + «похожие» (разовый запрос) ├── wsdyn.php фраза → динамика по месяцам / неделям / дням ├── wsbatch.php seeds.txt → out.tsv (маркер, top|assoc, частота, фраза) │ └── serpq.php отложенный поиск submit keys.txt [lr] [строка] → очередь операций status → сколько готово collect [домен] → pos_ГГГГММДД.tsv, serp_top_ГГГГММДД.tsv

Выход везде в TSV. Его откроет любая таблица, и с ним нормально работают cut, sort и awk.
Регионы в Вордстате задаются кодами: 10174 — Петербург с Ленобластью (наш основной), 2 — Петербург, 1 — Москва и область, 225 — вся Россия. Для выдачи регион передаётся как lr=2.
Общая библиотека
Сюда ушло всё, что повторялось в каждом скрипте: ключ, HTTP, повторы и учёт.
<?php // yc_lib.php — общая библиотека. Подключается из всех скриптов. // Запуск только через `wp eval-file`, поэтому WordPress уже загружен. const YC_SEARCH = 'https://searchapi.api.cloud.yandex.net/v2'; const YC_OPS = 'https://operation.api.cloud.yandex.net/operations/'; function yc_key(): string { $k = (string) get_option('yc_api_key', ''); if ($k === '') { WP_CLI::error('Нет ключа в опции yc_api_key'); } return $k; } function yc_folder(): string { return (string) get_option('yc_folder_id', ''); } // Учёт расхода: сколько запросов какого типа ушло за день. function yc_count(string $kind, int $n = 1): void { $u = get_option('yc_usage', []); $d = gmdate('Y-m-d'); $u[$d][$kind] = ($u[$d][$kind] ?? 0) + $n; update_option('yc_usage', $u, false); } // HTTP с повторами: 429 и 5xx — пауза и ещё попытка, 401/403 — сразу стоп. function yc_http(string $method, string $url, ?array $body = null, int $tries = 5): ?array { $args = [ 'method' => $method, 'timeout' => 60, 'headers' => [ 'Authorization' => 'Api-Key ' . yc_key(), 'Content-Type' => 'application/json', ], ]; if ($body !== null) { $args['body'] = wp_json_encode($body); } for ($i = 1; $i <= $tries; $i++) { $r = wp_remote_request($url, $args); if (is_wp_error($r)) { // таймаут, обрыв соединения sleep(2 * $i); continue; } $code = wp_remote_retrieve_response_code($r); if ($code === 200) { return json_decode(wp_remote_retrieve_body($r), true); } if ($code === 401 || $code === 403) { WP_CLI::error("HTTP $code: ключ отозван или у аккаунта нет роли"); } if ($code === 429 || $code >= 500) { // перегруз, 504 и прочее sleep(2 * $i); continue; } WP_CLI::warning("HTTP $code: " . wp_remote_retrieve_body($r)); return null; } return null; // попытки кончились }
Пара мест, которые стоит пояснить.
На 401 и 403 скрипт останавливается сразу. Это или отозванный ключ, или аккаунт без роли, и повтор тут ничего не даст. В облаке скрипт ничего не правит сам, только сообщает.
На 429 и 5xx он ждёт и пробует ещё раз. 429 API отдавал при перегрузе, а на длинных пакетах регулярно прилетали таймауты и 504.
Учёт по дням лежит в опции: wp option get yc_usage --format=json показывает, сколько и каких запросов ушло. Запросы стоят копейки, но копейки, умноженные на цикл с ошибкой, — это уже деньги, так что я туда поглядываю.
Вордстат пачкой
На входе файл с маркерами, по одному в строке, на выходе TSV. Главное, чего я хотел: чтобы пакет на пару тысяч маркеров после любого сбоя продолжал с места остановки, а не начинал с нуля.
<?php // wp eval-file wsbatch.php seeds.txt out.tsv [регион=10174] [фраз=2000] require __DIR__ . '/yc_lib.php'; [$seeds, $out] = [$args[0], $args[1]]; $region = $args[2] ?? '10174'; $limit = $args[3] ?? '2000'; // Маркеры, которые уже есть в out.tsv, пропускаем: так скрипт продолжает // с места остановки после 429, таймаута или обрыва сессии. $done = []; if (is_file($out)) { foreach (file($out, FILE_IGNORE_NEW_LINES) as $line) { $p = explode("\t", $line); if (($p[1] ?? '') !== 'err') { // упавшие маркеры пробуем снова $done[$p[0]] = true; } } } $fh = fopen($out, 'a'); foreach (file($seeds, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $seed) { $seed = trim($seed); if (isset($done[$seed])) { continue; } $res = yc_http('POST', YC_SEARCH . '/wordstat/topRequests', [ 'phrase' => $seed, 'numPhrases' => $limit, 'regions' => [$region], 'folderId' => yc_folder(), ]); yc_count('wordstat'); if ($res === null) { fwrite($fh, "$seed\terr\t0\t\n"); // прогоним потом отдельно continue; } foreach (['results' => 'top', 'associations' => 'assoc'] as $key => $type) { foreach ($res[$key] ?? [] as $row) { fwrite($fh, implode("\t", [$seed, $type, $row['count'], $row['phrase']]) . "\n"); } } fflush($fh); // строка на диске сразу } fclose($fh); WP_CLI::success('Готово');
Продолжение держится на двух вещах: fflush() после каждого маркера и пропуске того, что уже есть в файле. Маркер, который не прошёл и после всех повторов, записывается строкой err, и следующий запуск попробует его снова.
Длинный пакет я запускаю отвязанным от терминала, иначе обрыв SSH его убивает:
nohup setsid wp eval-file wsbatch.php seeds.txt out.tsv 10174 2000 \ < /dev/null > wsbatch.log 2>&1 &
Для понимания объёма: глубокое ядро у нас — 2 154 маркера. Держать такое в открытом терминале не хочется.
Позиции: submit, status, collect
Отложенный поиск разбит на три команды. submit отправляет запросы и складывает id операций в очередь, status показывает, сколько готово, collect забирает результаты, разбирает XML и пишет TSV.
<?php // wp eval-file serpq.php submit keys.txt [lr=2] [с какой строки=1] // wp eval-file serpq.php status // wp eval-file serpq.php collect [домен] require __DIR__ . '/yc_lib.php'; const QUEUE = __DIR__ . '/serp_queue.json'; function q_load(): array { return is_file(QUEUE) ? json_decode(file_get_contents(QUEUE), true) : []; } function q_save(array $q): void { file_put_contents(QUEUE, json_encode($q, JSON_UNESCAPED_UNICODE)); } $cmd = $args[0] ?? 'status'; if ($cmd === 'submit') { $keys = file($args[1], FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); $lr = $args[2] ?? '2'; $start = max(1, (int) ($args[3] ?? 1)); $queue = q_load(); for ($i = $start - 1; $i < count($keys); $i++) { $res = yc_http('POST', YC_SEARCH . '/web/searchAsync', [ 'query' => ['searchType' => 'SEARCH_TYPE_RU', 'queryText' => $keys[$i]], 'groupSpec' => ['groupMode' => 'GROUP_MODE_DEEP', 'groupsOnPage' => '100', 'docsInGroup' => '1'], 'region' => $lr, 'folderId' => yc_folder(), 'responseFormat' => 'FORMAT_XML', ]); if ($res === null || empty($res['id'])) { WP_CLI::error('Остановились на строке ' . ($i + 1) . '. Продолжить: submit ' . $args[1] . " $lr " . ($i + 1)); } yc_count('serp_async'); $queue[] = ['q' => $keys[$i], 'lr' => $lr, 'op' => $res['id'], 'done' => false]; q_save($queue); // очередь на диске после каждого запроса usleep(250000); // 4 запроса в секунду при квоте 10 } WP_CLI::success(count($queue) . ' в очереди'); } if ($cmd === 'status') { $q = q_load(); $ready = count(array_filter($q, fn($x) => $x['done'])); WP_CLI::line("Всего: " . count($q) . ", собрано: $ready"); } if ($cmd === 'collect') { $domain = $args[1] ?? 'example.ru'; $date = gmdate('Ymd'); $pos = fopen(__DIR__ . "/pos_$date.tsv", 'a'); $top = fopen(__DIR__ . "/serp_top_$date.tsv", 'a'); $queue = q_load(); foreach ($queue as &$item) { if ($item['done']) { continue; } $op = yc_http('GET', YC_OPS . $item['op']); if (empty($op['done'])) { continue; // ещё не готово, заберём в следующий раз } $xml = simplexml_load_string(base64_decode($op['response']['rawData'])); $urls = []; foreach ($xml->xpath('//group/doc/url') as $u) { $urls[] = (string) $u; } $place = 0; $found = ''; foreach ($urls as $n => $u) { $host = preg_replace('~^www\.~', '', (string) parse_url($u, PHP_URL_HOST)); if ($host === $domain) { $place = $n + 1; $found = $u; break; } } // дата, позиция (0 = вне 100), запрос, регион, URL fwrite($pos, implode("\t", [$date, $place, $item['q'], $item['lr'], $found]) . "\n"); foreach (array_slice($urls, 0, 20) as $n => $u) { fwrite($top, implode("\t", [$date, $item['q'], $n + 1, $u]) . "\n"); } $item['done'] = true; q_save($queue); } unset($item); fclose($pos); fclose($top); WP_CLI::success('Собрано'); }
Некоторые решения здесь появились только со второй-третьей попытки.
Пауза в 0,25 секунды. Квота отложенного поиска — 10 запросов в секунду. Четыре в секунду оставляют запас и под повторы, и под соседние скрипты, которые могут работать параллельно.
Очередь сохраняется на диск после каждого запроса. В упрощённой версии это JSON-файл рядом со скриптом. Если submit упал на 57-й строке, 56 операций уже в очереди, а скрипт подсказывает, откуда продолжить: submit keys.txt 2 57. Первая версия так не умела, и после 429 выбор был невесёлый: отправить всё заново и заплатить второй раз или руками искать, где остановились.
GROUP_MODE_DEEP и 100 групп. Выдача группируется по сайтам, так что номер группы и есть позиция домена. 0 в TSV значит «вне сотни».
Топ-20 рядом с позицией. collect пишет вторым файлом первые 20 сайтов по каждому запросу. Позиция просела — сразу видно, кто встал на ваше место. Заодно получается срез конкурентов без лишних запросов.
На что наступили
Позиции прыгают между замерами. 6 октября 4 запроса из 25 в одном замере показали «вне 100», а в другом нашлись. С тех пор выводы делаю только по двум замерам, благо второй замер по сотне запросов стоит около 3 ₽.
429 посреди пакета. Собственно, из-за него и появились продолжение с номера строки в submit и пропуск готовых маркеров в wsbatch.
Таймауты и 504 на Вордстате. Первая версия пакетного скрипта на них просто падала. Вторая повторяет запрос, а совсем безнадёжные маркеры помечает err и идёт дальше.
И синхронный поиск под рукой. Разовую проверку легко по привычке завернуть в цикл, и тогда тысяча запросов обойдётся в 488 ₽ вместо 30,5 ₽. Поэтому синхронный скрипт у меня лежит отдельно и в пакетах не участвует.
Итог
Библиотека и несколько небольших скриптов заменили нам платный съём позиций и ручной Вордстат. Данные лежат в TSV на нашем же сервере, расход видно по дням, а сбой API теперь стоит паузы, а не повторной оплаты.
Интерфейса, графиков и кластеризации тут нет, и я их не добавляю: TSV дальше разбираю своими скриптами и в таблицах. Если без дашборда никак, платный сервис по-прежнему удобнее. Мне важнее было видеть, за что именно я плачу.
А как вы снимаете позиции: сервисом, своими скриптами или уже никак, раз выдача у всех разная?