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
|
@@ -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"),
|
|
@@ -45,6 +46,7 @@ _FAILURE_KINDS = {
|
|
|
45
46
|
"unexpected_error": ("source_exception", ""),
|
|
46
47
|
"user_marked_no_entitlement": ("no_entitlement", "skipped_by_user"),
|
|
47
48
|
"user_skipped": ("user_skipped", "skipped_by_user"),
|
|
49
|
+
"batch_stopped": ("batch_stopped", "stopped_by_user"),
|
|
48
50
|
"pdf_wrong_article": ("pdf_wrong_article", "review_browser"),
|
|
49
51
|
"supplements_only": ("supplements_only", "supplements_only"),
|
|
50
52
|
# The DOI resolves to no site to sign in on (publishers/site.py): no driver can run.
|
|
@@ -508,7 +510,8 @@ def _try_browser_batch_download(
|
|
|
508
510
|
require_interactive_browser=True,
|
|
509
511
|
)
|
|
510
512
|
|
|
511
|
-
|
|
513
|
+
from uuid import uuid4
|
|
514
|
+
run_dir = Path(config.get("diagnostics_dir") or output_path.parent / "diagnostics") / uuid4().hex[:12]
|
|
512
515
|
run_dir.mkdir(parents=True, exist_ok=True)
|
|
513
516
|
|
|
514
517
|
log.info(f" [Institutional] Browser batch download: {doi} via {profile.name}")
|
|
@@ -536,10 +539,10 @@ def _try_browser_batch_download(
|
|
|
536
539
|
if watchdog.fired and not result.ok:
|
|
537
540
|
# The browser call never returned and the driver was stopped:
|
|
538
541
|
# whatever the engine reported after that is a consequence.
|
|
539
|
-
result.reason = result.state =
|
|
542
|
+
result.reason = result.state = watchdog.failure_code()
|
|
540
543
|
if supplements_mode in ("with", "only"):
|
|
541
|
-
if result.state
|
|
542
|
-
result.supplements = [{"status": "skipped", "reason":
|
|
544
|
+
if result.state in ("browser_unresponsive", "browser_operation_timeout"):
|
|
545
|
+
result.supplements = [{"status": "skipped", "reason": result.state,
|
|
543
546
|
"url": "", "label": ""}]
|
|
544
547
|
elif result.state == "challenge_or_viewer_timeout":
|
|
545
548
|
# The verification page was never completed: the article
|
|
@@ -547,13 +550,16 @@ def _try_browser_batch_download(
|
|
|
547
550
|
result.supplements = [{"status": "skipped", "reason": "challenge_timeout",
|
|
548
551
|
"url": "", "label": ""}]
|
|
549
552
|
elif result.state in ("unsupported_platform", "publisher_access_blocked", "publisher_unreachable",
|
|
550
|
-
"login_page_closed"
|
|
553
|
+
"login_page_closed", "user_skipped", "batch_stopped",
|
|
554
|
+
"user_marked_no_entitlement"):
|
|
551
555
|
# Nothing more to fetch there now.
|
|
552
556
|
result.supplements = [{"status": "skipped", "reason": result.state,
|
|
553
557
|
"url": "", "label": ""}]
|
|
554
558
|
else:
|
|
555
559
|
written = len(result.events)
|
|
556
|
-
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")
|
|
557
563
|
_note_supplements(result, written)
|
|
558
564
|
finally:
|
|
559
565
|
try:
|
|
@@ -576,6 +582,8 @@ def _try_browser_batch_download(
|
|
|
576
582
|
if output_path.exists():
|
|
577
583
|
output_path.unlink()
|
|
578
584
|
src.rename(output_path)
|
|
585
|
+
result.pdf_path = str(output_path)
|
|
586
|
+
_note_supplements(result, len(getattr(result, "events", [])))
|
|
579
587
|
log.info(f" [Institutional] Browser download succeeded: {result.size_bytes:,} bytes")
|
|
580
588
|
return {
|
|
581
589
|
"success": True, "identifier": doi, "doi": doi,
|
|
@@ -617,8 +625,9 @@ def _note_supplements(result: Any, written: int) -> None:
|
|
|
617
625
|
if not path.is_file():
|
|
618
626
|
continue
|
|
619
627
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
620
|
-
data["events"] = list(data.get("events") or []) + list(result
|
|
621
|
-
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 "")
|
|
622
631
|
if getattr(result, "phases", None):
|
|
623
632
|
data["phases"] = {k: round(float(v), 1) for k, v in result.phases.items()}
|
|
624
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,26 +89,35 @@ 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,
|
|
90
|
-
|
|
92
|
+
manifest_dir: Path | None = None,
|
|
93
|
+
target_dir: Path | None = None,
|
|
94
|
+
download: Callable[[str], tuple[bytes, str]] | None = None,
|
|
95
|
+
progress: Callable[[int], None] | None = None) -> list[dict[str, Any]]:
|
|
91
96
|
"""Fetch ``links`` with ``request`` (a Playwright APIRequestContext). A file
|
|
92
97
|
refused with 403 is taken through ``download`` when given: the browser
|
|
93
98
|
opens it and downloads it itself (Taylor & Francis behind Cloudflare
|
|
94
99
|
refused every scripted request with ego, the page's own fetch included,
|
|
95
|
-
and let the browser's own navigation through, 2026-11-01).
|
|
100
|
+
and let the browser's own navigation through, 2026-11-01). ``progress`` is
|
|
101
|
+
told how many files are saved after each one (the progress window)."""
|
|
96
102
|
unique: list[SupplementLink] = []
|
|
97
103
|
for link in links:
|
|
98
104
|
if link.url and link.url not in {u.url for u in unique}:
|
|
99
105
|
unique.append(link)
|
|
100
106
|
if not unique:
|
|
101
107
|
return []
|
|
102
|
-
target = supplements_dir(output_dir, doi)
|
|
108
|
+
target = target_dir or supplements_dir(output_dir, doi)
|
|
103
109
|
target.mkdir(parents=True, exist_ok=True)
|
|
104
110
|
started = clock()
|
|
105
111
|
taken: set[str] = set()
|
|
106
112
|
files: list[dict[str, Any]] = []
|
|
113
|
+
stopped_reason = ""
|
|
107
114
|
for index, link in enumerate(unique):
|
|
108
115
|
entry: dict[str, Any] = {"url": link.url, "label": link.label}
|
|
109
|
-
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:
|
|
110
121
|
entry.update(status="skipped", reason="file_limit")
|
|
111
122
|
elif clock() - started > TIME_BUDGET_SEC:
|
|
112
123
|
entry.update(status="skipped", reason="time_budget")
|
|
@@ -136,9 +147,21 @@ def collect(request: Any, links: Iterable[SupplementLink], output_dir: Path, doi
|
|
|
136
147
|
path.write_bytes(body)
|
|
137
148
|
entry.update(status="saved", file=str(path), size=len(body),
|
|
138
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)
|
|
139
158
|
except Exception as exc:
|
|
140
159
|
entry.update(status="failed", reason=f"{type(exc).__name__}: {str(exc)[:160]}")
|
|
141
160
|
files.append(entry)
|
|
142
|
-
|
|
161
|
+
if progress is not None:
|
|
162
|
+
progress(sum(1 for f in files if f.get("status") == "saved"))
|
|
163
|
+
manifest_target = manifest_dir or target
|
|
164
|
+
manifest_target.mkdir(parents=True, exist_ok=True)
|
|
165
|
+
(manifest_target / "manifest.json").write_text(
|
|
143
166
|
json.dumps({"doi": doi, "files": files}, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
144
167
|
return files
|
|
@@ -34,6 +34,14 @@ class UserSkippedArticle(UserMarkedNoEntitlement):
|
|
|
34
34
|
"""The user pressed "skip this paper": it ends, nothing is remembered."""
|
|
35
35
|
|
|
36
36
|
|
|
37
|
+
class UserStoppedBatch(UserMarkedNoEntitlement):
|
|
38
|
+
"""The user pressed "stop the task": this paper ends and the batch stops."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# Not an entitlement decision: nothing is recorded, the batch ends.
|
|
42
|
+
STOP_BATCH = "stop_batch"
|
|
43
|
+
|
|
44
|
+
|
|
37
45
|
SKIP = "skip"
|
|
38
46
|
ALLOW = "allow"
|
|
39
47
|
_ACTIONS = {
|
|
@@ -183,7 +191,7 @@ def clear(key: str) -> bool:
|
|
|
183
191
|
|
|
184
192
|
def request_action(current: str, action: str, run_id: str = "") -> None:
|
|
185
193
|
"""Written by the progress window when the user presses a button."""
|
|
186
|
-
if action not in _ACTIONS:
|
|
194
|
+
if action not in _ACTIONS and action != STOP_BATCH:
|
|
187
195
|
raise ValueError("unknown action")
|
|
188
196
|
_write_json(control_path(), {"current": current, "action": action, "run_id": run_id, "at": time.time()})
|
|
189
197
|
|
|
@@ -204,7 +212,7 @@ def take_action(doi: str, since: float) -> str | None:
|
|
|
204
212
|
except OSError:
|
|
205
213
|
pass
|
|
206
214
|
action = data.get("action")
|
|
207
|
-
return action if action in _ACTIONS else None
|
|
215
|
+
return action if action in _ACTIONS or action == STOP_BATCH else None
|
|
208
216
|
|
|
209
217
|
|
|
210
218
|
def apply_user_decision(doi: str, publisher: str, since: float) -> str | None:
|
|
@@ -212,6 +220,8 @@ def apply_user_decision(doi: str, publisher: str, since: float) -> str | None:
|
|
|
212
220
|
action = take_action(doi, since)
|
|
213
221
|
if not action:
|
|
214
222
|
return None
|
|
223
|
+
if action == STOP_BATCH:
|
|
224
|
+
raise UserStoppedBatch(action)
|
|
215
225
|
if action == "skip_item":
|
|
216
226
|
raise UserSkippedArticle(action)
|
|
217
227
|
try:
|
|
@@ -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)
|
|
@@ -29,6 +29,7 @@ from typing import Any
|
|
|
29
29
|
import os as _os
|
|
30
30
|
import sys as _sys
|
|
31
31
|
|
|
32
|
+
from . import user_settings
|
|
32
33
|
from .progress_reporter import read_state
|
|
33
34
|
|
|
34
35
|
# --- Win32 / DWM constants -------------------------------------------------
|
|
@@ -43,6 +44,13 @@ DWMSBT_MAINWINDOW = 2 # light Mica
|
|
|
43
44
|
|
|
44
45
|
# Palettes: (light, dark). Picked once at start from the OS appearance.
|
|
45
46
|
CONTENT_LABELS = {"main": "正文", "supplements": "附录", "both": "正文+附录"}
|
|
47
|
+
# The step of the main text, as the status line says it ("正文 · 正在获取 PDF").
|
|
48
|
+
STAGE_TEXT = {
|
|
49
|
+
"starting": "准备中", "opening_article": "正在打开文章", "checking_access": "正在检查访问权限",
|
|
50
|
+
"waiting_login": "等待机构登录", "waiting_verification": "等待人机验证", "fetching_pdf": "正在获取 PDF",
|
|
51
|
+
"pdf_captured": "已读到 PDF,正在核对", "saved_pending_verification": "已保存",
|
|
52
|
+
"route_failed": "这条路没走通,正在换路",
|
|
53
|
+
}
|
|
46
54
|
|
|
47
55
|
PALETTES = {
|
|
48
56
|
"light": {
|
|
@@ -227,11 +235,14 @@ class ProgressWindow:
|
|
|
227
235
|
self._apply_backdrop()
|
|
228
236
|
self._fonts()
|
|
229
237
|
self._bind_mouse()
|
|
238
|
+
self._draw_bindings: list[tuple[str, str, str]] = []
|
|
230
239
|
self._finished_elapsed: float | None = None
|
|
231
240
|
self._finished_run: object = None
|
|
232
241
|
self._flash: tuple[str, float, str] | None = None # (text, until, doi)
|
|
233
242
|
self._chosen: tuple[str, str] | None = None # (doi, action) pressed for the current paper
|
|
234
243
|
self._hover = "" # the button under the mouse; kept across redraws
|
|
244
|
+
self._settings: tk.Toplevel | None = None # the settings dialog, when open
|
|
245
|
+
self._last_current = "" # the paper shown, for a message after the dialog closes
|
|
235
246
|
self.root.after(400, self._tick)
|
|
236
247
|
|
|
237
248
|
# --- window plumbing ---------------------------------------------------
|
|
@@ -351,6 +362,21 @@ class ProgressWindow:
|
|
|
351
362
|
return _fmt_elapsed((end - started).total_seconds())
|
|
352
363
|
return _fmt_elapsed((datetime.now() - started).total_seconds())
|
|
353
364
|
|
|
365
|
+
@staticmethod
|
|
366
|
+
def _status_text(state: dict[str, Any], running: bool) -> str:
|
|
367
|
+
"""The status line: which part of the paper is being fetched and how far,
|
|
368
|
+
"正文 · 正在获取 PDF" or "附录 · 已保存 2/5"; between papers, the phase."""
|
|
369
|
+
phase = str(state.get("phase") or ("处理中" if running else ""))
|
|
370
|
+
part = str(state.get("part") or "")
|
|
371
|
+
if not running or state.get("awaiting_next") or part not in ("main", "supplements"):
|
|
372
|
+
return phase
|
|
373
|
+
if part == "supplements":
|
|
374
|
+
total = state.get("supplements_total")
|
|
375
|
+
if not isinstance(total, int):
|
|
376
|
+
return "附录 · 正在查找附录文件"
|
|
377
|
+
return f"附录 · 已保存 {int(state.get('supplements_saved') or 0)}/{total}"
|
|
378
|
+
return "正文 · " + STAGE_TEXT.get(str(state.get("stage") or ""), phase)
|
|
379
|
+
|
|
354
380
|
@staticmethod
|
|
355
381
|
def _attention_items(state: dict[str, Any]) -> list[dict[str, Any]]:
|
|
356
382
|
attention = state.get("attention")
|
|
@@ -362,15 +388,27 @@ class ProgressWindow:
|
|
|
362
388
|
return [v for v in attention if isinstance(v, dict)]
|
|
363
389
|
return []
|
|
364
390
|
|
|
391
|
+
def _bind_draw(self, tag: str, event: str, callback: Any) -> None:
|
|
392
|
+
"""A binding owned by this frame, released before its items are deleted."""
|
|
393
|
+
command = self.canvas.tag_bind(tag, event, callback)
|
|
394
|
+
self._draw_bindings.append((tag, event, command))
|
|
395
|
+
|
|
365
396
|
def _draw(self, state: dict[str, Any] | None) -> None:
|
|
366
397
|
c, col, s = self.canvas, self.c, self.s
|
|
398
|
+
# Canvas.delete removes items, not tag_bind's registered Tcl/Python
|
|
399
|
+
# commands. Keep neither old state nor callbacks across the 500 ms redraws.
|
|
400
|
+
for tag, event, command in self._draw_bindings:
|
|
401
|
+
c.tag_unbind(tag, event, command)
|
|
402
|
+
self._draw_bindings.clear()
|
|
367
403
|
c.delete("all")
|
|
368
404
|
P = self.P
|
|
369
405
|
Wl = max(self.W, c.winfo_width())
|
|
370
406
|
Hl = max(self.H, c.winfo_height())
|
|
371
407
|
u = lambda n: int(round(n * s))
|
|
372
408
|
|
|
409
|
+
gear_left = self._draw_gear(Wl - P, P + u(9))
|
|
373
410
|
if not state:
|
|
411
|
+
self._last_current = ""
|
|
374
412
|
c.create_text(Wl // 2, Hl // 2 - u(8), text="PaperSearch", fill=col["text"], font=self.f_title)
|
|
375
413
|
c.create_text(Wl // 2, Hl // 2 + u(14), text="暂无下载任务", fill=col["dim"], font=self.f_small)
|
|
376
414
|
return
|
|
@@ -401,8 +439,8 @@ class ProgressWindow:
|
|
|
401
439
|
dot = u(8)
|
|
402
440
|
c.create_oval(P, y1 - dot // 2, P + dot, y1 + dot // 2, fill=tone, outline=tone)
|
|
403
441
|
count = f"{done} / {total}" if total else f"{done}"
|
|
404
|
-
c.create_text(
|
|
405
|
-
count_w = self.f_count.measure(count)
|
|
442
|
+
c.create_text(gear_left - u(8), y1, text=count, fill=col["text"], font=self.f_count, anchor="e")
|
|
443
|
+
count_w = self.f_count.measure(count) + (Wl - P - gear_left) + u(8)
|
|
406
444
|
# What this download fetches, as a small label left of the count.
|
|
407
445
|
content = CONTENT_LABELS.get(str(state.get("content") or "main"), CONTENT_LABELS["main"])
|
|
408
446
|
chip_h = u(18)
|
|
@@ -440,13 +478,14 @@ class ProgressWindow:
|
|
|
440
478
|
self._pill(P - u(2), y2 - u(11), P + self.f_body.measure(text) + u(14), y2 + u(11), col["warn_bg"])
|
|
441
479
|
c.create_text(P + u(6), y2, text=text, fill=col["warn"], font=self.f_body, anchor="w")
|
|
442
480
|
else:
|
|
443
|
-
phase =
|
|
481
|
+
phase = self._status_text(state, running)
|
|
444
482
|
c.create_text(P, y2, anchor="w", fill=col["dim"], font=self.f_body,
|
|
445
483
|
text=_elide(self.f_body, phase, phase_room))
|
|
446
484
|
|
|
447
485
|
# Row 3: the paper being worked on
|
|
448
486
|
y3 = y2 + u(22)
|
|
449
487
|
current = str((attention[-1].get("current") if attention else "") or state.get("current") or "")
|
|
488
|
+
self._last_current = current
|
|
450
489
|
if current:
|
|
451
490
|
c.create_text(P, y3, anchor="w", fill=col["text"] if running else col["dim"], font=self.f_mono,
|
|
452
491
|
text=_elide(self.f_mono, current, Wl - 2 * P))
|
|
@@ -484,7 +523,7 @@ class ProgressWindow:
|
|
|
484
523
|
x = P
|
|
485
524
|
chosen = self._chosen[1] if self._chosen and self._chosen[0] == current else ""
|
|
486
525
|
for key, label in (("skip_item", "跳过本篇"), ("skip_journal", "本刊无权限"),
|
|
487
|
-
("skip_publisher", "出版社无权限")):
|
|
526
|
+
("skip_publisher", "出版社无权限"), ("stop_batch", "终止任务")):
|
|
488
527
|
pressed = key == chosen
|
|
489
528
|
text = ("✓ " + label) if pressed else label
|
|
490
529
|
w = self.f_small.measure(text) + u(18)
|
|
@@ -492,23 +531,24 @@ class ProgressWindow:
|
|
|
492
531
|
self._pill(x, y6 - u(11), x + w, y6 + u(11), col["warn"] if pressed else col["track"], tags=(tag,))
|
|
493
532
|
fill = col["bg"] if pressed else (col["text"] if self._hover == key else col["btn"])
|
|
494
533
|
label_id = c.create_text(x + w / 2, y6, text=text, fill=fill, font=self.f_small, tags=(tag,))
|
|
495
|
-
|
|
534
|
+
self._bind_draw(tag, "<Button-1>", lambda e, k=key, d=current, s=state: (
|
|
535
|
+
self._stop(d, s) if k == "stop_batch" else self._decide(k, d, s)))
|
|
496
536
|
if not pressed:
|
|
497
|
-
|
|
498
|
-
|
|
537
|
+
self._bind_draw(tag, "<Enter>", lambda e, k=key, i=label_id: self._hover_button(k, i))
|
|
538
|
+
self._bind_draw(tag, "<Leave>", lambda e, i=label_id: self._hover_button("", i))
|
|
499
539
|
x += w + u(8)
|
|
500
540
|
# Problem report for the agent to review: a small flag icon, right-aligned.
|
|
501
541
|
c.create_text(Wl - P, y6, text="\u2691", fill=col["faint"], font=self.f_body,
|
|
502
542
|
anchor="e", tags=("report",))
|
|
503
|
-
|
|
543
|
+
self._bind_draw("report", "<Button-1>", lambda e, d=current, s=state: self._report_menu(e, d, s))
|
|
504
544
|
|
|
505
545
|
output_dir = str(state.get("output_dir") or "")
|
|
506
546
|
if output_dir:
|
|
507
547
|
link = c.create_text(Wl - P, y5, text="打开文件夹", fill=col["link"], font=self.f_small,
|
|
508
548
|
anchor="e", tags=("outdir",))
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
549
|
+
self._bind_draw("outdir", "<Button-1>", lambda e: self._open_output_dir(state))
|
|
550
|
+
self._bind_draw("outdir", "<Enter>", lambda e: c.itemconfigure(link, font=self._underlined()))
|
|
551
|
+
self._bind_draw("outdir", "<Leave>", lambda e: c.itemconfigure(link, font=self.f_small))
|
|
512
552
|
|
|
513
553
|
def _decide(self, action: str, doi: str, state: dict[str, Any]) -> None:
|
|
514
554
|
"""Ask the running download to apply the user's entitlement decision."""
|
|
@@ -516,7 +556,8 @@ class ProgressWindow:
|
|
|
516
556
|
from .entitlements import request_action
|
|
517
557
|
|
|
518
558
|
request_action(doi, action, str(state.get("run_id") or ""))
|
|
519
|
-
text = {"
|
|
559
|
+
text = {"stop_batch": "已终止任务:这一篇结束,剩下的不再下载",
|
|
560
|
+
"skip_item": "已跳过本篇,进入下一篇",
|
|
520
561
|
"skip_journal": "已记住:本刊无权限,以后自动跳过(可用 entitlements 撤销)",
|
|
521
562
|
"skip_publisher": "已记住:该出版社无权限,以后自动跳过"}[action]
|
|
522
563
|
except Exception as exc:
|
|
@@ -525,6 +566,109 @@ class ProgressWindow:
|
|
|
525
566
|
self._chosen = (doi, action)
|
|
526
567
|
self._draw(state)
|
|
527
568
|
|
|
569
|
+
def _stop(self, doi: str, state: dict[str, Any]) -> None:
|
|
570
|
+
"""Stop the whole task, after asking: this paper ends, the rest are not downloaded."""
|
|
571
|
+
from tkinter import messagebox
|
|
572
|
+
|
|
573
|
+
left = max(0, int(state.get("total", 0) or 0) - int(state.get("done", 0) or 0) - 1)
|
|
574
|
+
question = "终止整个下载任务?这一篇立即结束" + (f",剩下的 {left} 篇不再下载。" if left else "。")
|
|
575
|
+
if messagebox.askyesno("终止任务", question, parent=self.root):
|
|
576
|
+
self._decide("stop_batch", doi, state)
|
|
577
|
+
|
|
578
|
+
def _draw_gear(self, right: int, y: int) -> int:
|
|
579
|
+
"""Round settings button with a small, font-independent gear."""
|
|
580
|
+
import math
|
|
581
|
+
|
|
582
|
+
c, col, s = self.canvas, self.c, self.s
|
|
583
|
+
d = 17.6 * s
|
|
584
|
+
x0, cx = right - d, right - d / 2
|
|
585
|
+
c.create_oval(x0, y - d / 2, right, y + d / 2,
|
|
586
|
+
fill=col["track"], outline=col["track"], tags=("settings",))
|
|
587
|
+
# 11.2 px = 70% of the previous 16 px icon. Angular teeth keep the
|
|
588
|
+
# original gear appearance without platform-specific font rendering.
|
|
589
|
+
points = []
|
|
590
|
+
for tooth in range(8):
|
|
591
|
+
for offset, radius in ((-0.5, 4.1), (-0.28, 5.6), (0.28, 5.6), (0.5, 4.1)):
|
|
592
|
+
angle = (tooth + offset) * math.pi / 4
|
|
593
|
+
points.extend((cx + radius * s * math.cos(angle), y + radius * s * math.sin(angle)))
|
|
594
|
+
gear = c.create_polygon(*points, fill=col["btn"], outline="", tags=("settings",))
|
|
595
|
+
r = 2.1 * s
|
|
596
|
+
c.create_oval(cx - r, y - r, cx + r, y + r, fill=col["track"], outline="", tags=("settings",))
|
|
597
|
+
self._bind_draw("settings", "<Button-1>", lambda e: self._open_settings())
|
|
598
|
+
self._bind_draw("settings", "<Enter>", lambda e: c.itemconfigure(gear, fill=col["text"]))
|
|
599
|
+
self._bind_draw("settings", "<Leave>", lambda e: c.itemconfigure(gear, fill=col["btn"]))
|
|
600
|
+
return x0
|
|
601
|
+
|
|
602
|
+
def _open_settings(self) -> None:
|
|
603
|
+
"""The settings dialog: each value typed in and checked; saved values
|
|
604
|
+
apply from the next paper."""
|
|
605
|
+
if self._settings is not None and self._settings.winfo_exists():
|
|
606
|
+
self._settings.lift()
|
|
607
|
+
return
|
|
608
|
+
col, pad = self.c, int(round(14 * self.s))
|
|
609
|
+
top = self._settings = tk.Toplevel(self.root, bg=col["bg"])
|
|
610
|
+
top.title("设置")
|
|
611
|
+
top.resizable(False, False)
|
|
612
|
+
top.attributes("-topmost", True)
|
|
613
|
+
top.transient(self.root)
|
|
614
|
+
frame = tk.Frame(top, bg=col["bg"], padx=pad, pady=pad)
|
|
615
|
+
frame.pack(fill="both", expand=True)
|
|
616
|
+
tk.Label(frame, text="设置", bg=col["bg"], fg=col["text"], font=self.f_title).grid(
|
|
617
|
+
row=0, column=0, columnspan=3, sticky="w", pady=(0, pad // 2))
|
|
618
|
+
values = user_settings.current()
|
|
619
|
+
entries: dict[str, tk.Entry] = {}
|
|
620
|
+
errors: dict[str, tk.Label] = {}
|
|
621
|
+
for i, field in enumerate(user_settings.FIELDS):
|
|
622
|
+
row = 1 + 2 * i
|
|
623
|
+
tk.Label(frame, text=field.label, bg=col["bg"], fg=col["text"], font=self.f_body).grid(
|
|
624
|
+
row=row, column=0, sticky="w", padx=(0, pad))
|
|
625
|
+
entry = tk.Entry(frame, width=6, justify="right", font=self.f_body)
|
|
626
|
+
entry.insert(0, str(values[field.name]))
|
|
627
|
+
entry.grid(row=row, column=1, pady=3)
|
|
628
|
+
tk.Label(frame, text=field.unit, bg=col["bg"], fg=col["dim"], font=self.f_body).grid(
|
|
629
|
+
row=row, column=2, sticky="w", padx=(6, 0))
|
|
630
|
+
error = tk.Label(frame, text="", bg=col["bg"], fg=col["bad"], font=self.f_small)
|
|
631
|
+
error.grid(row=row + 1, column=0, columnspan=3, sticky="w")
|
|
632
|
+
error.grid_remove()
|
|
633
|
+
entries[field.name], errors[field.name] = entry, error
|
|
634
|
+
|
|
635
|
+
def reset() -> None:
|
|
636
|
+
for field in user_settings.FIELDS:
|
|
637
|
+
entries[field.name].delete(0, "end")
|
|
638
|
+
entries[field.name].insert(0, str(field.default))
|
|
639
|
+
errors[field.name].grid_remove()
|
|
640
|
+
|
|
641
|
+
def save() -> None:
|
|
642
|
+
chosen: dict[str, int] = {}
|
|
643
|
+
for field in user_settings.FIELDS:
|
|
644
|
+
value, problem = user_settings.check(field, entries[field.name].get())
|
|
645
|
+
if problem:
|
|
646
|
+
errors[field.name].configure(text=problem)
|
|
647
|
+
errors[field.name].grid()
|
|
648
|
+
else:
|
|
649
|
+
errors[field.name].grid_remove()
|
|
650
|
+
chosen[field.name] = int(value)
|
|
651
|
+
if len(chosen) < len(user_settings.FIELDS):
|
|
652
|
+
return
|
|
653
|
+
try:
|
|
654
|
+
user_settings.save(chosen)
|
|
655
|
+
text = "设置已保存,从下一篇起生效"
|
|
656
|
+
except Exception as exc:
|
|
657
|
+
text = f"保存失败:{type(exc).__name__}"
|
|
658
|
+
top.destroy()
|
|
659
|
+
self._flash = (text, time.time() + 6, self._last_current)
|
|
660
|
+
|
|
661
|
+
buttons = tk.Frame(frame, bg=col["bg"])
|
|
662
|
+
buttons.grid(row=1 + 2 * len(user_settings.FIELDS), column=0, columnspan=3, sticky="e", pady=(pad // 2, 0))
|
|
663
|
+
for text, command in (("恢复默认", reset), ("取消", top.destroy), ("保存", save)):
|
|
664
|
+
tk.Button(buttons, text=text, command=command, font=self.f_small).pack(side="left", padx=(6, 0))
|
|
665
|
+
top.bind("<Return>", lambda e: save())
|
|
666
|
+
top.bind("<Escape>", lambda e: top.destroy())
|
|
667
|
+
top.update_idletasks()
|
|
668
|
+
x = self.root.winfo_rootx()
|
|
669
|
+
y = max(0, self.root.winfo_rooty() - top.winfo_height() - 8)
|
|
670
|
+
top.geometry(f"+{x}+{y}")
|
|
671
|
+
|
|
528
672
|
def _hover_button(self, key: str, label_id: int) -> None:
|
|
529
673
|
self._hover = key
|
|
530
674
|
self.canvas.itemconfigure(label_id, fill=self.c["text"] if key else self.c["btn"])
|
|
@@ -64,6 +64,7 @@ def carsi_download(
|
|
|
64
64
|
batch_failed: int | None = None,
|
|
65
65
|
supplements: str | None = None,
|
|
66
66
|
user_wait: str | None = None,
|
|
67
|
+
diagnostics_dir: str | None = None,
|
|
67
68
|
) -> str:
|
|
68
69
|
"""Synchronous download entry; ``download`` is module-level so tests can stub it.
|
|
69
70
|
|
|
@@ -79,6 +80,8 @@ def carsi_download(
|
|
|
79
80
|
kwargs = {"supplements": supplements} if supplements in ("with", "only") else {}
|
|
80
81
|
if user_wait == "short":
|
|
81
82
|
kwargs["user_wait"] = user_wait
|
|
83
|
+
if diagnostics_dir:
|
|
84
|
+
kwargs["diagnostics_dir"] = diagnostics_dir
|
|
82
85
|
result = download(identifier, output_dir, strategy=strategy, **kwargs)
|
|
83
86
|
outcome["result"] = result
|
|
84
87
|
if not result.get("success"):
|
|
@@ -113,6 +116,7 @@ async def _carsi_download_with_progress(
|
|
|
113
116
|
batch_failed: int | None = None,
|
|
114
117
|
supplements: Literal["with", "only"] | None = None,
|
|
115
118
|
user_wait: Literal["short"] | None = None,
|
|
119
|
+
diagnostics_dir: str | None = None,
|
|
116
120
|
ctx: Context = None,
|
|
117
121
|
) -> str:
|
|
118
122
|
"""Download one paper by DOI through institutional access only.
|
|
@@ -129,6 +133,9 @@ async def _carsi_download_with_progress(
|
|
|
129
133
|
(``<output_dir>/<DOI>_supplements/``, listed in the result's ``supplements``); "only"
|
|
130
134
|
downloads just those, for an article whose main text is already at hand.
|
|
131
135
|
|
|
136
|
+
diagnostics_dir: optional separate directory for browser records, screenshots and
|
|
137
|
+
attachment manifests. When set, attachments go in output_dir/supplements/.
|
|
138
|
+
|
|
132
139
|
user_wait: "short" when the user seems away (an earlier article of the batch waited on them
|
|
133
140
|
until its time was up): each wait on the user (school login, human verification) lasts at
|
|
134
141
|
most 60 s, unless someone starts typing on the login page.
|
|
@@ -140,7 +147,7 @@ async def _carsi_download_with_progress(
|
|
|
140
147
|
use_vpnsci, bibtex, strategy, batch_id=batch_id, batch_index=batch_index,
|
|
141
148
|
batch_total=batch_total, batch_done=batch_done,
|
|
142
149
|
batch_succeeded=batch_succeeded, batch_failed=batch_failed,
|
|
143
|
-
supplements=supplements, user_wait=user_wait)
|
|
150
|
+
supplements=supplements, user_wait=user_wait, diagnostics_dir=diagnostics_dir)
|
|
144
151
|
)
|
|
145
152
|
|
|
146
153
|
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""The settings the progress window lets the user type in: how long a download
|
|
2
|
+
waits on the school's login page and elsewhere, and how many papers each
|
|
3
|
+
publisher may open per hour. They apply from the next paper."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from . import config as _config
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class Field:
|
|
14
|
+
name: str
|
|
15
|
+
label: str
|
|
16
|
+
key: str # the backend's config key
|
|
17
|
+
default: int
|
|
18
|
+
low: int
|
|
19
|
+
high: int
|
|
20
|
+
unit: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
FIELDS = (
|
|
24
|
+
Field("login", "学校登录页最多等", "browser_login_timeout", 180, 30, 1800, "秒"),
|
|
25
|
+
Field("page", "其他页面最多等", "browser_page_wait_timeout", 60, 10, 600, "秒"),
|
|
26
|
+
Field("hourly", "每家出版社每小时最多", "publisher_hourly_limit", 100, 0, 1000, "篇"),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def current() -> dict[str, int]:
|
|
31
|
+
"""The values now set, by field name."""
|
|
32
|
+
config = _config.load_config()
|
|
33
|
+
return {field.name: int(config.get(field.key, field.default)) for field in FIELDS}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def check(field: Field, text: str) -> tuple[int | None, str]:
|
|
37
|
+
"""A typed value, or why it cannot be saved."""
|
|
38
|
+
try:
|
|
39
|
+
value = int(str(text).strip())
|
|
40
|
+
except ValueError:
|
|
41
|
+
value = None
|
|
42
|
+
if value is None or not field.low <= value <= field.high:
|
|
43
|
+
return None, f"请输入 {field.low} 到 {field.high} 之间的整数"
|
|
44
|
+
return value, ""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def save(values: dict[str, int]) -> None:
|
|
48
|
+
"""Save values by field name; each is checked again first."""
|
|
49
|
+
for field in FIELDS:
|
|
50
|
+
if field.name not in values:
|
|
51
|
+
continue
|
|
52
|
+
value, problem = check(field, str(values[field.name]))
|
|
53
|
+
if problem:
|
|
54
|
+
raise ValueError(f"{field.key}: {problem}")
|
|
55
|
+
_config.update_config(field.key, str(value))
|
package/carsi/pyproject.toml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "paper-search-carsi"
|
|
3
3
|
version = "0.1.0"
|
|
4
|
-
description = "Institutional (publisher-authorized) PDF download backend for PaperSearch
|
|
4
|
+
description = "Institutional (publisher-authorized) PDF and supplementary-file download backend for PaperSearch"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
7
7
|
license = { file = "LICENSE" }
|