Скрипт массового обогащения IP (Python)

Этот Python-скрипт читает CSV со списком IP-адресов, обращается к бесплатному API ip.xakki.pro для каждого адреса и записывает новый CSV с добавленными колонками: страна, ASN, имя AS и признаки прокси/анонимности/угрозы — со встроенными повторами с экспоненциальной задержкой и честной обработкой лимитов API по уникальному IP, так что скрипт безопасно запускать на реальном продакшн-логе или выгрузке.

Как запустить этот скрипт

Зависимости: нет — только стандартная библиотека Python 3.8+ (urllib, json, csv, ipaddress, argparse). pip install не требуется.

  1. Скачайте bulk-enrich.py ниже (или скопируйте из блока кода).
  2. Подготовьте CSV минимум с одной колонкой IP-адресов (по умолчанию колонка называется "ip").
  3. Запустите: python3 bulk-enrich.py input.csv output.csv
  4. Откройте output.csv — в нём все исходные колонки плюс country, asn, as_name, is_proxy, is_anonymous, is_malicious и enrich_status.

Полный исходный код

#!/usr/bin/env python3
# SPDX-License-Identifier: MIT
"""bulk-enrich.py -- enrich a CSV/log of IP addresses via the ip.xakki.pro API.

Reads a CSV with one IP address per row (default column name "ip"), calls
GET https://ip.xakki.pro/api/v1/ip?ip=<addr>&include=network,security for
each row, and writes a new CSV with extra columns: country, asn, as_name,
is_proxy, is_anonymous, is_malicious, enrich_status.

Zero dependencies -- stdlib only (urllib, json, csv, ipaddress, argparse,
time), no `pip install` required. Works on Python 3.8+.

Usage:
    python3 bulk-enrich.py ips.csv enriched.csv
    python3 bulk-enrich.py ips.csv enriched.csv --ip-column source_ip --delay 1.0

Rate-limit contract (read this before pointing it at a huge file):
  GET /api/v1/ip never returns a hard 429 from the app itself -- the "geo",
  "network" and "security" groups are metered PER UNIQUE IP PER DAY (plus a
  tighter per-minute budget), and going over the budget soft-withholds the
  group: HTTP 200, "status":"partial", the group's name in "limited", and
  RateLimit-Limit/RateLimit-Remaining/RateLimit-Reset headers on the
  response. A withheld group is recorded here as "withheld", never left
  blank/null -- a blank cell would silently look identical to "no data for
  this IP", which is a different fact. An anonymous caller's budget is quite
  small (see /api/v1/tiers or the RateLimit-Limit header for the live
  number -- it is not hardcoded here because it is admin-editable); register
  a free API token for a much higher one, or slow this script down with
  --delay if you see "withheld" often.
  A 429/503 CAN still happen at the edge (nginx's own per-IP flood guard,
  independent of the app-level metering above) if you hammer the endpoint
  too fast from one machine -- this script backs off exponentially on any
  429/5xx or transport error and honors a Retry-After header when present.
"""

import argparse
import csv
import ipaddress
import json
import sys
import time
import urllib.error
import urllib.request

DEFAULT_BASE_URL = "https://ip.xakki.pro"
DEFAULT_INCLUDE = "network,security"
USER_AGENT = "myip-bulk-enrich-recipe/1.0 (+https://ip.xakki.pro/tools/scripts/bulk-enrich.html)"
NEW_COLUMNS = [
    "country", "asn", "as_name", "is_proxy", "is_anonymous",
    "is_malicious", "enrich_status",
]


def is_valid_ip(value):
    """True if value parses as an IPv4 or IPv6 address (stdlib ipaddress)."""
    try:
        ipaddress.ip_address(value.strip())
        return True
    except ValueError:
        return False


