Съём позиций в Яндексе через 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 ₽.

Цены Search API на октябрь 2026, за 1 000 запросов

Разница между режимами простая. Синхронный отвечает сразу. Отложенный принимает задание, отдаёт 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 дальше разбираю своими скриптами и в таблицах. Если без дашборда никак, платный сервис по-прежнему удобнее. Мне важнее было видеть, за что именно я плачу.

А как вы снимаете позиции: сервисом, своими скриптами или уже никак, раз выдача у всех разная?

ссылка на оригинал статьи https://habr.com/ru/articles/1099628/