"""CC Backlink Explorer - a Majestic/Ahrefs-style backlink & rank tool built on the Common Crawl domain-level web graph (CC-MAIN-2026 Apr-May-Jun release). Architecture (built for speed on a free/basic CPU HF Space): - domain_vertices2.parquet (~0.9GB, incl. precomputed outbound_count) and domain_ranks.parquet (~2.5GB) are downloaded locally at startup and queried from local disk (instant). - domain_edges.parquet (~19GB, 3.9B edges, sorted by to_id, classic LFS storage -- NOT Xet, so plain HTTP range requests hit the CDN directly with no per-request reconstruction overhead) stays remote and is queried via DuckDB's httpfs with pre-resolved signed CDN URL. - CRITICAL for speed: every remote query is a PLAIN filter on domain_edges only (`WHERE to_id = X` / `WHERE to_id IN (...)`), never a JOIN mixed into the same query as the remote scan. Because the file is sorted by to_id, a plain filter lets DuckDB prune row groups via zone maps and only fetch the handful of row groups that matter (seconds, even for domains with 500k+ backlinks). So we always do it in two phases: (1) fetch raw ids remotely with a bare filter, (2) enrich that (small, already-local) id set locally. - Phase (2) does NOT use a SQL JOIN against the 121M-row vertices table. Measured via EXPLAIN: DuckDB does a full ~121M-row SEQ_SCAN for that join regardless of how few ids are being looked up (it never uses the id index for JOIN probes) -- that scan, not the network, was the real multi-second bottleneck. Instead, since domain ids are a dense 0..N-1 range, harmonic/ pagerank/outbound are loaded as plain numpy arrays indexed *positionally* by id (true O(1) lookups) and domain names as a compact Arrow string array (~3GB for 121M domains) that supports fast vectorized take(). This turns an O(121M) scan into an O(k) gather, k = number of ids being enriched. We do NOT also keep a DuckDB copy of these 121M rows -- holding both at once OOM'd the 16GB cpu-basic Space; columns are read one-at-a-time at startup to keep the load-time memory peak low. - The full result set from phase (1)/(2) is cached in a Gradio State so that pagination and "download full CSV" never re-hit the network. - Every remote query is ALSO cached process-wide (in-memory LRU keyed by the query text, see `_query_cache`), so re-querying the same domain / re-running the same gap analysis later in the same Space session is instant, with zero network round-trips. - CSV export uses `gr.DownloadButton`: one click computes the CSV (from the already-fetched, in-memory full result set) and the browser downloads it immediately -- no separate "hosted file" step, nothing is uploaded anywhere, it's just served straight from the Space's own temp disk for that instant. """ import os import time import tempfile from collections import OrderedDict import duckdb import numpy as np import pandas as pd import pyarrow as pa import pyarrow.compute as pc import pyarrow.parquet as pq import requests as _r import gradio as gr from huggingface_hub import hf_hub_download DATASET_REPO = os.environ.get("CC_DATASET_REPO", "metehan777/cc-webgraph-domain-2026-jun") HF_TOKEN = os.environ.get("HF_TOKEN", "") RESOLVE_URL = f"https://huggingface.co/datasets/{DATASET_REPO}/resolve/main/domain_edges.parquet" TOTAL_DOMAINS = 121_091_933 print("=== app.py booting ===", flush=True) _boot = time.time() print("Downloading vertices3 (all domain attrs inline) ...", flush=True) VERTICES = hf_hub_download(DATASET_REPO, "domain_vertices3.parquet", repo_type="dataset", token=HF_TOKEN or None) print(f"Local file ready in {time.time() - _boot:.1f}s.", flush=True) # DuckDB is used ONLY to range-scan the remote edges parquet. We deliberately # do NOT load the 121M-row vertices table into DuckDB -- doing that *and* the # numpy/Arrow arrays below at the same time held ~2x the data resident in RAM # and OOM'd the 16GB cpu-basic Space (RUNTIME_ERROR). All per-domain attribute # lookups are served from the arrays instead. con = duckdb.connect() con.execute("INSTALL httpfs; LOAD httpfs;") con.execute("SET enable_http_metadata_cache=true;") con.execute("SET enable_object_cache=true;") # --- id-indexed attribute arrays (the whole in-memory model) --------------- # IMPORTANT LEARNING: DuckDB does NOT use an ART index to speed up a JOIN/IN # against a 121M-row table -- EXPLAIN shows a full SEQ_SCAN every time. That # scan (not the remote edge fetch) was the real multi-second bottleneck. # Since domain ids are a dense 0..N-1 set, we store attributes in arrays indexed # *positionally by id* (true O(1) gather). The parquet is sorted by rev_domain, # so id is a permutation of 0..N-1; we scatter each column into id order. # Columns are read ONE AT A TIME (never the whole table at once) to keep the # startup memory peak well under the Space's RAM limit. rev_domain stays as a # compact Arrow string array (~3GB) instead of ~121M exploded Python str objects. print("Loading domain attributes (Arrow, single read) ...", flush=True) _t = time.time() # Read the whole 0.9GB file ONCE. (Reading it column-by-column was ~5x the IO # and on a throttled 2-vCPU free Space that alone blew startup past the # scheduler's patience -> stuck APP_STARTING.) A single ~4GB Arrow table plus # the numpy arrays below peaks well under the 16GB limit. _tbl = pq.read_table(VERTICES, columns=["id", "rev_domain", "n_hosts", "outbound_count", "harmonic_pos", "pr_pos"]) print(f" parquet read in {time.time() - _t:.1f}s", flush=True) _ids = _tbl.column("id").to_numpy() ARR_N = int(len(_ids)) if int(_ids.min()) != 0 or int(_ids.max()) != ARR_N - 1: raise RuntimeError("domain id column is not a dense 0..N-1 range") _inv = np.empty(ARR_N, dtype=np.int64) _inv[_ids] = np.arange(ARR_N, dtype=np.int64) # _inv[id] = row position holding that id def _scatter_num(col_name, fill_zero=False): src = _tbl.column(col_name).to_numpy(zero_copy_only=False).astype("float64") out = np.full(ARR_N, np.nan, dtype="float64") out[_ids] = src if fill_zero: out = np.nan_to_num(out, nan=0.0) return out ARR_HARMONIC = _scatter_num("harmonic_pos") ARR_PR = _scatter_num("pr_pos") ARR_OUTBOUND = _scatter_num("outbound_count", fill_zero=True).astype("int64") ARR_NHOSTS = _scatter_num("n_hosts", fill_zero=True).astype("int64") ARR_REV_ARROW = pc.take(_tbl.column("rev_domain"), pa.array(_inv)) # positionally aligned: ARR_REV_ARROW[id] == rev_domain del _tbl, _ids, _inv TOTAL_DOMAINS = ARR_N print(f"Domain arrays ready in {time.time() - _t:.1f}s ({ARR_N:,} domains).", flush=True) # The edges file is stored on classic HF LFS -> S3. Hitting resolve/main issues a # 302 to a *method-specific* presigned S3 URL (valid ~1h). We must resolve with GET # (not HEAD) so the presigned signature is valid for DuckDB's ranged GETs, cache the # URL, and transparently re-resolve when it expires (403). _edges_cache = {"url": None, "ts": 0.0} _EDGES_TTL = 45 * 60 # refresh well before the 1h expiry def _resolve_edges_url(force=False): if not force and _edges_cache["url"] and (time.time() - _edges_cache["ts"] < _EDGES_TTL): return _edges_cache["url"] headers = {"Authorization": f"Bearer {HF_TOKEN}"} if HF_TOKEN else {} # stream=True + read nothing: HF hands out a GET-valid presigned URL. with _r.get(RESOLVE_URL, headers=headers, allow_redirects=True, stream=True, timeout=30) as resp: resp.raise_for_status() url = resp.url _edges_cache["url"] = url _edges_cache["ts"] = time.time() return url # Transparent result cache for remote queries, keyed by the literal SQL text # (which already contains the specific to_id/domain, and never the presigned # CDN URL itself since that's swapped in at call time). The dataset is a static # crawl snapshot, so cached results never go stale within a Space's lifetime. # This means re-querying the same domain (or re-running the same gap analysis) # is instant on repeat, with zero network round-trips. _query_cache: "OrderedDict[str, pd.DataFrame]" = OrderedDict() _QUERY_CACHE_MAX = 300 def q_edges(sql_template: str): """Run a query against the remote edges file, transparently refreshing the presigned URL on expiry (403/HTTP error) and caching results by query text. sql_template must contain '{EDGES}'.""" if sql_template in _query_cache: _query_cache.move_to_end(sql_template) return _query_cache[sql_template] url = _resolve_edges_url() edges = f"read_parquet('{url}')" try: result = con.execute(sql_template.format(EDGES=edges)).df() except duckdb.HTTPException: url = _resolve_edges_url(force=True) edges = f"read_parquet('{url}')" result = con.execute(sql_template.format(EDGES=edges)).df() _query_cache[sql_template] = result if len(_query_cache) > _QUERY_CACHE_MAX: _query_cache.popitem(last=False) return result # Resolve the CDN URL + warm the httpfs footer cache in a BACKGROUND thread. # This must NOT block module import: it runs before demo.launch(), and a cold # metadata read of the 19GB remote edges file can be slow (or hang) -- if it # blocked here the Gradio port would never bind and the Space would sit in # "APP_STARTING" forever. Doing it in a daemon thread lets the app go live # immediately; the first user query just pays the warm-up cost if it hasn't # finished yet. def _warm_edges_bg(): try: _u = _resolve_edges_url() print(f"CDN URL ready: ...{_u[-60:]}", flush=True) _t = time.time() q_edges("SELECT from_id FROM {EDGES} WHERE to_id = 1") print(f"Edges footer warmed in {time.time() - _t:.1f}s", flush=True) except Exception as e: print(f"WARN could not pre-resolve/warm edges URL: {e}", flush=True) import threading threading.Thread(target=_warm_edges_bg, daemon=True).start() def rev(domain: str) -> str: return ".".join(reversed(domain.strip().lower().lstrip("www.").split("."))) def fwd(revdom: str) -> str: return ".".join(reversed(revdom.split("."))) def _lookup(domain): """Resolve a domain to (id, n_hosts, outbound_count, harmonic_pos, pr_pos). id == the array position, since arrays are aligned positionally by id.""" r = rev(domain) pos = pc.index(ARR_REV_ARROW, pa.scalar(r, type=ARR_REV_ARROW.type)).as_py() if pos is None or pos < 0: return None h = ARR_HARMONIC[pos] p = ARR_PR[pos] return ( int(pos), int(ARR_NHOSTS[pos]), int(ARR_OUTBOUND[pos]), None if np.isnan(h) else int(h), None if np.isnan(p) else int(p), ) def _rows_from_ids(ids: np.ndarray) -> pd.DataFrame: """O(k) enrichment via direct positional array indexing -- no scan, no SQL, no hashing. Sorted by harmonic rank (NULLs last).""" harmonic = ARR_HARMONIC[ids] pagerank = ARR_PR[ids] outbound = ARR_OUTBOUND[ids] sort_key = np.where(np.isnan(harmonic), np.inf, harmonic) order = np.argsort(sort_key, kind="stable") domains = pc.take(ARR_REV_ARROW, pa.array(ids[order])).to_pylist() return pd.DataFrame({ "domain": [fwd(d) for d in domains], "harmonic_rank": pd.array(harmonic[order], dtype="Int64"), "pagerank_rank": pd.array(pagerank[order], dtype="Int64"), "outbound_count": outbound[order], }) def _join_ids_locally(ids_df: pd.DataFrame, id_col: str = "from_id") -> pd.DataFrame: """Enrich a (small, already-local) id set with each referring domain's name and authority. Never touches the network; O(k) positional array gather.""" if len(ids_df) == 0: return pd.DataFrame(columns=["domain", "harmonic_rank", "pagerank_rank", "outbound_count"]) return _rows_from_ids(ids_df[id_col].to_numpy()) def _paginate(df: pd.DataFrame, page: int, page_size: int): if df is None or len(df) == 0: return df, "0 / 0" n = len(df) n_pages = max(1, (n + page_size - 1) // page_size) page = max(1, min(page, n_pages)) start = (page - 1) * page_size return df.iloc[start:start + page_size], f"Page {page} / {n_pages} ({n:,} total rows)" def _to_csv_file(df: pd.DataFrame, name: str): if df is None or len(df) == 0: return None path = os.path.join(tempfile.gettempdir(), name) df.to_csv(path, index=False) return path # ---------------------------------------------------------------- Backlink report def backlink_report(domain, page_size): domain = (domain or "").strip() if not domain: return "Enter a domain.", None, None, "0 / 0" row = _lookup(domain) if not row: return f"**{domain}** was not found in the Common Crawl web graph.", None, None, "0 / 0" did, nhosts, ob, self_harmonic, self_pr = row rk = (self_harmonic, self_pr) if self_harmonic is not None else None t0 = time.time() ids_df = q_edges(f"SELECT from_id FROM {{EDGES}} WHERE to_id = {did}") t1 = time.time() full = _join_ids_locally(ids_df, "from_id") fetch_s = t1 - t0 join_s = time.time() - t1 harmonic = f"#{rk[0]:,}" if rk else "-" pagerank = f"#{rk[1]:,}" if rk else "-" pct_s = f"(top {rk[0] / TOTAL_DOMAINS * 100:.4f}%)" if rk else "" cards = f"""
{len(full):,}
Referring domains (backlinks)
{harmonic}
Harmonic centrality rank {pct_s}
{pagerank}
PageRank rank
{ob:,}
Outbound linked domains
{nhosts}
Crawled hosts (subdomains)
remote fetch: {fetch_s:.1f}s · enrich/sort: {join_s:.1f}s · {domain}
""" page_df, pager = _paginate(full, 1, int(page_size)) return cards, full, page_df, pager def report_change_page(full_df, page, page_size, direction): if full_df is None: return None, "0 / 0", page n_pages = max(1, (len(full_df) + int(page_size) - 1) // int(page_size)) page = max(1, min(page + direction, n_pages)) page_df, pager = _paginate(full_df, page, int(page_size)) return page_df, pager, page def report_export(full_df, domain): return _to_csv_file(full_df, f"{(domain or 'domain').strip()}_backlinks.csv") # ---------------------------------------------------------------- Link gap def _compute_gap(target, competitors): """Core link-gap computation shared by the UI and the JSON API. Returns (note, high_full_df, niche_full_df, min_comp, missing_list).""" target = (target or "").strip() comps = [c.strip() for c in (competitors or "").replace("\n", ",").split(",") if c.strip()] empty_df = pd.DataFrame() if not target or not comps: return ("Enter a target domain and at least one competitor.", empty_df, empty_df, 1, []) tv = _lookup(target) if not tv: return (f"**{target}** not found in the web graph.", empty_df, empty_df, 1, []) tid = tv[0] comp_ids, missing = [], [] for c in comps: v = _lookup(c) (comp_ids if v else missing).append(v[0] if v else c) if not comp_ids: return ("None of the competitors were found.", empty_df, empty_df, 1, missing) ids_list = ",".join(str(i) for i in comp_ids) t0 = time.time() comp_df = q_edges(f"SELECT DISTINCT from_id FROM {{EDGES}} WHERE to_id IN ({ids_list})") tgt_df = q_edges(f"SELECT DISTINCT from_id FROM {{EDGES}} WHERE to_id = {tid}") counts_df = q_edges(f"SELECT from_id, count(DISTINCT to_id) AS competitors_linked FROM {{EDGES}} WHERE to_id IN ({ids_list}) GROUP BY from_id") fetch_s = time.time() - t0 con.register("_comp", comp_df) con.register("_tgt", tgt_df) gap_ids = con.execute("SELECT from_id FROM _comp WHERE from_id NOT IN (SELECT from_id FROM _tgt)").df() con.unregister("_comp") con.unregister("_tgt") high_full = _join_ids_locally(gap_ids, "from_id") if len(gap_ids) else pd.DataFrame() min_comp = 2 if len(comp_ids) >= 2 else 1 con.register("_counts", counts_df) con.register("_tgt2", tgt_df) niche_ids = con.execute(f""" SELECT from_id, competitors_linked FROM _counts WHERE from_id NOT IN (SELECT from_id FROM _tgt2) AND competitors_linked >= {min_comp} """).df() con.unregister("_counts") con.unregister("_tgt2") if len(niche_ids): ids = niche_ids["from_id"].to_numpy() harmonic = ARR_HARMONIC[ids] mask = (harmonic >= 300) & (harmonic <= 300000) # NaN comparisons are False -> ranks excluded, matching SQL BETWEEN ids, harmonic = ids[mask], harmonic[mask] pagerank = ARR_PR[ids] outbound = ARR_OUTBOUND[ids] comp_linked = niche_ids["competitors_linked"].to_numpy()[mask] order = np.lexsort((harmonic, -comp_linked)) # primary: competitors_linked DESC, secondary: harmonic ASC domains = pc.take(ARR_REV_ARROW, pa.array(ids[order])).to_pylist() niche_full = pd.DataFrame({ "domain": [fwd(d) for d in domains], "harmonic_rank": pd.array(harmonic[order], dtype="Int64"), "pagerank_rank": pd.array(pagerank[order], dtype="Int64"), "outbound_count": outbound[order], "competitors_linked": comp_linked[order], }) else: niche_full = pd.DataFrame() note = f"### Link-gap for {target}\nCompetitors analyzed: {', '.join(comps)} · fetch: {fetch_s:.1f}s" if missing: note += f"\n\n_(not found, skipped: {', '.join(missing)})_" note += f"\n\n**{len(high_full):,}** total gap domains found · **{len(niche_full):,}** are niche-relevant & attainable (link to ≥{min_comp} competitor(s), harmonic rank 300–300,000)." return note, high_full, niche_full, min_comp, missing def link_gap(target, competitors, page_size): note, high_full, niche_full, _min_comp, _missing = _compute_gap(target, competitors) high_page, high_pager = _paginate(high_full, 1, int(page_size)) niche_page, niche_pager = _paginate(niche_full, 1, int(page_size)) return note, high_full, high_page, high_pager, niche_full, niche_page, niche_pager def gap_change_page(full_df, page, page_size, direction): if full_df is None: return None, "0 / 0", page n_pages = max(1, (len(full_df) + int(page_size) - 1) // int(page_size)) page = max(1, min(page + direction, n_pages)) page_df, pager = _paginate(full_df, page, int(page_size)) return page_df, pager, page def gap_export(full_df, target, suffix): return _to_csv_file(full_df, f"{(target or 'domain').strip()}_{suffix}.csv") # ---------------------------------------------------------------- JSON API # Dedicated endpoints for external clients (e.g. the Cloudflare Worker UI). # These return the FULL result set as clean JSON in one call -- no HTML card # parsing and no page_size/dropdown limits like the Gradio UI functions have. def _records(df): """DataFrame -> list[dict] with NaN/NA -> None and native python scalars.""" if df is None or len(df) == 0: return [] recs = df.astype(object).where(pd.notnull(df), None).to_dict("records") out = [] for r in recs: out.append({ k: (int(v) if isinstance(v, np.integer) else float(v) if isinstance(v, np.floating) else v) for k, v in r.items() }) return out def api_backlink_report(domain): domain = (domain or "").strip() if not domain: return {"found": False, "error": "empty domain"} row = _lookup(domain) if not row: return {"found": False, "domain": domain} did, nhosts, ob, sh, spr = row full = _join_ids_locally(q_edges(f"SELECT from_id FROM {{EDGES}} WHERE to_id = {did}"), "from_id") return { "found": True, "domain": domain, "referringDomains": int(len(full)), "harmonicRank": sh, "pagerankRank": spr, "harmonicTopPct": round(sh / TOTAL_DOMAINS * 100, 4) if sh else None, "outboundCount": int(ob), "crawledHosts": int(nhosts), "rows": _records(full), } def api_link_gap(target, competitors): note, high_full, niche_full, min_comp, missing = _compute_gap(target, competitors) return { "target": (target or "").strip(), "note": note, "minCompetitors": min_comp, "missing": missing, "high": _records(high_full), "niche": _records(niche_full), } CSS = """ .gradio-container {max-width: 1250px !important} h1 {color:#1a3d7c} .pager-row {align-items:center} """ with gr.Blocks(title="CC Backlink Explorer", css=CSS, theme=gr.themes.Soft()) as demo: gr.Markdown("# 🔗 CC Backlink Explorer\nMajestic/Ahrefs-style backlinks & rank booster, built on the **Common Crawl** domain web graph (2026 Apr–May–Jun · 121M domains · 3.9B links). Harmonic centrality ≈ Trust Flow, PageRank ≈ Citation Flow (no anchor-text / referring-IP data at domain-graph granularity).") with gr.Tab("Backlink report"): with gr.Row(): d_in = gr.Textbox(label="Domain", placeholder="stripe.com", scale=3) d_page_size = gr.Dropdown([25, 50, 100, 250, 500], value=50, label="Rows per page", scale=1) d_btn = gr.Button("Analyze", variant="primary", scale=1) d_md = gr.Markdown() d_full_state = gr.State(None) d_page_state = gr.State(1) with gr.Row(elem_classes="pager-row"): d_prev = gr.Button("◀ Prev", size="sm") d_pager = gr.Markdown("0 / 0") d_next = gr.Button("Next ▶", size="sm") d_export = gr.DownloadButton("⬇ Download full CSV", size="sm") d_df = gr.Dataframe(label="Referring domains (sorted by their own authority)", wrap=True) d_btn.click(backlink_report, [d_in, d_page_size], [d_md, d_full_state, d_df, d_pager]) d_in.submit(backlink_report, [d_in, d_page_size], [d_md, d_full_state, d_df, d_pager]) d_prev.click(lambda f, p, s: report_change_page(f, p, s, -1), [d_full_state, d_page_state, d_page_size], [d_df, d_pager, d_page_state]) d_next.click(lambda f, p, s: report_change_page(f, p, s, 1), [d_full_state, d_page_state, d_page_size], [d_df, d_pager, d_page_state]) d_export.click(report_export, [d_full_state, d_in], d_export) with gr.Tab("Link gap / rank booster"): gr.Markdown("Find high-authority domains that link to your **competitors but not you** — the best targets to raise your harmonic/PageRank.") with gr.Row(): g_t = gr.Textbox(label="Your domain", placeholder="metehan.ai", scale=2) g_c = gr.Textbox(label="Competitors (comma-separated)", placeholder="peec.ai, ahrefs.com, semrush.com", scale=3) g_page_size = gr.Dropdown([25, 50, 100, 250, 500], value=50, label="Rows per page", scale=1) g_btn = gr.Button("Find link gaps", variant="primary") g_md = gr.Markdown() g_high_state = gr.State(None) g_niche_state_full = gr.State(None) g_high_page = gr.State(1) g_niche_page = gr.State(1) gr.Markdown("**Actionable / niche-relevant targets** (link to ≥2 competitors, realistic attainable authority):") with gr.Row(elem_classes="pager-row"): gn_prev = gr.Button("◀ Prev", size="sm") gn_pager = gr.Markdown("0 / 0") gn_next = gr.Button("Next ▶", size="sm") gn_export = gr.DownloadButton("⬇ Download full CSV", size="sm") g_niche = gr.Dataframe(wrap=True) gr.Markdown("**All gap domains** (every domain linking to a competitor but not you, sorted by authority):") with gr.Row(elem_classes="pager-row"): gh_prev = gr.Button("◀ Prev", size="sm") gh_pager = gr.Markdown("0 / 0") gh_next = gr.Button("Next ▶", size="sm") gh_export = gr.DownloadButton("⬇ Download full CSV", size="sm") g_high = gr.Dataframe(wrap=True) g_btn.click( link_gap, [g_t, g_c, g_page_size], [g_md, g_high_state, g_high, gh_pager, g_niche_state_full, g_niche, gn_pager], ) gh_prev.click(lambda f, p, s: gap_change_page(f, p, s, -1), [g_high_state, g_high_page, g_page_size], [g_high, gh_pager, g_high_page]) gh_next.click(lambda f, p, s: gap_change_page(f, p, s, 1), [g_high_state, g_high_page, g_page_size], [g_high, gh_pager, g_high_page]) gh_export.click(lambda f, t: gap_export(f, t, "link_gap_all"), [g_high_state, g_t], gh_export) gn_prev.click(lambda f, p, s: gap_change_page(f, p, s, -1), [g_niche_state_full, g_niche_page, g_page_size], [g_niche, gn_pager, g_niche_page]) gn_next.click(lambda f, p, s: gap_change_page(f, p, s, 1), [g_niche_state_full, g_niche_page, g_page_size], [g_niche, gn_pager, g_niche_page]) gn_export.click(lambda f, t: gap_export(f, t, "link_gap_niche"), [g_niche_state_full, g_t], gn_export) gr.Markdown("Data: Common Crawl web graph (CC-MAIN-2026 Apr-May-Jun), harmonic centrality & PageRank. URL-level backlinks are not available in the web graph (host & domain level only). Full result sets are always available via CSV export, not just the on-screen page.") # Hidden JSON API endpoints for external clients (Cloudflare Worker UI). # api_backlink(domain) and api_gap(target, competitors) each return the full # result set as JSON in a single call -- no dropdown/page_size constraints. with gr.Row(visible=False): _api_dom = gr.Textbox() _api_report_out = gr.JSON() _api_report_btn = gr.Button() _api_gap_t = gr.Textbox() _api_gap_c = gr.Textbox() _api_gap_out = gr.JSON() _api_gap_btn = gr.Button() _api_report_btn.click(api_backlink_report, _api_dom, _api_report_out, api_name="api_backlink") _api_gap_btn.click(api_link_gap, [_api_gap_t, _api_gap_c], _api_gap_out, api_name="api_gap") if __name__ == "__main__": demo.queue(max_size=32, default_concurrency_limit=4).launch()