Skip to content

Commit 20635f5

Browse files
committed
WebRunner: persistent Chrome profile + interactive-login mode for Scholar
Google's bot detection rejects WebRunner requests too once an IP is flagged from earlier scraping. The reliable workaround is to seed a real Google sign-in into a Chrome profile dir once; subsequent headless runs reuse the session cookie and Google trusts the authenticated request. Two new env vars in sources/scholar/webrunner_backend.py: AUTOPAPERTOPPT_CHROME_PROFILE_DIR When set, passes --user-data-dir=<path> to Chrome so cookies, login state, and captcha clearance survive across CLI runs. AUTOPAPERTOPPT_CHROME_HEADLESS Default '1' (headless). Set '0' for the one-time interactive sign-in that seeds the profile dir. When non-headless the Chrome window holds open for 60 s (vs the 3 s headless wait) so the user has time to log in / accept consent banners / solve any captcha. _build_chrome_args extracted from _drive_chrome_sync so the args construction is unit-testable without spinning up Chrome. Tests ----- +3 tests covering: default headless + no profile, profile dir passed as --user-data-dir, HEADLESS=0 drops the headless flag. 477 tests pass, ruff + bandit clean. Docs ---- configuration.md gains two env var rows + a new 'Suppressing Scholar captchas with a persistent Chrome profile' recipe walking through the one-time interactive setup and the steady-state headless usage, with caveats about profile-dir locking, cookie expiry, and treating the profile dir as a secret.
1 parent 1eb56fb commit 20635f5

3 files changed

Lines changed: 136 additions & 15 deletions

File tree

docs/configuration.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ each value into `os.environ` before any fetcher initialises.
3232
| `AUTOPAPERTOPPT_CROSSREF_PLUS_TOKEN` | unset | Crossref Plus subscriber token. Attached to requests as `Crossref-Plus-API-Token: Bearer <token>`. Raises rate limits and improves cache freshness on the `acm` and `crossref` plugins. |
3333
| `AUTOPAPERTOPPT_DISABLE_SCHOLAR_SCRAPING` | unset | **Scholar plugin is now default-ON.** Set `=1` to opt out. Google's ToS forbids automated access; default-on for coverage, opt-out if you'd rather not take the captcha / IP-block risk. |
3434
| `AUTOPAPERTOPPT_DISABLE_WEBRUNNER` | unset | The Scholar plugin defaults to driving a real Chrome through **WebRunner** (`je_web_runner` is a default dependency) — Google's bot-detection is far less aggressive on real browsers. Set `=1` to force the httpx scrape path even when WebRunner is available (useful for CI / Docker containers without a Chrome binary, or latency-sensitive runs where Chrome's 5-10 s boot isn't worth it). When set, the plugin's existing captcha-cooldown logic kicks in for the httpx path. |
35+
| `AUTOPAPERTOPPT_CHROME_PROFILE_DIR` | unset | When set, passes `--user-data-dir=<path>` to Chrome so cookies / login state / captcha clearance survive across CLI invocations. A one-time interactive Google sign-in seeded into this profile dir reliably suppresses Scholar captchas on subsequent headless runs. See the **Suppressing Scholar captchas with a persistent profile** recipe below. |
36+
| `AUTOPAPERTOPPT_CHROME_HEADLESS` | `1` (headless) | Set `=0` to open Chrome visibly. Used ONLY for the one-time interactive Google sign-in that seeds `AUTOPAPERTOPPT_CHROME_PROFILE_DIR`. Subsequent runs should drop this env var so Chrome stays headless. |
3537
| `AUTOPAPERTOPPT_CORE_API_KEY` | unset | Free key from <https://core.ac.uk/services/api>. Enables the OA resolver's CORE.ac.uk lookup step (200M+ institutional / regional OA repository items). Skipped silently when unset (the other OA strategies — Unpaywall, Semantic Scholar, arXiv — still run). |
3638
| `AUTOPAPERTOPPT_CONTACT_EMAIL` | unset | Sent to Crossref / OpenAlex as the `mailto=` parameter (entry into their polite pool), to NCBI as `tool` / `email` headers, **and to Unpaywall as `email=`** for the post-dedup OA PDF resolver. Highly recommended — without it the resolver skips Unpaywall lookups entirely, which is the single biggest PDF coverage win for IEEE / ACM / Springer / Elsevier paywalled papers (typical lift 40-70%). |
3739

@@ -164,6 +166,50 @@ Override via:
164166
Clear the cache by deleting the directory; AutoPaperToPPT
165167
re-creates it on demand.
166168

