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 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