isam commited on
Commit
3b972d4
·
verified ·
1 Parent(s): ac4b202

Upload folder using huggingface_hub

Browse files
Files changed (4) hide show
  1. README.md +55 -6
  2. app.py +76 -0
  3. build.py +149 -0
  4. requirements.txt +3 -0
README.md CHANGED
@@ -1,13 +1,62 @@
1
  ---
2
- title: Cc Domain Graph Builder
3
- emoji: 😻
4
  colorFrom: indigo
5
- colorTo: green
6
  sdk: gradio
7
- sdk_version: 6.15.2
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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