paytrace 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- paytrace/__init__.py +98 -0
- paytrace/adstxt.py +468 -0
- paytrace/adversarial.py +334 -0
- paytrace/agent/__init__.py +20 -0
- paytrace/agent/agent.py +432 -0
- paytrace/agent/fixtures.py +210 -0
- paytrace/agent/guards.py +269 -0
- paytrace/agent/tools.py +435 -0
- paytrace/catalog.py +174 -0
- paytrace/cli.py +284 -0
- paytrace/collectors/__init__.py +26 -0
- paytrace/collectors/analytics.py +288 -0
- paytrace/collectors/artifacts.py +439 -0
- paytrace/collectors/base.py +60 -0
- paytrace/collectors/business.py +558 -0
- paytrace/collectors/disclosure.py +347 -0
- paytrace/collectors/infra.py +246 -0
- paytrace/collectors/lookups.py +454 -0
- paytrace/collectors/persona.py +411 -0
- paytrace/collectors/records.py +200 -0
- paytrace/collectors/registries.py +405 -0
- paytrace/collectors/surface.py +385 -0
- paytrace/data/registries.yaml +484 -0
- paytrace/egress.py +491 -0
- paytrace/enrich.py +400 -0
- paytrace/extract.py +374 -0
- paytrace/fingerprint.py +333 -0
- paytrace/index.py +460 -0
- paytrace/ingest.py +341 -0
- paytrace/net.py +580 -0
- paytrace/netsec.py +241 -0
- paytrace/pinned.py +135 -0
- paytrace/pivot.py +231 -0
- paytrace/portfolio.py +331 -0
- paytrace/robin_ingest.py +337 -0
- paytrace/sellersjson.py +259 -0
- paytrace-2.0.0.dist-info/METADATA +780 -0
- paytrace-2.0.0.dist-info/RECORD +42 -0
- paytrace-2.0.0.dist-info/WHEEL +4 -0
- paytrace-2.0.0.dist-info/entry_points.txt +3 -0
- paytrace-2.0.0.dist-info/licenses/LICENSE +146 -0
- paytrace-2.0.0.dist-info/licenses/NOTICE +14 -0
paytrace/__init__.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""paytrace: ad-tech supply-chain collectors for attribution-graph.
|
|
2
|
+
|
|
3
|
+
Turns the monetization layer into an attribution surface. An operator can hide
|
|
4
|
+
registrant, hosting and email; to be paid, a real legal entity must be named to
|
|
5
|
+
the ad system, and sellers.json publishes that name.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .adstxt import (
|
|
9
|
+
Account,
|
|
10
|
+
AccountClass,
|
|
11
|
+
AdsTxt,
|
|
12
|
+
KeyAccounts,
|
|
13
|
+
Overlap,
|
|
14
|
+
classify_account,
|
|
15
|
+
compare_domains,
|
|
16
|
+
key_accounts,
|
|
17
|
+
parse_ads_txt,
|
|
18
|
+
)
|
|
19
|
+
from .catalog import Registry, coverage_report, federated_warning, load_catalog, query
|
|
20
|
+
from .collectors import build_all, registry
|
|
21
|
+
from .egress import (
|
|
22
|
+
PROVIDERS,
|
|
23
|
+
Egress,
|
|
24
|
+
EgressPool,
|
|
25
|
+
GeoDivergence,
|
|
26
|
+
NetworkType,
|
|
27
|
+
ProxyError,
|
|
28
|
+
VantageCapture,
|
|
29
|
+
redact,
|
|
30
|
+
verify_egress_country,
|
|
31
|
+
)
|
|
32
|
+
from .enrich import (
|
|
33
|
+
ROUTES_FOR,
|
|
34
|
+
Expansion,
|
|
35
|
+
Route,
|
|
36
|
+
expand_from_name,
|
|
37
|
+
name_variants,
|
|
38
|
+
person_routes_available,
|
|
39
|
+
)
|
|
40
|
+
from .extract import (
|
|
41
|
+
SENSITIVE_PATHS,
|
|
42
|
+
Extraction,
|
|
43
|
+
extract_artifacts,
|
|
44
|
+
probe_sensitive_paths,
|
|
45
|
+
)
|
|
46
|
+
from .fingerprint import (
|
|
47
|
+
CDN_RANGES,
|
|
48
|
+
ContentFingerprint,
|
|
49
|
+
FingerprintComparison,
|
|
50
|
+
Resolution,
|
|
51
|
+
cdn_for,
|
|
52
|
+
compare_fingerprints,
|
|
53
|
+
content_sha256,
|
|
54
|
+
favicon_mmh3,
|
|
55
|
+
fingerprint_content,
|
|
56
|
+
gravatar_hash,
|
|
57
|
+
hamming,
|
|
58
|
+
resolve_host,
|
|
59
|
+
simhash,
|
|
60
|
+
similarity,
|
|
61
|
+
)
|
|
62
|
+
from .index import AdsTxtIndex, crawl_ads_txt, crawl_sellers_json
|
|
63
|
+
from .ingest import from_opencti_bundle, from_spiderfoot_csv, from_spiderfoot_db
|
|
64
|
+
from .net import Fetcher
|
|
65
|
+
from .pivot import PivotResult, Sibling, pivot_expand
|
|
66
|
+
from .robin_ingest import from_robin, to_handle_observations
|
|
67
|
+
from .sellersjson import (
|
|
68
|
+
SELLERS_JSON_LOCATIONS,
|
|
69
|
+
SellerNameKind,
|
|
70
|
+
SellerRecord,
|
|
71
|
+
classify_seller_name,
|
|
72
|
+
find_seller_in_text,
|
|
73
|
+
resolve_seller,
|
|
74
|
+
sellers_json_url,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
__version__ = "2.0.0"
|
|
78
|
+
__all__ = [
|
|
79
|
+
"AdsTxtIndex", "crawl_ads_txt", "crawl_sellers_json",
|
|
80
|
+
"Registry", "load_catalog", "query", "coverage_report", "federated_warning",
|
|
81
|
+
"parse_ads_txt", "AdsTxt", "Account", "AccountClass", "classify_account",
|
|
82
|
+
"SellerRecord", "SellerNameKind", "classify_seller_name", "resolve_seller",
|
|
83
|
+
"expand_from_name", "Expansion", "Route", "ROUTES_FOR", "name_variants",
|
|
84
|
+
"extract_artifacts", "Extraction", "gravatar_hash", "SENSITIVE_PATHS",
|
|
85
|
+
"probe_sensitive_paths", "pivot_expand", "PivotResult", "Sibling",
|
|
86
|
+
"person_routes_available",
|
|
87
|
+
"Egress", "EgressPool", "NetworkType", "ProxyError", "PROVIDERS",
|
|
88
|
+
"GeoDivergence", "VantageCapture", "redact", "verify_egress_country",
|
|
89
|
+
"resolve_host", "Resolution", "cdn_for", "CDN_RANGES", "simhash",
|
|
90
|
+
"hamming", "similarity", "content_sha256", "favicon_mmh3",
|
|
91
|
+
"fingerprint_content", "ContentFingerprint", "compare_fingerprints",
|
|
92
|
+
"FingerprintComparison",
|
|
93
|
+
"find_seller_in_text", "sellers_json_url", "SELLERS_JSON_LOCATIONS",
|
|
94
|
+
"key_accounts", "KeyAccounts", "compare_domains", "Overlap",
|
|
95
|
+
"from_spiderfoot_csv", "from_spiderfoot_db", "from_opencti_bundle",
|
|
96
|
+
"from_robin", "to_handle_observations",
|
|
97
|
+
"Fetcher", "build_all", "registry", "__version__",
|
|
98
|
+
]
|
paytrace/adstxt.py
ADDED
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
"""ads.txt account classification: separating real relationships from paste.
|
|
2
|
+
|
|
3
|
+
## The problem with DIRECT
|
|
4
|
+
|
|
5
|
+
Many ad networks hand publishers a block of ads.txt lines and tell them to paste
|
|
6
|
+
it in. The result is that a ``DIRECT`` label may represent the publisher's own
|
|
7
|
+
account, or the network's direct relationship one layer up, or a partner's two
|
|
8
|
+
layers up. The same seller IDs then appear across tens of thousands of unrelated
|
|
9
|
+
domains.
|
|
10
|
+
|
|
11
|
+
Treating every ``DIRECT`` record as evidence of a relationship is therefore
|
|
12
|
+
wrong, and wrong in the direction that manufactures false attributions: two
|
|
13
|
+
sites sharing four hundred pasted records look identical and have nothing to do
|
|
14
|
+
with each other.
|
|
15
|
+
|
|
16
|
+
A typical publisher ads.txt has several hundred records. **One to five of them
|
|
17
|
+
are the publisher's actual accounts.** Finding those is the entire problem; the
|
|
18
|
+
rest is noise that has to be positively identified as noise rather than
|
|
19
|
+
optimistically included.
|
|
20
|
+
|
|
21
|
+
## What actually discriminates
|
|
22
|
+
|
|
23
|
+
Not the label. Four things, in order of strength:
|
|
24
|
+
|
|
25
|
+
1. **Rarity.** An account on 4 domains is an account. An account on 40,000
|
|
26
|
+
domains is a template line. This is the selectivity model applied to the
|
|
27
|
+
monetization layer, and it needs a corpus.
|
|
28
|
+
2. **Reciprocity.** ``sellers.json`` naming this domain back requires control of
|
|
29
|
+
both sides. A pasted line is not reciprocated for the site that pasted it.
|
|
30
|
+
3. **Self-declaration.** ``OWNERDOMAIN`` and ``MANAGERDOMAIN`` are statements
|
|
31
|
+
about the operator, published because DSPs penalise their absence.
|
|
32
|
+
4. **Certification.** The certification authority ID (TAG-ID) ties a record to a
|
|
33
|
+
certified entity rather than to a string someone copied.
|
|
34
|
+
|
|
35
|
+
## Template detection
|
|
36
|
+
|
|
37
|
+
Two domains sharing a large account set are usually sharing a *template*, not an
|
|
38
|
+
operator. The discriminator is what remains after removing accounts that appear
|
|
39
|
+
widely:
|
|
40
|
+
|
|
41
|
+
shared accounts: 412
|
|
42
|
+
shared after boilerplate: 3 <- this is the signal
|
|
43
|
+
jaccard on rare accounts: 0.75
|
|
44
|
+
|
|
45
|
+
An overlap of 412 that collapses to 0 rare accounts is two sites that pasted the
|
|
46
|
+
same network block. An overlap of 3 rare accounts shared by nobody else is an
|
|
47
|
+
operator.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
from __future__ import annotations
|
|
51
|
+
|
|
52
|
+
import hashlib
|
|
53
|
+
import re
|
|
54
|
+
from dataclasses import dataclass, field
|
|
55
|
+
from enum import StrEnum
|
|
56
|
+
|
|
57
|
+
# --------------------------------------------------------------------------- #
|
|
58
|
+
# Thresholds
|
|
59
|
+
# --------------------------------------------------------------------------- #
|
|
60
|
+
|
|
61
|
+
#: Above this many holder domains an account is treated as template boilerplate.
|
|
62
|
+
#: Deliberately low: large publishers legitimately share sales houses, but the
|
|
63
|
+
#: cost of a false "boilerplate" call is one missed lead, while the cost of a
|
|
64
|
+
#: false "rare" call is a fabricated portfolio.
|
|
65
|
+
BOILERPLATE_THRESHOLD = 150
|
|
66
|
+
|
|
67
|
+
#: Below this, an account is discriminating enough to anchor an attribution.
|
|
68
|
+
RARE_THRESHOLD = 25
|
|
69
|
+
|
|
70
|
+
#: An ads.txt with more records than this is almost certainly aggregating
|
|
71
|
+
#: network templates rather than declaring relationships.
|
|
72
|
+
LARGE_FILE_RECORDS = 200
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class AccountClass(StrEnum):
|
|
76
|
+
PUBLISHER = "publisher_account" # rare, reciprocated, seller_type PUBLISHER
|
|
77
|
+
LIKELY_OWNED = "likely_owned" # rare and DIRECT, reciprocity unconfirmed
|
|
78
|
+
INTERMEDIARY = "intermediary" # reciprocated but seller_type INTERMEDIARY
|
|
79
|
+
RESELLER_CHAIN = "reseller_chain" # RESELLER record
|
|
80
|
+
BOILERPLATE = "boilerplate" # widely duplicated; carries no signal
|
|
81
|
+
UNKNOWN = "unknown" # no corpus to judge against
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
#: Scoring weight by class. BOILERPLATE is zero, not small: a line present on
|
|
85
|
+
#: forty thousand domains is not weak evidence, it is no evidence.
|
|
86
|
+
CLASS_WEIGHT: dict[AccountClass, float] = {
|
|
87
|
+
AccountClass.PUBLISHER: 1.0,
|
|
88
|
+
AccountClass.LIKELY_OWNED: 0.6,
|
|
89
|
+
AccountClass.INTERMEDIARY: 0.3,
|
|
90
|
+
AccountClass.RESELLER_CHAIN: 0.15,
|
|
91
|
+
AccountClass.BOILERPLATE: 0.0,
|
|
92
|
+
AccountClass.UNKNOWN: 0.2,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True)
|
|
97
|
+
class Account:
|
|
98
|
+
"""One ads.txt record, in well-known.dev's account form."""
|
|
99
|
+
|
|
100
|
+
adsystem: str
|
|
101
|
+
seller_id: str
|
|
102
|
+
relationship: str = "DIRECT"
|
|
103
|
+
cid: str = "" # certification authority ID (TAG-ID)
|
|
104
|
+
inline_comment: str = ""
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def key(self) -> str:
|
|
108
|
+
return f"{self.adsystem}|{self.seller_id}"
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def is_direct(self) -> bool:
|
|
112
|
+
return self.relationship == "DIRECT"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass
|
|
116
|
+
class AccountAssessment:
|
|
117
|
+
account: Account
|
|
118
|
+
klass: AccountClass
|
|
119
|
+
holders: int = 0
|
|
120
|
+
reciprocated: bool | None = None
|
|
121
|
+
seller_type: str = ""
|
|
122
|
+
seller_domain: str = ""
|
|
123
|
+
reasons: list[str] = field(default_factory=list)
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def weight(self) -> float:
|
|
127
|
+
return CLASS_WEIGHT[self.klass]
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def discriminating(self) -> bool:
|
|
131
|
+
return self.klass in (AccountClass.PUBLISHER, AccountClass.LIKELY_OWNED)
|
|
132
|
+
|
|
133
|
+
def render(self) -> str:
|
|
134
|
+
recip = {True: "reciprocated", False: "NOT reciprocated",
|
|
135
|
+
None: "reciprocity unknown"}[self.reciprocated]
|
|
136
|
+
return (f"{self.account.key} [{self.account.relationship}] "
|
|
137
|
+
f"-> {self.klass.value} (holders={self.holders}, {recip})")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# --------------------------------------------------------------------------- #
|
|
141
|
+
# Parsing
|
|
142
|
+
# --------------------------------------------------------------------------- #
|
|
143
|
+
|
|
144
|
+
_VARIABLE = re.compile(
|
|
145
|
+
r"^\s*(OWNERDOMAIN|MANAGERDOMAIN|CONTACT|SUBDOMAIN|INVENTORYPARTNERDOMAIN)"
|
|
146
|
+
r"\s*=\s*([^\s#]+)\s*(?:#(.*))?$", re.I)
|
|
147
|
+
|
|
148
|
+
#: Placeholder records signal an unmaintained or templated file.
|
|
149
|
+
_PLACEHOLDER = re.compile(r"placeholder|example\.com|yourdomain|REPLACE", re.I)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass
|
|
153
|
+
class AdsTxt:
|
|
154
|
+
"""A parsed ads.txt, in the shape well-known.dev exposes."""
|
|
155
|
+
|
|
156
|
+
domain: str
|
|
157
|
+
accounts: list[Account] = field(default_factory=list)
|
|
158
|
+
variables: dict[str, list[str]] = field(default_factory=dict)
|
|
159
|
+
variable_comments: dict[str, str] = field(default_factory=dict)
|
|
160
|
+
standalone_comments: list[str] = field(default_factory=list)
|
|
161
|
+
has_placeholder: bool = False
|
|
162
|
+
is_app_ads: bool = False
|
|
163
|
+
|
|
164
|
+
# -- well-known.dev style stats ------------------------------------------ #
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def record_count(self) -> int:
|
|
168
|
+
return len(self.accounts)
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def account_count(self) -> int:
|
|
172
|
+
return len({a.key for a in self.accounts})
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def direct_count(self) -> int:
|
|
176
|
+
return len({a.key for a in self.accounts if a.is_direct})
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def reseller_count(self) -> int:
|
|
180
|
+
return len({a.key for a in self.accounts if not a.is_direct})
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def system_count(self) -> int:
|
|
184
|
+
return len({a.adsystem for a in self.accounts})
|
|
185
|
+
|
|
186
|
+
@property
|
|
187
|
+
def owner_domain(self) -> str:
|
|
188
|
+
v = self.variables.get("OWNERDOMAIN", [])
|
|
189
|
+
return v[0] if v else ""
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def manager_domains(self) -> list[str]:
|
|
193
|
+
return self.variables.get("MANAGERDOMAIN", [])
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def looks_aggregated(self) -> bool:
|
|
197
|
+
"""A file this large is aggregating network templates, not declaring
|
|
198
|
+
relationships."""
|
|
199
|
+
return self.record_count > LARGE_FILE_RECORDS
|
|
200
|
+
|
|
201
|
+
def template_fingerprint(self) -> str:
|
|
202
|
+
"""Stable hash of the account set. Identical fingerprints across
|
|
203
|
+
unrelated domains are the signature of a shared template."""
|
|
204
|
+
keys = sorted({f"{a.key}|{a.relationship}" for a in self.accounts})
|
|
205
|
+
return hashlib.sha256("\n".join(keys).encode()).hexdigest()[:16]
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def parse(body: str, domain: str = "", is_app_ads: bool = False) -> AdsTxt:
|
|
209
|
+
out = AdsTxt(domain=domain, is_app_ads=is_app_ads)
|
|
210
|
+
|
|
211
|
+
for line in body.splitlines():
|
|
212
|
+
stripped = line.strip()
|
|
213
|
+
if not stripped:
|
|
214
|
+
continue
|
|
215
|
+
|
|
216
|
+
if stripped.startswith("#"):
|
|
217
|
+
comment = stripped.lstrip("#").strip()
|
|
218
|
+
if comment:
|
|
219
|
+
out.standalone_comments.append(comment)
|
|
220
|
+
continue
|
|
221
|
+
|
|
222
|
+
if m := _VARIABLE.match(stripped):
|
|
223
|
+
name, value, comment = m.group(1).upper(), m.group(2), m.group(3)
|
|
224
|
+
out.variables.setdefault(name, []).append(value.strip().lower())
|
|
225
|
+
if comment and comment.strip():
|
|
226
|
+
out.variable_comments[name] = comment.strip()
|
|
227
|
+
continue
|
|
228
|
+
|
|
229
|
+
payload, _, inline = stripped.partition("#")
|
|
230
|
+
parts = [p.strip() for p in payload.split(",")]
|
|
231
|
+
if len(parts) < 3 or "." not in parts[0]:
|
|
232
|
+
continue
|
|
233
|
+
|
|
234
|
+
relationship = parts[2].upper()
|
|
235
|
+
if relationship not in ("DIRECT", "RESELLER"):
|
|
236
|
+
continue
|
|
237
|
+
|
|
238
|
+
if _PLACEHOLDER.search(payload):
|
|
239
|
+
out.has_placeholder = True
|
|
240
|
+
continue
|
|
241
|
+
|
|
242
|
+
out.accounts.append(Account(
|
|
243
|
+
adsystem=parts[0].lower(),
|
|
244
|
+
seller_id=parts[1],
|
|
245
|
+
relationship=relationship,
|
|
246
|
+
cid=parts[3].strip() if len(parts) > 3 else "",
|
|
247
|
+
inline_comment=inline.strip(),
|
|
248
|
+
))
|
|
249
|
+
|
|
250
|
+
return out
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# --------------------------------------------------------------------------- #
|
|
254
|
+
# Classification
|
|
255
|
+
# --------------------------------------------------------------------------- #
|
|
256
|
+
|
|
257
|
+
def classify_account(
|
|
258
|
+
account: Account,
|
|
259
|
+
*,
|
|
260
|
+
holders: int | None = None,
|
|
261
|
+
reciprocated: bool | None = None,
|
|
262
|
+
seller_type: str = "",
|
|
263
|
+
seller_domain: str = "",
|
|
264
|
+
subject_domain: str = "",
|
|
265
|
+
owner_domain: str = "",
|
|
266
|
+
) -> AccountAssessment:
|
|
267
|
+
"""Classify one account. ``holders`` is the corpus count; without it the
|
|
268
|
+
verdict is UNKNOWN rather than optimistic."""
|
|
269
|
+
reasons: list[str] = []
|
|
270
|
+
|
|
271
|
+
if holders is None:
|
|
272
|
+
return AccountAssessment(
|
|
273
|
+
account, AccountClass.UNKNOWN, 0, reciprocated, seller_type,
|
|
274
|
+
seller_domain,
|
|
275
|
+
["no corpus index — cannot distinguish an account from a template line"])
|
|
276
|
+
|
|
277
|
+
if holders > BOILERPLATE_THRESHOLD:
|
|
278
|
+
reasons.append(
|
|
279
|
+
f"present on {holders} domains — a pasted network template line, "
|
|
280
|
+
"not a declared relationship")
|
|
281
|
+
return AccountAssessment(account, AccountClass.BOILERPLATE, holders,
|
|
282
|
+
reciprocated, seller_type, seller_domain, reasons)
|
|
283
|
+
|
|
284
|
+
if not account.is_direct:
|
|
285
|
+
reasons.append("RESELLER record: describes a supply path, not ownership")
|
|
286
|
+
return AccountAssessment(account, AccountClass.RESELLER_CHAIN, holders,
|
|
287
|
+
reciprocated, seller_type, seller_domain, reasons)
|
|
288
|
+
|
|
289
|
+
if holders <= RARE_THRESHOLD:
|
|
290
|
+
reasons.append(f"rare: only {holders} domain(s) declare this account")
|
|
291
|
+
|
|
292
|
+
if reciprocated is False:
|
|
293
|
+
reasons.append(
|
|
294
|
+
"sellers.json does not name this domain — the DIRECT label is "
|
|
295
|
+
"unreciprocated and may have been copied")
|
|
296
|
+
return AccountAssessment(account, AccountClass.RESELLER_CHAIN, holders,
|
|
297
|
+
reciprocated, seller_type, seller_domain, reasons)
|
|
298
|
+
|
|
299
|
+
st = (seller_type or "").upper()
|
|
300
|
+
if reciprocated and st == "PUBLISHER":
|
|
301
|
+
reasons.append("sellers.json names this domain with seller_type PUBLISHER")
|
|
302
|
+
if owner_domain and seller_domain and seller_domain.endswith(owner_domain):
|
|
303
|
+
reasons.append("seller domain matches the declared OWNERDOMAIN")
|
|
304
|
+
return AccountAssessment(account, AccountClass.PUBLISHER, holders,
|
|
305
|
+
reciprocated, seller_type, seller_domain, reasons)
|
|
306
|
+
|
|
307
|
+
if reciprocated and st in ("INTERMEDIARY", "BOTH"):
|
|
308
|
+
reasons.append(f"reciprocated but seller_type is {st}")
|
|
309
|
+
return AccountAssessment(account, AccountClass.INTERMEDIARY, holders,
|
|
310
|
+
reciprocated, seller_type, seller_domain, reasons)
|
|
311
|
+
|
|
312
|
+
if holders <= RARE_THRESHOLD:
|
|
313
|
+
reasons.append("DIRECT and rare, but reciprocity unconfirmed")
|
|
314
|
+
return AccountAssessment(account, AccountClass.LIKELY_OWNED, holders,
|
|
315
|
+
reciprocated, seller_type, seller_domain, reasons)
|
|
316
|
+
|
|
317
|
+
reasons.append(f"DIRECT but present on {holders} domains — inconclusive")
|
|
318
|
+
return AccountAssessment(account, AccountClass.RESELLER_CHAIN, holders,
|
|
319
|
+
reciprocated, seller_type, seller_domain, reasons)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
# --------------------------------------------------------------------------- #
|
|
323
|
+
# Overlap
|
|
324
|
+
# --------------------------------------------------------------------------- #
|
|
325
|
+
|
|
326
|
+
@dataclass
|
|
327
|
+
class Overlap:
|
|
328
|
+
"""Account overlap between two domains, boilerplate removed."""
|
|
329
|
+
|
|
330
|
+
a: str
|
|
331
|
+
b: str
|
|
332
|
+
shared_total: int
|
|
333
|
+
shared_rare: int
|
|
334
|
+
rare_keys: list[str]
|
|
335
|
+
jaccard_all: float
|
|
336
|
+
jaccard_rare: float
|
|
337
|
+
same_fingerprint: bool = False
|
|
338
|
+
|
|
339
|
+
@property
|
|
340
|
+
def is_template_sharing(self) -> bool:
|
|
341
|
+
"""Large overlap that vanishes once boilerplate is removed."""
|
|
342
|
+
return self.shared_total >= 20 and self.shared_rare == 0
|
|
343
|
+
|
|
344
|
+
@property
|
|
345
|
+
def is_operator_signal(self) -> bool:
|
|
346
|
+
return self.shared_rare >= 1
|
|
347
|
+
|
|
348
|
+
def render(self) -> str:
|
|
349
|
+
if self.same_fingerprint:
|
|
350
|
+
verdict = "IDENTICAL FILE — same template, not evidence of control"
|
|
351
|
+
elif self.is_template_sharing:
|
|
352
|
+
verdict = ("shared template only — the overlap collapses to nothing "
|
|
353
|
+
"once widely-duplicated accounts are removed")
|
|
354
|
+
elif self.is_operator_signal:
|
|
355
|
+
verdict = (f"{self.shared_rare} rare account(s) in common: "
|
|
356
|
+
f"{', '.join(self.rare_keys[:5])}")
|
|
357
|
+
else:
|
|
358
|
+
verdict = "no meaningful overlap"
|
|
359
|
+
return (f"{self.a} <-> {self.b}: {self.shared_total} shared "
|
|
360
|
+
f"({self.shared_rare} rare) — {verdict}")
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def compare_domains(
|
|
364
|
+
a: AdsTxt, b: AdsTxt, holder_lookup,
|
|
365
|
+
) -> Overlap:
|
|
366
|
+
"""Compare two ads.txt files, discounting boilerplate.
|
|
367
|
+
|
|
368
|
+
``holder_lookup(adsystem, seller_id) -> int`` supplies corpus counts.
|
|
369
|
+
|
|
370
|
+
This is the function that stops two sites which pasted the same network
|
|
371
|
+
block from resolving as one operator.
|
|
372
|
+
"""
|
|
373
|
+
ka = {x.key for x in a.accounts}
|
|
374
|
+
kb = {x.key for x in b.accounts}
|
|
375
|
+
shared = ka & kb
|
|
376
|
+
union = ka | kb
|
|
377
|
+
|
|
378
|
+
rare = [k for k in shared
|
|
379
|
+
if holder_lookup(*k.split("|", 1)) <= BOILERPLATE_THRESHOLD]
|
|
380
|
+
rare_union = [k for k in union
|
|
381
|
+
if holder_lookup(*k.split("|", 1)) <= BOILERPLATE_THRESHOLD]
|
|
382
|
+
|
|
383
|
+
return Overlap(
|
|
384
|
+
a=a.domain, b=b.domain,
|
|
385
|
+
shared_total=len(shared),
|
|
386
|
+
shared_rare=len(rare),
|
|
387
|
+
rare_keys=sorted(rare),
|
|
388
|
+
jaccard_all=len(shared) / len(union) if union else 0.0,
|
|
389
|
+
jaccard_rare=len(rare) / len(rare_union) if rare_union else 0.0,
|
|
390
|
+
same_fingerprint=a.template_fingerprint() == b.template_fingerprint(),
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
# --------------------------------------------------------------------------- #
|
|
395
|
+
# The headline operation
|
|
396
|
+
# --------------------------------------------------------------------------- #
|
|
397
|
+
|
|
398
|
+
@dataclass
|
|
399
|
+
class KeyAccounts:
|
|
400
|
+
domain: str
|
|
401
|
+
discriminating: list[AccountAssessment] = field(default_factory=list)
|
|
402
|
+
boilerplate_count: int = 0
|
|
403
|
+
total: int = 0
|
|
404
|
+
notes: list[str] = field(default_factory=list)
|
|
405
|
+
|
|
406
|
+
def render(self) -> str:
|
|
407
|
+
L = [f"{self.domain}: {self.total} record(s), "
|
|
408
|
+
f"{self.boilerplate_count} boilerplate, "
|
|
409
|
+
f"{len(self.discriminating)} discriminating"]
|
|
410
|
+
if self.discriminating:
|
|
411
|
+
L.append("")
|
|
412
|
+
for a in self.discriminating:
|
|
413
|
+
L.append(f" {a.render()}")
|
|
414
|
+
for r in a.reasons:
|
|
415
|
+
L.append(f" {r}")
|
|
416
|
+
else:
|
|
417
|
+
L.append(" no discriminating accounts — this file declares nothing "
|
|
418
|
+
"that distinguishes it from the network templates it pastes")
|
|
419
|
+
for n in self.notes:
|
|
420
|
+
L.append(f" note: {n}")
|
|
421
|
+
return "\n".join(L)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def key_accounts(
|
|
425
|
+
ads: AdsTxt,
|
|
426
|
+
holder_lookup,
|
|
427
|
+
reciprocity_lookup=None,
|
|
428
|
+
) -> KeyAccounts:
|
|
429
|
+
"""Reduce an ads.txt to the handful of accounts that actually identify it.
|
|
430
|
+
|
|
431
|
+
Several hundred records typically collapse to one to five. Those are the
|
|
432
|
+
ones worth pivoting on; everything else is a line the publisher was told to
|
|
433
|
+
paste.
|
|
434
|
+
"""
|
|
435
|
+
out = KeyAccounts(domain=ads.domain, total=len(ads.accounts))
|
|
436
|
+
|
|
437
|
+
for acct in ads.accounts:
|
|
438
|
+
holders = holder_lookup(acct.adsystem, acct.seller_id)
|
|
439
|
+
recip, stype, sdomain = (None, "", "")
|
|
440
|
+
if reciprocity_lookup and holders is not None and holders <= BOILERPLATE_THRESHOLD:
|
|
441
|
+
recip, stype, sdomain = reciprocity_lookup(acct.adsystem, acct.seller_id)
|
|
442
|
+
|
|
443
|
+
a = classify_account(
|
|
444
|
+
acct, holders=holders, reciprocated=recip, seller_type=stype,
|
|
445
|
+
seller_domain=sdomain, subject_domain=ads.domain,
|
|
446
|
+
owner_domain=ads.owner_domain)
|
|
447
|
+
|
|
448
|
+
if a.klass is AccountClass.BOILERPLATE:
|
|
449
|
+
out.boilerplate_count += 1
|
|
450
|
+
elif a.discriminating:
|
|
451
|
+
out.discriminating.append(a)
|
|
452
|
+
|
|
453
|
+
out.discriminating.sort(key=lambda a: (a.holders, a.account.key))
|
|
454
|
+
|
|
455
|
+
if ads.looks_aggregated:
|
|
456
|
+
out.notes.append(
|
|
457
|
+
f"{ads.record_count} records — this file aggregates network "
|
|
458
|
+
"templates; DIRECT labels in it are not reliable on their own")
|
|
459
|
+
if ads.has_placeholder:
|
|
460
|
+
out.notes.append("placeholder records present — file appears unmaintained")
|
|
461
|
+
if ads.owner_domain:
|
|
462
|
+
out.notes.append(f"OWNERDOMAIN={ads.owner_domain} (self-declared, "
|
|
463
|
+
"and the strongest thing in the file)")
|
|
464
|
+
return out
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
#: Public alias — `parse` is too generic at package level.
|
|
468
|
+
parse_ads_txt = parse
|