Skip to content

Commit 1da3235

Browse files
jmxpearsonclaudegithub-actions[bot]
authored
Fix site-health and update-publications CI workflows (#53)
* Accept HTTP 415 in lychee link checker Two valid publication URLs (an OJS journal article and a ccneuro.org PDF) fail the site-health link check with 415 Unsupported Media Type. The servers reject lychee's request based on the Accept header even though the resources load fine in a browser. Treat 415 as success, consistent with how 403/405 server quirks are already handled. * Pin free-proxy < 1.1.0 for scholarly proxy compatibility scholarly 1.7.11's proxy rotation calls FreeProxy.get_proxy_list() with no arguments, but free-proxy >= 1.1.0 makes the `repeat` argument required. This raised an uncaught TypeError that broke proxy setup, so the scraper fell back to an unproxied request that Google Scholar blocks ("Cannot Fetch from Google Scholar"), failing the workflow. Pinning free-proxy to the < 1.1.0 line (no-arg get_proxy_list) restores a working proxy setup. * Retry Scholar fetch with fresh proxies; exit cleanly if all fail Google Scholar frequently blocks GitHub Actions runners, and the free proxies we rotate through are individually unreliable, so a single fetch attempt is a coin flip. Retry the fetch up to 4 times, rebuilding the proxy generator each attempt so a different proxy is tried. If every attempt is blocked, leave the existing publications.yaml untouched and exit 0 instead of failing the workflow. The merge logic already preserves existing entries on partial failure; this extends the same resilience to a total fetch failure so a transient Scholar block no longer turns the scheduled run red. Nothing is committed in that case since the file is not rewritten, and the next run retries. * Ignore Python __pycache__ directory scholar_scraper.py is a Python script; running it locally creates a __pycache__/ bytecode cache that shouldn't be tracked. * Two-phase Scholar fetch: new entries (retried) then updates (best-effort) Previously every run re-filled all ~92 publications, which is slow and the main thing that trips Google Scholar's block. Split the fetch: - Phase 1 fetches full details only for publications not already in publications.yaml (matched by normalized title, since the generated id depends on author data missing from the Scholar preview). This is how new papers get added, so it retries up to 3 times with fresh proxies. - Phase 2 best-effort refreshes the already-known entries in a single pass with no retry, reusing the working proxy. Any that fail are left as-is (merge preserves the existing entry). A normal run now fills just the handful of new pubs instead of all of them, making it far faster and much less likely to be blocked. If phase 1 can't reach Scholar at all, the YAML is left untouched and the job exits 0, as before. * Skip unproxied fetch and retry when proxy setup fails When the free-proxy setup failed, the scraper continued without a proxy and hit Google Scholar directly, which is always blocked and wastes several minutes per attempt timing out. Since an unproxied request can't succeed, treat a failed proxy setup as a failed attempt and retry immediately with a fresh proxy. Also bump max attempts from 3 to 5 since proxy-setup failures now fail fast. * Auto-update publications from Google Scholar [skip ci] --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent a6e68a6 commit 1da3235

4 files changed

Lines changed: 218 additions & 87 deletions

File tree

.github/workflows/update-publications.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,12 @@ jobs:
2424
- name: Install dependencies
2525
run: |
2626
python -m pip install --upgrade pip
27-
pip install scholarly pyyaml "httpx==0.27.2"
27+
# Pin free-proxy < 1.1.0: scholarly 1.7.11's proxy rotation calls
28+
# FreeProxy.get_proxy_list() with no args, but free-proxy >= 1.1.0
29+
# makes the `repeat` argument required, raising an uncaught
30+
# TypeError that breaks proxy setup. Without a proxy, Google
31+
# Scholar blocks the request and the scrape fails.
32+
pip install scholarly pyyaml "httpx==0.27.2" "free-proxy<1.1.0"
2833
2934
- name: Run publication updater
3035
run: |

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ _site/
33
.lycheecache
44
**/.DS_Store
55
docs/screenshots/
6+
__pycache__/

_data/publications.yaml

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,18 @@
5353
publisher: Cold Spring Harbor Laboratory
5454
page: 2026.02. 12.705535
5555
URL: https://www.biorxiv.org/content/10.64898/2026.02.12.705535.abstract
56+
- id: wei2026flow
57+
type: article-journal
58+
author:
59+
- family: Wei
60+
given: Ganchao
61+
- family: Pearson
62+
given: John
63+
issued:
64+
- year: 2026
65+
title: Flow Matching for Count Data
66+
container-title: arXiv preprint arXiv:2605.07746
67+
URL: https://arxiv.org/abs/2605.07746
5668
- id: albuquerque2025inflationary
5769
type: article-journal
5870
author:
@@ -767,15 +779,14 @@
767779
author:
768780
- family: Draelos
769781
given: Anne
770-
- family: Naumann
771-
given: Eva A
772782
- family: Pearson
773783
given: John M
774784
issued:
775785
- year: 2020
776-
title: Online neural connectivity estimation with ensemble stimulation
777-
container-title: arXiv preprint arXiv:2007.13911
778-
URL: https://arxiv.org/abs/2007.13911
786+
title: Online Neural Connectivity Estimation with Noisy Group Testing
787+
container-title: Advances in Neural Information Processing Systems
788+
volume: '33'
789+
URL: https://proceedings.neurips.cc/paper/2020/hash/531d29a813ef9471aad0a5558d449a73-Abstract.html
779790
- id: mcdonald2020dorsolateral
780791
type: article-journal
781792
author:

scholar_scraper.py

Lines changed: 195 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import yaml
88
import sys
99
import re
10+
import time
1011
from scholarly import scholarly, ProxyGenerator
1112

1213
# Force unbuffered output for GitHub Actions
@@ -88,108 +89,201 @@ def extract_journal_from_citation(citation):
8889

8990
return None
9091

91-
def get_author_publications(scholar_id):
92+
def setup_proxy():
9293
"""
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.
9695
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+
"""
98101
try:
99102
print("Setting up proxy to avoid rate limiting...", flush=True)
100103
pg = ProxyGenerator()
101104
pg.FreeProxies()
102105
scholarly.use_proxy(pg)
103106
print("Proxy configured successfully", flush=True)
107+
return True
104108
except Exception as e:
105109
print(f"Warning: Could not set up proxy: {e}", flush=True)
106110
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()
107122

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+
"""
108129
try:
109-
# Search for author by ID
110130
print("Searching for author...", flush=True)
111131
author = scholarly.search_author_id(scholar_id)
112132
print("Filling author publications...", flush=True)
113133
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']
114147

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', ''))
118150

119-
for idx, pub in enumerate(author['publications'], 1):
151+
# Get year
152+
year = None
153+
if bib.get('pub_year'):
120154
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
189205

190206
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
193287

194288
def load_existing_yaml(path):
195289
"""
@@ -261,7 +355,27 @@ def save_to_yaml(publications, output_file):
261355

262356
print("Starting publication update...", flush=True)
263357
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)
265379
merged = merge_publications(existing, fetched)
266380
save_to_yaml(merged, OUTPUT_FILE)
267381
print("Done!", flush=True)

0 commit comments

Comments
 (0)