#!/usr/bin/env python3 from __future__ import annotations import argparse import json import random import re import time from dataclasses import dataclass, asdict from datetime import datetime, timezone from html import unescape from html.parser import HTMLParser from pathlib import Path from typing import Any from urllib.error import HTTPError, URLError from urllib.parse import parse_qs, urlencode, urlparse from urllib.request import Request, urlopen ROBOTS_URL = "https://scholar.google.com/robots.txt" PROFILE_URL = "https://scholar.google.com/citations" DEFAULT_USER_AGENTS = [ ( "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" ), ( "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7) AppleWebKit/605.1.15 " "(KHTML, like Gecko) Version/18.1 Safari/605.1.15" ), ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) " "Gecko/20100101 Firefox/131.0" ), ] BLOCKED_KEYWORDS = [ "unusual traffic", "sorry/index", "not a robot", "our systems have detected", "automated queries", ] NO_RESULTS_KEYWORDS = [ "didn't match any articles", "did not match any articles", "no articles", "no documents", ] MARKER_KEYS = [ "gsc_a_tr", "gsc_a_at", "gsc_a_ac", "gsc_a_h", "gsc_a_y", "gs_gray", "gsc_prf_in", "gsc_rsb_st", ] @dataclass class FetchRecord: source: str url: str file_name: str status_code: int | None fetched_at_utc: str elapsed_seconds: float final_url: str | None error: str | None @dataclass class PublicationCandidate: title: str title_url: str | None cluster_id: str | None year: int | None citation_count: int | None authors_text: str | None venue_text: str | None @dataclass class PageAnalysis: source: str status: str profile_name: str | None publication_count: int articles_range: str | None has_show_more_button: bool has_operation_error_banner: bool marker_counts: dict[str, int] field_presence: dict[str, int] parse_warnings: list[str] def normalize_space(value: str) -> str: return " ".join(unescape(value).split()) TAG_RE = re.compile(r"<[^>]+>", re.S) def strip_tags(value: str) -> str: return normalize_space(TAG_RE.sub(" ", value)) def attr_class(attrs: list[tuple[str, str | None]]) -> str: for name, raw_value in attrs: if name.lower() == "class": return raw_value or "" return "" def attr_href(attrs: list[tuple[str, str | None]]) -> str | None: for name, raw_value in attrs: if name.lower() == "href": return raw_value return None class ScholarRowParser(HTMLParser): def __init__(self) -> None: super().__init__(convert_charrefs=True) self.title_href: str | None = None self.title_parts: list[str] = [] self.citation_parts: list[str] = [] self.year_parts: list[str] = [] self.gray_texts: list[str] = [] self._title_depth = 0 self._citation_depth = 0 self._year_depth = 0 self._gray_stack: list[dict[str, Any]] = [] def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: if self._title_depth > 0: self._title_depth += 1 if self._citation_depth > 0: self._citation_depth += 1 if self._year_depth > 0: self._year_depth += 1 if self._gray_stack: self._gray_stack[-1]["depth"] += 1 classes = attr_class(attrs) if tag == "a" and "gsc_a_at" in classes: self._title_depth = 1 self.title_href = attr_href(attrs) return if tag == "a" and "gsc_a_ac" in classes: self._citation_depth = 1 return if tag in {"span", "a"} and ("gsc_a_h" in classes or "gsc_a_y" in classes): self._year_depth = 1 return if tag == "div" and "gs_gray" in classes: self._gray_stack.append({"depth": 1, "parts": []}) return def handle_data(self, data: str) -> None: if self._title_depth > 0: self.title_parts.append(data) if self._citation_depth > 0: self.citation_parts.append(data) if self._year_depth > 0: self.year_parts.append(data) if self._gray_stack: self._gray_stack[-1]["parts"].append(data) def handle_endtag(self, _tag: str) -> None: if self._title_depth > 0: self._title_depth -= 1 if self._citation_depth > 0: self._citation_depth -= 1 if self._year_depth > 0: self._year_depth -= 1 if self._gray_stack: self._gray_stack[-1]["depth"] -= 1 if self._gray_stack[-1]["depth"] == 0: text = normalize_space("".join(self._gray_stack[-1]["parts"])) if text: self.gray_texts.append(text) self._gray_stack.pop() def extract_rows(html: str) -> list[str]: pattern = re.compile( r"]*\bclass\s*=\s*['\"][^'\"]*\bgsc_a_tr\b[^'\"]*['\"])[^>]*>(.*?)", re.I | re.S, ) return [match.group(1) for match in pattern.finditer(html)] def parse_cluster_id_from_href(href: str | None) -> str | None: if not href: return None parsed = urlparse(href) query = parse_qs(parsed.query) citation_for_view = query.get("citation_for_view") if citation_for_view: token = citation_for_view[0].strip() if token: if ":" in token: return token.rsplit(":", 1)[-1] or None return token cluster = query.get("cluster") if cluster: token = cluster[0].strip() if token: return token return None def parse_year(parts: list[str]) -> int | None: text = normalize_space(" ".join(parts)) match = re.search(r"\b(19|20)\d{2}\b", text) if not match: return None try: return int(match.group(0)) except ValueError: return None def parse_citation_count(parts: list[str]) -> int | None: text = normalize_space(" ".join(parts)) if not text: return 0 match = re.search(r"\d+", text) if not match: return None return int(match.group(0)) def parse_publications(html: str) -> tuple[list[PublicationCandidate], list[str]]: rows = extract_rows(html) warnings: list[str] = [] publications: list[PublicationCandidate] = [] for row_html in rows: parser = ScholarRowParser() parser.feed(row_html) title = normalize_space("".join(parser.title_parts)) if not title: warnings.append("row_missing_title") continue authors_text = parser.gray_texts[0] if len(parser.gray_texts) > 0 else None venue_text = parser.gray_texts[1] if len(parser.gray_texts) > 1 else None publications.append( PublicationCandidate( title=title, title_url=parser.title_href, cluster_id=parse_cluster_id_from_href(parser.title_href), year=parse_year(parser.year_parts), citation_count=parse_citation_count(parser.citation_parts), authors_text=authors_text, venue_text=venue_text, ) ) if not rows: warnings.append("no_rows_detected") return publications, sorted(set(warnings)) def extract_profile_name(html: str) -> str | None: pattern = re.compile( r"<[^>]*\bid\s*=\s*['\"]gsc_prf_in['\"][^>]*>(.*?)]+>", re.I | re.S, ) match = pattern.search(html) if not match: return None value = strip_tags(match.group(1)) return value or None def extract_articles_range(html: str) -> str | None: pattern = re.compile( r"<[^>]*\bid\s*=\s*['\"]gsc_a_nn['\"][^>]*>(.*?)]+>", re.I | re.S, ) match = pattern.search(html) if not match: return None value = strip_tags(match.group(1)) return value or None def has_show_more_button(html: str) -> bool: lowered = html.lower() return "id=\"gsc_bpf_more\"" in lowered or "id='gsc_bpf_more'" in lowered def has_operation_error_banner(html: str) -> bool: lowered = html.lower() if "id=\"gsc_a_err\"" not in lowered and "id='gsc_a_err'" not in lowered: return False return "can't perform the operation now" in lowered or "cannot perform the operation now" in lowered def count_markers(html: str) -> dict[str, int]: lowered = html.lower() return {key: lowered.count(key.lower()) for key in MARKER_KEYS} def detect_status( *, status_code: int | None, final_url: str | None, html: str, publication_count: int, marker_counts: dict[str, int], ) -> str: if status_code is None: return "network_error" lowered = html.lower() final = (final_url or "").lower() if "accounts.google.com" in final and ("signin" in final or "servicelogin" in final): return "blocked_or_captcha" if any(keyword in lowered for keyword in BLOCKED_KEYWORDS) or "sorry/index" in final: return "blocked_or_captcha" if publication_count == 0 and any(keyword in lowered for keyword in NO_RESULTS_KEYWORDS): return "no_results" if publication_count == 0: has_profile_markers = marker_counts.get("gsc_prf_in", 0) > 0 has_table_markers = marker_counts.get("gsc_a_tr", 0) > 0 or marker_counts.get("gsc_a_at", 0) > 0 if not has_profile_markers and not has_table_markers: return "layout_changed" return "ok" def load_scholar_ids(id_file: Path | None, cli_ids: list[str]) -> list[str]: seen: set[str] = set() collected: list[str] = [] def maybe_add(raw: str) -> None: item = raw.strip() if not item: return if item.startswith("#"): return if item in seen: return seen.add(item) collected.append(item) for item in cli_ids: maybe_add(item) if id_file and id_file.exists(): for line in id_file.read_text(encoding="utf-8").splitlines(): maybe_add(line) return collected def fetch_url(url: str, user_agent: str, timeout_seconds: float) -> tuple[int | None, str | None, str, str | None]: request = Request( url, headers={ "User-Agent": user_agent, "Accept": "text/html,application/xhtml+xml", "Accept-Language": "en-US,en;q=0.9", "Connection": "close", }, ) start = time.perf_counter() try: with urlopen(request, timeout=timeout_seconds) as response: body = response.read().decode("utf-8", errors="replace") elapsed = time.perf_counter() - start status = getattr(response, "status", 200) final_url = response.geturl() return status, final_url, body, None except HTTPError as exc: body = "" try: body = exc.read().decode("utf-8", errors="replace") except Exception: body = "" elapsed = time.perf_counter() - start _ = elapsed return exc.code, exc.geturl(), body, str(exc) except URLError as exc: elapsed = time.perf_counter() - start _ = elapsed return None, None, "", str(exc) def write_json(path: Path, payload: Any) -> None: path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") def render_markdown( *, generated_at: str, run_dir: Path, fetch_records: list[FetchRecord], page_analyses: list[PageAnalysis], publications_by_source: dict[str, list[PublicationCandidate]], robots_excerpt: str, ) -> str: lines: list[str] = [] lines.append("# Scholar Scrape Probe Report") lines.append("") lines.append(f"Generated UTC: `{generated_at}`") lines.append(f"Run fixtures dir: `{run_dir.as_posix()}`") lines.append("") lines.append("## Robots Snapshot") lines.append("") lines.append("```text") lines.append(robots_excerpt.rstrip() or "(robots fetch unavailable)") lines.append("```") lines.append("") lines.append("## Fetch Summary") lines.append("") lines.append("| Source | Status Code | Status/Error | Final URL |") lines.append("| --- | --- | --- | --- |") for record in fetch_records: status = str(record.status_code) if record.status_code is not None else "-" state = record.error or "ok" final_url = record.final_url or "-" lines.append(f"| `{record.source}` | {status} | {state} | `{final_url}` |") lines.append("") lines.append("## Parse Summary") lines.append("") lines.append("| Source | Parse Status | Profile | Publications | Articles Range | Show More | Warnings |") lines.append("| --- | --- | --- | --- | --- | --- | --- |") for analysis in page_analyses: warnings = ", ".join(analysis.parse_warnings) if analysis.parse_warnings else "-" profile = analysis.profile_name or "-" articles_range = analysis.articles_range or "-" show_more = "yes" if analysis.has_show_more_button else "no" lines.append( f"| `{analysis.source}` | `{analysis.status}` | {profile} | {analysis.publication_count} | {articles_range} | {show_more} | {warnings} |" ) lines.append("") all_publications = [ publication for publications in publications_by_source.values() for publication in publications ] if all_publications: total = len(all_publications) def pct(count: int) -> str: return f"{(count / total) * 100:.1f}%" title_count = sum(1 for item in all_publications if item.title) cluster_count = sum(1 for item in all_publications if item.cluster_id) year_count = sum(1 for item in all_publications if item.year is not None) citation_count = sum(1 for item in all_publications if item.citation_count is not None) author_count = sum(1 for item in all_publications if item.authors_text) venue_count = sum(1 for item in all_publications if item.venue_text) lines.append("## Field Coverage") lines.append("") lines.append(f"Total parsed publication rows: **{total}**") lines.append("") lines.append("| Field | Present | Coverage |") lines.append("| --- | --- | --- |") lines.append(f"| `title` | {title_count} | {pct(title_count)} |") lines.append(f"| `cluster_id` | {cluster_count} | {pct(cluster_count)} |") lines.append(f"| `year` | {year_count} | {pct(year_count)} |") lines.append(f"| `citation_count` | {citation_count} | {pct(citation_count)} |") lines.append(f"| `authors_text` | {author_count} | {pct(author_count)} |") lines.append(f"| `venue_text` | {venue_count} | {pct(venue_count)} |") lines.append("") lines.append("## Parser Contract Recommendation") lines.append("") lines.append("- Primary row marker: `tr.gsc_a_tr`.") lines.append("- Title anchor marker: `a.gsc_a_at`; derive `cluster_id` from `citation_for_view` query token.") lines.append("- Metadata text markers: first/second `div.gs_gray` per row for authors and venue.") lines.append("- Year marker fallback: classes containing `gsc_a_h` or `gsc_a_y` and 4-digit year regex.") lines.append("- Failure states to persist: `ok`, `no_results`, `blocked_or_captcha`, `layout_changed`, `network_error`.") lines.append("") lines.append("## Future-Proofing Notes") lines.append("") lines.append("- Keep raw HTML fixture snapshots and update parser tests on DOM drift.") lines.append("- Treat blocked pages as retriable with backoff, not parser errors.") lines.append("- If `Show more` is present, treat first-page-only results as partial and surface that in run status/UI.") lines.append("- Track robots policy changes because `/citations?*cstart=` is currently disallowed.") lines.append("- Add marker-count assertions in CI to catch silent layout shifts early.") lines.append("- Use explicit parse status per run/scholar so automation can degrade gracefully.") lines.append("") return "\n".join(lines) def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description="Temporary probe: capture and analyze Google Scholar profile HTML for parser planning." ) parser.add_argument( "--output-root", default="planning/scholar_probe_tmp", help="Root directory of temporary probe workspace.", ) parser.add_argument( "--scholar-id", action="append", default=[], help="Scholar profile id (can be passed multiple times).", ) parser.add_argument( "--id-file", default="planning/scholar_probe_tmp/notes/seed_scholar_ids.txt", help="Path to newline-delimited scholar ids.", ) parser.add_argument( "--max-profiles", type=int, default=5, help="Maximum number of scholar profiles to fetch in this probe run.", ) parser.add_argument( "--request-delay-seconds", type=float, default=8.0, help="Base delay between live requests.", ) parser.add_argument( "--request-jitter-seconds", type=float, default=2.0, help="Randomized additional delay in seconds.", ) parser.add_argument( "--timeout-seconds", type=float, default=25.0, help="HTTP timeout seconds.", ) parser.add_argument( "--allow-live-fetch", action="store_true", help="Enable outbound fetch for robots and scholar profile pages.", ) parser.add_argument( "--analyze-existing-fixtures", action="store_true", help="Analyze existing fixtures in output-root/fixtures even if live fetch is disabled.", ) return parser def main() -> int: args = build_parser().parse_args() output_root = Path(args.output_root) fixtures_root = output_root / "fixtures" notes_root = output_root / "notes" fixtures_root.mkdir(parents=True, exist_ok=True) notes_root.mkdir(parents=True, exist_ok=True) now = datetime.now(timezone.utc) run_id = now.strftime("run_%Y%m%dT%H%M%SZ") run_dir = fixtures_root / run_id run_dir.mkdir(parents=True, exist_ok=True) id_file = Path(args.id_file) scholar_ids = load_scholar_ids(id_file, args.scholar_id) if args.max_profiles > 0: scholar_ids = scholar_ids[: args.max_profiles] fetch_records: list[FetchRecord] = [] robots_excerpt = "" if args.allow_live_fetch: robots_start = time.perf_counter() robots_status, robots_final_url, robots_body, robots_error = fetch_url( ROBOTS_URL, user_agent=random.choice(DEFAULT_USER_AGENTS), timeout_seconds=args.timeout_seconds, ) robots_elapsed = time.perf_counter() - robots_start robots_path = run_dir / "robots.txt" robots_path.write_text(robots_body or "", encoding="utf-8") robots_excerpt = "\n".join((robots_body or "").splitlines()[:50]) fetch_records.append( FetchRecord( source="robots", url=ROBOTS_URL, file_name=robots_path.name, status_code=robots_status, fetched_at_utc=datetime.now(timezone.utc).isoformat(), elapsed_seconds=round(robots_elapsed, 3), final_url=robots_final_url, error=robots_error, ) ) for index, scholar_id in enumerate(scholar_ids): params = {"hl": "en", "user": scholar_id} url = f"{PROFILE_URL}?{urlencode(params)}" user_agent = DEFAULT_USER_AGENTS[index % len(DEFAULT_USER_AGENTS)] start = time.perf_counter() status_code, final_url, body, error = fetch_url( url, user_agent=user_agent, timeout_seconds=args.timeout_seconds, ) elapsed = time.perf_counter() - start safe_source = f"profile_{scholar_id}" html_name = f"{safe_source}.html" html_path = run_dir / html_name html_path.write_text(body or "", encoding="utf-8") fetch_records.append( FetchRecord( source=safe_source, url=url, file_name=html_name, status_code=status_code, fetched_at_utc=datetime.now(timezone.utc).isoformat(), elapsed_seconds=round(elapsed, 3), final_url=final_url, error=error, ) ) if index < len(scholar_ids) - 1: delay = max(0.0, args.request_delay_seconds) + random.uniform( 0.0, max(0.0, args.request_jitter_seconds), ) time.sleep(delay) if not robots_excerpt: robots_candidates = sorted(fixtures_root.glob("run_*/robots.txt"), reverse=True) if robots_candidates: robots_excerpt = "\n".join( robots_candidates[0].read_text(encoding="utf-8").splitlines()[:50] ) html_files: list[Path] = [] html_files.extend(run_dir.glob("profile_*.html")) if args.analyze_existing_fixtures: latest_runs = sorted(fixtures_root.glob("run_*"), reverse=True) for existing_run in latest_runs: if existing_run == run_dir: continue html_files.extend(existing_run.glob("profile_*.html")) by_source: dict[str, Path] = {} for candidate in sorted(html_files, reverse=True): source = candidate.stem if source in by_source: continue by_source[source] = candidate deduped_files = [by_source[source] for source in sorted(by_source)] page_analyses: list[PageAnalysis] = [] publications_by_source: dict[str, list[PublicationCandidate]] = {} fetch_record_by_source = {record.source: record for record in fetch_records} for html_path in deduped_files: source = html_path.stem html = html_path.read_text(encoding="utf-8") publications, warnings = parse_publications(html) markers = count_markers(html) articles_range = extract_articles_range(html) show_more = has_show_more_button(html) operation_error_banner = has_operation_error_banner(html) if show_more: warnings.append("possible_partial_page_show_more_present") if operation_error_banner: warnings.append("operation_error_banner_present") warnings = sorted(set(warnings)) record = fetch_record_by_source.get(source) status = detect_status( status_code=record.status_code if record else 200, final_url=record.final_url if record else None, html=html, publication_count=len(publications), marker_counts=markers, ) field_presence = { "title": sum(1 for item in publications if item.title), "cluster_id": sum(1 for item in publications if item.cluster_id), "year": sum(1 for item in publications if item.year is not None), "citation_count": sum(1 for item in publications if item.citation_count is not None), "authors_text": sum(1 for item in publications if item.authors_text), "venue_text": sum(1 for item in publications if item.venue_text), } analysis = PageAnalysis( source=source, status=status, profile_name=extract_profile_name(html), publication_count=len(publications), articles_range=articles_range, has_show_more_button=show_more, has_operation_error_banner=operation_error_banner, marker_counts=markers, field_presence=field_presence, parse_warnings=warnings, ) page_analyses.append(analysis) publications_by_source[source] = publications report_payload = { "generated_at_utc": datetime.now(timezone.utc).isoformat(), "run_dir": run_dir.as_posix(), "fetch_records": [asdict(record) for record in fetch_records], "page_analyses": [asdict(analysis) for analysis in page_analyses], "publications_by_source": { source: [asdict(item) for item in publications] for source, publications in publications_by_source.items() }, } json_report_path = notes_root / f"probe_report_{run_id}.json" write_json(json_report_path, report_payload) markdown_report = render_markdown( generated_at=report_payload["generated_at_utc"], run_dir=run_dir, fetch_records=fetch_records, page_analyses=page_analyses, publications_by_source=publications_by_source, robots_excerpt=robots_excerpt, ) markdown_report_path = notes_root / f"probe_report_{run_id}.md" markdown_report_path.write_text(markdown_report, encoding="utf-8") summary = { "run_id": run_id, "run_dir": run_dir.as_posix(), "json_report": json_report_path.as_posix(), "markdown_report": markdown_report_path.as_posix(), "profiles_analyzed": len(page_analyses), } print(json.dumps(summary, indent=2)) return 0 if __name__ == "__main__": raise SystemExit(main())