ESPNNFL tight ends show out on National Tight Ends DayThe Jerusalem PostPutin to meet Iran's Pezeshkian, Emir of Qatar on Tuesday in Moscow, Kremlin saysESPN DeportesBrewers, expertos en remontadas, en camino al título de MLBDaily MaverickEl Niño threatens to deepen southern Africa’s poverty crisisInquirer EntertainmentLove Knots, October 27, 2026ZDF heuteEntdecken Sie das ZDF-NachrichtenstudioNMEPatti Smith tells Dua Lipa bout her “book of gratitude” ‘Bread Of Angels’ at emotional London Literature Festival appearance: “I swore I’d never write another memoir”Numerama« Si une bande de gamins peut faire ça, les Chinois ne sont peut-être pas loin derrière » : un incident chez Anthropic inquiète Washington20 Minuten«Er fiel 1500 Meter»: Sturz von Wolfgang W. gibt Rätsel aufSRF NewsAuslosung Viertelfinals – GC – YB und Servette – Basel als Schlagerpartien im Frauen-CupWirtualna PolskaSerbia skręciła na Zachód. Ekspertka: W Belgradzie są cały czas media spod znaku PutinaScreen RantAlexander Skarsgård's New Fantasy Movie Wicker Makes Back Half Its Budget In One Weekend
The Daily Newsstand · Free, Always
Monday, October 26, 2026

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

Translate

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

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

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.