fractus-cte / scripts /gutenberg_to_jsonl.py
thefinalboss's picture
Upload folder using huggingface_hub
6c223ff verified
Raw History Blame
6.02 kB
#!/usr/bin/env python
"""Fetch public-domain esoteric/occult/masonic books from Project Gutenberg
via the Gutendex API, strip the boilerplate, and package as JSONL.
Only books with copyright == false (or null) are taken — genuinely public
domain. Uploaded to the HF dataset repo so build_corpus.py consumes them.
Usage:
python scripts/gutenberg_to_jsonl.py # default esoteric topics
python scripts/gutenberg_to_jsonl.py --topics magic alchemy
"""
import argparse, os, sys, json, re, time, io
import urllib.request
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
HF_REPO = "thefinalboss/fractus-datasets"
# Topics that surface the wanted corpus. Queried against Gutendex.
ESOTERIC_TOPICS = [
"occultism", "magic", "alchemy", "mysticism", "rosicrucian",
"hermeticism", "tarot", "theosophy", "kabbalah", "masonry",
"freemasonry", "gnosticism", "witchcraft", "demonology", "astrology",
"divination", "spiritualism", "esoteric", "ritual", "symbolism",
]
UA = {"User-Agent": "fractus-dataset/1.0"}
def http_get(url, timeout=120, retries=2):
last = None
for _ in range(retries + 1):
try:
req = urllib.request.Request(url, headers=UA)
with urllib.request.urlopen(req, timeout=timeout) as r:
return r.read()
except Exception as e:
last = e
time.sleep(2)
raise last
def query_topic(topic, max_pages=4):
"""Yield book dicts for a topic (paginated). Only public-domain."""
url = f"https://gutendex.com/books/?topic={topic}"
pages = 0
seen = set()
while url and pages < max_pages:
try:
data = json.loads(http_get(url, timeout=60).decode("utf-8"))
except Exception as e:
print(f" gutendex page failed: {e}", flush=True)
break
for b in data.get("results", []):
if b["id"] in seen:
continue
seen.add(b["id"])
# copyright: False or None => public domain. True => skip.
if b.get("copyright") is True:
continue
yield b
url = data.get("next")
pages += 1
time.sleep(1) # be polite to the API
def pick_text_url(formats):
"""Prefer utf-8 plain text; fall back to us-ascii."""
for key in ("text/plain; charset=utf-8", "text/plain; charset=us-ascii",
"text/plain"):
if key in formats:
return formats[key]
return None
# Project Gutenberg boilerplate markers (handle older + newer phrasings).
START_RE = re.compile(r"\*\*\*\s*START OF (?:THE|THIS)?\s*PROJECT GUTENBERG.*?\*\*\*",
re.IGNORECASE | re.DOTALL)
END_RE = re.compile(r"\*\*\*\s*END OF (?:THE|THIS)?\s*PROJECT GUTENBERG.*?\*\*\*",
re.IGNORECASE | re.DOTALL)
def strip_boilerplate(text):
"""Cut to the text between the Gutenberg START/END markers."""
s = START_RE.search(text)
e = END_RE.search(text)
if s and e and e.start() > s.end():
return text[s.end():e.start()].strip()
return text.strip()
def main():
ap = argparse.ArgumentParser(description="Fetch public-domain books from Gutenberg")
ap.add_argument("--topics", nargs="*", default=ESOTERIC_TOPICS)
ap.add_argument("--out", default="data/_gutenberg/esoteric.jsonl")
ap.add_argument("--max-per-topic", type=int, default=60)
ap.add_argument("--min-chars", type=int, default=5000,
help="skip tiny fragments (likely indexes)")
ap.add_argument("--no-upload", action="store_true")
args = ap.parse_args()
os.makedirs(os.path.dirname(args.out) or ".", exist_ok=True)
print(f"=== Gutenberg fetch: topics={args.topics} ===", flush=True)
# Collect unique books across all topics.
books = {}
for topic in args.topics:
n = 0
for b in query_topic(topic):
if b["id"] not in books:
books[b["id"]] = b
n += 1
if n >= args.max_per_topic:
break
print(f" {topic:<14} -> {len(books)} unique total", flush=True)
print(f"\n{len(books)} unique public-domain books to fetch.\n", flush=True)
n_ok, n_skip, total_chars = 0, 0, 0
with open(args.out, "w", encoding="utf-8") as out:
for bid, b in books.items():
url = pick_text_url(b.get("formats", {}))
if not url:
n_skip += 1
continue
try:
raw = http_get(url, timeout=180)
text = raw.decode("utf-8", errors="ignore")
except Exception as e:
print(f" skip #{bid}: {str(e)[:60]}", flush=True)
n_skip += 1
continue
text = strip_boilerplate(text)
if len(text) < args.min_chars:
n_skip += 1
continue
authors = ", ".join(a.get("name", "?") for a in b.get("authors", []))
out.write(json.dumps({
"text": text,
"title": b.get("title", "?"),
"author": authors,
"source": f"gutenberg/{bid}",
}, ensure_ascii=False) + "\n")
total_chars += len(text)
n_ok += 1
if n_ok % 5 == 0:
print(f" {n_ok}/{len(books)} fetched "
f"(~{total_chars//4:,} tokens so far)", flush=True)
time.sleep(0.5) # politeness
print(f"\nFetched {n_ok} books ({n_skip} skipped), "
f"{total_chars:,} chars (~{total_chars//4:,} tokens)", flush=True)
if not args.no_upload and n_ok:
from huggingface_hub import HfApi
api = HfApi()
api.upload_file(
path_or_fileobj=args.out,
path_in_repo="gutenberg/esoteric.jsonl",
repo_id=HF_REPO, repo_type="dataset")
print(f" → uploaded to {HF_REPO}/gutenberg/esoteric.jsonl", flush=True)
if __name__ == "__main__":
main()