papersearch 0.4.0 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +292 -0
- package/README.md +203 -122
- package/carsi/README.md +9 -3
- package/carsi/paper_search_carsi/__init__.py +1 -4
- package/carsi/paper_search_carsi/adapters/__init__.py +1 -1
- package/carsi/paper_search_carsi/adapters/base.py +2 -0
- package/carsi/paper_search_carsi/adapters/frontiers/__init__.py +45 -0
- package/carsi/paper_search_carsi/adapters/mdpi/__init__.py +33 -1
- package/carsi/paper_search_carsi/adapters/platforms/figshare/__init__.py +185 -0
- package/carsi/paper_search_carsi/adapters/thieme/__init__.py +6 -5
- package/carsi/paper_search_carsi/browser/__init__.py +8 -1
- package/carsi/paper_search_carsi/browser/backend.py +23 -147
- package/carsi/paper_search_carsi/browser/challenge_interaction.py +137 -0
- package/carsi/paper_search_carsi/browser/challenges.py +57 -0
- package/carsi/paper_search_carsi/browser/engine.py +2 -2
- package/carsi/paper_search_carsi/browser/preview.py +44 -26
- package/carsi/paper_search_carsi/browser/profiles.py +161 -0
- package/carsi/paper_search_carsi/browser/watchdog.py +74 -17
- package/carsi/paper_search_carsi/config.py +4 -1
- package/carsi/paper_search_carsi/download_diagnostics.py +1 -1
- package/carsi/paper_search_carsi/engine/batch.py +140 -69
- package/carsi/paper_search_carsi/engine/batch_access.py +8 -3
- package/carsi/paper_search_carsi/engine/batch_capture.py +54 -9
- package/carsi/paper_search_carsi/engine/batch_common.py +27 -14
- package/carsi/paper_search_carsi/engine/batch_diagnostics.py +20 -15
- package/carsi/paper_search_carsi/engine/batch_login.py +85 -30
- package/carsi/paper_search_carsi/engine/batch_runs.py +2 -7
- package/carsi/paper_search_carsi/engine/batch_waits.py +97 -47
- package/carsi/paper_search_carsi/engine/bridge.py +17 -8
- package/carsi/paper_search_carsi/engine/supplements.py +28 -5
- package/carsi/paper_search_carsi/entitlements.py +12 -2
- package/carsi/paper_search_carsi/log.py +13 -7
- package/carsi/paper_search_carsi/orchestrator.py +7 -1
- package/carsi/paper_search_carsi/progress_bar.py +156 -12
- package/carsi/paper_search_carsi/server.py +8 -1
- package/carsi/paper_search_carsi/user_settings.py +55 -0
- package/carsi/pyproject.toml +1 -1
- package/dist/capabilities/citation-expansion/schemas.d.ts +2 -2
- package/dist/capabilities/metadata-search/MultiSourceSearchService.d.ts.map +1 -1
- package/dist/capabilities/metadata-search/MultiSourceSearchService.js +17 -4
- package/dist/capabilities/metadata-search/MultiSourceSearchService.js.map +1 -1
- package/dist/capabilities/metadata-search/handler.d.ts.map +1 -1
- package/dist/capabilities/metadata-search/handler.js +6 -0
- package/dist/capabilities/metadata-search/handler.js.map +1 -1
- package/dist/capabilities/metadata-search/schemas.d.ts +3 -3
- package/dist/capabilities/metadata-search/schemas.d.ts.map +1 -1
- package/dist/capabilities/metadata-search/schemas.js +1 -0
- package/dist/capabilities/metadata-search/schemas.js.map +1 -1
- package/dist/capabilities/metadata-search/tools.js +1 -1
- package/dist/capabilities/metadata-search/tools.js.map +1 -1
- package/dist/capabilities/pdf-discovery/DownloadTier.d.ts +11 -0
- package/dist/capabilities/pdf-discovery/DownloadTier.d.ts.map +1 -1
- package/dist/capabilities/pdf-discovery/OpenAccessFallbackService.d.ts.map +1 -1
- package/dist/capabilities/pdf-discovery/OpenAccessFallbackService.js +31 -1
- package/dist/capabilities/pdf-discovery/OpenAccessFallbackService.js.map +1 -1
- package/dist/capabilities/pdf-discovery/arxiv.d.ts +4 -0
- package/dist/capabilities/pdf-discovery/arxiv.d.ts.map +1 -0
- package/dist/capabilities/pdf-discovery/arxiv.js +42 -0
- package/dist/capabilities/pdf-discovery/arxiv.js.map +1 -0
- package/dist/capabilities/pdf-discovery/batch.d.ts +1 -1
- package/dist/capabilities/pdf-discovery/batch.d.ts.map +1 -1
- package/dist/capabilities/pdf-discovery/batch.js +87 -15
- package/dist/capabilities/pdf-discovery/batch.js.map +1 -1
- package/dist/capabilities/pdf-discovery/carsi/CarsiAttempt.d.ts.map +1 -1
- package/dist/capabilities/pdf-discovery/carsi/CarsiAttempt.js +2 -0
- package/dist/capabilities/pdf-discovery/carsi/CarsiAttempt.js.map +1 -1
- package/dist/capabilities/pdf-discovery/carsi/CarsiMcpClient.d.ts.map +1 -1
- package/dist/capabilities/pdf-discovery/carsi/CarsiMcpClient.js +3 -3
- package/dist/capabilities/pdf-discovery/carsi/CarsiMcpClient.js.map +1 -1
- package/dist/capabilities/pdf-discovery/carsi/CarsiReadiness.d.ts +2 -1
- package/dist/capabilities/pdf-discovery/carsi/CarsiReadiness.d.ts.map +1 -1
- package/dist/capabilities/pdf-discovery/carsi/CarsiReadiness.js +3 -1
- package/dist/capabilities/pdf-discovery/carsi/CarsiReadiness.js.map +1 -1
- package/dist/capabilities/pdf-discovery/carsi/CarsiResultNormalizer.d.ts +1 -1
- package/dist/capabilities/pdf-discovery/carsi/CarsiResultNormalizer.d.ts.map +1 -1
- package/dist/capabilities/pdf-discovery/carsi/CarsiResultNormalizer.js +4 -4
- package/dist/capabilities/pdf-discovery/carsi/CarsiResultNormalizer.js.map +1 -1
- package/dist/capabilities/pdf-discovery/carsi/types.d.ts +2 -0
- package/dist/capabilities/pdf-discovery/carsi/types.d.ts.map +1 -1
- package/dist/capabilities/pdf-discovery/handler.d.ts.map +1 -1
- package/dist/capabilities/pdf-discovery/handler.js +3 -2
- package/dist/capabilities/pdf-discovery/handler.js.map +1 -1
- package/dist/capabilities/pdf-discovery/oaHosts.d.ts +1 -1
- package/dist/capabilities/pdf-discovery/oaHosts.d.ts.map +1 -1
- package/dist/capabilities/pdf-discovery/oaHosts.js +4 -4
- package/dist/capabilities/pdf-discovery/outputLayout.d.ts +29 -0
- package/dist/capabilities/pdf-discovery/outputLayout.d.ts.map +1 -0
- package/dist/capabilities/pdf-discovery/outputLayout.js +144 -0
- package/dist/capabilities/pdf-discovery/outputLayout.js.map +1 -0
- package/dist/capabilities/pdf-discovery/tiers/directPdfUrl.d.ts.map +1 -1
- package/dist/capabilities/pdf-discovery/tiers/directPdfUrl.js +6 -0
- package/dist/capabilities/pdf-discovery/tiers/directPdfUrl.js.map +1 -1
- package/dist/capabilities/pdf-discovery/tiers/entitledAccess.d.ts +2 -1
- package/dist/capabilities/pdf-discovery/tiers/entitledAccess.d.ts.map +1 -1
- package/dist/capabilities/pdf-discovery/tiers/entitledAccess.js +8 -1
- package/dist/capabilities/pdf-discovery/tiers/entitledAccess.js.map +1 -1
- package/dist/capabilities/pdf-discovery/tiers/preprint.d.ts +5 -0
- package/dist/capabilities/pdf-discovery/tiers/preprint.d.ts.map +1 -0
- package/dist/capabilities/pdf-discovery/tiers/preprint.js +80 -0
- package/dist/capabilities/pdf-discovery/tiers/preprint.js.map +1 -0
- package/dist/capabilities/pdf-discovery/tiers/primary.js +2 -0
- package/dist/capabilities/pdf-discovery/tiers/primary.js.map +1 -1
- package/dist/capabilities/pdf-discovery/tiers/unpaywall.d.ts.map +1 -1
- package/dist/capabilities/pdf-discovery/tiers/unpaywall.js +12 -3
- package/dist/capabilities/pdf-discovery/tiers/unpaywall.js.map +1 -1
- package/dist/capabilities/pdf-discovery/types.d.ts +6 -0
- package/dist/capabilities/pdf-discovery/types.d.ts.map +1 -1
- package/dist/core/handleToolCall.d.ts.map +1 -1
- package/dist/core/handleToolCall.js +12 -0
- package/dist/core/handleToolCall.js.map +1 -1
- package/dist/core/schemas.d.ts +26 -1
- package/dist/core/schemas.d.ts.map +1 -1
- package/dist/core/schemas.js +13 -0
- package/dist/core/schemas.js.map +1 -1
- package/dist/core/searchers.d.ts +2 -0
- package/dist/core/searchers.d.ts.map +1 -1
- package/dist/core/searchers.js.map +1 -1
- package/dist/core/tools.d.ts.map +1 -1
- package/dist/core/tools.js +30 -0
- package/dist/core/tools.js.map +1 -1
- package/dist/infrastructure/arxiv.d.ts +5 -0
- package/dist/infrastructure/arxiv.d.ts.map +1 -0
- package/dist/infrastructure/arxiv.js +36 -0
- package/dist/infrastructure/arxiv.js.map +1 -0
- package/dist/infrastructure/pdf/PdfDownload.d.ts +2 -0
- package/dist/infrastructure/pdf/PdfDownload.d.ts.map +1 -1
- package/dist/infrastructure/pdf/PdfDownload.js +14 -0
- package/dist/infrastructure/pdf/PdfDownload.js.map +1 -1
- package/dist/infrastructure/pdf/PdfFirstPage.cjs +4 -1
- package/dist/infrastructure/pdf/PdfFirstPage.cjs.map +1 -1
- package/dist/infrastructure/pdf/PdfFirstPage.d.cts.map +1 -1
- package/dist/infrastructure/runlog/RunLedger.d.ts.map +1 -1
- package/dist/infrastructure/runlog/RunLedger.js +17 -1
- package/dist/infrastructure/runlog/RunLedger.js.map +1 -1
- package/dist/management/carsi/installer.d.ts +1 -1
- package/dist/platforms/ArxivSearcher.d.ts +62 -0
- package/dist/platforms/ArxivSearcher.d.ts.map +1 -0
- package/dist/platforms/ArxivSearcher.js +544 -0
- package/dist/platforms/ArxivSearcher.js.map +1 -0
- package/dist/platforms/SemanticScholarSearcher.d.ts.map +1 -1
- package/dist/platforms/SemanticScholarSearcher.js +4 -4
- package/dist/platforms/SemanticScholarSearcher.js.map +1 -1
- package/dist/registry/platformFactories.d.ts.map +1 -1
- package/dist/registry/platformFactories.js +2 -0
- package/dist/registry/platformFactories.js.map +1 -1
- package/dist/registry/platformMetadata.d.ts +1 -1
- package/dist/registry/platformMetadata.d.ts.map +1 -1
- package/dist/registry/platformMetadata.js +16 -0
- package/dist/registry/platformMetadata.js.map +1 -1
- package/package.json +2 -2
- package/skills/paper-search/SKILL.md +7 -4
- package/skills/paper-search/references/browser-download.md +10 -6
- package/skills/paper-search/references/capability-routing.md +4 -3
- package/skills/paper-search/references/cli-contract.md +10 -3
- package/skills/paper-search/references/publisher-support.md +6 -4
- package/skills/paper-search/references/troubleshooting.md +1 -1
- package/README.zh.md +0 -211
|
@@ -44,7 +44,8 @@ from .router import (
|
|
|
44
44
|
from ..publishers.profiles import ACS_PROFILE, PublisherProfile
|
|
45
45
|
from ..entitlements import UserMarkedNoEntitlement # noqa: E402 (re-exported)
|
|
46
46
|
from ..adapters import PublisherAdapter, adapter_class # noqa: E402
|
|
47
|
-
from .batch_common import (
|
|
47
|
+
from .batch_common import (
|
|
48
|
+
page_operation, # re-exported: other modules import these from batch
|
|
48
49
|
open_page,
|
|
49
50
|
settle,
|
|
50
51
|
_is_network_failure,
|
|
@@ -86,7 +87,8 @@ from .batch_pdf_urls import PdfUrlsMixin
|
|
|
86
87
|
from .batch_runs import RunsMixin
|
|
87
88
|
from .batch_diagnostics import DiagnosticsMixin
|
|
88
89
|
from .batch_user import PdfFoundWhileWaiting, UserProvidedPdf, UserRequestsMixin
|
|
89
|
-
from ..
|
|
90
|
+
from ..browser import watchdog
|
|
91
|
+
from ..entitlements import UserSkippedArticle, UserStoppedBatch
|
|
90
92
|
|
|
91
93
|
|
|
92
94
|
class PublisherBatchDownloader(
|
|
@@ -147,12 +149,13 @@ class PublisherBatchDownloader(
|
|
|
147
149
|
tab.close()
|
|
148
150
|
|
|
149
151
|
def collect_supplements(self, context: Any, doi: str, output_dir: Path,
|
|
150
|
-
result: "DownloadResult") -> list[dict[str, Any]]:
|
|
152
|
+
result: "DownloadResult", *, manifest_dir: Path | None = None) -> list[dict[str, Any]]:
|
|
151
153
|
"""Open the article page in a new tab of ``context`` (already logged in)
|
|
152
154
|
and download every supplementary file the adapter lists there."""
|
|
153
155
|
from . import supplements
|
|
154
156
|
|
|
155
157
|
self._phase(result, "supplements")
|
|
158
|
+
progress_reporter.update(part="supplements", supplements_saved=0, supplements_total=None)
|
|
156
159
|
try:
|
|
157
160
|
page = context.new_page()
|
|
158
161
|
except Exception as exc:
|
|
@@ -162,34 +165,56 @@ class PublisherBatchDownloader(
|
|
|
162
165
|
# Digest 2026-11-02): no supplements, and the article keeps its result.
|
|
163
166
|
self._event(result, "supplements_skipped", f"browser closed: {type(exc).__name__}")
|
|
164
167
|
return [{"status": "skipped", "reason": "browser_closed", "url": "", "label": ""}]
|
|
168
|
+
user_watch = self._start_user_watch(page, result)
|
|
165
169
|
try:
|
|
166
|
-
self.
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
if
|
|
178
|
-
self.
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
self.
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
170
|
+
with watchdog.operation(self.page_wait_sec, "supplement_discovery"):
|
|
171
|
+
self._track_article_for_adapter(page, doi)
|
|
172
|
+
links: list = []
|
|
173
|
+
# The journal's own article page the main text came from (cell.com,
|
|
174
|
+
# journal-of-hepatology.eu) lists every file it has: no detour to the
|
|
175
|
+
# platform, also when it lists none.
|
|
176
|
+
final_url = str(getattr(result, "final_url", "") or "")
|
|
177
|
+
own_page = [final_url] if final_url and self.adapter.is_article_page_url(final_url) else []
|
|
178
|
+
candidates = self.adapter.supplements_page_urls(doi) or [self._article_url(doi)]
|
|
179
|
+
for url in dict.fromkeys(own_page + candidates):
|
|
180
|
+
open_page(page, url, timeout_ms=60_000)
|
|
181
|
+
if self._challenge_shell(page) or self._is_challenge_page(page):
|
|
182
|
+
if not self._wait_for_challenge(page, result):
|
|
183
|
+
self._event(result, "supplements_failed", "verification not completed")
|
|
184
|
+
return [{"status": "failed", "reason": "challenge_timeout", "url": url, "label": ""}]
|
|
185
|
+
settle(page, 2_000)
|
|
186
|
+
self._wait_for_page_text(page, result)
|
|
187
|
+
links = self._settled_supplement_links(page, doi)
|
|
188
|
+
if links is None:
|
|
189
|
+
self._event(result, "supplements_not_supported", self.profile.name)
|
|
190
|
+
return []
|
|
191
|
+
if links or url in own_page:
|
|
192
|
+
break
|
|
193
|
+
self._event(result, "supplements_found", str(len(links)))
|
|
194
|
+
progress_reporter.update(supplements_total=len(links))
|
|
188
195
|
files = supplements.collect(page.context.request, links, Path(output_dir), doi,
|
|
189
|
-
|
|
196
|
+
manifest_dir=manifest_dir,
|
|
197
|
+
target_dir=Path(output_dir) / "supplements" if self.config.get("diagnostics_dir") else None,
|
|
198
|
+
download=lambda url: self._download_in_new_tab(page, url),
|
|
199
|
+
progress=lambda saved: progress_reporter.update(supplements_saved=saved))
|
|
190
200
|
saved = sum(1 for f in files if f.get("status") == "saved")
|
|
191
201
|
self._event(result, "supplements_saved", f"{saved}/{len(files)}")
|
|
192
202
|
return files
|
|
203
|
+
except PublisherContentError as exc:
|
|
204
|
+
self._event(result, "supplements_failed", str(exc))
|
|
205
|
+
return [{"status": "failed", "reason": "publisher_content_error", "url": "", "label": ""}]
|
|
206
|
+
except watchdog.BrowserUnresponsive as exc:
|
|
207
|
+
reason = ("browser_operation_timeout" if isinstance(exc, watchdog.BrowserOperationTimeout)
|
|
208
|
+
else "browser_unresponsive")
|
|
209
|
+
self._event(result, "supplements_failed", reason)
|
|
210
|
+
return [{"status": "failed", "reason": reason, "url": "", "label": ""}]
|
|
211
|
+
except UserMarkedNoEntitlement as exc:
|
|
212
|
+
reason = ("batch_stopped" if isinstance(exc, UserStoppedBatch) else
|
|
213
|
+
"user_skipped" if isinstance(exc, UserSkippedArticle) else "user_marked_no_entitlement")
|
|
214
|
+
self._event(result, "supplements_skipped", reason)
|
|
215
|
+
if not result.ok:
|
|
216
|
+
result.reason = result.state = reason
|
|
217
|
+
return [{"status": "skipped", "reason": reason, "url": "", "label": ""}]
|
|
193
218
|
except PublisherAccessBlocked:
|
|
194
219
|
# The publisher paused access (BJSM, 2026-11-01): the article ends as
|
|
195
220
|
# blocked when its main text failed, so the batch stops as for any block.
|
|
@@ -203,6 +228,8 @@ class PublisherBatchDownloader(
|
|
|
203
228
|
self._event(result, "supplements_failed", f"{type(exc).__name__}: {str(exc)[:160]}")
|
|
204
229
|
return [{"status": "failed", "reason": type(exc).__name__, "url": "", "label": ""}]
|
|
205
230
|
finally:
|
|
231
|
+
user_watch.set()
|
|
232
|
+
watchdog.clear_interrupt()
|
|
206
233
|
self._close_phase(result)
|
|
207
234
|
try:
|
|
208
235
|
page.close()
|
|
@@ -214,7 +241,13 @@ class PublisherBatchDownloader(
|
|
|
214
241
|
moves /pii/ to /abs/pii/ by script after the document has loaded)."""
|
|
215
242
|
for attempt in range(3):
|
|
216
243
|
try:
|
|
217
|
-
|
|
244
|
+
from ..adapters.platforms.figshare import supplement_links as external_links
|
|
245
|
+
|
|
246
|
+
own = self.adapter.supplement_links(page, doi)
|
|
247
|
+
external = external_links(page, doi)
|
|
248
|
+
if own is None and external is None:
|
|
249
|
+
return None
|
|
250
|
+
return list(dict.fromkeys([*(own or []), *(external or [])]))
|
|
218
251
|
except Exception as exc:
|
|
219
252
|
if (attempt == 2 or not _is_browser_error(exc)
|
|
220
253
|
or "Execution context was destroyed" not in str(exc)):
|
|
@@ -304,6 +337,8 @@ class PublisherBatchDownloader(
|
|
|
304
337
|
config["browser_headless"] = False
|
|
305
338
|
started = time.time()
|
|
306
339
|
ctx = get_persistent_context(profile_path, config)
|
|
340
|
+
from ..browser.challenge_interaction import prepare_context
|
|
341
|
+
prepare_context(ctx)
|
|
307
342
|
self._browser_start = {**dict(getattr(browser_backend, "LAST_LAUNCH", {}) or {}),
|
|
308
343
|
"seconds": round(time.time() - started, 1), **({"relaunch": relaunch} if relaunch else {})}
|
|
309
344
|
from ..browser.backend import browser_info
|
|
@@ -341,11 +376,13 @@ class PublisherBatchDownloader(
|
|
|
341
376
|
self._item_started = time.time()
|
|
342
377
|
self._item_result = result
|
|
343
378
|
self._record_title = record.title
|
|
379
|
+
self._expected_title = ""
|
|
344
380
|
self._manual_school = False
|
|
345
381
|
self._track_article_for_adapter(page, record.doi)
|
|
346
382
|
from ..browser import watchdog
|
|
347
383
|
|
|
348
384
|
self._phase(result, "open")
|
|
385
|
+
progress_reporter.update(part="main", supplements_saved=0, supplements_total=None)
|
|
349
386
|
self._run_dir = run_dir
|
|
350
387
|
watchdog.on_slow_call = lambda name, seconds, ended, where: self._event(
|
|
351
388
|
result, "wait", f"browser call {name}: {seconds:.0f} s, ended: {ended} ({where})")
|
|
@@ -356,46 +393,17 @@ class PublisherBatchDownloader(
|
|
|
356
393
|
result.browser_start = dict(start)
|
|
357
394
|
self._event(result, "browser_start", self._browser_start_line(start))
|
|
358
395
|
user_watch = self._start_user_watch(page, result)
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
else:
|
|
365
|
-
return self._login_failed(page, result, run_dir)
|
|
366
|
-
|
|
367
|
-
self._return_from_other_article(page, result)
|
|
368
|
-
settle(page, 2_000)
|
|
369
|
-
if not self._pdf_first_unless_signed_out(page, result):
|
|
370
|
-
self._event(result, "auth_wall_after_article_load", self._decision_text(page))
|
|
371
|
-
# This item has been through the school's login and the article still
|
|
372
|
-
# denies it, shows only its abstract or offers it: the school has no
|
|
373
|
-
# access, and a second login would only wait out the user's time
|
|
374
|
-
# (JAMA Oncology back from the store page, 2026-11-02).
|
|
375
|
-
evidence = self._no_access_after_login(page, result)
|
|
376
|
-
if evidence:
|
|
377
|
-
result.reason = result.state = "institution_pdf_entitlement_missing"
|
|
378
|
-
self._event(result, "no_access_after_login", evidence[:300])
|
|
379
|
-
self._snapshot(page, result, "not subscribed")
|
|
380
|
-
result.final_url = page.url
|
|
381
|
-
result.title = self._title(page)
|
|
382
|
-
self._write_diagnostic(page, result, run_dir)
|
|
383
|
-
return result
|
|
384
|
-
if not self._complete_login_from_current_page(page, result):
|
|
385
|
-
if self._article_access_available(page):
|
|
386
|
-
self._event(result, "login_completed_after_timeout", getattr(page, "url", ""))
|
|
387
|
-
else:
|
|
388
|
-
return self._login_failed(page, result, run_dir)
|
|
389
|
-
settle(page, 2_000)
|
|
390
|
-
if not self._pdf_first_unless_signed_out(page, result):
|
|
391
|
-
return self._login_failed(page, result, run_dir)
|
|
396
|
+
# Reuse confirmed public access only for this document's initial PDF
|
|
397
|
+
# attempt. A redirect or reload (even at the same URL) invalidates it.
|
|
398
|
+
def invalidate_open_access(frame: Any) -> None:
|
|
399
|
+
if frame == page.main_frame:
|
|
400
|
+
result._open_access_current = False
|
|
392
401
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
self._hold_after_login(page, result)
|
|
402
|
+
page.on("framenavigated", invalidate_open_access)
|
|
403
|
+
try:
|
|
404
|
+
preparation = self._prepare_article(page, result, run_dir)
|
|
405
|
+
if preparation is not None:
|
|
406
|
+
return preparation
|
|
399
407
|
ip_block: str | None = None
|
|
400
408
|
# Whether this capture comes after the school's login, and its first attempt.
|
|
401
409
|
signed_in, attempts_from = self._school_login_done(), len(result.pdf_attempts)
|
|
@@ -512,6 +520,12 @@ class PublisherBatchDownloader(
|
|
|
512
520
|
self._event(result, "pdf_found_while_waiting" if isinstance(provided, PdfFoundWhileWaiting)
|
|
513
521
|
else "pdf_from_user_report", provided.url)
|
|
514
522
|
return self._save_captured_pdf(page, record, result, run_dir, provided.body, provided.url)
|
|
523
|
+
except UserStoppedBatch as decision:
|
|
524
|
+
watchdog.clear_interrupt()
|
|
525
|
+
result.reason = result.state = "batch_stopped"
|
|
526
|
+
self._event(result, "batch_stopped", str(decision))
|
|
527
|
+
self._write_diagnostic(page, result, run_dir)
|
|
528
|
+
return result
|
|
515
529
|
except UserSkippedArticle as decision:
|
|
516
530
|
watchdog.clear_interrupt()
|
|
517
531
|
result.reason = result.state = "user_skipped"
|
|
@@ -542,6 +556,12 @@ class PublisherBatchDownloader(
|
|
|
542
556
|
progress_reporter.update(phase="出版社内容服务错误,当前路径已停止")
|
|
543
557
|
self._write_diagnostic(page, result, run_dir)
|
|
544
558
|
return result
|
|
559
|
+
except watchdog.BrowserUnresponsive as exc:
|
|
560
|
+
result.reason = result.state = ("browser_operation_timeout" if isinstance(exc, watchdog.BrowserOperationTimeout)
|
|
561
|
+
else "browser_unresponsive")
|
|
562
|
+
self._event(result, result.state, str(exc))
|
|
563
|
+
self._write_diagnostic(page, result, run_dir)
|
|
564
|
+
return result
|
|
545
565
|
except Exception as exc:
|
|
546
566
|
import traceback
|
|
547
567
|
|
|
@@ -563,22 +583,72 @@ class PublisherBatchDownloader(
|
|
|
563
583
|
self._write_diagnostic(page, result, run_dir)
|
|
564
584
|
return result
|
|
565
585
|
finally:
|
|
586
|
+
page.remove_listener("framenavigated", invalidate_open_access)
|
|
566
587
|
user_watch.set()
|
|
567
588
|
watchdog.on_slow_call = None
|
|
568
589
|
if isinstance(watchdog.interrupted, UserMarkedNoEntitlement) and result.ok:
|
|
569
590
|
# The click came while the PDF was being saved.
|
|
570
591
|
self._tell_user("点击时这一篇已经保存好了,没有跳过", result)
|
|
571
592
|
watchdog.clear_interrupt()
|
|
572
|
-
|
|
593
|
+
stopped = bool(watchdog.fired) or result.state in {
|
|
594
|
+
"browser_unresponsive", "browser_operation_timeout", "user_skipped", "batch_stopped", "user_marked_no_entitlement"}
|
|
595
|
+
if not stopped:
|
|
596
|
+
self._capture_reported_issues(page, result, act=False)
|
|
573
597
|
if not result.ok:
|
|
574
598
|
progress_reporter.report_stage("route_failed", current=record.doi)
|
|
575
|
-
|
|
599
|
+
if not stopped:
|
|
600
|
+
self._hold_after_run(page, result)
|
|
576
601
|
if _owns_page:
|
|
577
602
|
try:
|
|
578
603
|
page.close()
|
|
579
604
|
except Exception:
|
|
580
605
|
pass
|
|
581
606
|
|
|
607
|
+
@page_operation
|
|
608
|
+
def _prepare_article(self, page: Any, result: DownloadResult, run_dir: Path) -> DownloadResult | None:
|
|
609
|
+
"""Reach the first PDF attempt within one shared page budget."""
|
|
610
|
+
self._event(result, "article_open", result.article_url)
|
|
611
|
+
if not self._ensure_login(page, result):
|
|
612
|
+
if self._article_access_available(page):
|
|
613
|
+
self._event(result, "login_completed_after_timeout", getattr(page, "url", ""))
|
|
614
|
+
else:
|
|
615
|
+
return self._login_failed(page, result, run_dir)
|
|
616
|
+
|
|
617
|
+
self._return_from_other_article(page, result)
|
|
618
|
+
settle(page, 2_000)
|
|
619
|
+
if not (getattr(result, "_open_access_current", False)
|
|
620
|
+
or self._pdf_first_unless_signed_out(page, result)):
|
|
621
|
+
self._event(result, "auth_wall_after_article_load", self._decision_text(page))
|
|
622
|
+
# This item has been through the school's login and the article still
|
|
623
|
+
# denies it, shows only its abstract or offers it: the school has no
|
|
624
|
+
# access, and a second login would only wait out the user's time
|
|
625
|
+
# (JAMA Oncology back from the store page, 2026-11-02).
|
|
626
|
+
evidence = self._no_access_after_login(page, result)
|
|
627
|
+
if evidence:
|
|
628
|
+
result.reason = result.state = "institution_pdf_entitlement_missing"
|
|
629
|
+
self._event(result, "no_access_after_login", evidence[:300])
|
|
630
|
+
self._snapshot(page, result, "not subscribed")
|
|
631
|
+
result.final_url = page.url
|
|
632
|
+
result.title = self._title(page)
|
|
633
|
+
self._write_diagnostic(page, result, run_dir)
|
|
634
|
+
return result
|
|
635
|
+
if not self._complete_login_from_current_page(page, result):
|
|
636
|
+
if self._article_access_available(page):
|
|
637
|
+
self._event(result, "login_completed_after_timeout", getattr(page, "url", ""))
|
|
638
|
+
else:
|
|
639
|
+
return self._login_failed(page, result, run_dir)
|
|
640
|
+
settle(page, 2_000)
|
|
641
|
+
if not self._pdf_first_unless_signed_out(page, result):
|
|
642
|
+
return self._login_failed(page, result, run_dir)
|
|
643
|
+
|
|
644
|
+
self._return_from_other_article(page, result)
|
|
645
|
+
result.final_url = page.url
|
|
646
|
+
result.title = self._title(page)
|
|
647
|
+
result.state = "article_loaded"
|
|
648
|
+
self._event(result, "article_access_ready")
|
|
649
|
+
self._hold_after_login(page, result)
|
|
650
|
+
return None
|
|
651
|
+
|
|
582
652
|
@staticmethod
|
|
583
653
|
def _browser_start_line(start: dict) -> str:
|
|
584
654
|
"""``browser_start``'s detail: "profile free; launch 3.1 s", or the holder,
|
|
@@ -658,7 +728,8 @@ class PublisherBatchDownloader(
|
|
|
658
728
|
result.pdf_path = str(pdf_path)
|
|
659
729
|
result.size_bytes = len(pdf_bytes)
|
|
660
730
|
result.text_length = len(text or "")
|
|
661
|
-
result.verified_match = self._text_matches_record(
|
|
731
|
+
result.verified_match = self._text_matches_record(
|
|
732
|
+
text, record, fallback_title=self._identity_title() or result.title)
|
|
662
733
|
self._save_publisher_session(page, result)
|
|
663
734
|
if not any(e["state"] == "pdf_saved_unconfirmed" for e in result.events):
|
|
664
735
|
from ..browser.preview import discard_frames
|
|
@@ -267,19 +267,24 @@ class AccessMixin:
|
|
|
267
267
|
return result is not None and (getattr(result, "_visited_idp", False) or getattr(result, "_login_round_trip", False)
|
|
268
268
|
or any(e.get("state") in self._SCHOOL_CHOSEN for e in result.events))
|
|
269
269
|
|
|
270
|
-
def _institution_pdf_entitlement_missing(self, page: Any) -> bool:
|
|
270
|
+
def _institution_pdf_entitlement_missing(self, page: Any, snapshot: SimpleNamespace | None = None) -> bool:
|
|
271
271
|
"""The article page denies access (access_rules.denial_evidence) to a
|
|
272
272
|
reader who is the institution: its session shows, this item has been
|
|
273
273
|
through the school's login, or the denial names the configured school."""
|
|
274
274
|
url = str(getattr(page, "url", "") or "")
|
|
275
275
|
if not self._is_success_article_url(url) or self._is_auth_page(url):
|
|
276
276
|
return False
|
|
277
|
-
if self._is_challenge_page(page):
|
|
277
|
+
if snapshot is None and self._is_challenge_page(page):
|
|
278
278
|
return False
|
|
279
|
-
snap = self._access_snapshot(page)
|
|
279
|
+
snap = snapshot or self._access_snapshot(page)
|
|
280
280
|
evidence, named = access_rules.denial_evidence(url, snap.lines, snap.probe)
|
|
281
281
|
if not evidence:
|
|
282
282
|
return False
|
|
283
|
+
# A supplied snapshot already rules out a denial without live reads.
|
|
284
|
+
# Only a possible denial needs the verification guard; re-reading a
|
|
285
|
+
# slow article here otherwise delays even confirmed public access.
|
|
286
|
+
if snapshot is not None and self._is_challenge_page(page):
|
|
287
|
+
return False
|
|
283
288
|
if self._school_login_done() or self._institution_session(page, snap) == "ours":
|
|
284
289
|
return True
|
|
285
290
|
return bool(named) and self._names_configured_school(named)
|
|
@@ -4,6 +4,8 @@ Part of ``PublisherBatchDownloader`` (batch.py), which combines the mixins."""
|
|
|
4
4
|
|
|
5
5
|
from __future__ import annotations
|
|
6
6
|
|
|
7
|
+
from ..browser import watchdog
|
|
8
|
+
|
|
7
9
|
import base64
|
|
8
10
|
import inspect
|
|
9
11
|
import json
|
|
@@ -18,6 +20,7 @@ import requests
|
|
|
18
20
|
|
|
19
21
|
from . import access_rules
|
|
20
22
|
from .batch_common import (
|
|
23
|
+
page_operation,
|
|
21
24
|
POLL_MS,
|
|
22
25
|
pump_until,
|
|
23
26
|
settle,
|
|
@@ -194,6 +197,7 @@ class CaptureMixin:
|
|
|
194
197
|
self._note_wait(result, "a PDF probe", probe_started, "done")
|
|
195
198
|
self._capture_doi, self._expected_title, self._fallback_pdf = saved
|
|
196
199
|
|
|
200
|
+
@page_operation
|
|
197
201
|
def _capture_pdf(self, page: Any, doi: str, result: DownloadResult) -> tuple[bytes | None, str, str | None]:
|
|
198
202
|
"""Run the capture channels; a PDF without DOI/title evidence is used
|
|
199
203
|
only when nothing better arrived, and is marked unconfirmed."""
|
|
@@ -203,7 +207,10 @@ class CaptureMixin:
|
|
|
203
207
|
self._fallback_pdf = None
|
|
204
208
|
tried_before = len(result.pdf_attempts)
|
|
205
209
|
try:
|
|
206
|
-
|
|
210
|
+
from ..browser.preview import pause as pause_preview
|
|
211
|
+
|
|
212
|
+
with pause_preview(getattr(page, "context", None)):
|
|
213
|
+
body, url, block = self._capture_pdf_channels(page, doi, result)
|
|
207
214
|
if body is None and not block and self._fallback_pdf is not None:
|
|
208
215
|
body, url = self._fallback_pdf
|
|
209
216
|
self._event(result, "pdf_saved_unconfirmed", f"{url} | no DOI or title on the first pages")
|
|
@@ -578,6 +585,22 @@ class CaptureMixin:
|
|
|
578
585
|
for pattern in native_pdf_patterns
|
|
579
586
|
]})
|
|
580
587
|
self._return_to_record_article_if_needed(page, result, doi)
|
|
588
|
+
# A login can return directly to a PDF before these listeners were
|
|
589
|
+
# installed. PDF.js has no article button or metadata to discover.
|
|
590
|
+
current_pdf_url = str(getattr(page, "url", "") or "")
|
|
591
|
+
if (self._is_pdf_candidate_url(current_pdf_url) and not self._is_supplementary_url(current_pdf_url)
|
|
592
|
+
and self._native_viewer_pdf_allowed(current_pdf_url, **fallback_options)):
|
|
593
|
+
if entitlement_denied():
|
|
594
|
+
return None, "", None
|
|
595
|
+
if not self._is_chromium(page):
|
|
596
|
+
begin("url", current_pdf_url, time.time())
|
|
597
|
+
body, final_url = self._capture_native_viewer_pdf(page, current_pdf_url, result, **fallback_options)
|
|
598
|
+
if body:
|
|
599
|
+
captured["bytes"], captured["url"] = body, final_url
|
|
600
|
+
return body, final_url, None
|
|
601
|
+
finish()
|
|
602
|
+
else:
|
|
603
|
+
current_pdf_url = ""
|
|
581
604
|
# The article page's own PDF links, read before the click: the click can
|
|
582
605
|
# end on a reader page that links no PDF (ASM's "PDF/EPUB", while the
|
|
583
606
|
# article page shows "Download PDF", 2026-11-02).
|
|
@@ -591,6 +614,8 @@ class CaptureMixin:
|
|
|
591
614
|
pump_until(page, lambda: bool(captured["bytes"] or captured["block_reason"]) or (
|
|
592
615
|
not self._is_chromium(page) and self._pdf_viewer_present(page)
|
|
593
616
|
and self._native_viewer_pdf_allowed(str(getattr(page, "url", "") or ""), **fallback_options)), 5_000)
|
|
617
|
+
if captured["bytes"] or captured["block_reason"]:
|
|
618
|
+
return captured["bytes"], str(captured["url"]), captured["block_reason"]
|
|
594
619
|
self._wait_past_redirect_page(page, result) # e.g. a sign-in round trip first
|
|
595
620
|
if not self._resolve_suspect_block(page, result, captured):
|
|
596
621
|
return None, "", captured["block_reason"]
|
|
@@ -621,8 +646,13 @@ class CaptureMixin:
|
|
|
621
646
|
captured["url"] = final_url
|
|
622
647
|
if block_reason and not captured["block_reason"]:
|
|
623
648
|
captured["block_reason"] = block_reason
|
|
624
|
-
|
|
625
|
-
|
|
649
|
+
# A validated PDF is already in hand. Reading the viewer DOM for
|
|
650
|
+
# more links can block (Royal Society, 2026-11-04) and turn this
|
|
651
|
+
# successful capture into a page-budget timeout.
|
|
652
|
+
if captured["bytes"] or captured["block_reason"]:
|
|
653
|
+
return captured["bytes"], str(captured["url"]), captured["block_reason"]
|
|
654
|
+
candidates = [current_pdf_url, *(list(bound_pdf_urls) if bound_pdf_urls is not None
|
|
655
|
+
else [*metadata_pdf_urls, *self._pdf_candidates(page, doi), *before_click])]
|
|
626
656
|
# In-page anchors (#pane-pcw-references, #addtoany …) are the same
|
|
627
657
|
# document; try each URL once.
|
|
628
658
|
candidates = list(dict.fromkeys(str(url).split("#", 1)[0] for url in candidates if url))
|
|
@@ -711,14 +741,21 @@ class CaptureMixin:
|
|
|
711
741
|
# Chrome's PDF having been kept out of it (a check at the PDF
|
|
712
742
|
# address, 2026-11-02).
|
|
713
743
|
break
|
|
744
|
+
response_before_wait = captured.get("nav")
|
|
714
745
|
if not self._wait_for_challenge(page, result):
|
|
715
746
|
return None, str(getattr(page, "url", "") or ""), captured["block_reason"]
|
|
716
747
|
if entitlement_denied():
|
|
717
748
|
return None, "", None
|
|
718
749
|
pump_until(page, lambda: bool(captured["bytes"]), 3_000)
|
|
719
750
|
if not captured["bytes"]:
|
|
751
|
+
# A new PDF during verification can leave the tab on its
|
|
752
|
+
# completion page. Only that new response warrants retrying
|
|
753
|
+
# the candidate through the existing validated fallback.
|
|
754
|
+
response_after_wait = captured.get("nav") or {}
|
|
755
|
+
retry = ([pdf_url] if response_after_wait is not response_before_wait
|
|
756
|
+
and str(response_after_wait.get("type", "")).lower() == "application/pdf" else [])
|
|
720
757
|
body, final_url, block_reason = self._fetch_page_state_pdf(
|
|
721
|
-
page, extra_urls=[str(captured["deferred_url"])], **fallback_options)
|
|
758
|
+
page, extra_urls=[*retry, str(captured["deferred_url"])], **fallback_options)
|
|
722
759
|
if body:
|
|
723
760
|
captured["bytes"] = body
|
|
724
761
|
captured["url"] = final_url
|
|
@@ -845,6 +882,8 @@ class CaptureMixin:
|
|
|
845
882
|
self._event(result, "pdf_button_clicked", detail)
|
|
846
883
|
return True
|
|
847
884
|
except Exception as exc:
|
|
885
|
+
if not _is_browser_error(exc):
|
|
886
|
+
raise
|
|
848
887
|
self._event(result, "pdf_button_error", f"{type(exc).__name__}: {str(exc).splitlines()[0][:160] if str(exc) else ''}")
|
|
849
888
|
return False
|
|
850
889
|
|
|
@@ -898,12 +937,14 @@ class CaptureMixin:
|
|
|
898
937
|
href = ""
|
|
899
938
|
try:
|
|
900
939
|
text = control.inner_text(timeout=1_000)
|
|
901
|
-
except Exception:
|
|
902
|
-
|
|
940
|
+
except Exception as exc:
|
|
941
|
+
if not _is_browser_error(exc):
|
|
942
|
+
raise
|
|
903
943
|
try:
|
|
904
944
|
href = control.get_attribute("href", timeout=1_000) or ""
|
|
905
|
-
except Exception:
|
|
906
|
-
|
|
945
|
+
except Exception as exc:
|
|
946
|
+
if not _is_browser_error(exc):
|
|
947
|
+
raise
|
|
907
948
|
if (self._is_supplementary_url(href)
|
|
908
949
|
or re.search(r"\b(?:supplement(?:ary|al)?|supporting information|disclosure form)\b", text, re.I)):
|
|
909
950
|
continue
|
|
@@ -913,6 +954,8 @@ class CaptureMixin:
|
|
|
913
954
|
self._event(result, "pdf_button_clicked", json.dumps(detail, ensure_ascii=False))
|
|
914
955
|
return True
|
|
915
956
|
except Exception as exc:
|
|
957
|
+
if not _is_browser_error(exc):
|
|
958
|
+
raise
|
|
916
959
|
# Kept, not dropped: three such 10 s click timeouts made Sage's silent
|
|
917
960
|
# 30 s before its PDF (a toolbar copy outside the viewport, 2026-11-02).
|
|
918
961
|
self._event(result, "pdf_button_error",
|
|
@@ -978,6 +1021,8 @@ class CaptureMixin:
|
|
|
978
1021
|
self._event(result, "pdf_button_clicked", json.dumps(clicked, ensure_ascii=False))
|
|
979
1022
|
return True
|
|
980
1023
|
except Exception as exc:
|
|
1024
|
+
if not _is_browser_error(exc):
|
|
1025
|
+
raise
|
|
981
1026
|
self._event(result, "pdf_button_error", f"{type(exc).__name__}: {exc}")
|
|
982
1027
|
return False
|
|
983
1028
|
|
|
@@ -1256,7 +1301,7 @@ class CaptureMixin:
|
|
|
1256
1301
|
# Human verification gets its own budget: the user's time must
|
|
1257
1302
|
# not use up the PDF step, and once the check clears the PDF
|
|
1258
1303
|
# gets its full time again.
|
|
1259
|
-
challenge_deadline = time.time() + max(self.
|
|
1304
|
+
challenge_deadline = time.time() + max(self.page_wait_sec, timeout_sec)
|
|
1260
1305
|
if not self._wait_for_challenge_with_deadline(page, result, challenge_deadline):
|
|
1261
1306
|
return None, str(getattr(page, "url", "") or "")
|
|
1262
1307
|
deadline = time.time() + timeout_sec
|
|
@@ -8,12 +8,16 @@ import re
|
|
|
8
8
|
import threading
|
|
9
9
|
import time
|
|
10
10
|
from dataclasses import dataclass, field
|
|
11
|
+
from functools import wraps
|
|
11
12
|
from pathlib import Path
|
|
12
13
|
from typing import Any, Callable
|
|
13
14
|
from urllib.parse import urlencode
|
|
14
15
|
|
|
15
16
|
import requests
|
|
16
17
|
|
|
18
|
+
from ..browser import watchdog
|
|
19
|
+
from ..browser.challenges import looks_like_challenge
|
|
20
|
+
|
|
17
21
|
|
|
18
22
|
EST_ISSN = "1520-5851"
|
|
19
23
|
MIN_PDF_BYTES = 5_000
|
|
@@ -23,6 +27,15 @@ PDF_URL_RE = re.compile(r"https?://[^\s\"'<>]+", re.IGNORECASE)
|
|
|
23
27
|
logger = logging.getLogger(__package__ + ".batch") # the engine's one channel
|
|
24
28
|
|
|
25
29
|
|
|
30
|
+
def page_operation(method):
|
|
31
|
+
"""Bound an engine page operation by its existing page-wait setting."""
|
|
32
|
+
@wraps(method)
|
|
33
|
+
def bounded(self, *args, **kwargs):
|
|
34
|
+
with watchdog.operation(self.page_wait_sec, method.__name__):
|
|
35
|
+
return method(self, *args, **kwargs)
|
|
36
|
+
return bounded
|
|
37
|
+
|
|
38
|
+
|
|
26
39
|
def _resolve_profile_dir(config: dict[str, Any], explicit: str | Path | None = None) -> Path:
|
|
27
40
|
"""Fixed browser profile dir; explicit/config wins, default is data-dir scoped.
|
|
28
41
|
|
|
@@ -121,6 +134,19 @@ def _is_browser_error(exc: BaseException, name: str = "") -> bool:
|
|
|
121
134
|
and (not name or type(exc).__name__ == name))
|
|
122
135
|
|
|
123
136
|
|
|
137
|
+
def page_title(page: Any) -> str:
|
|
138
|
+
"""Read optional title text without consuming the whole page budget."""
|
|
139
|
+
try:
|
|
140
|
+
# Page.title has no timeout. A busy renderer held it for 30 s
|
|
141
|
+
# after access was ready, before the first PDF attempt (2026-11-04).
|
|
142
|
+
value = page.locator("head > title").first.text_content(timeout=1_000)
|
|
143
|
+
return re.sub(r"\s+", " ", str(value or "")).strip()
|
|
144
|
+
except Exception as exc:
|
|
145
|
+
if not _is_browser_error(exc):
|
|
146
|
+
raise
|
|
147
|
+
return ""
|
|
148
|
+
|
|
149
|
+
|
|
124
150
|
# A browser call cut off by the page navigating: its document replaced, its frame gone.
|
|
125
151
|
_NAVIGATION_CUT = ("Execution context was destroyed", "Cannot find context with specified id",
|
|
126
152
|
"Frame was detached", "because of a navigation")
|
|
@@ -181,19 +207,6 @@ def pump_until(page: Any, done: Callable[[], bool], timeout_ms: int, step_ms: in
|
|
|
181
207
|
return bool(done())
|
|
182
208
|
|
|
183
209
|
|
|
184
|
-
# A Cloudflare interstitial, recognised from its URL and title alone: scripts
|
|
185
|
-
# reading the page (innerText, DOM queries) can make the check fail and come
|
|
186
|
-
# back in a loop; an untouched page passes. "Attention Required! | Cloudflare" is
|
|
187
|
-
# Cloudflare's block page, not a check.
|
|
188
|
-
CHALLENGE_TITLES = ("just a moment", "请稍候")
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
def looks_like_challenge(url: str, title: str) -> bool:
|
|
192
|
-
url = str(url or "").lower()
|
|
193
|
-
return ("__cf_chl" in url or "/cdn-cgi/challenge-platform/" in url
|
|
194
|
-
or str(title or "").strip().lower().startswith(CHALLENGE_TITLES))
|
|
195
|
-
|
|
196
|
-
|
|
197
210
|
# The page's document can be read: parsed (DOMContentLoaded), or its body text has
|
|
198
211
|
# stopped growing. pubs.acs.org showed its article and citation metadata 4.5 s
|
|
199
212
|
# after the request and held DOMContentLoaded back until 33 s on an ad script
|
|
@@ -213,7 +226,7 @@ def open_page(page: Any, url: str, *, timeout_ms: int = 60_000, ready_ms: int =
|
|
|
213
226
|
|
|
214
227
|
def readable() -> bool:
|
|
215
228
|
try:
|
|
216
|
-
if looks_like_challenge(str(getattr(page, "url", "") or ""),
|
|
229
|
+
if looks_like_challenge(str(getattr(page, "url", "") or ""), page_title(page)):
|
|
217
230
|
return True
|
|
218
231
|
state = page.evaluate(_DOCUMENT_STATE_JS)
|
|
219
232
|
except Exception as exc:
|