169+
## Suppressing Scholar captchas with a persistent Chrome profile
170+
171+
Google flags an IP after a few automated Scholar requests even with
172+
WebRunner's real-browser path. The reliable workaround is to seed a
173+
persistent Chrome profile with a real Google sign-in once; subsequent
174+
headless runs reuse the same session cookies, which Google trusts.
175+
176+
**One-time setup:**
177+
178+
```powershell
179+
# 1. Pick a directory anywhere on disk
180+
$env:AUTOPAPERTOPPT_CHROME_PROFILE_DIR = "D:\autopapertoppt-scholar-profile"
181+
182+
# 2. Open Chrome visibly and trigger one Scholar request
183+
$env:AUTOPAPERTOPPT_CHROME_HEADLESS = "0"
184+
autopapertoppt --query "any keywords" --source scholar --max 1 --out .\tmp\
185+
186+
# Chrome opens. Sign into your Google account, accept any consent
187+
# banners, complete any captcha. The window holds open for 60s.
188+
```
189+
190+
**Every run after that:**
191+
192+
```powershell
193+
$env:AUTOPAPERTOPPT_CHROME_PROFILE_DIR = "D:\autopapertoppt-scholar-profile"
194+
Remove-Item Env:\AUTOPAPERTOPPT_CHROME_HEADLESS # back to headless
195+
autopapertoppt --query "..." --out .\exports\
196+
```
197+
198+
Chrome boots headless but loads the same profile dir, sends your
199+
authenticated Google session cookie, and Scholar serves real results
200+
instead of a captcha page.
201+
202+
**Caveats:**
203+
204+
- Only one Chrome process can hold the profile dir at a time. If you
205+
have a regular Chrome open on the same profile path, the
206+
WebRunner instance will fail to start. Use a dedicated path.
207+
- The session cookie is a real authentication credential. Treat the
208+
profile directory like a secret — back it up if you re-image the
209+
machine, restrict file permissions.
210+
- Cookie eventually expires (~1-2 months for Google). Re-do the
211+
interactive sign-in then.
212+
167213
## Settings the project explicitly does NOT have
168214

169215
By design — listing them so a contributor doesn't accidentally

sources/scholar/webrunner_backend.py

Lines changed: 57 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,19 @@
3737
_LOG = get_logger(__name__)
3838
_SEARCH_URL_BASE = "https://scholar.google.com/scholar"
3939
_DISABLE_ENV = "AUTOPAPERTOPPT_DISABLE_WEBRUNNER"
40+
#: Chrome user-data directory to persist between runs. When set, the
41+
#: session cookies from any prior login (Google account, captcha
42+
#: clearance, etc.) survive across CLI invocations — a one-time
43+
#: interactive login in this profile dramatically reduces captcha hits.
44+
_PROFILE_DIR_ENV = "AUTOPAPERTOPPT_CHROME_PROFILE_DIR"
45+
#: Set to ``0`` to disable headless mode (needed for the one-time
46+
#: interactive login into Google when seeding the profile dir).
47+
_HEADLESS_ENV = "AUTOPAPERTOPPT_CHROME_HEADLESS"
4048
_PAGE_LOAD_WAIT_SECONDS = 3.0
49+
#: When the headless flag is OFF (interactive login mode) we hold the
50+
#: window open longer so the user has time to complete the Google
51+
#: sign-in flow before the search returns.
52+
_INTERACTIVE_WAIT_SECONDS = 60.0
4153

4254

4355
def is_available() -> bool:
@@ -86,29 +98,27 @@ def _drive_chrome_sync(url: str) -> str:
8698
# path before it ever runs.
8799
from je_web_runner import webdriver_wrapper_instance
88100

89-
chrome_args = [
90-
"--disable-blink-features=AutomationControlled",
91-
"--lang=en-US",
92-
# Headless new mode passes more of Google's automation
93-
# detection than the legacy --headless flag.
94-
"--headless=new",
95-
# Reduce fingerprint surface.
96-
"--disable-gpu",
97-
"--no-sandbox",
98-
"--window-size=1280,720",
99-
]
101+
chrome_args, headless = _build_chrome_args()
100102
try:
101103
webdriver_wrapper_instance.set_driver("chrome", options=chrome_args)
102104
except Exception as err: # noqa: BLE001 — Selenium raises many types
103105
raise RuntimeError(f"WebRunner cannot start chrome: {err}") from err
104106

