scripts/ads_txt_evidence.py
Rama main · réplica de lectura del repositorio original.
"""Collect advertising-system declarations from publisher ads.txt files.
IAB ads.txt is a file a publisher serves from its own root listing every
advertising system it authorises to sell its inventory. Each data line starts
with that system's domain, so the file is the publisher's own statement that a
domain is advertising infrastructure — first-party evidence, gathered without
consulting anyone else's rule list.
This complements the traffic observation in ``rule_evidence.py``: traffic shows
what a page actually loaded, ads.txt shows the declared ecosystem behind it.
Neither admits a rule on its own; both feed the per-host review in
docs/SELF-OWNED-REBUILD.md.
Usage::
python scripts/ads_txt_evidence.py publishers.txt evidence.json
``publishers.txt`` holds one publisher origin per line.
"""
from __future__ import annotations
import concurrent.futures
import json
import sys
import urllib.error
import urllib.request
from collections import defaultdict
from pathlib import Path
TIMEOUT = 20
WORKERS = 12
MAX_BYTES = 4_000_000
USER_AGENT = "ekko-rules-evidence/1 (+https://github.com/ZaunEkko/ekko-rules)"
def fetch(origin: str) -> tuple[str, str | None]:
"""Fetch ``origin``'s ads.txt, returning its text or None."""
url = origin.rstrip("/") + "/ads.txt"
request = urllib.request.Request(url, headers={"user-agent": USER_AGENT})
try:
with urllib.request.urlopen(request, timeout=TIMEOUT) as response:
if response.status != 200:
return origin, None
body = response.read(MAX_BYTES)
except (urllib.error.URLError, TimeoutError, OSError):
return origin, None
return origin, body.decode("utf-8", errors="replace")
def parse(text: str) -> set[str]:
"""Return the advertising-system domains declared in an ads.txt body.
A data line is ``<system domain>, <publisher id>, <DIRECT|RESELLER>[, cert]``.
Variable declarations such as ``CONTACT=`` and ``OWNERDOMAIN=`` are skipped,
as are comments and anything that does not look like a hostname.
"""
systems: set[str] = set()
for raw in text.splitlines():
line = raw.split("#", 1)[0].strip()
if not line or "=" in line.split(",", 1)[0]:
continue
field = line.split(",", 1)[0].strip().lower().rstrip(".")
if not field or " " in field or "." not in field:
continue
if field.startswith(("http://", "https://")):
field = field.split("//", 1)[1].split("/", 1)[0]
labels = field.split(".")
if len(labels) < 2 or not all(labels):
continue
if not all(part.replace("-", "").isalnum() for part in labels):
continue
systems.add(field)
return systems
def main(argv: list[str]) -> int:
if len(argv) != 3:
print(__doc__, file=sys.stderr)
return 2
publishers = [
line.strip()
for line in Path(argv[1]).read_text(encoding="utf-8").splitlines()
if line.strip() and not line.startswith("#")
]
declared_by: dict[str, set[str]] = defaultdict(set)
served, missing = [], []
with concurrent.futures.ThreadPoolExecutor(max_workers=WORKERS) as pool:
for origin, text in pool.map(fetch, publishers):
if text is None:
missing.append(origin)
continue
served.append(origin)
for system in parse(text):
declared_by[system].add(origin)
records = [
{
"system": system,
"declared_by": sorted(origins),
"publisher_count": len(origins),
}
for system, origins in sorted(
declared_by.items(), key=lambda item: (-len(item[1]), item[0])
)
]
document = {
"schema_version": 1,
"method": "IAB ads.txt declarations fetched from publisher roots; no upstream rule list consumed",
"publishers_served": sorted(served),
"publishers_without_ads_txt": sorted(missing),
"summary": {
"publishers": len(publishers),
"served": len(served),
"systems": len(records),
"systems_declared_by_two_or_more": sum(
1 for record in records if record["publisher_count"] >= 2
),
},
"records": records,
}
output = Path(argv[2])
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(
json.dumps(document, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
newline="\n",
)
summary = document["summary"]
print(
f"{summary['served']}/{summary['publishers']} publishers served ads.txt; "
f"{summary['systems']} advertising systems declared, "
f"{summary['systems_declared_by_two_or_more']} by two or more"
)
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))