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

в 14:04, , рубрики: search api, seo, wp-cli, вордстат, позиции сайта

У нас небольшой сайт на 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 и WP-CLI: около 3 ₽ за сотню запросов - 1

Цены 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 'rn ' < 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
Съём позиций в Яндексе через Search API и WP-CLI: около 3 ₽ за сотню запросов - 2

Как устроен съём позиций через отложенный поиск

Выход везде в 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, "$seedterrt0tn");   // прогоним потом отдельно
        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 дальше разбираю своими скриптами и в таблицах. Если без дашборда никак, платный сервис по-прежнему удобнее. Мне важнее было видеть, за что именно я плачу.

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

Автор: gyfus

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js