|
7 | 7 | import yaml |
8 | 8 | import sys |
9 | 9 | import re |
| 10 | +import time |
10 | 11 | from scholarly import scholarly, ProxyGenerator |
11 | 12 |
|
12 | 13 | # Force unbuffered output for GitHub Actions |
@@ -88,108 +89,201 @@ def extract_journal_from_citation(citation): |
88 | 89 |
|
89 | 90 | return None |
90 | 91 |
|
91 | | -def get_author_publications(scholar_id): |
| 92 | +def setup_proxy(): |
92 | 93 | """ |
93 | | - Fetch publications from Google Scholar for a given author ID |
94 | | - """ |
95 | | - print(f"Fetching publications for scholar ID: {scholar_id}", flush=True) |
| 94 | + Configure scholarly to route through a rotating free proxy. |
96 | 95 |
|
97 | | - # Set up a proxy generator to avoid rate limiting |
| 96 | + Google Scholar blocks based on client IP, so each call builds a fresh |
| 97 | + ProxyGenerator (and thus a different proxy). Returns True on success; |
| 98 | + on failure we continue without a proxy (which usually gets blocked, |
| 99 | + prompting the caller to retry with a new proxy). |
| 100 | + """ |
98 | 101 | try: |
99 | 102 | print("Setting up proxy to avoid rate limiting...", flush=True) |
100 | 103 | pg = ProxyGenerator() |
101 | 104 | pg.FreeProxies() |
102 | 105 | scholarly.use_proxy(pg) |
103 | 106 | print("Proxy configured successfully", flush=True) |
| 107 | + return True |
104 | 108 | except Exception as e: |
105 | 109 | print(f"Warning: Could not set up proxy: {e}", flush=True) |
106 | 110 | print("Continuing without proxy (may be slower)...", flush=True) |
| 111 | + return False |
| 112 | + |
| 113 | +def normalize_title(title): |
| 114 | + """ |
| 115 | + Normalize a title for matching against existing entries: lowercase, |
| 116 | + drop punctuation, collapse whitespace. Used to decide whether a |
| 117 | + publication is already in publications.yaml without relying on the |
| 118 | + generated id (which depends on author data missing from the Scholar |
| 119 | + publication preview). |
| 120 | + """ |
| 121 | + return re.sub(r'\s+', ' ', re.sub(r'[^\w\s]', ' ', (title or '').lower())).strip() |
107 | 122 |
|
| 123 | +def get_publication_stubs(scholar_id): |
| 124 | + """ |
| 125 | + Fetch the author's publication list (lightweight previews, not full |
| 126 | + details). Returns the list of publication stubs, or None on failure. |
| 127 | + Assumes a proxy has already been configured. |
| 128 | + """ |
108 | 129 | try: |
109 | | - # Search for author by ID |
110 | 130 | print("Searching for author...", flush=True) |
111 | 131 | author = scholarly.search_author_id(scholar_id) |
112 | 132 | print("Filling author publications...", flush=True) |
113 | 133 | author = scholarly.fill(author, sections=['publications']) |
| 134 | + return author['publications'] |
| 135 | + except Exception as e: |
| 136 | + print(f"Error fetching publication list: {e}", flush=True) |
| 137 | + return None |
| 138 | + |
| 139 | +def build_pub_data(pub): |
| 140 | + """ |
| 141 | + Fill a single publication's details from Google Scholar and convert it |
| 142 | + to a CSL-style dict. Returns None if the fetch fails (e.g. blocked). |
| 143 | + """ |
| 144 | + try: |
| 145 | + filled_pub = scholarly.fill(pub) |
| 146 | + bib = filled_pub['bib'] |
114 | 147 |
|
115 | | - publications = [] |
116 | | - total_pubs = len(author['publications']) |
117 | | - print(f"Found {total_pubs} publications to process", flush=True) |
| 148 | + # Parse authors |
| 149 | + authors = parse_authors(bib.get('author', '')) |
118 | 150 |
|
119 | | - for idx, pub in enumerate(author['publications'], 1): |
| 151 | + # Get year |
| 152 | + year = None |
| 153 | + if bib.get('pub_year'): |
120 | 154 | try: |
121 | | - print(f"Processing publication {idx}/{total_pubs}...", flush=True) |
122 | | - # Fill in publication details |
123 | | - filled_pub = scholarly.fill(pub) |
124 | | - bib = filled_pub['bib'] |
125 | | - |
126 | | - # Parse authors |
127 | | - authors = parse_authors(bib.get('author', '')) |
128 | | - |
129 | | - # Get year |
130 | | - year = None |
131 | | - if bib.get('pub_year'): |
132 | | - try: |
133 | | - year = int(bib['pub_year']) |
134 | | - except (ValueError, TypeError): |
135 | | - pass |
136 | | - |
137 | | - # Create ID |
138 | | - first_author_last = authors[0]['family'] if authors else 'unknown' |
139 | | - title = bib.get('title', 'untitled') |
140 | | - pub_id = create_id_from_publication(first_author_last, year or 0, title) |
141 | | - |
142 | | - # Build publication entry in CSL format |
143 | | - pub_data = { |
144 | | - 'id': pub_id, |
145 | | - 'type': 'article-journal', |
146 | | - 'author': authors, |
147 | | - 'issued': [{'year': year}] if year else [], |
148 | | - 'title': bib.get('title', ''), |
149 | | - } |
150 | | - |
151 | | - # Add optional fields if they exist |
152 | | - # Try multiple possible fields for journal/venue |
153 | | - container_title = (bib.get('journal') or |
154 | | - bib.get('venue') or |
155 | | - bib.get('conference') or |
156 | | - bib.get('booktitle')) |
157 | | - |
158 | | - # If still no journal, try parsing from citation string |
159 | | - if not container_title and bib.get('citation'): |
160 | | - container_title = extract_journal_from_citation(bib['citation']) |
161 | | - |
162 | | - if container_title: |
163 | | - pub_data['container-title'] = container_title |
164 | | - |
165 | | - if bib.get('publisher'): |
166 | | - pub_data['publisher'] = bib['publisher'] |
167 | | - |
168 | | - if bib.get('pages'): |
169 | | - pub_data['page'] = bib['pages'] |
170 | | - |
171 | | - if bib.get('volume'): |
172 | | - pub_data['volume'] = str(bib['volume']) |
173 | | - |
174 | | - if bib.get('number') or bib.get('issue'): |
175 | | - pub_data['issue'] = str(bib.get('number') or bib.get('issue')) |
176 | | - |
177 | | - # Add URL if available |
178 | | - if filled_pub.get('pub_url'): |
179 | | - pub_data['URL'] = filled_pub['pub_url'] |
180 | | - |
181 | | - publications.append(pub_data) |
182 | | - print(f" - Added: {pub_id}", flush=True) |
183 | | - |
184 | | - except Exception as e: |
185 | | - print(f" - Error processing publication: {e}", flush=True) |
186 | | - continue |
187 | | - |
188 | | - return publications |
| 155 | + year = int(bib['pub_year']) |
| 156 | + except (ValueError, TypeError): |
| 157 | + pass |
| 158 | + |
| 159 | + # Create ID |
| 160 | + first_author_last = authors[0]['family'] if authors else 'unknown' |
| 161 | + title = bib.get('title', 'untitled') |
| 162 | + pub_id = create_id_from_publication(first_author_last, year or 0, title) |
| 163 | + |
| 164 | + # Build publication entry in CSL format |
| 165 | + pub_data = { |
| 166 | + 'id': pub_id, |
| 167 | + 'type': 'article-journal', |
| 168 | + 'author': authors, |
| 169 | + 'issued': [{'year': year}] if year else [], |
| 170 | + 'title': bib.get('title', ''), |
| 171 | + } |
| 172 | + |
| 173 | + # Add optional fields if they exist |
| 174 | + # Try multiple possible fields for journal/venue |
| 175 | + container_title = (bib.get('journal') or |
| 176 | + bib.get('venue') or |
| 177 | + bib.get('conference') or |
| 178 | + bib.get('booktitle')) |
| 179 | + |
| 180 | + # If still no journal, try parsing from citation string |
| 181 | + if not container_title and bib.get('citation'): |
| 182 | + container_title = extract_journal_from_citation(bib['citation']) |
| 183 | + |
| 184 | + if container_title: |
| 185 | + pub_data['container-title'] = container_title |
| 186 | + |
| 187 | + if bib.get('publisher'): |
| 188 | + pub_data['publisher'] = bib['publisher'] |
| 189 | + |
| 190 | + if bib.get('pages'): |
| 191 | + pub_data['page'] = bib['pages'] |
| 192 | + |
| 193 | + if bib.get('volume'): |
| 194 | + pub_data['volume'] = str(bib['volume']) |
| 195 | + |
| 196 | + if bib.get('number') or bib.get('issue'): |
| 197 | + pub_data['issue'] = str(bib.get('number') or bib.get('issue')) |
| 198 | + |
| 199 | + # Add URL if available |
| 200 | + if filled_pub.get('pub_url'): |
| 201 | + pub_data['URL'] = filled_pub['pub_url'] |
| 202 | + |
| 203 | + print(f" - Added: {pub_id}", flush=True) |
| 204 | + return pub_data |
189 | 205 |
|
190 | 206 | except Exception as e: |
191 | | - print(f"Error fetching author publications: {e}", flush=True) |
192 | | - sys.exit(1) |
| 207 | + print(f" - Error processing publication: {e}", flush=True) |
| 208 | + return None |
| 209 | + |
| 210 | +def fetch_new_publications(scholar_id, existing_titles, max_attempts=5, wait_between=15): |
| 211 | + """ |
| 212 | + Phase 1: fetch full details for publications NOT already in the YAML. |
| 213 | +
|
| 214 | + This is the important fetch (it's how genuinely new papers get added), |
| 215 | + so it retries up to max_attempts times, each with a fresh proxy. |
| 216 | +
|
| 217 | + Returns (stubs, new_pubs): |
| 218 | + - stubs: the full publication-stub list (reused by phase 2) |
| 219 | + - new_pubs: CSL dicts for new publications (empty if none are new) |
| 220 | + On total failure (couldn't get the publication list, or there were new |
| 221 | + entries but every detail fetch was blocked), returns (None, None). |
| 222 | + """ |
| 223 | + for attempt in range(1, max_attempts + 1): |
| 224 | + print(f"\n=== New-publication fetch, attempt {attempt}/{max_attempts} ===", flush=True) |
| 225 | + |
| 226 | + # If we can't get a proxy, don't bother hitting Scholar unproxied: |
| 227 | + # it just gets blocked after a long timeout. Retry for a fresh proxy. |
| 228 | + if not setup_proxy(): |
| 229 | + if attempt < max_attempts: |
| 230 | + print(f"Proxy setup failed; retrying in {wait_between}s with a fresh proxy...", flush=True) |
| 231 | + time.sleep(wait_between) |
| 232 | + continue |
| 233 | + |
| 234 | + stubs = get_publication_stubs(scholar_id) |
| 235 | + |
| 236 | + if stubs is None: |
| 237 | + if attempt < max_attempts: |
| 238 | + print(f"Could not fetch publication list; retrying in {wait_between}s with a fresh proxy...", flush=True) |
| 239 | + time.sleep(wait_between) |
| 240 | + continue |
| 241 | + |
| 242 | + new_stubs = [p for p in stubs |
| 243 | + if normalize_title(p.get('bib', {}).get('title', '')) not in existing_titles] |
| 244 | + print(f"{len(new_stubs)} of {len(stubs)} publications are not yet in the YAML", flush=True) |
| 245 | + |
| 246 | + if not new_stubs: |
| 247 | + # Got the list; nothing new to add. Success. |
| 248 | + return stubs, [] |
| 249 | + |
| 250 | + new_pubs = [] |
| 251 | + for idx, pub in enumerate(new_stubs, 1): |
| 252 | + print(f"Fetching new publication {idx}/{len(new_stubs)}...", flush=True) |
| 253 | + data = build_pub_data(pub) |
| 254 | + if data: |
| 255 | + new_pubs.append(data) |
| 256 | + |
| 257 | + if new_pubs: |
| 258 | + return stubs, new_pubs |
| 259 | + |
| 260 | + # Had new publications but couldn't fetch any details (blocked). |
| 261 | + if attempt < max_attempts: |
| 262 | + print(f"Could not fetch any new publication details; retrying in {wait_between}s with a fresh proxy...", flush=True) |
| 263 | + time.sleep(wait_between) |
| 264 | + |
| 265 | + return None, None |
| 266 | + |
| 267 | +def update_existing_publications(stubs, existing_titles): |
| 268 | + """ |
| 269 | + Phase 2: best-effort refresh of publications already in the YAML. |
| 270 | +
|
| 271 | + Updates are nice-to-have (correcting metadata on known papers), so this |
| 272 | + is a single pass with no retry, reusing the proxy from phase 1. Any |
| 273 | + publication that fails to fetch is left as-is (merge_publications keeps |
| 274 | + the existing entry). Returns the list of refreshed CSL dicts. |
| 275 | + """ |
| 276 | + old_stubs = [p for p in stubs |
| 277 | + if normalize_title(p.get('bib', {}).get('title', '')) in existing_titles] |
| 278 | + print(f"\n=== Updating {len(old_stubs)} existing publications (no retry) ===", flush=True) |
| 279 | + |
| 280 | + updated = [] |
| 281 | + for idx, pub in enumerate(old_stubs, 1): |
| 282 | + print(f"Refreshing existing publication {idx}/{len(old_stubs)}...", flush=True) |
| 283 | + data = build_pub_data(pub) |
| 284 | + if data: |
| 285 | + updated.append(data) |
| 286 | + return updated |
193 | 287 |
|
194 | 288 | def load_existing_yaml(path): |
195 | 289 | """ |
@@ -261,7 +355,27 @@ def save_to_yaml(publications, output_file): |
261 | 355 |
|
262 | 356 | print("Starting publication update...", flush=True) |
263 | 357 | existing = load_existing_yaml(OUTPUT_FILE) |
264 | | - fetched = get_author_publications(SCHOLAR_ID) |
| 358 | + existing_titles = {normalize_title(p.get('title', '')) for p in existing} |
| 359 | + |
| 360 | + # Phase 1: fetch genuinely new publications (retried with fresh proxies). |
| 361 | + stubs, new_pubs = fetch_new_publications(SCHOLAR_ID, existing_titles) |
| 362 | + |
| 363 | + if stubs is None: |
| 364 | + # Couldn't reach Google Scholar at all. Leave publications.yaml |
| 365 | + # untouched (no diff, nothing committed) and exit cleanly so a |
| 366 | + # transient block doesn't fail the workflow; the next run retries. |
| 367 | + print( |
| 368 | + "Could not fetch from Google Scholar after retries; leaving " |
| 369 | + "existing publications unchanged and exiting without error.", |
| 370 | + flush=True, |
| 371 | + ) |
| 372 | + sys.exit(0) |
| 373 | + |
| 374 | + # Phase 2: best-effort refresh of existing entries (not retried). |
| 375 | + updated_pubs = update_existing_publications(stubs, existing_titles) |
| 376 | + |
| 377 | + fetched = new_pubs + updated_pubs |
| 378 | + print(f"\nFetched {len(new_pubs)} new and {len(updated_pubs)} updated publications.", flush=True) |
265 | 379 | merged = merge_publications(existing, fetched) |
266 | 380 | save_to_yaml(merged, OUTPUT_FILE) |
267 | 381 | print("Done!", flush=True) |
0 commit comments