def fetch_with_retry(url, max_retries, base_backoff):
    """GET url, retrying on 429/5xx and transport errors with exponential
    backoff. Honors a Retry-After header (seconds) when the server sends
    one, otherwise doubles base_backoff each attempt. Returns the parsed
    JSON body, or raises the last exception after exhausting retries."""
    req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Accept": "application/json"})
    attempt = 0
    while True:
        try:
            with urllib.request.urlopen(req, timeout=15) as resp:
                return json.loads(resp.read().decode("utf-8"))
        except urllib.error.HTTPError as e:
            retryable = e.code == 429 or e.code >= 500
            if not retryable or attempt >= max_retries:
                raise
            wait = float(e.headers.get("Retry-After", 0)) or (base_backoff * (2 ** attempt))
        except (urllib.error.URLError, TimeoutError, ConnectionError) as e:
            if attempt >= max_retries:
                raise
            wait = base_backoff * (2 ** attempt)
        attempt += 1
        time.sleep(wait)


def enrich_row(base_url, include, ip_value, max_retries, base_backoff):
    """Returns a dict of NEW_COLUMNS for one IP, never raising -- every
    failure mode (invalid input, transport error, unexpected response)
    degrades to an explicit status string instead of a blank/null cell."""
    out = {c: "" for c in NEW_COLUMNS}
    if not is_valid_ip(ip_value):
        out["enrich_status"] = "invalid_ip"
        return out

    url = "{}/api/v1/ip?ip={}&include={}".format(base_url, ip_value.strip(), include)
    try:
        data = fetch_with_retry(url, max_retries, base_backoff)
    except Exception as e:  # noqa: BLE001 -- one recipe-wide catch, logged below
        out["enrich_status"] = "error: {}".format(e)
        return out

    # Guard against the API's own documented behavior: an invalid `ip` query
    # param is silently IGNORED (not rejected), and the endpoint falls back
    # to answering about the CALLER's own IP instead. We already validated
    # ip_value above, so this should never fire -- but if it ever does, never
    # attribute the wrong IP's data to this row.
    try:
        if ipaddress.ip_address(data.get("ip", "")) != ipaddress.ip_address(ip_value.strip()):
            out["enrich_status"] = "unexpected_response: ip mismatch"
            return out
    except ValueError:
        out["enrich_status"] = "unexpected_response: no ip in response"
        return out

    limited = set(data.get("limited") or [])
    network = data.get("network") or {}
    geo = data.get("geo") or {}
    privacy = data.get("privacy")
    threat = data.get("threat")

    out["country"] = geo.get("countryCode", "") or ""
    out["asn"] = network.get("asn", "") or ""
    out["as_name"] = network.get("asName", "") or ""

    if "security" in limited:
        out["is_proxy"] = "withheld"
        out["is_anonymous"] = "withheld"
        out["is_malicious"] = "withheld"
    else:
        out["is_proxy"] = str(privacy.get("isProxy")) if privacy else ""
        out["is_anonymous"] = str(privacy.get("isAnonymous")) if privacy else ""
        out["is_malicious"] = str(threat.get("isMalicious")) if threat else ""

    out["enrich_status"] = "partial" if data.get("status") == "partial" else "ok"
    return out


