Introduction
In modern web advertising, the relationships between publishers, supply-side platforms (SSPs), and demand-side platforms (DSPs) form a sprawling graph of trust and money. A single publisher might use dozens of SSPs, which in turn resell inventory through intermediary chains tracked in ads.txt and sellers.json files.
Querying this “supply chain” with standard SQL requires fragile WITH RECURSIVE CTEs that are hard to read and hard to scale. BigQuery recently introduced native Property Graphs and Graph Query Language (GQL), letting us model and traverse these multi-hop relationships directly.
This analysis uses the HTTP Archive monthly web crawl. Before drawing conclusions keep the following structural constraints in mind:
- Coverage bias — Only top-ranked pages are crawled. Long-tail publishers — often the most affected by supply chain opacity — are absent from the data.
- Monthly snapshot —
ads.txt and sellers.json files change constantly (additions, revocations, corrections). A single monthly crawl captures a point-in-time view.
- Google patched via direct download — Google’s
sellers.json (~120 MB) is skipped by the HTTP Archive crawler due to a 5-second HTTP fetch timeout. Step 1c in the notebook downloads it directly from storage.googleapis.com/adx-rtb-dictionaries/sellers.json, normalises domains with NET.REG_DOMAIN() in BigQuery, and appends them to sellers_json_relationships before the graph is built. Without this step google.com would have no RepresentsPublisher edges despite appearing in millions of ads.txt DIRECT entries.
- Confidential sellers hidden — SSPs can mark sellers as
is_confidential = 1. The crawler counts these entries but drops the domain, so those publisher-platform relationships are invisible.
- Domain-level only — The crawler deduplicates by domain (a JS
Set), not by seller_id. Cross-checking the exact account record in ads.txt against the exact seller in sellers.json is therefore not possible — matches are domain-level approximations.
- 5-second HTTP timeout — Slow or oversized
sellers.json files are silently dropped, biasing the dataset toward fast-serving SSPs.
Better datasets for deeper compliance analysis: crawl the SSP universe yourself (there are ~500–1,000 active SSPs so full coverage is achievable); use Scope3 (commercial, supply chain + carbon focus); Pixalate (commercial, IVT + compliance); or the IAB Tech Lab’s ads.txt compliance tools.
The Advertising Supply Chain
To understand the graph schema it helps to know who the actors are and which protocols they use to declare their relationships.
Participants
| Term |
Role |
Graph element |
| Publisher |
Website or app with ad inventory to sell (e.g. nytimes.com) |
Publisher node |
| SSP — Supply-Side Platform |
Helps publishers sell inventory programmatically; runs the auction (e.g. Pubmatic, OpenX, Rubicon) |
AdTechPlatform node |
| DSP — Demand-Side Platform |
Buys inventory on behalf of advertisers (e.g. DV360, The Trade Desk) |
AdTechPlatform node |
| Ad Exchange |
Marketplace connecting SSPs and DSPs; often operated by an SSP itself |
AdTechPlatform node |
Declaration Protocols
ads.txt is a plain-text file that publishers host at publisher.com/ads.txt. Each line authorises one seller:
pubmatic.com, 123456, DIRECT, f5ab79cb980f11d1
openx.com, 537153765, RESELLER
DIRECT — highest trust. The publisher explicitly authorises this platform to sell their inventory without an intermediary.
RESELLER — lower trust. The publisher allows the platform to resell through a third party. Legitimacy is verified by cross-referencing the platform’s own sellers.json.
sellers.json is the SSP-side counterpart hosted at ssp.com/sellers.json. It declares every entity the SSP represents or routes through:
seller_type |
Meaning |
Graph edge |
publisher |
SSP directly represents this domain’s inventory |
RepresentsPublisher (Platform → Publisher) |
intermediary |
SSP sells through another SSP — a hop in the supply chain |
SellsThrough (Platform → Platform) |
both |
Partner is simultaneously publisher AND intermediary |
Both edge types |
The HTTP Archive crawler stores these files under the //[ads] custom metric key. Inside custom_metrics.other the JSON paths are: $.ads.ads.* for ads.txt and $.ads.sellers.* for sellers.json.
Graph Schema
Publisher ←─[DirectBid]──────────── AdTechPlatform
Publisher ←─[ResellerBid]─────────── AdTechPlatform
Publisher ←─[RepresentsPublisher]─── AdTechPlatform
AdTechPlatform ←─[SellsThrough]─────── AdTechPlatform
The SellsThrough edges form the intermediary graph that the original WITH RECURSIVE CTE was tracing step by step. GQL’s {1,3} path quantifier replaces the entire recursion — and prevents revisiting nodes without any extra cycle-guard logic.
Building and Querying the Graph
The notebook below builds the full pipeline: from raw HTTP Archive data to a queryable BigQuery property graph, then runs four analysis queries in GQL.
Mapping the AdTech Ecosystem with BigQuery Graphs

