Этот Python-скрипт читает CSV со списком IP-адресов, обращается к бесплатному API ip.xakki.pro для каждого адреса и записывает новый CSV с добавленными колонками: страна, ASN, имя AS и признаки прокси/анонимности/угрозы — со встроенными повторами с экспоненциальной задержкой и честной обработкой лимитов API по уникальному IP, так что скрипт безопасно запускать на реальном продакшн-логе или выгрузке.
Зависимости: нет — только стандартная библиотека Python 3.8+ (urllib, json, csv, ipaddress, argparse). pip install не требуется.
#!/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 в начале файла) — копируйте, изменяйте и используйте свободно в своём проекте, указывать авторство не обязательно.
По умолчанию скрипт запрашивает группы «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-токен.
Сам этот скрипт и есть пример работы с API — отдельный блок curl/ответ не нужен, код выше и есть запрос. Полная схема ответа и все параметры — в документации API:
Понравился инструмент? Бесплатный аккаунт нашего API даёт 1000 запросов в день.Смотреть документацию API →