This Python script reads a text file of IP addresses (one per line), checks each one against the free ip.xakki.pro API's proxy/VPN/Tor and hosting signals, and splits the list into clean.txt (no signal) and flagged.txt (at least one signal, with the reason written next to each address) — treating a rate-limited, unverifiable IP as flagged rather than silently letting it through as clean.
placeholder
#!/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()
This script is MIT-licensed (SPDX-License-Identifier: MIT header at the top of the file) — copy, modify and reuse it freely in your own project, no attribution required.
An IP is flagged when ANY of three independent signals fires: privacy.isProxy (a known anonymizing-proxy network), privacy.isAnonymous (the API's own isProxy-OR-isVpn-OR-isTor rollup), or a network.classification.type of "hosting"/"datacenter" — not itself proof of a proxy, but a strong correlate worth surfacing as its own named reason rather than folding silently into one boolean. Every flagged line in flagged.txt says WHICH of these fired, not just that something did.
A non-routable input (private/loopback/CGNAT/bogon) gets "privacy": null from the API — there is no meaningful proxy classification for an address that was never publicly routable — and this script treats that as clean by omission rather than as an error, since a private-range address has no business appearing in a real proxy-filter input to begin with.
"Unverified" outcomes (a rate-limited security group, a transport failure, or an invalid input line) are all routed to flagged.txt with an explicit "unverified: ..." reason instead of clean.txt — this is a deliberately conservative, fail-closed design: for a security-adjacent filter, treating "couldn't check" the same as "checked and clean" would silently defeat the whole point of running it. Re-running later picks up a real verdict once the rate limit resets (a repeat lookup of the same IP later the same UTC day is free).
GET /api/v1/ip never returns a hard 429 from the app itself — its enrichment groups (geo, network, security) are metered per unique target IP per UTC day, plus a tighter per-minute budget, and going over either soft-withholds the group instead: HTTP 200, "status":"partial", the group's name in "limited", and RateLimit-Limit/RateLimit-Remaining/RateLimit-Reset headers on the response. This script reads those signals rather than guessing, and records a withheld result explicitly (never as a blank/null, which would look identical to "the API has no data for this IP"). A real 429/503 can still happen at the edge — nginx's own flood guard, independent of the metering above — if you call the endpoint too fast from one machine; the script backs off exponentially on any 429/5xx and honors a Retry-After header when the server sends one. Anonymous callers get a modest daily/per-minute budget per group (see the RateLimit-Limit header on any response, or /api/v1/tiers); register a free API token for a much larger one if you plan to run this against more than a handful of IPs.
This script itself is the API example — there is no separate curl/response block to show, because the code above already IS the request. See the full response schema and every parameter in the API docs:
Like this tool? Our API gives free accounts 1,000 requests/day.See the API docs →