105107
try:
106108
webdriver_wrapper_instance.to_url(url)
107-
# Page needs a beat to render results client-side. We don't use
108-
# a smart-wait helper here because the SERP layout has no single
109-
# stable readiness signal that survives captcha vs. real results.
109+
# Headless: tight wait, only need the SERP HTML. Non-headless
110+
# (interactive login mode): hold the window open so the user
111+
# has time to complete the Google sign-in flow.
112+
wait = _PAGE_LOAD_WAIT_SECONDS if headless else _INTERACTIVE_WAIT_SECONDS
113+
if not headless:
114+
_LOG.warning(
115+
"Chrome opened in interactive mode for %.0fs — sign into "
116+
"Google in the window now. Session cookies will persist "
117+
"in the profile dir for subsequent headless runs.",
118+
wait,
119+
)
110120
import time
111-
time.sleep(_PAGE_LOAD_WAIT_SECONDS)
121+
time.sleep(wait)
112122
return webdriver_wrapper_instance.current_webdriver.page_source
113123
except Exception as err: # noqa: BLE001 — best-effort
114124
raise RuntimeError(f"WebRunner page-load failed: {err}") from err
@@ -117,3 +127,35 @@ def _drive_chrome_sync(url: str) -> str:
117127
webdriver_wrapper_instance.quit()
118128
except Exception as err: # noqa: BLE001 # nosec B110 — best-effort cleanup
119129
_LOG.debug("WebRunner cleanup failed: %s", err)
130+
131+
132+
def _build_chrome_args() -> tuple[list[str], bool]:
133+
"""Return ``(chrome_args, is_headless)`` based on env-var overrides.
134+
135+
Layered on top of the always-applied anti-detection flags:
136+
137+
- ``AUTOPAPERTOPPT_CHROME_PROFILE_DIR=<path>``: pass
138+
``--user-data-dir=<path>`` so cookies / login state persist.
139+
- ``AUTOPAPERTOPPT_CHROME_HEADLESS=0``: drop ``--headless=new`` so
140+
the user can interact with the Chrome window (required for the
141+
one-time Google sign-in that seeds the profile dir).
142+
"""
143+
chrome_args = [
144+
"--disable-blink-features=AutomationControlled",
145+
"--lang=en-US",
146+
# Reduce fingerprint surface; safe in both headless and visible
147+
# modes.
148+
"--disable-gpu",
149+
"--no-sandbox",
150+
"--window-size=1280,720",
151+
]
152+
headless = os.environ.get(_HEADLESS_ENV, "1") != "0"
153+
if headless:
154+
# Headless 'new' passes more of Google's automation detection
155+
# than legacy --headless.
156+
chrome_args.append("--headless=new")
157+
profile_dir = os.environ.get(_PROFILE_DIR_ENV, "").strip()
158+
if profile_dir:
159+
chrome_args.append(f"--user-data-dir={profile_dir}")
160+
_LOG.info("Chrome using persistent profile at %s", profile_dir)
161+
return chrome_args, headless

tests/sources/test_scholar.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,3 +205,36 @@ def test_webrunner_is_available_respects_disable_env(monkeypatch):
205205

206206
monkeypatch.setenv("AUTOPAPERTOPPT_DISABLE_WEBRUNNER", "1")
207207
assert webrunner_backend.is_available() is False
208+
209+
210+
def test_chrome_args_default_headless_with_no_profile(monkeypatch):
211+
"""No env vars → headless + anti-detection flags, no profile dir."""
212+
from scholar import webrunner_backend
213+
214+
for var in ("AUTOPAPERTOPPT_CHROME_PROFILE_DIR", "AUTOPAPERTOPPT_CHROME_HEADLESS"):
215+
monkeypatch.delenv(var, raising=False)
216+
args, headless = webrunner_backend._build_chrome_args() # noqa: SLF001
217+
assert headless is True
218+
assert "--headless=new" in args
219+
assert "--disable-blink-features=AutomationControlled" in args
220+
assert not any(a.startswith("--user-data-dir=") for a in args)
221+
222+
223+
def test_chrome_args_with_profile_dir_passes_user_data_dir(monkeypatch):
224+
from scholar import webrunner_backend
225+
226+
monkeypatch.setenv("AUTOPAPERTOPPT_CHROME_PROFILE_DIR", "D:/scholar-profile")
227+
monkeypatch.delenv("AUTOPAPERTOPPT_CHROME_HEADLESS", raising=False)
228+
args, headless = webrunner_backend._build_chrome_args() # noqa: SLF001
229+
assert headless is True
230+
assert "--user-data-dir=D:/scholar-profile" in args
231+
232+
233+
def test_chrome_args_headless_zero_drops_headless_flag(monkeypatch):
234+
"""AUTOPAPERTOPPT_CHROME_HEADLESS=0 enables the interactive-login mode."""
235+
from scholar import webrunner_backend
236+
237+
monkeypatch.setenv("AUTOPAPERTOPPT_CHROME_HEADLESS", "0")
238+
args, headless = webrunner_backend._build_chrome_args() # noqa: SLF001
239+
assert headless is False
240+
assert "--headless=new" not in args

0 commit comments

Comments
 (0)