Съём позиций в Яндексе через Search API и WP-CLI: около 3 ₽ за сотню запросов

У нас небольшой сайт на 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 дальше разбираю своими скриптами и в таблицах. Если без дашборда никак, платный сервис по-прежнему удобнее. Мне важнее было видеть, за что именно я плачу.
А как вы снимаете позиции: сервисом, своими скриптами или уже никак, раз выдача у всех разная?
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.