Show / Hide Code
!pip install bigquery_magics==0.12.1 -q
[1m[[0m[34;49mnotice[0m[1;39;49m][0m[39;49m A new release of pip is available: [0m[31;49m25.3[0m[39;49m -> [0m[32;49m26.0.1[0m
[1m[[0m[34;49mnotice[0m[1;39;49m][0m[39;49m To update, run: [0m[32;49mpip install --upgrade pip[0m
Show / Hide Code
try:
from google.colab import auth
auth.authenticate_user()
except ImportError:
pass # Not running in Google Colab
import bigquery_magics
%load_ext bigquery_magics
bigquery_magics.context.project = 'httparchive'
Build the Graph
The cells below create all base tables, node tables, edge tables, and the property graph
in the scratchspace dataset. Source: HTTP Archive monthly
crawl at httparchive.crawl.pages.
Show / Hide Code
%%bigquery
-- ============================================================
-- Step 1: Base tables
-- Source: httparchive.crawl.pages | date 2026-02-01 | all clients | root pages
--
-- crawl.pages is scanned ONCE and deduplicated by domain. Both mobile and desktop
-- clients crawl the same pages; ads.txt / sellers.json are server-side so their
-- metrics are identical across clients. ANY_VALUE picks one client's JSON blob.
--
-- The //[ads] custom metric is stored in custom_metrics.other as JSON.
-- Path conventions inside that JSON object:
-- ads.txt data → $.ads.ads.*
-- sellers.json → $.ads.sellers.*
-- app-ads.txt → $.ads.app_ads.* (not included in this analysis)
--
-- Crawl limitations relevant to this data:
-- • Google's sellers.json (~120 MB) is explicitly excluded: the JS metric
-- has a 5-second HTTP timeout + file-size limit. google.com therefore
-- appears in millions of ads.txt DIRECT entries but has no
-- RepresentsPublisher edges in this graph.
-- • Confidential sellers (is_confidential=1) are counted but their domains
-- are not stored — those publisher domains are invisible in this dataset.
-- • Domain deduplication only (JS Set): no seller_id is retained, so
-- cross-checks between ads.txt and sellers.json are domain-level only.
-- ============================================================
-- Materialise the crawl scan once so both base tables share a single read.
-- GROUP BY NET.REG_DOMAIN(page) deduplicates mobile / desktop rows for the same domain.
CREATE TEMP TABLE pages AS
SELECT
NET.REG_DOMAIN(page) AS page,
ANY_VALUE(custom_metrics.other.ads) AS metrics
FROM `httparchive.crawl.pages`
WHERE date = '2026-02-01'
AND is_root_page = TRUE
GROUP BY NET.REG_DOMAIN(page);
-- Persist crawled domains so later cells can derive is_crawled without re-scanning.
-- A domain is "crawled" when the HTTP Archive fetched its root page; only crawled
-- domains have ads.txt / sellers.json data in this pipeline.
CREATE OR REPLACE TABLE scratchspace.crawled_pages AS
SELECT page AS domain FROM pages;
-- 1a. ads.txt relationships
CREATE OR REPLACE TABLE scratchspace.ads_txt_relationships AS
SELECT
NET.REG_DOMAIN(REGEXP_EXTRACT(NORMALIZE_AND_CASEFOLD(domain),
r'\b[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b')) AS platform_domain,
page AS publisher_domain,
'direct' AS relationship
FROM pages,
UNNEST(JSON_VALUE_ARRAY(metrics.ads.account_types.direct.domains)) AS domain
WHERE SAFE.INT64(metrics.ads.account_count) > 0
UNION ALL
SELECT
NET.REG_DOMAIN(REGEXP_EXTRACT(NORMALIZE_AND_CASEFOLD(domain),
r'\b[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b')) AS platform_domain,
page AS publisher_domain,
'reseller' AS relationship
FROM pages,
UNNEST(JSON_VALUE_ARRAY(metrics.ads.account_types.reseller.domains)) AS domain
WHERE SAFE.INT64(metrics.ads.account_count) > 0;
-- 1b. sellers.json relationships
-- $.both seller type means the partner acts as both publisher AND intermediary;
-- it is inserted into both groups so both RepresentsPublisher and SellsThrough
-- edges are populated.
CREATE OR REPLACE TABLE scratchspace.sellers_json_relationships AS
-- $.publisher → represents_publisher
SELECT page AS ssp_domain,
NET.REG_DOMAIN(REGEXP_EXTRACT(NORMALIZE_AND_CASEFOLD(domain),
r'\b[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b')) AS target_domain,
'represents_publisher' AS seller_type
FROM pages,
UNNEST(JSON_VALUE_ARRAY(metrics.ads.sellers.seller_types.publisher.domains)) AS domain
WHERE SAFE.INT64(metrics.ads.sellers.seller_count) > 0
UNION ALL
-- $.both → represents_publisher direction
SELECT page AS ssp_domain,
NET.REG_DOMAIN(REGEXP_EXTRACT(NORMALIZE_AND_CASEFOLD(domain),
r'\b[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b')) AS target_domain,
'represents_publisher' AS seller_type
FROM pages,
UNNEST(JSON_VALUE_ARRAY(metrics.ads.sellers.seller_types.both.domains)) AS domain
WHERE SAFE.INT64(metrics.ads.sellers.seller_count) > 0
UNION ALL
-- $.intermediary → intermediary
SELECT page AS ssp_domain,
NET.REG_DOMAIN(REGEXP_EXTRACT(NORMALIZE_AND_CASEFOLD(domain),
r'\b[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b')) AS target_domain,
'intermediary' AS seller_type
FROM pages,
UNNEST(JSON_VALUE_ARRAY(metrics.ads.sellers.seller_types.intermediary.domains)) AS domain
WHERE SAFE.INT64(metrics.ads.sellers.seller_count) > 0
UNION ALL
-- $.both → intermediary direction
SELECT page AS ssp_domain,
NET.REG_DOMAIN(REGEXP_EXTRACT(NORMALIZE_AND_CASEFOLD(domain),
r'\b[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b')) AS target_domain,
'intermediary' AS seller_type
FROM pages,
UNNEST(JSON_VALUE_ARRAY(metrics.ads.sellers.seller_types.both.domains)) AS domain
WHERE SAFE.INT64(metrics.ads.sellers.seller_count) > 0;
Step 1c — Supplement sellers.json for the top 50 platforms
The HTTP Archive's //[ads] custom metric fetches /sellers.json from each crawled
domain with a 5-second HTTP timeout. Major SSP sellers.json files are far too large
to complete within that window — pubmatic (500 MB), appnexus (300 MB), openx (~200 MB)
all time out silently, leaving sellers_json_relationships empty for them.
This cell queries ads_txt_relationships for the 50 most-mentioned platform domains,
then directly downloads each one's sellers.json (120-second timeout) and appends
normalized records to scratchspace.sellers_json_relationships. Crawl data for smaller
SSPs that did fit within the timeout is preserved — SELECT DISTINCT in Step 3 handles
any overlap. NET.REG_DOMAIN() normalization is applied in BigQuery to stay consistent
with the rest of the pipeline.
A small URL_OVERRIDES dict handles SSPs that serve sellers.json from a different domain
than the one used in ads.txt (e.g. appnexus.com in ads.txt → file served at xandr.com).
Show / Hide Code
import time
import requests
from google.cloud import bigquery
PROJECT = "httparchive"
STAGING = f"{PROJECT}.scratchspace.sellers_json_staging"
TARGET = f"{PROJECT}.scratchspace.sellers_json_relationships"
HEADERS = {"User-Agent": "sellers-json-fetcher/1.0 (research; +https://httparchive.org)"}
# Overrides for SSPs that serve sellers.json from a different domain than
# the one used in publishers' ads.txt files.
URL_OVERRIDES = {
"google.com": "https://storage.googleapis.com/adx-rtb-dictionaries/sellers.json",
"appnexus.com": "https://xandr.com/sellers.json",
"rhythmone.com": "https://nexxen.com/sellers.json",
}
client = bigquery.Client(project=PROJECT)
# Build target list from actual data: top 50 platforms by ads.txt mention count.
rows = client.query("""
SELECT platform_domain AS domain
FROM `httparchive.scratchspace.ads_txt_relationships`
GROUP BY platform_domain
ORDER BY COUNT(*) DESC
LIMIT 50
""").result()
SUPPLEMENTAL_SELLERS = {
row.domain: URL_OVERRIDES.get(row.domain, f"https://{row.domain}/sellers.json")
for row in rows
if row.domain
}
print(f"Targeting {len(SUPPLEMENTAL_SELLERS)} platforms:")
for domain, url in SUPPLEMENTAL_SELLERS.items():
print(f" {domain} → {url}")
total_inserted = 0
for ssp_domain, url in SUPPLEMENTAL_SELLERS.items():
print(f"\nFetching {ssp_domain} …")
try:
resp = requests.get(url, headers=HEADERS, timeout=120)
resp.raise_for_status()
sellers = resp.json().get("sellers", [])
except Exception as e:
print(f" ✗ {e}")
time.sleep(0.5)
continue
raw_rows = []
for s in sellers:
if s.get("is_confidential", 0) == 1:
continue
domain = (s.get("domain") or "").strip().lower() # guard against explicit null
if not domain:
continue
st = s.get("seller_type", "").upper()
if st in ("PUBLISHER", "BOTH"):
raw_rows.append({"ssp_domain": ssp_domain, "raw_domain": domain, "seller_type": "represents_publisher"})
if st in ("INTERMEDIARY", "BOTH"):
raw_rows.append({"ssp_domain": ssp_domain, "raw_domain": domain, "seller_type": "intermediary"})
if not raw_rows:
print(f" ✗ No valid sellers parsed")
continue
print(f" {len(raw_rows):,} entries — staging and normalizing …")
staging_config = bigquery.LoadJobConfig(
write_disposition=bigquery.WriteDisposition.WRITE_TRUNCATE,
schema=[
bigquery.SchemaField("ssp_domain", "STRING"),
bigquery.SchemaField("raw_domain", "STRING"),
bigquery.SchemaField("seller_type", "STRING"),
],
)
client.load_table_from_json(raw_rows, STAGING, job_config=staging_config).result()
sql = (
f"INSERT INTO `{TARGET}` (ssp_domain, target_domain, seller_type) "
f"SELECT ssp_domain, NET.REG_DOMAIN(raw_domain), seller_type "
f"FROM `{STAGING}` WHERE NET.REG_DOMAIN(raw_domain) IS NOT NULL"
)
job = client.query(sql)
job.result()
inserted = job.num_dml_affected_rows or 0
total_inserted += inserted
print(f" ✓ {inserted:,} rows appended")
client.delete_table(STAGING, not_found_ok=True)
print(f"\nDone — {total_inserted:,} total rows appended to {TARGET}")
Downloading Google sellers.json (~120 MB)…
Parsed 143,628 non-confidential entries from 991,615 total sellers
Staged 143,628 rows — running NET.REG_DOMAIN normalization in BigQuery…
Done — Google sellers.json appended to httparchive.scratchspace.sellers_json_relationships
Show / Hide Code
%%bigquery
-- ============================================================
-- Step 2: Node tables
-- is_crawled = TRUE when the HTTP Archive fetched that domain as a root page
-- and we therefore have its ads.txt / sellers.json data in this pipeline.
-- ============================================================
-- Publisher: web page domains that declare ads.txt or appear as publisher
-- entries in sellers.json. These are the inventory sources in the ecosystem.
CREATE OR REPLACE TABLE scratchspace.Publisher AS
SELECT
d.domain,
c.domain IS NOT NULL AS is_crawled
FROM (
SELECT publisher_domain AS domain FROM scratchspace.ads_txt_relationships
UNION DISTINCT
-- SSPs declare these as publishers they represent; include as Publisher nodes.
SELECT target_domain AS domain FROM scratchspace.sellers_json_relationships
WHERE seller_type = 'represents_publisher'
) d
LEFT JOIN scratchspace.crawled_pages c USING (domain)
WHERE d.domain IS NOT NULL;
ALTER TABLE scratchspace.Publisher ADD PRIMARY KEY (domain) NOT ENFORCED;
-- AdTechPlatform: DSPs, SSPs, and intermediary platforms.
-- A domain can appear in both Publisher and AdTechPlatform since many SSPs
-- also operate publisher properties alongside their platform business.
CREATE OR REPLACE TABLE scratchspace.AdTechPlatform AS
SELECT
d.domain,
c.domain IS NOT NULL AS is_crawled
FROM (
SELECT platform_domain AS domain FROM scratchspace.ads_txt_relationships
UNION DISTINCT
SELECT ssp_domain AS domain FROM scratchspace.sellers_json_relationships
UNION DISTINCT
SELECT target_domain AS domain FROM scratchspace.sellers_json_relationships
WHERE seller_type = 'intermediary'
) d
LEFT JOIN scratchspace.crawled_pages c USING (domain)
WHERE d.domain IS NOT NULL;
ALTER TABLE scratchspace.AdTechPlatform ADD PRIMARY KEY (domain) NOT ENFORCED;
Show / Hide Code
%%bigquery
-- ============================================================
-- Step 3: Edge tables
-- Each edge source is wrapped in SELECT DISTINCT to collapse any residual
-- duplicates (e.g. same domain appearing twice in a JSON array).
-- GENERATE_UUID() then runs once per unique key pair.
-- ============================================================
-- DirectBid: AdTechPlatform → Publisher
-- Source: ads.txt DIRECT. Publisher explicitly authorises the platform to sell
-- their inventory. Highest trust — typically commands higher CPMs.
CREATE OR REPLACE TABLE scratchspace.DirectBid AS
SELECT GENERATE_UUID() AS edge_id, platform_domain, publisher_domain
FROM (
SELECT DISTINCT platform_domain, publisher_domain
FROM scratchspace.ads_txt_relationships
WHERE relationship = 'direct'
AND platform_domain IS NOT NULL
AND publisher_domain IS NOT NULL
);
ALTER TABLE scratchspace.DirectBid ADD PRIMARY KEY (edge_id) NOT ENFORCED;
-- ResellerBid: AdTechPlatform → Publisher
-- Source: ads.txt RESELLER. Publisher allows indirect reselling.
-- Reseller entries without a sellers.json counterpart are a compliance red flag.
CREATE OR REPLACE TABLE scratchspace.ResellerBid AS
SELECT GENERATE_UUID() AS edge_id, platform_domain, publisher_domain
FROM (
SELECT DISTINCT platform_domain, publisher_domain
FROM scratchspace.ads_txt_relationships
WHERE relationship = 'reseller'
AND platform_domain IS NOT NULL
AND publisher_domain IS NOT NULL
);
ALTER TABLE scratchspace.ResellerBid ADD PRIMARY KEY (edge_id) NOT ENFORCED;
-- RepresentsPublisher: AdTechPlatform → Publisher
-- Source: sellers.json $.publisher and $.both. SSP claims to represent this publisher.
CREATE OR REPLACE TABLE scratchspace.RepresentsPublisher AS
SELECT GENERATE_UUID() AS edge_id, ssp_domain, publisher_domain
FROM (
SELECT DISTINCT ssp_domain, target_domain AS publisher_domain
FROM scratchspace.sellers_json_relationships
WHERE seller_type = 'represents_publisher'
AND ssp_domain IS NOT NULL
AND target_domain IS NOT NULL
);
ALTER TABLE scratchspace.RepresentsPublisher ADD PRIMARY KEY (edge_id) NOT ENFORCED;
-- SellsThrough: AdTechPlatform → AdTechPlatform
-- Source: sellers.json $.intermediary and $.both. Supply chain hop between SSPs.
-- These are the edges the original WITH RECURSIVE CTE was tracing depth-first.
CREATE OR REPLACE TABLE scratchspace.SellsThrough AS
SELECT GENERATE_UUID() AS edge_id, source_platform, dest_platform
FROM (
SELECT DISTINCT ssp_domain AS source_platform, target_domain AS dest_platform
FROM scratchspace.sellers_json_relationships
WHERE seller_type = 'intermediary'
AND ssp_domain IS NOT NULL
AND target_domain IS NOT NULL
);
ALTER TABLE scratchspace.SellsThrough ADD PRIMARY KEY (edge_id) NOT ENFORCED;
Show / Hide Code
%%bigquery
-- ============================================================
-- Step 4: Property Graph
-- ============================================================
CREATE OR REPLACE PROPERTY GRAPH scratchspace.AdsGraph
NODE TABLES (
scratchspace.Publisher
KEY (domain)
LABEL Publisher,
scratchspace.AdTechPlatform
KEY (domain)
LABEL AdTechPlatform
)
EDGE TABLES (
scratchspace.DirectBid
KEY (edge_id)
SOURCE KEY (platform_domain) REFERENCES AdTechPlatform (domain)
DESTINATION KEY (publisher_domain) REFERENCES Publisher (domain)
LABEL DirectBid,
scratchspace.ResellerBid
KEY (edge_id)
SOURCE KEY (platform_domain) REFERENCES AdTechPlatform (domain)
DESTINATION KEY (publisher_domain) REFERENCES Publisher (domain)
LABEL ResellerBid,
scratchspace.RepresentsPublisher
KEY (edge_id)
SOURCE KEY (ssp_domain) REFERENCES AdTechPlatform (domain)
DESTINATION KEY (publisher_domain) REFERENCES Publisher (domain)
LABEL RepresentsPublisher,
scratchspace.SellsThrough
KEY (edge_id)
SOURCE KEY (source_platform) REFERENCES AdTechPlatform (domain)
DESTINATION KEY (dest_platform) REFERENCES AdTechPlatform (domain)
LABEL SellsThrough
);
Analysis
Each query uses BigQuery GQL (GRAPH … MATCH) to traverse the property graph, replacing
the original WITH RECURSIVE CTEs.
The graph has two node labels — Publisher and AdTechPlatform — and four edge
labels: DirectBid, ResellerBid, RepresentsPublisher, SellsThrough.
Q1 — Direct publisher reach
Which demand platforms appear in the most publishers' ads.txt DIRECT entries?
DIRECT is the highest-trust record type: the publisher explicitly authorises the platform
to sell inventory without an intermediary. A large direct-reach score means broad, verified
relationships across the open web.
Show / Hide Code
%%bigquery
GRAPH scratchspace.AdsGraph
MATCH (platform:AdTechPlatform)-[:DirectBid]->(publisher:Publisher)
RETURN
platform.domain AS platform,
COUNT(DISTINCT publisher.domain) AS direct_reach
ORDER BY direct_reach DESC
LIMIT 50;
Q2 — Reseller reach
Which platforms appear most often as RESELLER in publishers' ads.txt?
RESELLER entries have lower trust: the publisher allows the platform to resell inventory
through a third party. High reseller reach with low direct reach is a signal the platform
mainly operates as an aggregator rather than a direct partner.
Show / Hide Code
%%bigquery
GRAPH scratchspace.AdsGraph
MATCH (platform:AdTechPlatform)-[:ResellerBid]->(publisher:Publisher)
RETURN
platform.domain AS platform,
COUNT(DISTINCT publisher.domain) AS reseller_reach
ORDER BY reseller_reach DESC
LIMIT 50;
Q3 — Multi-hop supply chains
Which publishers are reached through exactly two SSP hops?
Publisher ← ResellerBid ← EntrySSP → SellsThrough → MidSSP → SellsThrough → DownstreamSSP
A fixed-length path variable is required to use TO_JSON(p) for graph visualization —
BigQuery GQL does not support binding a path variable to a quantified pattern ({m,n}).
The 2-hop chain is the most analytically interesting case: it reveals genuine intermediary
SSPs that sit between the entry platform and the final downstream buyer.
Show / Hide Code
%%bigquery --graph
GRAPH scratchspace.AdsGraph
MATCH p = (publisher:Publisher)<-[:ResellerBid]-(entry:AdTechPlatform)
-[:SellsThrough]->(mid:AdTechPlatform)
-[:SellsThrough]->(downstream:AdTechPlatform)
RETURN TO_JSON(p) AS path
LIMIT 100;
Q4 — Cross-declaration consistency (ads.txt vs sellers.json)
An SSP listed as RESELLER in a publisher's ads.txt should ideally also list that
publisher in its own sellers.json. SSPs with high reseller_reach but low
confirmed_in_sellers_json are flagging a compliance gap — they claim reseller rights
not backed by their own declaration.
platform_crawled shows whether the HTTP Archive fetched that SSP's root page.
SSPs absent from both the crawl and SUPPLEMENTAL_SELLERS will always show zero —
the data simply isn't in the pipeline for them.
Show / Hide Code
%%bigquery
GRAPH scratchspace.AdsGraph
MATCH (platform:AdTechPlatform)-[:ResellerBid]->(publisher:Publisher)
OPTIONAL MATCH (platform)-[confirms:RepresentsPublisher]->(publisher)
RETURN
platform.domain AS platform,
platform.is_crawled AS platform_crawled,
COUNT(DISTINCT publisher.domain) AS reseller_reach,
COUNTIF(confirms IS NOT NULL) AS confirmed_in_sellers_json
ORDER BY reseller_reach DESC
LIMIT 50;