papersearch 0.4.2 → 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 -125
- package/carsi/README.md +8 -2
- 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 +71 -14
- package/carsi/paper_search_carsi/download_diagnostics.py +1 -1
- package/carsi/paper_search_carsi/engine/batch.py +129 -68
- package/carsi/paper_search_carsi/engine/batch_access.py +8 -3
- package/carsi/paper_search_carsi/engine/batch_capture.py +53 -8
- 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 +47 -27
- package/carsi/paper_search_carsi/engine/batch_runs.py +2 -7
- package/carsi/paper_search_carsi/engine/batch_waits.py +82 -42
- package/carsi/paper_search_carsi/engine/bridge.py +16 -8
- package/carsi/paper_search_carsi/engine/supplements.py +22 -3
- 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 +39 -18
- package/carsi/paper_search_carsi/server.py +8 -1
- 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 +56 -16
- 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/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 +7 -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 -214
|
@@ -8,6 +8,7 @@ import json
|
|
|
8
8
|
import re
|
|
9
9
|
import time
|
|
10
10
|
from typing import Any
|
|
11
|
+
from types import SimpleNamespace
|
|
11
12
|
from urllib.parse import unquote, urljoin, urlparse
|
|
12
13
|
|
|
13
14
|
from . import access_rules
|
|
@@ -65,20 +66,23 @@ class LoginMixin:
|
|
|
65
66
|
self._wait_past_redirect_page(page, result)
|
|
66
67
|
self._raise_on_publisher_content_error(page)
|
|
67
68
|
self._dismiss_cookie_banners(page, result)
|
|
68
|
-
if self.
|
|
69
|
+
snapshot = self._access_snapshot(page) if not self._is_auth_page(str(page.url)) else None
|
|
70
|
+
if self._institution_pdf_entitlement_missing(page, snapshot=snapshot):
|
|
69
71
|
return False
|
|
70
|
-
if self._pdf_first_unless_signed_out(page, result):
|
|
72
|
+
if self._pdf_first_unless_signed_out(page, result, snapshot=snapshot):
|
|
71
73
|
return True
|
|
72
74
|
return self._complete_login_from_current_page(page, result)
|
|
73
75
|
|
|
74
|
-
def _pdf_first_unless_signed_out(self, page: Any, result: DownloadResult
|
|
76
|
+
def _pdf_first_unless_signed_out(self, page: Any, result: DownloadResult,
|
|
77
|
+
snapshot: SimpleNamespace | None = None) -> bool:
|
|
75
78
|
"""True unless the page says the reader is signed out (access_rules OUT).
|
|
76
79
|
|
|
77
80
|
With the institution's session, or an open-access article, the PDF is
|
|
78
81
|
taken. With nothing either way, or with only the reader's access (also
|
|
79
82
|
a personal account's or ClinicalKey's, J Hepatol 2026-10-31), the PDF is
|
|
80
83
|
tried first and the school's login follows once if it fails (batch.py)."""
|
|
81
|
-
|
|
84
|
+
result._open_access_current = False
|
|
85
|
+
verdict, basis, evidence = self._access_decision(page, snapshot) if snapshot is not None else self._access_decision(page)
|
|
82
86
|
if verdict != access_rules.UNKNOWN:
|
|
83
87
|
detail = f"{verdict} ({basis}): {evidence}"[:240]
|
|
84
88
|
if next((e["detail"] for e in reversed(result.events) if e["state"] == "access_decision"), None) != detail:
|
|
@@ -86,6 +90,7 @@ class LoginMixin:
|
|
|
86
90
|
if verdict == access_rules.OUT:
|
|
87
91
|
return False
|
|
88
92
|
open_to_all = basis == "reader" and access_rules.OPEN_TO_ALL.search(evidence.split("badge: ", 1)[-1])
|
|
93
|
+
result._open_access_current = bool(open_to_all)
|
|
89
94
|
if (basis != "session" and not open_to_all and not getattr(result, "_pdf_first", False)
|
|
90
95
|
and not self._school_login_done()):
|
|
91
96
|
result._pdf_first = True
|
|
@@ -122,32 +127,33 @@ class LoginMixin:
|
|
|
122
127
|
return url[:200]
|
|
123
128
|
return f"{url[:200]} | {verdict} ({basis or 'no rule'}): {evidence[:160]}"
|
|
124
129
|
|
|
125
|
-
# How long a page that shows no text yet is given to draw it.
|
|
126
|
-
PAGE_TEXT_WAIT_SEC = 20
|
|
127
130
|
_PAGE_TEXT_JS = "() => document.contentType !== 'text/html' ? -1 : (document.body ? document.body.innerText.trim().length : 0)"
|
|
128
131
|
|
|
129
132
|
def _wait_for_page_text(self, page: Any, result: DownloadResult) -> None:
|
|
130
|
-
"""
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
133
|
+
"""Wait for the document before judging access, within the page budget.
|
|
134
|
+
|
|
135
|
+
A verification can disappear before the article draws. The old fixed
|
|
136
|
+
20 s cut this wait short even with automation time left (AIP,
|
|
137
|
+
2026-11-04). The enclosing page operation still bounds the whole step.
|
|
138
|
+
"""
|
|
134
139
|
def shown() -> bool:
|
|
135
140
|
try:
|
|
136
141
|
return page.evaluate(self._PAGE_TEXT_JS) != 0
|
|
137
142
|
except Exception as exc:
|
|
138
143
|
if not _is_browser_error(exc):
|
|
139
144
|
raise
|
|
140
|
-
return
|
|
145
|
+
return False # a document being replaced has not been read yet
|
|
141
146
|
started = time.time()
|
|
142
147
|
if shown():
|
|
143
148
|
return
|
|
144
|
-
|
|
149
|
+
timeout_sec = self.page_wait_sec
|
|
150
|
+
drawn = pump_until(page, shown, int(timeout_sec * 1000), step_ms=500)
|
|
145
151
|
# Recorded however short: a blank page is worth knowing about.
|
|
146
152
|
self._event(result, "wait", f"blank page: {time.time() - started:.0f} s, ended: "
|
|
147
|
-
f"{'text shown' if drawn else f'its {
|
|
153
|
+
f"{'text shown' if drawn else f'its {timeout_sec:g} s'}")
|
|
148
154
|
if not drawn:
|
|
149
155
|
url = str(getattr(page, "url", "") or "")
|
|
150
|
-
raise PublisherContentError(f"publisher_page_blank: {url[:160]} showed no text for {
|
|
156
|
+
raise PublisherContentError(f"publisher_page_blank: {url[:160]} showed no text for {timeout_sec:g} s")
|
|
151
157
|
|
|
152
158
|
def _wait_past_redirect_page(self, page: Any, result: DownloadResult, timeout_sec: float = 20) -> None:
|
|
153
159
|
"""Let a publisher's script redirect page (linkinghub "Redirecting") move on
|
|
@@ -187,9 +193,12 @@ class LoginMixin:
|
|
|
187
193
|
self._phase(result, "login")
|
|
188
194
|
started, signed_in = time.time(), False
|
|
189
195
|
try:
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
196
|
+
from ..browser import watchdog
|
|
197
|
+
# Human authentication has its own budget, not the automation's.
|
|
198
|
+
with watchdog.pause_operation():
|
|
199
|
+
signed_in = self._complete_login_steps(page, result)
|
|
200
|
+
if signed_in:
|
|
201
|
+
self._return_to_article_after_login(page, result)
|
|
193
202
|
return signed_in
|
|
194
203
|
finally:
|
|
195
204
|
last = next((e["state"] for e in reversed(result.events) if e["state"] != "wait"), "")
|
|
@@ -631,17 +640,23 @@ class LoginMixin:
|
|
|
631
640
|
host = urlparse(self._entity_id()).hostname or getattr(result, "_idp_host", "")
|
|
632
641
|
return access_rules.registrable_domain(host) if host else ""
|
|
633
642
|
|
|
634
|
-
def _school_or_federation_page(self, url: str, result: DownloadResult | None = None
|
|
643
|
+
def _school_or_federation_page(self, url: str, result: DownloadResult | None = None, *,
|
|
644
|
+
include_publisher_chooser: bool = True) -> bool:
|
|
635
645
|
"""The school's IdP or another page of its own site (its registrable domain),
|
|
636
|
-
or a federation's
|
|
637
|
-
|
|
646
|
+
or a federation's school chooser. Publisher-owned choosers are included
|
|
647
|
+
by default; their cookie prompts can be answered separately. The school's
|
|
648
|
+
own pages remain protected even if an adapter calls one a chooser."""
|
|
638
649
|
host = (urlparse(url).hostname or "").lower()
|
|
639
650
|
if not host:
|
|
640
651
|
return False
|
|
641
|
-
if self._is_idp_host(host)
|
|
652
|
+
if self._is_idp_host(host):
|
|
642
653
|
return True
|
|
643
654
|
school = self._school_domain(result)
|
|
644
|
-
|
|
655
|
+
if school and access_rules.registrable_domain(host) == school:
|
|
656
|
+
return True
|
|
657
|
+
if not include_publisher_chooser and self.adapter.is_institution_chooser(url):
|
|
658
|
+
return False
|
|
659
|
+
return self._is_federation_chooser(url)
|
|
645
660
|
|
|
646
661
|
def _stuck_on_school_site(self, page: Any, result: DownloadResult) -> str:
|
|
647
662
|
"""The page's URL when the login has stayed on a page of the school's own
|
|
@@ -798,7 +813,9 @@ class LoginMixin:
|
|
|
798
813
|
for (const el of document.querySelectorAll('button, [role="button"], a')) {
|
|
799
814
|
if (!shown(el) || el.closest('form')) continue;
|
|
800
815
|
const label = (el.innerText || el.getAttribute('aria-label') || '').replace(/\s+/g, ' ').trim();
|
|
801
|
-
|
|
816
|
+
// A screen-reader hint can be part of innerText ("Sign In Open Menu").
|
|
817
|
+
const menuLabel = label.replace(/\s+open menu$/i, '');
|
|
818
|
+
if (!MENU.test(menuLabel)) continue;
|
|
802
819
|
const href = (el.getAttribute('href') || '').trim();
|
|
803
820
|
if (el.tagName === 'A' && href && !href.startsWith('#') && !/^javascript:/i.test(href)
|
|
804
821
|
&& !TOGGLES.some(name => el.hasAttribute(name))) continue;
|
|
@@ -1374,8 +1391,9 @@ class LoginMixin:
|
|
|
1374
1391
|
def _dismiss_cookie_banners(self, page: Any, result: DownloadResult | None = None, *,
|
|
1375
1392
|
blocking: bool = False) -> bool:
|
|
1376
1393
|
"""Answer a cookie banner (``_COOKIE_BANNER_JS``) in the page or one of its
|
|
1377
|
-
frames, at most ``BANNER_ANSWERS`` times per banner and page
|
|
1378
|
-
|
|
1394
|
+
frames, at most ``BANNER_ANSWERS`` times per banner and page. The school's
|
|
1395
|
+
and external federation pages stay protected; a publisher-owned chooser
|
|
1396
|
+
may answer its own cookie prompt (``_school_or_federation_page``).
|
|
1379
1397
|
``blocking``: the banner covered a control the engine clicked. An
|
|
1380
1398
|
advertisement over the page is closed first (``_close_advertisement_overlay``).
|
|
1381
1399
|
True when either was done."""
|
|
@@ -1396,12 +1414,14 @@ class LoginMixin:
|
|
|
1396
1414
|
pass
|
|
1397
1415
|
for target in targets:
|
|
1398
1416
|
where = str(getattr(target, "url", "") or "").split("#", 1)[0]
|
|
1399
|
-
if self._school_or_federation_page(where, item):
|
|
1417
|
+
if self._school_or_federation_page(where, item, include_publisher_chooser=False):
|
|
1400
1418
|
continue
|
|
1401
1419
|
skip = [key for (url, key), count in answered.items() if url == where and count >= self.BANNER_ANSWERS]
|
|
1402
1420
|
try:
|
|
1403
1421
|
clicked = target.evaluate(self._COOKIE_BANNER_JS, {"blocking": bool(blocking), "skip": skip})
|
|
1404
|
-
except Exception:
|
|
1422
|
+
except Exception as exc:
|
|
1423
|
+
if not _is_browser_error(exc):
|
|
1424
|
+
raise
|
|
1405
1425
|
continue # a frame gone meanwhile; the banner is a convenience, never the item's end
|
|
1406
1426
|
if not isinstance(clicked, dict):
|
|
1407
1427
|
continue
|
|
@@ -610,13 +610,8 @@ class RunsMixin:
|
|
|
610
610
|
if not any(kw in url_lower for kw in ("login", "cas", "sso", "signin", "sign-in")):
|
|
611
611
|
return True
|
|
612
612
|
# Check for SSO-specific indicators
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
if any(kw in title for kw in ("登录成功", "welcome", "dashboard")):
|
|
616
|
-
return True
|
|
617
|
-
except Exception:
|
|
618
|
-
pass
|
|
619
|
-
return False
|
|
613
|
+
title = self._title(page).lower()
|
|
614
|
+
return any(kw in title for kw in ("登录成功", "welcome", "dashboard"))
|
|
620
615
|
|
|
621
616
|
def _save_publisher_session(self, page: Any, result: DownloadResult) -> None:
|
|
622
617
|
if not self.profile.base_domains:
|
|
@@ -7,12 +7,13 @@ from __future__ import annotations
|
|
|
7
7
|
import inspect
|
|
8
8
|
import re
|
|
9
9
|
import time
|
|
10
|
+
from contextlib import ExitStack
|
|
10
11
|
from typing import Any
|
|
11
12
|
from urllib.parse import urlparse
|
|
12
13
|
|
|
13
14
|
from ..entitlements import UserMarkedNoEntitlement
|
|
14
|
-
from
|
|
15
|
-
|
|
15
|
+
from ..browser import challenge_interaction, challenges, watchdog
|
|
16
|
+
from .batch_common import (AWAY_WAIT_SEC, DownloadResult, _IP_BLOCK_STATUS_CODES, _observe_preview)
|
|
16
17
|
|
|
17
18
|
|
|
18
19
|
class UserWaitMixin:
|
|
@@ -113,6 +114,10 @@ class UserWaitMixin:
|
|
|
113
114
|
def _wait_out_suspect_block(self, page: Any, result: DownloadResult, captured: dict[str, Any], suspect: str,
|
|
114
115
|
suspect_host: str, started: float) -> bool:
|
|
115
116
|
deadline = started + self.page_wait_sec
|
|
117
|
+
automatic: dict[str, Any] = {}
|
|
118
|
+
interaction = ExitStack()
|
|
119
|
+
manual = False
|
|
120
|
+
state = ""
|
|
116
121
|
reporter = None
|
|
117
122
|
attention_key = f"pdf-step:{id(self)}:{result.doi}"
|
|
118
123
|
try:
|
|
@@ -156,6 +161,8 @@ class UserWaitMixin:
|
|
|
156
161
|
if self._pdf_step_needs_login(candidate):
|
|
157
162
|
state, tab = "login", candidate
|
|
158
163
|
break
|
|
164
|
+
except (watchdog.BrowserUnresponsive, UserMarkedNoEntitlement):
|
|
165
|
+
raise
|
|
159
166
|
except Exception:
|
|
160
167
|
continue
|
|
161
168
|
if not state and time.time() - started < self.SUSPECT_UNKNOWN_GRACE_SEC:
|
|
@@ -167,8 +174,8 @@ class UserWaitMixin:
|
|
|
167
174
|
except Exception:
|
|
168
175
|
continue
|
|
169
176
|
if not state:
|
|
170
|
-
if shown:
|
|
171
|
-
self._event(result, "challenge_resolved", f"{shown} page gone")
|
|
177
|
+
if shown or automatic:
|
|
178
|
+
self._event(result, "challenge_resolved", f"{shown or 'verification'} page gone")
|
|
172
179
|
captured["suspect_block"] = None
|
|
173
180
|
return True # the capture loop retries the PDF now
|
|
174
181
|
if state == "login" and tab == page:
|
|
@@ -179,6 +186,13 @@ class UserWaitMixin:
|
|
|
179
186
|
self._event(result, "pdf_step_login_required", str(getattr(page, "url", "") or ""))
|
|
180
187
|
captured["suspect_block"] = None
|
|
181
188
|
return False
|
|
189
|
+
if state == "verification":
|
|
190
|
+
self._raise_on_publisher_access_blocked(tab)
|
|
191
|
+
if not automatic:
|
|
192
|
+
deadline = min(deadline, self._wait_deadline(result))
|
|
193
|
+
if self._automatic_challenge_step(tab, result, automatic, deadline):
|
|
194
|
+
page.wait_for_timeout(min(1000, max(0, (deadline - time.time()) * 1000)))
|
|
195
|
+
continue
|
|
182
196
|
if state != shown:
|
|
183
197
|
if state in ("verification", "login"):
|
|
184
198
|
deadline = min(deadline, self._wait_deadline(result))
|
|
@@ -198,14 +212,19 @@ class UserWaitMixin:
|
|
|
198
212
|
except Exception:
|
|
199
213
|
pass
|
|
200
214
|
shown = state
|
|
215
|
+
if not manual:
|
|
216
|
+
interaction.enter_context(watchdog.pause_operation())
|
|
217
|
+
manual = True
|
|
201
218
|
try:
|
|
202
219
|
_observe_preview(page) # what the user is asked to act on
|
|
203
|
-
page.wait_for_timeout(1000
|
|
220
|
+
page.wait_for_timeout(min(1000, max(0, (deadline - time.time()) * 1000)))
|
|
221
|
+
except (watchdog.BrowserUnresponsive, UserMarkedNoEntitlement):
|
|
222
|
+
raise
|
|
204
223
|
except Exception:
|
|
205
|
-
time.sleep(1)
|
|
206
|
-
if
|
|
224
|
+
time.sleep(min(1, max(0, deadline - time.time())))
|
|
225
|
+
if state in ("verification", "login"):
|
|
207
226
|
self._wait_ran_out(result)
|
|
208
|
-
if
|
|
227
|
+
if state == "verification":
|
|
209
228
|
# The verification page stayed up: nobody completed it. That
|
|
210
229
|
# is a timeout, not evidence that the publisher blocked us.
|
|
211
230
|
captured["block_reason"] = "challenge_or_viewer_timeout"
|
|
@@ -215,6 +234,7 @@ class UserWaitMixin:
|
|
|
215
234
|
self._event(result, "ip_blocked_response", f"{suspect} | no PDF after waiting ({shown or 'nothing shown'})")
|
|
216
235
|
return False
|
|
217
236
|
finally:
|
|
237
|
+
interaction.close()
|
|
218
238
|
if shown and reporter is not None:
|
|
219
239
|
try:
|
|
220
240
|
reporter.clear_attention(attention_key)
|
|
@@ -327,8 +347,43 @@ class UserWaitMixin:
|
|
|
327
347
|
result._user_wait_expired = True
|
|
328
348
|
self._event(result, "user_wait_expired", "short wait" if self._user_away else "")
|
|
329
349
|
|
|
350
|
+
def _automatic_challenge_step(self, page: Any, result: DownloadResult,
|
|
351
|
+
attempt: dict[str, Any], deadline: float) -> bool:
|
|
352
|
+
"""A short automatic phase inside the existing wait, shared by both entry points.
|
|
353
|
+
|
|
354
|
+
True means keep observing, never that verification passed. Each wait
|
|
355
|
+
allows at most three interactions, including failed clicks, without a
|
|
356
|
+
new budget. Observe for at least three seconds after each attempt.
|
|
357
|
+
"""
|
|
358
|
+
now = time.time()
|
|
359
|
+
if not attempt:
|
|
360
|
+
attempt.update(started=now, attempts=0, retry_at=now + 2)
|
|
361
|
+
self._event(result, "challenge_wait", "automatic verification check")
|
|
362
|
+
# Live widgets may not ask for a click until ~14 s after navigation.
|
|
363
|
+
# Reserve up to 2 s for manual handling without extending short waits.
|
|
364
|
+
manual_reserve = min(2, max(0, deadline - attempt["started"]) * 0.2)
|
|
365
|
+
automatic_end = min(deadline - manual_reserve, attempt["started"] + 20)
|
|
366
|
+
if now >= automatic_end:
|
|
367
|
+
return False
|
|
368
|
+
if now >= attempt["retry_at"] and attempt["attempts"] < 3:
|
|
369
|
+
# Camoufox's native humanized move can take 1.5 s before mouse-down.
|
|
370
|
+
# Leave time for that action, still inside the original wait budget.
|
|
371
|
+
outcome = challenge_interaction.click_checkbox(
|
|
372
|
+
page, timeout_ms=min(2500, max(1, int((automatic_end - now) * 1000))),
|
|
373
|
+
checkpoint=lambda: self._check_user_decision(result, page),
|
|
374
|
+
)
|
|
375
|
+
if outcome != "absent":
|
|
376
|
+
attempt["attempts"] += 1
|
|
377
|
+
attempt["retry_at"] = time.time() + 3
|
|
378
|
+
self._event(result, "challenge_wait",
|
|
379
|
+
f"checkbox {outcome}; attempt {attempt['attempts']}/3; awaiting verification")
|
|
380
|
+
return time.time() < automatic_end
|
|
381
|
+
|
|
330
382
|
def _wait_for_challenge(self, page: Any, result: DownloadResult, *, deadline: float | None = None) -> bool:
|
|
331
383
|
"""Wait while a verification page is up, until ``_wait_deadline``."""
|
|
384
|
+
interaction = ExitStack()
|
|
385
|
+
automatic: dict[str, Any] = {}
|
|
386
|
+
manual = False
|
|
332
387
|
wait_interval_sec = 1 # a cleared verification goes on within a second
|
|
333
388
|
reporter = None
|
|
334
389
|
attention_key = f"publisher-challenge:{id(self)}:{result.doi}"
|
|
@@ -352,6 +407,14 @@ class UserWaitMixin:
|
|
|
352
407
|
if not waited:
|
|
353
408
|
deadline = self._wait_deadline(result, deadline)
|
|
354
409
|
started, phase_before = time.time(), self._phase(result, "verification")
|
|
410
|
+
waited = True
|
|
411
|
+
result.state = "challenge_or_viewer_timeout"
|
|
412
|
+
if time.time() >= deadline:
|
|
413
|
+
break
|
|
414
|
+
auto_pending = self._automatic_challenge_step(page, result, automatic, deadline)
|
|
415
|
+
if not auto_pending and not manual:
|
|
416
|
+
interaction.enter_context(watchdog.pause_operation())
|
|
417
|
+
manual = True
|
|
355
418
|
self._event(result, "challenge_manual_wait", "complete verification in visible browser")
|
|
356
419
|
if not any(s["why"] == "waiting for the user: verification" for s in result.snapshots):
|
|
357
420
|
self._snapshot(page, result, "waiting for the user: verification")
|
|
@@ -363,10 +426,8 @@ class UserWaitMixin:
|
|
|
363
426
|
phase="人工验证",
|
|
364
427
|
)
|
|
365
428
|
attention_open = True
|
|
366
|
-
waited = True
|
|
367
|
-
result.state = "challenge_or_viewer_timeout"
|
|
368
429
|
checks += 1
|
|
369
|
-
self._event(result, "challenge_wait",
|
|
430
|
+
self._event(result, "challenge_wait", f"{'manual' if manual else 'automatic'} {checks}")
|
|
370
431
|
# Pump browser events while the user completes verification.
|
|
371
432
|
# Plain sleep can leave sync-API navigation state stale.
|
|
372
433
|
wait_ms = min(wait_interval_sec * 1000, max(0, (deadline - time.time()) * 1000))
|
|
@@ -389,6 +450,7 @@ class UserWaitMixin:
|
|
|
389
450
|
self._wait_ran_out(result)
|
|
390
451
|
return passed
|
|
391
452
|
finally:
|
|
453
|
+
interaction.close()
|
|
392
454
|
if waited:
|
|
393
455
|
self._phase(result, phase_before or "open")
|
|
394
456
|
self._note_wait(result, "verification", started, "passed" if result.state != "challenge_or_viewer_timeout"
|
|
@@ -397,45 +459,23 @@ class UserWaitMixin:
|
|
|
397
459
|
reporter.clear_attention(attention_key)
|
|
398
460
|
|
|
399
461
|
def _challenge_shell(self, page: Any) -> bool:
|
|
400
|
-
"""
|
|
401
|
-
|
|
402
|
-
Scripts reading the challenge page (innerText, DOM queries) can make
|
|
403
|
-
Cloudflare fail and re-issue the check (``__cf_chl_rt_tk``) in a loop;
|
|
404
|
-
an untouched page passes. Callers waiting on a challenge use this and
|
|
405
|
-
nothing else.
|
|
406
|
-
"""
|
|
462
|
+
"""Read only URL/title while a verification interstitial is active."""
|
|
407
463
|
try:
|
|
408
464
|
url = str(getattr(page, "url", "") or "")
|
|
409
465
|
except Exception:
|
|
410
466
|
url = ""
|
|
411
|
-
return looks_like_challenge(url, self._title(page))
|
|
467
|
+
return challenges.looks_like_challenge(url, self._title(page))
|
|
412
468
|
|
|
413
469
|
def _is_challenge_page(self, page: Any) -> bool:
|
|
470
|
+
"""Apply engine block precedence before the independent recogniser.
|
|
471
|
+
|
|
472
|
+
Read lazily: an interstitial or crawl-prevention URL needs no body read;
|
|
473
|
+
an explicit publisher block must never turn into a verification wait.
|
|
474
|
+
"""
|
|
414
475
|
if self._challenge_shell(page):
|
|
415
476
|
return True
|
|
416
477
|
if self._publisher_access_blocked(page):
|
|
417
478
|
return False
|
|
418
|
-
|
|
419
|
-
# reCAPTCHA page, /crawlprevention/governor?content=<the article> ("Validate
|
|
420
|
-
# User", 2026-11-01); it leads back to the article once the user passes it.
|
|
421
|
-
if "/crawlprevention/" in urlparse(str(getattr(page, "url", "") or "")).path.lower():
|
|
422
|
-
return True
|
|
423
|
-
haystack = f"{self._title(page)} {self._body_text(page, 1_200)}".lower()
|
|
424
|
-
direct_markers = (
|
|
425
|
-
"just a moment",
|
|
426
|
-
"attention required",
|
|
427
|
-
"verify you are human",
|
|
428
|
-
"checking your browser",
|
|
429
|
-
"are you a robot",
|
|
430
|
-
"please confirm you are a human",
|
|
431
|
-
"confirm that you are not a robot",
|
|
432
|
-
)
|
|
433
|
-
if any(marker in haystack for marker in direct_markers):
|
|
479
|
+
if challenges.is_challenge_path(str(getattr(page, "url", "") or "")):
|
|
434
480
|
return True
|
|
435
|
-
return
|
|
436
|
-
"ray id:" in haystack
|
|
437
|
-
or "security verification" in haystack
|
|
438
|
-
or "security service" in haystack
|
|
439
|
-
or "not a robot" in haystack
|
|
440
|
-
or "瀹夊叏楠岃瘉" in haystack
|
|
441
|
-
)
|
|
481
|
+
return challenges.has_challenge_text(self._title(page), self._body_text(page, 1_200))
|
|
@@ -36,6 +36,7 @@ _FAILURE_KINDS = {
|
|
|
36
36
|
"publisher_content_error": ("publisher_content_error", "review_publisher_error"),
|
|
37
37
|
"unsupported_platform": ("unsupported_platform", "review_publisher_error"),
|
|
38
38
|
"browser_unresponsive": ("browser_unresponsive", "review_browser"),
|
|
39
|
+
"browser_operation_timeout": ("browser_operation_timeout", "review_browser"),
|
|
39
40
|
"publisher_access_blocked": ("access_blocked", "review_access"),
|
|
40
41
|
"publisher_unreachable": ("publisher_unreachable", "review_publisher_error"),
|
|
41
42
|
"sso_required": ("paywall", "login_required"),
|
|
@@ -509,7 +510,8 @@ def _try_browser_batch_download(
|
|
|
509
510
|
require_interactive_browser=True,
|
|
510
511
|
)
|
|
511
512
|
|
|
512
|
-
|
|
513
|
+
from uuid import uuid4
|
|
514
|
+
run_dir = Path(config.get("diagnostics_dir") or output_path.parent / "diagnostics") / uuid4().hex[:12]
|
|
513
515
|
run_dir.mkdir(parents=True, exist_ok=True)
|
|
514
516
|
|
|
515
517
|
log.info(f" [Institutional] Browser batch download: {doi} via {profile.name}")
|
|
@@ -537,10 +539,10 @@ def _try_browser_batch_download(
|
|
|
537
539
|
if watchdog.fired and not result.ok:
|
|
538
540
|
# The browser call never returned and the driver was stopped:
|
|
539
541
|
# whatever the engine reported after that is a consequence.
|
|
540
|
-
result.reason = result.state =
|
|
542
|
+
result.reason = result.state = watchdog.failure_code()
|
|
541
543
|
if supplements_mode in ("with", "only"):
|
|
542
|
-
if result.state
|
|
543
|
-
result.supplements = [{"status": "skipped", "reason":
|
|
544
|
+
if result.state in ("browser_unresponsive", "browser_operation_timeout"):
|
|
545
|
+
result.supplements = [{"status": "skipped", "reason": result.state,
|
|
544
546
|
"url": "", "label": ""}]
|
|
545
547
|
elif result.state == "challenge_or_viewer_timeout":
|
|
546
548
|
# The verification page was never completed: the article
|
|
@@ -548,13 +550,16 @@ def _try_browser_batch_download(
|
|
|
548
550
|
result.supplements = [{"status": "skipped", "reason": "challenge_timeout",
|
|
549
551
|
"url": "", "label": ""}]
|
|
550
552
|
elif result.state in ("unsupported_platform", "publisher_access_blocked", "publisher_unreachable",
|
|
551
|
-
"login_page_closed"
|
|
553
|
+
"login_page_closed", "user_skipped", "batch_stopped",
|
|
554
|
+
"user_marked_no_entitlement"):
|
|
552
555
|
# Nothing more to fetch there now.
|
|
553
556
|
result.supplements = [{"status": "skipped", "reason": result.state,
|
|
554
557
|
"url": "", "label": ""}]
|
|
555
558
|
else:
|
|
556
559
|
written = len(result.events)
|
|
557
|
-
result.supplements = downloader.collect_supplements(context, doi, output_path.parent, result)
|
|
560
|
+
result.supplements = downloader.collect_supplements(context, doi, output_path.parent, result, manifest_dir=run_dir)
|
|
561
|
+
if supplements_mode == "only" and (run_dir / "manifest.json").is_file():
|
|
562
|
+
result.record_path = str(run_dir / "manifest.json")
|
|
558
563
|
_note_supplements(result, written)
|
|
559
564
|
finally:
|
|
560
565
|
try:
|
|
@@ -577,6 +582,8 @@ def _try_browser_batch_download(
|
|
|
577
582
|
if output_path.exists():
|
|
578
583
|
output_path.unlink()
|
|
579
584
|
src.rename(output_path)
|
|
585
|
+
result.pdf_path = str(output_path)
|
|
586
|
+
_note_supplements(result, len(getattr(result, "events", [])))
|
|
580
587
|
log.info(f" [Institutional] Browser download succeeded: {result.size_bytes:,} bytes")
|
|
581
588
|
return {
|
|
582
589
|
"success": True, "identifier": doi, "doi": doi,
|
|
@@ -618,8 +625,9 @@ def _note_supplements(result: Any, written: int) -> None:
|
|
|
618
625
|
if not path.is_file():
|
|
619
626
|
continue
|
|
620
627
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
621
|
-
data["events"] = list(data.get("events") or []) + list(result
|
|
622
|
-
data["supplements"] = result
|
|
628
|
+
data["events"] = list(data.get("events") or []) + list(getattr(result, "events", [])[written:])
|
|
629
|
+
data["supplements"] = getattr(result, "supplements", [])
|
|
630
|
+
data["pdf_path"] = str(getattr(result, "pdf_path", "") or "")
|
|
623
631
|
if getattr(result, "phases", None):
|
|
624
632
|
data["phases"] = {k: round(float(v), 1) for k, v in result.phases.items()}
|
|
625
633
|
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
@@ -21,6 +21,8 @@ from typing import Any, Callable, Iterable
|
|
|
21
21
|
from urllib.parse import unquote, urlparse
|
|
22
22
|
|
|
23
23
|
from ..adapters.base import SupplementLink
|
|
24
|
+
from ..browser.watchdog import BrowserUnresponsive, BrowserOperationTimeout
|
|
25
|
+
from ..entitlements import UserMarkedNoEntitlement, UserSkippedArticle, UserStoppedBatch
|
|
24
26
|
|
|
25
27
|
MAX_FILES = 50
|
|
26
28
|
MAX_FILE_BYTES = 200 * 1024 * 1024
|
|
@@ -87,6 +89,8 @@ def _get(request: Any, url: str, entry: dict[str, Any], sleep) -> Any:
|
|
|
87
89
|
|
|
88
90
|
def collect(request: Any, links: Iterable[SupplementLink], output_dir: Path, doi: str, *,
|
|
89
91
|
clock=time.monotonic, sleep=time.sleep,
|
|
92
|
+
manifest_dir: Path | None = None,
|
|
93
|
+
target_dir: Path | None = None,
|
|
90
94
|
download: Callable[[str], tuple[bytes, str]] | None = None,
|
|
91
95
|
progress: Callable[[int], None] | None = None) -> list[dict[str, Any]]:
|
|
92
96
|
"""Fetch ``links`` with ``request`` (a Playwright APIRequestContext). A file
|
|
@@ -101,14 +105,19 @@ def collect(request: Any, links: Iterable[SupplementLink], output_dir: Path, doi
|
|
|
101
105
|
unique.append(link)
|
|
102
106
|
if not unique:
|
|
103
107
|
return []
|
|
104
|
-
target = supplements_dir(output_dir, doi)
|
|
108
|
+
target = target_dir or supplements_dir(output_dir, doi)
|
|
105
109
|
target.mkdir(parents=True, exist_ok=True)
|
|
106
110
|
started = clock()
|
|
107
111
|
taken: set[str] = set()
|
|
108
112
|
files: list[dict[str, Any]] = []
|
|
113
|
+
stopped_reason = ""
|
|
109
114
|
for index, link in enumerate(unique):
|
|
110
115
|
entry: dict[str, Any] = {"url": link.url, "label": link.label}
|
|
111
|
-
if
|
|
116
|
+
if stopped_reason:
|
|
117
|
+
entry.update(status="skipped", reason=stopped_reason)
|
|
118
|
+
elif link.error:
|
|
119
|
+
entry.update(status="failed", reason=link.error)
|
|
120
|
+
elif index >= MAX_FILES:
|
|
112
121
|
entry.update(status="skipped", reason="file_limit")
|
|
113
122
|
elif clock() - started > TIME_BUDGET_SEC:
|
|
114
123
|
entry.update(status="skipped", reason="time_budget")
|
|
@@ -138,11 +147,21 @@ def collect(request: Any, links: Iterable[SupplementLink], output_dir: Path, doi
|
|
|
138
147
|
path.write_bytes(body)
|
|
139
148
|
entry.update(status="saved", file=str(path), size=len(body),
|
|
140
149
|
content_type=content_type.split(";")[0].strip())
|
|
150
|
+
except (BrowserUnresponsive, UserMarkedNoEntitlement) as exc:
|
|
151
|
+
stopped_reason = ("batch_stopped" if isinstance(exc, UserStoppedBatch) else
|
|
152
|
+
"user_skipped" if isinstance(exc, UserSkippedArticle) else
|
|
153
|
+
"user_marked_no_entitlement" if isinstance(exc, UserMarkedNoEntitlement) else
|
|
154
|
+
"browser_operation_timeout" if isinstance(exc, BrowserOperationTimeout) else
|
|
155
|
+
"browser_unresponsive")
|
|
156
|
+
entry.update(status="skipped" if isinstance(exc, UserMarkedNoEntitlement) else "failed",
|
|
157
|
+
reason=stopped_reason)
|
|
141
158
|
except Exception as exc:
|
|
142
159
|
entry.update(status="failed", reason=f"{type(exc).__name__}: {str(exc)[:160]}")
|
|
143
160
|
files.append(entry)
|
|
144
161
|
if progress is not None:
|
|
145
162
|
progress(sum(1 for f in files if f.get("status") == "saved"))
|
|
146
|
-
|
|
163
|
+
manifest_target = manifest_dir or target
|
|
164
|
+
manifest_target.mkdir(parents=True, exist_ok=True)
|
|
165
|
+
(manifest_target / "manifest.json").write_text(
|
|
147
166
|
json.dumps({"doi": doi, "files": files}, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
148
167
|
return files
|
|
@@ -29,9 +29,8 @@ def _file_handler() -> logging.Handler | None:
|
|
|
29
29
|
|
|
30
30
|
|
|
31
31
|
def enable_stack_dump() -> None:
|
|
32
|
-
"""
|
|
33
|
-
``<data dir>/logs/stacks-<date>.txt
|
|
34
|
-
call (Camoufox, 2026-10-31) shows where it waits."""
|
|
32
|
+
"""Prepare watchdog dumps on every OS; POSIX also supports ``kill -USR1``.
|
|
33
|
+
Stacks go to ``<data dir>/logs/stacks-<date>.txt``."""
|
|
35
34
|
global _stack_file
|
|
36
35
|
import faulthandler
|
|
37
36
|
import os
|
|
@@ -39,15 +38,22 @@ def enable_stack_dump() -> None:
|
|
|
39
38
|
from datetime import date
|
|
40
39
|
from pathlib import Path
|
|
41
40
|
|
|
42
|
-
if _stack_file is not None or
|
|
41
|
+
if _stack_file is not None or os.environ.get("CARSI_FILE_LOG") == "0":
|
|
43
42
|
return
|
|
44
43
|
try:
|
|
45
44
|
log_dir = Path(os.environ.get("CARSI_DATA_DIR", str(Path.home() / ".local" / "share" / "paper-search-cli" / "carsi"))) / "logs"
|
|
46
45
|
log_dir.mkdir(parents=True, exist_ok=True)
|
|
47
46
|
_stack_file = open(log_dir / f"stacks-{date.today().isoformat()}.txt", "a", encoding="utf-8")
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
47
|
+
except OSError:
|
|
48
|
+
get_logger().warning("Could not open the browser watchdog stack log")
|
|
49
|
+
return
|
|
50
|
+
# Windows has no SIGUSR1/register, but dump_traceback works there. A failed
|
|
51
|
+
# optional signal hook must not disable explicit watchdog dumps either.
|
|
52
|
+
if hasattr(signal, "SIGUSR1"):
|
|
53
|
+
try:
|
|
54
|
+
faulthandler.register(signal.SIGUSR1, file=_stack_file, all_threads=True)
|
|
55
|
+
except (OSError, RuntimeError, ValueError):
|
|
56
|
+
get_logger().warning("Stack log ready, but the SIGUSR1 hook is unavailable")
|
|
51
57
|
|
|
52
58
|
|
|
53
59
|
def dump_stacks(reason: str) -> bool:
|
|
@@ -32,6 +32,7 @@ def download_institutional(
|
|
|
32
32
|
strategy: str | None = None,
|
|
33
33
|
supplements: str | None = None,
|
|
34
34
|
user_wait: str | None = None,
|
|
35
|
+
diagnostics_dir: str | None = None,
|
|
35
36
|
) -> dict[str, Any]:
|
|
36
37
|
"""Download one DOI through institutional access only.
|
|
37
38
|
|
|
@@ -66,6 +67,8 @@ def download_institutional(
|
|
|
66
67
|
extra = {"supplements": supplements} if supplements in ("with", "only") else {}
|
|
67
68
|
if user_wait == "short":
|
|
68
69
|
extra["user_wait"] = user_wait
|
|
70
|
+
if diagnostics_dir:
|
|
71
|
+
extra["diagnostics_dir"] = diagnostics_dir
|
|
69
72
|
return _download_impl(doi, output_dir, **extra)
|
|
70
73
|
finally:
|
|
71
74
|
with _INFLIGHT_LOCK:
|
|
@@ -73,7 +76,8 @@ def download_institutional(
|
|
|
73
76
|
|
|
74
77
|
|
|
75
78
|
def _download_impl(doi: str, output_dir: str | Path | None, *,
|
|
76
|
-
supplements: str | None = None, user_wait: str | None = None
|
|
79
|
+
supplements: str | None = None, user_wait: str | None = None,
|
|
80
|
+
diagnostics_dir: str | None = None) -> dict[str, Any]:
|
|
77
81
|
config = load_config()
|
|
78
82
|
if user_wait == "short":
|
|
79
83
|
config["user_wait"] = "short"
|
|
@@ -82,6 +86,8 @@ def _download_impl(doi: str, output_dir: str | Path | None, *,
|
|
|
82
86
|
|
|
83
87
|
target_dir = Path(output_dir) if output_dir else Path(config["output_dir"])
|
|
84
88
|
target_dir.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
if diagnostics_dir:
|
|
90
|
+
config["diagnostics_dir"] = str(Path(diagnostics_dir).resolve())
|
|
85
91
|
output_path = target_dir / f"{safe_filename(doi)}.pdf"
|
|
86
92
|
|
|
87
93
|
cached = cache_get(doi, config)
|