Этот Python-скрипт читает текстовый файл с IP-адресами (по одному на строку), проверяет каждый по признакам прокси/VPN/Tor и хостинга бесплатного API ip.xakki.pro и разбивает список на clean.txt (нет признаков) и flagged.txt (есть хотя бы один признак, причина указана рядом с адресом) — непроверенный из-за ограничения частоты IP считается помеченным, а не молча пропускается как чистый.
Зависимости: нет — только стандартная библиотека Python 3.8+ (urllib, json, csv, ipaddress, argparse). pip install не требуется.
#!/usr/bin/env python3
# SPDX-License-Identifier: MIT
"""proxy-filter.py -- split a list of IP addresses into clean.txt/flagged.txt
by proxy/VPN/Tor/hosting signal, via the ip.xakki.pro API.
Reads a text file with one IP address per line, calls
GET /api/v1/ip?ip=<addr>&include=security for each one, and writes:
- clean.txt -- IPs with no proxy/VPN/Tor/hosting signal.
- flagged.txt -- IPs with at least one signal, one per line, with the
reason(s) appended after a tab.
An IP is flagged when ANY of these hold:
- privacy.isProxy -- a known anonymizing-proxy network (IPFire libloc).
- privacy.isAnonymous -- isProxy OR isVpn OR isTor (the API's own rollup).
- network.classification.type is "hosting" or "datacenter" -- not itself
proof of a proxy, but a strong correlate (real residential ISPs almost
never show up here) worth surfacing as its own reason.
An IP whose "security" group comes back rate-limited (withheld -- see the
rate-limit note in bulk-enrich.py's docstring) is CONSERVATIVELY flagged
with reason "unverified: rate-limited" rather than assumed clean -- silently
letting an unverified IP through to clean.txt would defeat the point of this
script. Re-run later (a repeat lookup of the same IP the same UTC day is
free once the group's budget resets) to get a real verdict.
A non-routable input (private/loopback/CGNAT/bogon) gets NO privacy signal
at all -- the API returns "privacy": null for those (there is no meaningful
proxy/VPN classification for an address that was never publicly routable),
so this script treats it as clean by omission. That is intentional, not a
gap: a private-range IP has no business appearing in a real proxy-filter
input in the first place.
Zero dependencies -- stdlib only. Python 3.8+.
Usage:
python3 proxy-filter.py ips.txt
python3 proxy-filter.py ips.txt --delay 1.0 --base-url http://localhost:11120
"""
import argparse
import ipaddress
import json
import sys
import time
import urllib.error
import urllib.request
DEFAULT_BASE_URL = "https://ip.xakki.pro"
USER_AGENT = "myip-proxy-filter-recipe/1.0 (+https://ip.xakki.pro/tools/scripts/proxy-filter.html)"
HOSTING_TYPES = {"hosting", "datacenter"}
def fetch_security(base_url, ip_value, max_retries=5, base_backoff=0.5):
url = "{}/api/v1/ip?ip={}&include=security".format(base_url, ip_value)
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:
if not (e.code == 429 or e.code >= 500) or attempt >= max_retries:
raise
wait = float(e.headers.get("Retry-After", 0)) or (base_backoff * (2 ** attempt))
except (urllib.error.URLError, TimeoutError, ConnectionError):
if attempt >= max_retries:
raise
wait = base_backoff * (2 ** attempt)
attempt += 1
time.sleep(wait)
def classify(base_url, ip_value):
"""Returns (is_flagged, reasons_list) for one IP. Never raises -- a
transport failure or an invalid input is itself a flagged reason."""
try:
ipaddress.ip_address(ip_value)
except ValueError:
return True, ["invalid IP, could not check"]
try:
data = fetch_security(base_url, ip_value)
except Exception as e: # noqa: BLE001
return True, ["unverified: request failed ({})".format(e)]
try:
if ipaddress.ip_address(data.get("ip", "")) != ipaddress.ip_address(ip_value):
return True, ["unverified: unexpected response (ip mismatch)"]
except ValueError:
return True, ["unverified: unexpected response"]
if "security" in (data.get("limited") or []):
return True, ["unverified: rate-limited"]
reasons = []
privacy = data.get("privacy") or {}
if privacy.get("isProxy"):
reasons.append("privacy.isProxy")
if privacy.get("isAnonymous"):
reasons.append("privacy.isAnonymous")
net_type = ((data.get("network") or {}).get("classification") or {}).get("type")
if net_type in HOSTING_TYPES:
reasons.append("network type = {}".format(net_type))
return (len(reasons) > 0), reasons
def main():
parser = argparse.ArgumentParser(description="Split an IP list into clean/flagged by proxy signal.")
parser.add_argument("input_file", help="Path to a text file, one IP per line.")
parser.add_argument("--clean-out", default="clean.txt", help="Output file for clean IPs (default: clean.txt).")
parser.add_argument("--flagged-out", default="flagged.txt", help="Output file for flagged IPs (default: flagged.txt).")
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 between lookups (default: 0.5).")
args = parser.parse_args()
with open(args.input_file, encoding="utf-8") as f:
ip_lines = [line.strip() for line in f if line.strip()]
clean, flagged = [], []
for i, ip_value in enumerate(ip_lines):
is_flagged, reasons = classify(args.base_url, ip_value)
if is_flagged:
flagged.append((ip_value, reasons))
else:
clean.append(ip_value)
print("[{}/{}] {} -> {}".format(i + 1, len(ip_lines), ip_value,
"FLAGGED: " + ", ".join(reasons) if is_flagged else "clean"),
file=sys.stderr)
if i < len(ip_lines) - 1:
time.sleep(args.delay)
with open(args.clean_out, "w", encoding="utf-8") as f:
f.write("\n".join(clean) + ("\n" if clean else ""))
with open(args.flagged_out, "w", encoding="utf-8") as f:
for ip_value, reasons in flagged:
f.write("{}\t{}\n".format(ip_value, "; ".join(reasons)))
print("done: {} clean -> {}, {} flagged -> {}".format(
len(clean), args.clean_out, len(flagged), args.flagged_out), file=sys.stderr)
if __name__ == "__main__":
main()
Скрипт распространяется по лицензии MIT (заголовок SPDX-License-Identifier: MIT в начале файла) — копируйте, изменяйте и используйте свободно в своём проекте, указывать авторство не обязательно.
IP помечается, если срабатывает ЛЮБОЙ из трёх независимых признаков: privacy.isProxy (известная сеть анонимизирующих прокси), privacy.isAnonymous (собственная сводка API isProxy-ИЛИ-isVpn-ИЛИ-isTor) или network.classification.type равный «hosting»/«datacenter» — само по себе не доказательство прокси, но сильный коррелят, который стоит вывести отдельной именованной причиной, а не тихо свернуть в одно булево значение. Каждая помеченная строка в flagged.txt указывает, КАКОЙ именно из признаков сработал, а не просто факт срабатывания.
Для немаршрутизируемого адреса (private/loopback/CGNAT/bogon) API возвращает "privacy": null — для адреса, никогда не маршрутизировавшегося публично, нет осмысленной классификации прокси — и этот скрипт трактует это как «чистый» по умолчанию, а не как ошибку, поскольку приватному адресу вообще не место во входных данных для реальной фильтрации прокси.
Результаты «не проверено» (ограничение по частоте для группы security, сбой сети или невалидная строка на входе) всегда направляются в flagged.txt с явной причиной «unverified: ...», а не в clean.txt — это намеренно консервативный дизайн с отказом в безопасную сторону: для фильтра, связанного с безопасностью, приравнивание «не удалось проверить» к «проверено и чисто» тихо свело бы на нет весь смысл его запуска. Повторный запуск позже получит настоящий вердикт после сброса лимита (повторный запрос того же IP позже в те же сутки UTC бесплатен).
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 →