Spaces:
Runtime error
Runtime error
Upload folder using huggingface_hub
Browse files
README.md
CHANGED
|
@@ -1,13 +1,62 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
colorFrom: indigo
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
-
python_version: '3.13'
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: CC Domain Backlink Graph Builder
|
| 3 |
+
emoji: 🕸️
|
| 4 |
colorFrom: indigo
|
| 5 |
+
colorTo: gray
|
| 6 |
sdk: gradio
|
| 7 |
+
sdk_version: 4.44.0
|
|
|
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
---
|
| 11 |
|
| 12 |
+
# Common Crawl domain backlink-graph builder
|
| 13 |
+
|
| 14 |
+
A one-time (per quarterly release) build job that turns the Common Crawl
|
| 15 |
+
**domain hyperlink graph** into partitioned Parquet on a Hugging Face dataset
|
| 16 |
+
repo, so you can query *backlinks* ("who links to domain X") remotely with
|
| 17 |
+
DuckDB — no 24 GB local download.
|
| 18 |
+
|
| 19 |
+
## How it works
|
| 20 |
+
|
| 21 |
+
`build.py` streams CC's gzipped graph CSVs straight from the CDN
|
| 22 |
+
(`data.commoncrawl.org`) via DuckDB + httpfs, in a single pass:
|
| 23 |
+
|
| 24 |
+
- **vertices** → `vertices_by_domain.parquet` (sorted by domain) +
|
| 25 |
+
`vertices_by_id.parquet` (sorted by id)
|
| 26 |
+
- **ranks** → `ranks_by_id.parquet` (harmonic-centrality position per node)
|
| 27 |
+
- **edges** → `edges/part=<n>/` partitioned by `to_id // part_size`
|
| 28 |
+
(default 1,000,000 → ~122 partitions)
|
| 29 |
+
|
| 30 |
+
Because edges are bucketed by `to_id`, a backlink query reads only the one
|
| 31 |
+
partition whose range contains the target's id — a few MB over HTTP Range,
|
| 32 |
+
not the whole 22.9 GB edge set.
|
| 33 |
+
|
| 34 |
+
## Deploy as a Space
|
| 35 |
+
|
| 36 |
+
1. Create a new **Gradio Space** and add these files (`app.py`, `build.py`,
|
| 37 |
+
`requirements.txt`, this `README.md`).
|
| 38 |
+
2. Add two **Space secrets**:
|
| 39 |
+
- `HF_TOKEN` — a write token for your target dataset repo
|
| 40 |
+
- `REPO` — e.g. `yourname/cc-domain-graph`
|
| 41 |
+
3. Open the Space, click **Build & push**. Progress streams in the box.
|
| 42 |
+
|
| 43 |
+
Notes:
|
| 44 |
+
- The Space needs ~30 GB ephemeral disk for the Parquet output (the *source*
|
| 45 |
+
is streamed, not stored). If the build runs out of disk, upgrade the Space
|
| 46 |
+
hardware for the one-time run, or run `build.py` on Colab/Kaggle instead.
|
| 47 |
+
- The build takes roughly 1–2 hours (dominated by streaming + partitioning the
|
| 48 |
+
22.9 GB edge file).
|
| 49 |
+
|
| 50 |
+
## Or run it anywhere
|
| 51 |
+
|
| 52 |
+
```bash
|
| 53 |
+
pip install -r requirements.txt
|
| 54 |
+
HF_TOKEN=hf_xxx python3 build.py --repo yourname/cc-domain-graph
|
| 55 |
+
# or build locally without pushing:
|
| 56 |
+
python3 build.py --repo x --no-push --out ./graph_out
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
## Refresh
|
| 60 |
+
|
| 61 |
+
Common Crawl publishes a new graph quarterly. Bump `RELEASE` in `build.py`
|
| 62 |
+
to the newest `cc-main-YYYY-...` and re-run to keep link rot bounded to months.
|
app.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Hugging Face Space: one-click build of the CC domain backlink graph.
|
| 2 |
+
|
| 3 |
+
Set two Space secrets:
|
| 4 |
+
HF_TOKEN — a write token for the target dataset repo
|
| 5 |
+
REPO — target dataset repo id, e.g. "isam/cc-domain-graph"
|
| 6 |
+
|
| 7 |
+
Then open the Space and click "Build & push". Progress streams below.
|
| 8 |
+
The build streams the source from CC (no large source download to the Space);
|
| 9 |
+
it writes ~25-30 GB of Parquet locally, so give the Space enough ephemeral
|
| 10 |
+
disk (free tier is usually fine; upgrade hardware if it runs out of space).
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import os
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
|
| 16 |
+
import gradio as gr
|
| 17 |
+
|
| 18 |
+
from build import build, push, RELEASE
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def run(repo: str):
|
| 22 |
+
repo = (repo or os.environ.get("REPO") or "").strip()
|
| 23 |
+
token = os.environ.get("HF_TOKEN")
|
| 24 |
+
logs = []
|
| 25 |
+
|
| 26 |
+
def emit(line):
|
| 27 |
+
logs.append(line)
|
| 28 |
+
return "\n".join(logs)
|
| 29 |
+
|
| 30 |
+
if not repo:
|
| 31 |
+
yield emit("ERROR: set REPO secret or type a repo id (e.g. you/cc-domain-graph)")
|
| 32 |
+
return
|
| 33 |
+
if not token:
|
| 34 |
+
yield emit("ERROR: set the HF_TOKEN Space secret (write access).")
|
| 35 |
+
return
|
| 36 |
+
|
| 37 |
+
yield emit(f"Building {RELEASE} → {repo}")
|
| 38 |
+
out = Path("/tmp/graph_out")
|
| 39 |
+
|
| 40 |
+
# build() prints with flush; capture by redirecting print via a simple shim.
|
| 41 |
+
import builtins
|
| 42 |
+
orig_print = builtins.print
|
| 43 |
+
buffer = {"last": ""}
|
| 44 |
+
|
| 45 |
+
def teed_print(*a, **k):
|
| 46 |
+
msg = " ".join(str(x) for x in a)
|
| 47 |
+
buffer["last"] = msg
|
| 48 |
+
orig_print(*a, **k)
|
| 49 |
+
|
| 50 |
+
builtins.print = teed_print
|
| 51 |
+
try:
|
| 52 |
+
manifest = build(out)
|
| 53 |
+
yield emit(f"Built {manifest['domain_nodes']:,} nodes. Uploading ...")
|
| 54 |
+
push(out, repo, token)
|
| 55 |
+
yield emit("✅ Done. Query with backlinks.py --repo " + repo)
|
| 56 |
+
except Exception as e:
|
| 57 |
+
yield emit(f"❌ FAILED: {e}")
|
| 58 |
+
finally:
|
| 59 |
+
builtins.print = orig_print
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
with gr.Blocks(title="CC domain backlink graph builder") as demo:
|
| 63 |
+
gr.Markdown(
|
| 64 |
+
"# Common Crawl domain backlink-graph builder\n"
|
| 65 |
+
f"Transforms the **{RELEASE}** domain hyperlink graph into partitioned "
|
| 66 |
+
"Parquet on a Hugging Face dataset repo, queryable remotely with DuckDB.\n\n"
|
| 67 |
+
"Set `HF_TOKEN` and `REPO` as Space secrets, then build."
|
| 68 |
+
)
|
| 69 |
+
repo_in = gr.Textbox(label="Target dataset repo", placeholder="you/cc-domain-graph",
|
| 70 |
+
value=os.environ.get("REPO", ""))
|
| 71 |
+
btn = gr.Button("Build & push", variant="primary")
|
| 72 |
+
out = gr.Textbox(label="Progress", lines=20, max_lines=40)
|
| 73 |
+
btn.click(run, inputs=repo_in, outputs=out)
|
| 74 |
+
|
| 75 |
+
if __name__ == "__main__":
|
| 76 |
+
demo.queue().launch()
|
build.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""One-time build: transform a Common Crawl domain hyperlink graph into a
|
| 3 |
+
partitioned Parquet dataset on Hugging Face, queryable remotely for backlinks.
|
| 4 |
+
|
| 5 |
+
Streams the graph straight from CC's CDN (no 24 GB local copy of the source —
|
| 6 |
+
DuckDB reads the gzipped CSVs over httpfs in a single pass). Output (~25-30 GB
|
| 7 |
+
of Parquet) is written locally, then pushed to an HF dataset repo.
|
| 8 |
+
|
| 9 |
+
Run anywhere with ~30 GB free disk + an HF token:
|
| 10 |
+
HF_TOKEN=hf_xxx python3 build.py --repo isam/cc-domain-graph
|
| 11 |
+
|
| 12 |
+
Produces on the repo:
|
| 13 |
+
manifest.json
|
| 14 |
+
vertices_by_domain.parquet (id, rev_domain) sorted by rev_domain
|
| 15 |
+
vertices_by_id.parquet (id, rev_domain) sorted by id
|
| 16 |
+
ranks_by_id.parquet (id, harmonic_pos)
|
| 17 |
+
edges/part=<n>/data_0.parquet (from_id, to_id) partitioned by to_id // PART_SIZE
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
import argparse
|
| 21 |
+
import json
|
| 22 |
+
import os
|
| 23 |
+
import shutil
|
| 24 |
+
import sys
|
| 25 |
+
from pathlib import Path
|
| 26 |
+
|
| 27 |
+
import duckdb
|
| 28 |
+
|
| 29 |
+
RELEASE = "cc-main-2025-oct-nov-dec"
|
| 30 |
+
BASE = f"https://data.commoncrawl.org/projects/hyperlinkgraph/{RELEASE}/domain"
|
| 31 |
+
V_URL = f"{BASE}/{RELEASE}-domain-vertices.txt.gz"
|
| 32 |
+
E_URL = f"{BASE}/{RELEASE}-domain-edges.txt.gz"
|
| 33 |
+
R_URL = f"{BASE}/{RELEASE}-domain-ranks.txt.gz"
|
| 34 |
+
|
| 35 |
+
# ~121M domain nodes → ~1M ids per partition → ~122 partition files.
|
| 36 |
+
PART_SIZE = 1_000_000
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def log(msg: str):
|
| 40 |
+
print(msg, flush=True)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def build(out_dir: Path, memory_limit: str = "12GB") -> dict:
|
| 44 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 45 |
+
(out_dir / "edges").mkdir(exist_ok=True)
|
| 46 |
+
tmp = out_dir / "_duck_tmp"
|
| 47 |
+
tmp.mkdir(exist_ok=True)
|
| 48 |
+
|
| 49 |
+
con = duckdb.connect(config={"memory_limit": memory_limit, "temp_directory": str(tmp)})
|
| 50 |
+
con.execute("INSTALL httpfs; LOAD httpfs;")
|
| 51 |
+
|
| 52 |
+
# ---- vertices: id, rev_domain (3rd col n_hosts ignored) ----
|
| 53 |
+
log("Loading vertices (851 MiB gz) ...")
|
| 54 |
+
con.execute(f"""
|
| 55 |
+
CREATE TABLE v AS
|
| 56 |
+
SELECT c0::UINTEGER AS id, c1 AS rev_domain
|
| 57 |
+
FROM read_csv('{V_URL}', delim='\t', header=false, auto_detect=false,
|
| 58 |
+
columns={{'c0':'UINTEGER','c1':'VARCHAR','c2':'UBIGINT'}})
|
| 59 |
+
""")
|
| 60 |
+
n_nodes = con.execute("SELECT count(*) FROM v").fetchone()[0]
|
| 61 |
+
log(f" {n_nodes:,} domain nodes")
|
| 62 |
+
con.execute(f"COPY (SELECT * FROM v ORDER BY rev_domain) TO '{out_dir}/vertices_by_domain.parquet' (FORMAT parquet)")
|
| 63 |
+
con.execute(f"COPY (SELECT * FROM v ORDER BY id) TO '{out_dir}/vertices_by_id.parquet' (FORMAT parquet)")
|
| 64 |
+
log(" wrote vertices_by_domain.parquet, vertices_by_id.parquet")
|
| 65 |
+
|
| 66 |
+
# ---- ranks: harmonicc_pos keyed by host_rev → join to id ----
|
| 67 |
+
log("Loading ranks ...")
|
| 68 |
+
con.execute(f"""
|
| 69 |
+
CREATE TABLE r AS
|
| 70 |
+
SELECT c0::UBIGINT AS harmonic_pos, c4 AS rev_domain
|
| 71 |
+
FROM read_csv('{R_URL}', delim='\t', header=false, skip=1, auto_detect=false,
|
| 72 |
+
columns={{'c0':'UBIGINT','c1':'VARCHAR','c2':'UBIGINT',
|
| 73 |
+
'c3':'VARCHAR','c4':'VARCHAR','c5':'UBIGINT'}})
|
| 74 |
+
""")
|
| 75 |
+
con.execute(f"""
|
| 76 |
+
COPY (SELECT v.id, r.harmonic_pos
|
| 77 |
+
FROM r JOIN v USING (rev_domain) ORDER BY v.id)
|
| 78 |
+
TO '{out_dir}/ranks_by_id.parquet' (FORMAT parquet)
|
| 79 |
+
""")
|
| 80 |
+
log(" wrote ranks_by_id.parquet")
|
| 81 |
+
|
| 82 |
+
# ---- edges: partition by to_id // PART_SIZE (no global sort needed,
|
| 83 |
+
# each partition already spans a narrow to_id range) ----
|
| 84 |
+
log("Streaming + partitioning edges (22.9 GiB gz, single pass) — this is the long step ...")
|
| 85 |
+
con.execute(f"""
|
| 86 |
+
COPY (SELECT c0::UINTEGER AS from_id, c1::UINTEGER AS to_id,
|
| 87 |
+
(c1::UBIGINT // {PART_SIZE})::UINTEGER AS part
|
| 88 |
+
FROM read_csv('{E_URL}', delim='\t', header=false, auto_detect=false,
|
| 89 |
+
columns={{'c0':'UINTEGER','c1':'UINTEGER'}}))
|
| 90 |
+
TO '{out_dir}/edges' (FORMAT parquet, PARTITION_BY (part), OVERWRITE_OR_IGNORE)
|
| 91 |
+
""")
|
| 92 |
+
n_parts = len(list((out_dir / "edges").glob("part=*")))
|
| 93 |
+
log(f" wrote {n_parts} edge partitions")
|
| 94 |
+
|
| 95 |
+
shutil.rmtree(tmp, ignore_errors=True)
|
| 96 |
+
|
| 97 |
+
manifest = {
|
| 98 |
+
"release": RELEASE,
|
| 99 |
+
"source": BASE,
|
| 100 |
+
"part_size": PART_SIZE,
|
| 101 |
+
"domain_nodes": n_nodes,
|
| 102 |
+
"schema": {
|
| 103 |
+
"vertices": ["id", "rev_domain"],
|
| 104 |
+
"edges": ["from_id", "to_id"],
|
| 105 |
+
"ranks": ["id", "harmonic_pos"],
|
| 106 |
+
},
|
| 107 |
+
"note": "rev_domain is label-reversed (blackstump.com.au -> au.com.blackstump). "
|
| 108 |
+
"Backlinks: read edges/part=<to_id//part_size> WHERE to_id=<id>.",
|
| 109 |
+
}
|
| 110 |
+
(out_dir / "manifest.json").write_text(json.dumps(manifest, indent=2))
|
| 111 |
+
log(" wrote manifest.json")
|
| 112 |
+
return manifest
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def push(out_dir: Path, repo: str, token: str):
|
| 116 |
+
from huggingface_hub import HfApi
|
| 117 |
+
api = HfApi(token=token)
|
| 118 |
+
api.create_repo(repo, repo_type="dataset", exist_ok=True)
|
| 119 |
+
log(f"Uploading {out_dir} → {repo} (dataset) ...")
|
| 120 |
+
# upload_large_folder handles many files + resumes; fall back to upload_folder.
|
| 121 |
+
try:
|
| 122 |
+
api.upload_large_folder(folder_path=str(out_dir), repo_id=repo, repo_type="dataset")
|
| 123 |
+
except AttributeError:
|
| 124 |
+
api.upload_folder(folder_path=str(out_dir), repo_id=repo, repo_type="dataset")
|
| 125 |
+
log("Done. Query with: python3 backlinks.py <domain> --repo " + repo)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def main():
|
| 129 |
+
ap = argparse.ArgumentParser(description="Build the hosted CC domain backlink graph.")
|
| 130 |
+
ap.add_argument("--repo", required=True, help="Target HF dataset repo, e.g. isam/cc-domain-graph")
|
| 131 |
+
ap.add_argument("--out", default="./graph_out", help="Local output dir (default: ./graph_out)")
|
| 132 |
+
ap.add_argument("--memory-limit", default="12GB", help="DuckDB memory limit (default: 12GB)")
|
| 133 |
+
ap.add_argument("--no-push", action="store_true", help="Build locally only, skip HF upload")
|
| 134 |
+
args = ap.parse_args()
|
| 135 |
+
|
| 136 |
+
out = Path(args.out)
|
| 137 |
+
build(out, memory_limit=args.memory_limit)
|
| 138 |
+
|
| 139 |
+
if args.no_push:
|
| 140 |
+
log(f"Built in {out} (not pushed).")
|
| 141 |
+
return
|
| 142 |
+
token = os.environ.get("HF_TOKEN")
|
| 143 |
+
if not token:
|
| 144 |
+
sys.exit("Set HF_TOKEN env var to push (or use --no-push).")
|
| 145 |
+
push(out, args.repo, token)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
if __name__ == "__main__":
|
| 149 |
+
main()
|
requirements.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
duckdb>=1.0
|
| 2 |
+
huggingface_hub>=0.25
|
| 3 |
+
gradio>=4.0
|