"""Export helpers: post identifier parsing/resolution, CSV/JSON output.""" import csv import json import re import urllib.parse from .crawler import CommentCrawler, CommentCrawlerError, GRAPH as _GRAPH class ExportError(RuntimeError): pass def parse_post_identifier(url_or_id: str) -> str: """Return the Graph API object id for a Facebook post URL. Accepts: - ``https://www.facebook.com//posts/`` - ``https://www.facebook.com//posts/pfbid0...`` - legacy ``https://www.facebook.com/permalink.php?story_fbid=...&id=...`` - ``https://www.facebook.com/photo.php?fbid=...&set=a...`` (photo comments) - plain ``_`` or ```` Returns e.g. ``123456789_101579...``. """ s = (url_or_id or "").strip() if not s: raise ExportError("Empty post URL/id.") if re.fullmatch(r"\d+_\d+", s): return s if re.fullmatch(r"\d+", s): return s if re.fullmatch(r"pfbid\w+", s) or re.fullmatch(r"pfbidp\w+", s): return s parsed = urllib.parse.urlparse(s if s.startswith("http") else "https://" + s) host = (parsed.netloc or "").lower() if "facebook.com" not in host: raise ExportError(f"Not a Facebook URL: {s}") path = parsed.path.strip("/") qs = urllib.parse.parse_qs(parsed.query) if path.startswith("permalink.php"): story = qs.get("story_fbid", [None])[0] page_id = qs.get("id", [None])[0] if story and page_id: return f"{page_id}_{story}" raise ExportError("permalink.php URL missing story_fbid/id parameters.") if path.startswith("photo.php"): fbid = qs.get("fbid", [None])[0] page_id = qs.get("id", [None])[0] if fbid and page_id: return f"{page_id}_{fbid}" raise ExportError("photo.php URL missing fbid/id parameters.") parts = path.split("/") if len(parts) >= 3 and parts[1] == "posts": return f"{parts[0]}_{parts[2]}" if len(parts) == 3 and parts[1] == "videos": return f"{parts[0]}_{parts[2]}" raise ExportError(f"Unrecognized Facebook post URL pattern: {s} (path={path})") def resolve_post_id(post_identifier: str, crawler: CommentCrawler) -> str: """Best-effort resolution of a handle to a stable object id. If the identifier is already ``_`` it is returned as-is. If it is a page handle + pfbid (e.g. ``MaiAnhTaiSinger_pfbid0...``) we call the post node and read ``id``. Falls back gracefully when the API can't. """ if re.fullmatch(r"\d+_\d+", post_identifier): return post_identifier page, _, story = post_identifier.partition("_") if not story: return post_identifier try: data = crawler._page(f"{_GRAPH}/{crawler.api_version}/{post_identifier}?" f"fields=id&access_token={crawler.token}") if data.get("id"): return data["id"] except CommentCrawlerError: pass return post_identifier def export(comments, out_csv: str | None = None, out_json: str | None = None): """Write comments (iterable of dicts) to CSV and/or JSON. Returns the list of dicts written (useful to count). """ rows = [] for c in comments: author = c.get("from") or {} rows.append( { "comment_id": c.get("id"), "parent_id": (c.get("parent") or {}).get("id"), "name": author.get("name"), "uid": author.get("id"), # PSID for page comments "message": c.get("message") or "", "created_time": c.get("created_time"), "like_count": c.get("like_count"), } ) if out_csv: with open(out_csv, "w", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter( f, fieldnames=["comment_id", "parent_id", "name", "uid", "message", "created_time", "like_count"], ) writer.writeheader() writer.writerows(rows) if out_json: with open(out_json, "w", encoding="utf-8") as f: json.dump(rows, f, ensure_ascii=False, indent=2) if not out_csv and not out_json: import sys for r in rows: sys.stdout.write( f"[{r['comment_id']}] {r['name']} ({r['uid']}): {r['message']}\n" ) return rows