| """ |
| build_corpus.py — Reproducible corpus builder for CineMatch. |
| |
| Merges three public, no-auth sources into one clean corpus: |
| |
| 1. Pablinho "9000plus" TMDB dump -> base catalog. Every row has a REAL |
| TMDB poster URL, overview, genre, release date, rating, popularity. |
| 2. TMDB-5000 (rashida048 mirror) -> enriches marquee films with structured |
| cast / director / keywords / tagline / runtime / tmdb id. |
| 3. The repo's own movies_dataframe.json (45k pre-tokenized bags) -> adds |
| extra lexical tokens (plot/cast/keyword terms) for BM25 depth. |
| |
| Output: backend/data/corpus.json — a list of movie records with BOTH the raw |
| display fields (preserved verbatim for the UI and for grounded explanations) |
| and two derived text fields used by the retrieval engine: |
| - semantic_text : a natural-language blob for the embedding model |
| - lexical_text : a keyword-dense blob for BM25 tokenization |
| |
| Run: python scripts/build_corpus.py |
| It caches raw downloads under data/raw/ so re-runs are offline & deterministic. |
| """ |
| from __future__ import annotations |
|
|
| import ast |
| import json |
| import re |
| import sys |
| import unicodedata |
| import urllib.request |
| from pathlib import Path |
|
|
| import pandas as pd |
|
|
| ROOT = Path(__file__).resolve().parents[1] |
| RAW = ROOT / "data" / "raw" |
| OUT = ROOT / "data" / "corpus.json" |
| REPO_JSON = ROOT / "data" / "movies_dataframe.json" |
|
|
| SOURCES = { |
| "pablinho_movies.csv": "https://huggingface.co/datasets/Pablinho/movies-dataset/resolve/main/9000plus.csv", |
| "tmdb_5000.csv": "https://raw.githubusercontent.com/rashida048/Some-NLP-Projects/master/movie_dataset.csv", |
| } |
|
|
|
|
| def log(msg: str) -> None: |
| print(f"[build_corpus] {msg}", flush=True) |
|
|
|
|
| def ensure_raw() -> None: |
| RAW.mkdir(parents=True, exist_ok=True) |
| for name, url in SOURCES.items(): |
| dst = RAW / name |
| if dst.exists() and dst.stat().st_size > 1000: |
| continue |
| log(f"downloading {name} ...") |
| req = urllib.request.Request(url, headers={"User-Agent": "cinematch/1.0"}) |
| with urllib.request.urlopen(req, timeout=120) as r, open(dst, "wb") as f: |
| f.write(r.read()) |
| log(f" saved {dst.stat().st_size // 1024} KB") |
|
|
|
|
| def norm_key(s: str) -> str: |
| """Aggressive normalization for cross-source title joins (no separators).""" |
| s = unicodedata.normalize("NFKD", str(s)) |
| s = "".join(c for c in s if not unicodedata.combining(c)) |
| return re.sub(r"[^a-z0-9]", "", s.lower()) |
|
|
|
|
| def slugify(s: str) -> str: |
| """Human-readable, URL-safe slug used as the public movie id.""" |
| s = unicodedata.normalize("NFKD", str(s)) |
| s = "".join(c for c in s if not unicodedata.combining(c)) |
| s = re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-") |
| return s or "untitled" |
|
|
|
|
| def parse_tmdb_json(raw, key: str = "name", limit: int | None = None) -> list[str]: |
| """Parse a stringified list-of-dicts (raw TMDB JSON dump) into names.""" |
| if not isinstance(raw, str) or not raw.strip().startswith("["): |
| return [] |
| data = None |
| for loader in (json.loads, ast.literal_eval): |
| try: |
| data = loader(raw) |
| break |
| except Exception: |
| continue |
| if not isinstance(data, list): |
| return [] |
| out: list[str] = [] |
| for item in data: |
| if isinstance(item, dict) and item.get(key): |
| out.append(str(item[key]).strip()) |
| if limit and len(out) >= limit: |
| break |
| return out |
|
|
|
|
| _KW_STOP = { |
| "of", "the", "a", "an", "and", "or", "to", "in", "on", "at", "by", "for", |
| "with", "is", "it", "as", "de", "la", "el", "los", "las", |
| } |
|
|
|
|
| def parse_keywords(raw, limit: int = 20) -> list[str]: |
| """The rashida mirror pre-flattens keywords to a space-joined token string |
| (e.g. 'culture clash future space war'). Fall back to raw JSON if present.""" |
| js = parse_tmdb_json(raw, limit=limit) |
| if js: |
| return js |
| if not isinstance(raw, str) or not raw.strip(): |
| return [] |
| seen, out = set(), [] |
| for tok in raw.split(): |
| t = tok.strip().lower() |
| if len(t) < 3 or t in _KW_STOP or t in seen: |
| continue |
| seen.add(t) |
| out.append(t) |
| if len(out) >= limit: |
| break |
| return out |
|
|
|
|
| def year_of(date_str) -> int | None: |
| m = re.search(r"(\d{4})", str(date_str)) |
| if not m: |
| return None |
| y = int(m.group(1)) |
| return y if 1878 <= y <= 2030 else None |
|
|
|
|
| def clean_ws(text) -> str: |
| return re.sub(r"\s+", " ", str(text or "")).strip() |
|
|
|
|
| def main() -> None: |
| ensure_raw() |
|
|
| |
| pab = pd.read_csv(RAW / "pablinho_movies.csv") |
| log(f"pablinho rows: {len(pab)}") |
|
|
| |
| tm = pd.read_csv(RAW / "tmdb_5000.csv") |
| enrich: dict[str, dict] = {} |
| for _, r in tm.iterrows(): |
| key = norm_key(r.get("title")) |
| if not key: |
| continue |
| |
| |
| |
| cast_json = parse_tmdb_json(r.get("cast"), limit=6) |
| enrich[key] = { |
| "tmdb_id": int(r["id"]) if pd.notna(r.get("id")) else None, |
| "cast": cast_json, |
| "cast_text": "" if cast_json else clean_ws(r.get("cast")), |
| "director": clean_ws(r.get("director")) or None, |
| "keywords": parse_keywords(r.get("keywords"), limit=18), |
| "tagline": clean_ws(r.get("tagline")) or None, |
| "runtime": int(r["runtime"]) if pd.notna(r.get("runtime")) and r["runtime"] else None, |
| } |
| log(f"tmdb-5000 enrichment entries: {len(enrich)}") |
|
|
| |
| repo_tokens: dict[str, list[str]] = {} |
| if REPO_JSON.exists(): |
| repo = json.loads(REPO_JSON.read_text()) |
| for m in repo: |
| key = norm_key(m.get("title")) |
| if key and isinstance(m.get("tags"), list): |
| repo_tokens[key] = m["tags"] |
| log(f"repo-json token bags: {len(repo_tokens)}") |
|
|
| |
| records: list[dict] = [] |
| seen: dict[str, int] = {} |
| used_slugs: dict[str, str] = {} |
| n_dir = n_kw = n_repo = 0 |
|
|
| def unique_slug(title: str, year, key: str) -> str: |
| base = slugify(title) |
| cand = base |
| if cand in used_slugs and used_slugs[cand] != key: |
| cand = f"{base}-{year}" if year else f"{base}-x" |
| n = 2 |
| while cand in used_slugs and used_slugs[cand] != key: |
| cand = f"{base}-{year}-{n}" if year else f"{base}-{n}" |
| n += 1 |
| used_slugs[cand] = key |
| return cand |
|
|
| for _, row in pab.iterrows(): |
| title = clean_ws(row.get("Title")) |
| if not title: |
| continue |
| key = norm_key(title) |
| overview = clean_ws(row.get("Overview")) |
| genres = [g.strip() for g in str(row.get("Genre") or "").split(",") if g.strip()] |
| year = year_of(row.get("Release_Date")) |
|
|
| try: |
| vote = round(float(row.get("Vote_Average")), 1) |
| except (TypeError, ValueError): |
| vote = None |
| try: |
| popularity = round(float(row.get("Popularity")), 3) |
| except (TypeError, ValueError): |
| popularity = 0.0 |
| try: |
| vote_count = int(float(row.get("Vote_Count"))) |
| except (TypeError, ValueError): |
| vote_count = 0 |
|
|
| |
| if key in seen: |
| prev = records[seen[key]] |
| if vote_count <= prev.get("vote_count", 0): |
| continue |
| records[seen[key]] = None |
|
|
| enr = enrich.get(key, {}) |
| if enr.get("director"): |
| n_dir += 1 |
| if enr.get("keywords"): |
| n_kw += 1 |
| extra = repo_tokens.get(key, []) |
| if extra: |
| n_repo += 1 |
|
|
| cast = enr.get("cast", []) |
| cast_text = enr.get("cast_text", "") |
| director = enr.get("director") |
| keywords = enr.get("keywords", []) |
| tagline = enr.get("tagline") |
|
|
| rec = { |
| "id": unique_slug(title, year, key), |
| "tmdb_id": enr.get("tmdb_id"), |
| "title": title, |
| "year": year, |
| "genres": genres, |
| "overview": overview, |
| "tagline": tagline, |
| "poster_url": clean_ws(row.get("Poster_Url")) or None, |
| "vote_average": vote, |
| "vote_count": vote_count, |
| "popularity": popularity, |
| "language": clean_ws(row.get("Original_Language")) or None, |
| "cast": cast, |
| "director": director, |
| "keywords": keywords, |
| "runtime": enr.get("runtime"), |
| } |
|
|
| |
| |
| parts = [title] |
| if year: |
| parts.append(f"({year})") |
| if genres: |
| parts.append("Genres: " + ", ".join(genres) + ".") |
| if tagline: |
| parts.append(tagline) |
| if overview: |
| parts.append(overview) |
| if director: |
| parts.append(f"Directed by {director}.") |
| if cast: |
| parts.append("Starring " + ", ".join(cast) + ".") |
| elif cast_text: |
| parts.append("Starring " + cast_text + ".") |
| if keywords: |
| parts.append("Themes: " + ", ".join(keywords) + ".") |
| rec["semantic_text"] = clean_ws(" ".join(parts)) |
|
|
| |
| |
| |
| lex = [ |
| title, |
| " ".join(genres * 2), |
| overview, |
| tagline or "", |
| " ".join(keywords), |
| " ".join(cast) or cast_text, |
| director or "", |
| " ".join(extra), |
| ] |
| rec["lexical_text"] = clean_ws(" ".join(lex)) |
|
|
| if key in seen: |
| records[seen[key]] = rec |
| else: |
| seen[key] = len(records) |
| records.append(rec) |
|
|
| records = [r for r in records if r is not None] |
|
|
| OUT.write_text(json.dumps(records, ensure_ascii=False)) |
| log(f"WROTE {OUT} ({len(records)} movies, {OUT.stat().st_size // 1024} KB)") |
| log(f" with director:{n_dir} with keywords:{n_kw} with repo-tokens:{n_repo}") |
| with_year = sum(1 for r in records if r["year"]) |
| with_poster = sum(1 for r in records if r["poster_url"]) |
| log(f" with year:{with_year} with poster:{with_poster}") |
| decades = {} |
| for r in records: |
| if r["year"]: |
| decades[r["year"] // 10 * 10] = decades.get(r["year"] // 10 * 10, 0) + 1 |
| log(" decades: " + ", ".join(f"{k}s:{v}" for k, v in sorted(decades.items()))) |
|
|
|
|
| if __name__ == "__main__": |
| sys.exit(main()) |
|
|