def main():
    parser = argparse.ArgumentParser(description="Enrich a CSV of IP addresses via the ip.xakki.pro API.")
    parser.add_argument("input_csv", help="Path to the input CSV (must have an IP column).")
    parser.add_argument("output_csv", help="Path to write the enriched CSV to.")
    parser.add_argument("--ip-column", default="ip", help='Column holding the IP address (default: "ip").')
    parser.add_argument("--include", default=DEFAULT_INCLUDE, help="Groups to request (default: network,security).")
    parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help="API base URL (default: production).")
    parser.add_argument("--delay", type=float, default=0.5, help="Seconds to sleep between requests (default: 0.5).")
    parser.add_argument("--max-retries", type=int, default=5, help="Max retries per request on 429/5xx (default: 5).")
    args = parser.parse_args()

    with open(args.input_csv, newline="", encoding="utf-8") as f_in:
        reader = csv.DictReader(f_in)
        if args.ip_column not in (reader.fieldnames or []):
            sys.exit('error: column "{}" not found in {} (columns: {})'.format(
                args.ip_column, args.input_csv, reader.fieldnames))
        fieldnames = list(reader.fieldnames) + [c for c in NEW_COLUMNS if c not in reader.fieldnames]
        rows = list(reader)

    counts = {"ok": 0, "partial": 0, "invalid_ip": 0, "error": 0}
    with open(args.output_csv, "w", newline="", encoding="utf-8") as f_out:
        writer = csv.DictWriter(f_out, fieldnames=fieldnames)
        writer.writeheader()
        for i, row in enumerate(rows):
            enrichment = enrich_row(args.base_url, args.include, row.get(args.ip_column, ""),
                                     args.max_retries, args.delay)
            row.update(enrichment)
            writer.writerow(row)
            status = enrichment["enrich_status"]
            key = status if status in counts else ("error" if status.startswith("error") else status)
            counts[key] = counts.get(key, 0) + 1
            print("[{}/{}] {} -> {}".format(i + 1, len(rows), row.get(args.ip_column, ""), status), file=sys.stderr)
            if i < len(rows) - 1:
                time.sleep(args.delay)

    print("done: {} written to {}".format(counts, args.output_csv), file=sys.stderr)


if __name__ == "__main__":
    main()

Скрипт распространяется по лицензии MIT (заголовок SPDX-License-Identifier: MIT в начале файла) — копируйте, изменяйте и используйте свободно в своём проекте, указывать авторство не обязательно.

Скрипт массового обогащения IP (Python)

По умолчанию скрипт запрашивает группы «network» и «security» (?include=network,security) — этого достаточно для страны, ASN/имени AS и булевых признаков прокси/анонимности/угрозы; базовый запрос (вообще без ?include=) уже бесплатно несёт флаги и укороченный (только страна) блок geo, поэтому рецепт платит только за реально нужные группы. Каждая добавленная колонка при успехе всегда заполнена — пустая ячейка в выводе означает, что сама строка не прошла валидацию или запрос завершился ошибкой, а не «API ничего не вернул».

Важный нюанс, о котором стоит знать до запуска на реальных данных: параметр запроса ?ip= в API не отклоняет некорректный адрес — если он не парсится, запрос молча откатывается к ответу о СОБСТВЕННОМ IP вызывающей стороны. Для одиночного ручного запроса это безобидно, но в неконтролируемом массовом скрипте это привело бы к тому, что «плохие» строки молча подписывались бы данными IP самого скрипта, а не пропускались. Этот скрипт сначала сам проверяет каждый IP локально (модуль ipaddress в Python / net.isIP в Node), а затем дополнительно сверяет поле "ip" в ответе с запрошенным значением, отказываясь приписать несовпадающий ответ не той строке.

Придержанная (ограниченная по частоте) группа записывается буквальной строкой "withheld" в колонки is_proxy/is_anonymous/is_malicious, никогда как пустая ячейка или false — записать "false" для проверки, которая по факту не выполнялась, было бы придуманным отрицательным результатом, что для колонки, связанной с безопасностью, хуже отсутствия данных. --delay (по умолчанию 0.5с между строками) и --max-retries (по умолчанию 5) настраиваются из командной строки, если входных данных достаточно много, чтобы регулярно упираться в поминутный бюджет.

Об ограничениях частоты запросов

