Скрипт фильтрации прокси/VPN/Tor (Python)

Этот Python-скрипт читает текстовый файл с IP-адресами (по одному на строку), проверяет каждый по признакам прокси/VPN/Tor и хостинга бесплатного API ip.xakki.pro и разбивает список на clean.txt (нет признаков) и flagged.txt (есть хотя бы один признак, причина указана рядом с адресом) — непроверенный из-за ограничения частоты IP считается помеченным, а не молча пропускается как чистый.

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

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

  1. Скачайте proxy-filter.py ниже.
  2. Подготовьте текстовый файл с одним IP-адресом на строку.
  3. Запустите: python3 proxy-filter.py ips.txt
  4. Прочитайте clean.txt и flagged.txt — в flagged.txt после каждого IP через табуляцию указана причина.

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

#!/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 в начале файла) — копируйте, изменяйте и используйте свободно в своём проекте, указывать авторство не обязательно.

Скрипт фильтрации прокси/VPN/Tor (Python)

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-токен.

Глоссарий

privacy.isProxy
Флаг API для адреса из известной сети анонимизирующих прокси (данные IPFire libloc) — один из трёх независимых признаков, которые проверяет этот скрипт.
privacy.isAnonymous
Собственная сводка API: true, если истинно isProxy ИЛИ isVpn ИЛИ isTor — более широкий признак «анонимизирован тем или иным способом», чем один только isProxy.
классификация hosting/datacenter
network.classification.type, когда адрес принадлежит хостинг-/облачному провайдеру, а не бытовому интернет-провайдеру — коррелят использования прокси, но не самостоятельное доказательство.
Отказ в безопасную сторону
Подход, при котором неопределённая или неудавшаяся проверка по умолчанию даёт БОЛЕЕ строгий результат (здесь: помечен, а не чист), а не более разрешительный.
Богон
Адрес из диапазона, который никогда не должен встречаться в публичном интернете (нераспределённое/зарезервированное пространство) — одна из нескольких немаршрутизируемых категорий, которые обрабатываются в скрипте наравне с приватными адресами.
Причина через табуляцию
Собственный формат flagged.txt: IP-адрес, символ табуляции, затем список всех сработавших причин через точку с запятой.

Аналог в API

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

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

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

Что считается «помеченным» — возможны ли ложные срабатывания?
Да, особенно из-за одного только признака hosting/datacenter — легитимный сервер, корпоративный VPN или узел CDN могут получить ту же классификацию, что и анонимизирующий прокси. Считайте flagged.txt списком «требует более пристального взгляда», а не автоматическим бан-листом; колонка причины точно говорит, какой признак учитывать.
Почему два выходных файла, а не один CSV с булевой колонкой?
clean.txt и flagged.txt рассчитаны на прямую передачу дальше (в allow-list, в block-list) без дополнительного разбора — обычный файл с одним IP на строку это простейший формат обмена для такой задачи.
Обнаруживает ли это резидентные прокси?
Нет — блок privacy API явно всегда возвращает isResidentialProxy как false (сейчас нет бесплатного источника данных для этого, задокументированный известный пробел), поэтому скрипт честно наследует то же ограничение, а не делает вид, что его нет.
Можно ли запускать это на IP из собственного лога подключений сервера?
Да — это именно тот сценарий использования, для которого он предназначен: вставьте уникальные IP посетителей (см. рецепт log-aggregate.py для их предварительного извлечения из сырого access-лога) и получите разбивку на чистые/помеченные.
Всегда ли Tor помечается?
Да — privacy.isTor напрямую входит в privacy.isAnonymous, один из трёх признаков пометки в этом скрипте, поэтому выходной узел Tor всегда помечается (причина: privacy.isAnonymous, часто вместе с privacy.isProxy).

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