GET /api/v1/ip никогда не отвечает жёстким 429 от самого приложения — группы обогащения (geo, network, security) лимитируются по уникальному целевому IP в сутки (UTC) плюс более жёсткий поминутный бюджет; при превышении группа мягко придерживается: HTTP 200, "status":"partial", имя группы в "limited", и заголовки RateLimit-Limit/RateLimit-Remaining/RateLimit-Reset в ответе. Этот скрипт читает эти сигналы, а не угадывает, и явно фиксирует придержанный результат (никогда как пустое/null значение — это выглядело бы так же, как «у API нет данных по этому IP»). Настоящий 429/503 всё же возможен на границе — это собственный анти-флуд nginx, независимый от лимитов выше — если слать запросы слишком быстро с одной машины; скрипт делает экспоненциальные повторы на 429/5xx и учитывает заголовок Retry-After, если сервер его прислал. У анонимных вызовов небольшой суточный/поминутный бюджет на группу (см. заголовок RateLimit-Limit в любом ответе или /api/v1/tiers); для заметно бо́льшего бюджета зарегистрируйте бесплатный API-токен.

Глоссарий

Обогащение
Добавление производных фактов (страна, ASN, признаки прокси/угрозы) к сырому IP-адресу через поиск, а не просто наличие самого адреса.
Лимит по уникальному IP в сутки
Модель лимитов этого API: повторный запрос ТОГО ЖЕ целевого IP тем же вызывающим в те же сутки (UTC) бесплатен, в суточный бюджет засчитывается только реально новый целевой IP.
Мягкое придерживание
Реакция этого API на превышение бюджета для группы обогащения: HTTP 200 с исключённой группой, названной в "limited", а не жёсткая ошибка.
Экспоненциальная задержка
Стратегия повторов, при которой время ожидания удваивается после каждой неудачной попытки — перегруженный сервер получает всё больше «воздуха» вместо постоянной частоты запросов.
Заголовок Retry-After
Стандартный HTTP-заголовок ответа, сообщающий клиенту, сколько секунд ждать перед повтором — этот скрипт читает его значение, если оно есть, вместо того чтобы угадывать задержку самостоятельно.
Только стандартная библиотека
Использование только модулей, идущих в комплекте с самим языком (здесь — urllib/json/csv/ipaddress Python) — не требуется pip install, фиксация версий и разбор supply-chain рисков.

Аналог в API

Сам этот скрипт и есть пример работы с API — отдельный блок curl/ответ не нужен, код выше и есть запрос. Полная схема ответа и все параметры — в документации API:

Читать документацию API

Часто задаваемые вопросы

Почему в выводе есть колонка «enrich_status», а не просто ошибка на плохой строке?
Массовый скрипт, который прерывается на первой плохой строке в файле из 10 000 строк, бесполезен. Каждая строка обрабатывается независимо и получает свой статус (ok / partial / invalid_ip / error: ...), поэтому одна плохая строка никогда не приводит к потере результатов по остальному файлу.
Нужен ли токен API или аккаунт, чтобы запустить это?
Нет — скрипт обращается к API анонимно, в рамках бесплатного анонимного бюджета на IP в сутки. Регистрируйте бесплатный API-токен, только если анонимного бюджета вам не хватает.
Что будет, если в CSV миллионы строк?
Скрипт отработает, но медленно и по большей части с пометкой «withheld» после исчерпания суточного бюджета одного вызывающего — рецепт рассчитан на сотни-первые тысячи уникальных IP в день. Для реальных объёмов зарегистрируйте платный тариф с большим суточным бюджетом группы (см. /pricing.html).
Можно ли запускать это из cron без присмотра?
Да — любой сбой сводится к строке статуса в выходном CSV, а не к необработанному исключению, а логирование в stderr (по строке на запись) удобно перенаправлять в файл лога.
Почему для этого рецепта выбран Python, а не JavaScript?
Особой причины нет, кроме привычности работы с CSV именно на Python — csv/ipaddress являются удобными модулями стандартной библиотеки без зависимостей ровно для такой задачи. Рецепт «поиск ASN» в этом разделе показывает тот же вызов API на обоих языках, если удобнее адаптировать JS-версию.
Изменяет ли скрипт исходный входной файл?
Нет — скрипт только читает input_csv и записывает отдельный output_csv; исходный файл никогда не открывается на запись.

Понравился инструмент? Бесплатный аккаунт нашего API даёт 1000 запросов в день.Смотреть документацию API →