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.
Files changed (157) hide show
  1. package/README.en.md +292 -0
  2. package/README.md +203 -122
  3. package/carsi/README.md +9 -3
  4. package/carsi/paper_search_carsi/__init__.py +1 -4
  5. package/carsi/paper_search_carsi/adapters/__init__.py +1 -1
  6. package/carsi/paper_search_carsi/adapters/base.py +2 -0
  7. package/carsi/paper_search_carsi/adapters/frontiers/__init__.py +45 -0
  8. package/carsi/paper_search_carsi/adapters/mdpi/__init__.py +33 -1
  9. package/carsi/paper_search_carsi/adapters/platforms/figshare/__init__.py +185 -0
  10. package/carsi/paper_search_carsi/adapters/thieme/__init__.py +6 -5
  11. package/carsi/paper_search_carsi/browser/__init__.py +8 -1
  12. package/carsi/paper_search_carsi/browser/backend.py +23 -147
  13. package/carsi/paper_search_carsi/browser/challenge_interaction.py +137 -0
  14. package/carsi/paper_search_carsi/browser/challenges.py +57 -0
  15. package/carsi/paper_search_carsi/browser/engine.py +2 -2
  16. package/carsi/paper_search_carsi/browser/preview.py +44 -26
  17. package/carsi/paper_search_carsi/browser/profiles.py +161 -0
  18. package/carsi/paper_search_carsi/browser/watchdog.py +74 -17
  19. package/carsi/paper_search_carsi/config.py +4 -1
  20. package/carsi/paper_search_carsi/download_diagnostics.py +1 -1
  21. package/carsi/paper_search_carsi/engine/batch.py +140 -69
  22. package/carsi/paper_search_carsi/engine/batch_access.py +8 -3
  23. package/carsi/paper_search_carsi/engine/batch_capture.py +54 -9
  24. package/carsi/paper_search_carsi/engine/batch_common.py +27 -14
  25. package/carsi/paper_search_carsi/engine/batch_diagnostics.py +20 -15
  26. package/carsi/paper_search_carsi/engine/batch_login.py +85 -30
  27. package/carsi/paper_search_carsi/engine/batch_runs.py +2 -7
  28. package/carsi/paper_search_carsi/engine/batch_waits.py +97 -47
  29. package/carsi/paper_search_carsi/engine/bridge.py +17 -8
  30. package/carsi/paper_search_carsi/engine/supplements.py +28 -5
  31. package/carsi/paper_search_carsi/entitlements.py +12 -2
  32. package/carsi/paper_search_carsi/log.py +13 -7
  33. package/carsi/paper_search_carsi/orchestrator.py +7 -1
  34. package/carsi/paper_search_carsi/progress_bar.py +156 -12
  35. package/carsi/paper_search_carsi/server.py +8 -1
  36. package/carsi/paper_search_carsi/user_settings.py +55 -0
  37. package/carsi/pyproject.toml +1 -1
  38. package/dist/capabilities/citation-expansion/schemas.d.ts +2 -2
  39. package/dist/capabilities/metadata-search/MultiSourceSearchService.d.ts.map +1 -1
  40. package/dist/capabilities/metadata-search/MultiSourceSearchService.js +17 -4
  41. package/dist/capabilities/metadata-search/MultiSourceSearchService.js.map +1 -1
  42. package/dist/capabilities/metadata-search/handler.d.ts.map +1 -1
  43. package/dist/capabilities/metadata-search/handler.js +6 -0
  44. package/dist/capabilities/metadata-search/handler.js.map +1 -1
  45. package/dist/capabilities/metadata-search/schemas.d.ts +3 -3
  46. package/dist/capabilities/metadata-search/schemas.d.ts.map +1 -1
  47. package/dist/capabilities/metadata-search/schemas.js +1 -0
  48. package/dist/capabilities/metadata-search/schemas.js.map +1 -1
  49. package/dist/capabilities/metadata-search/tools.js +1 -1
  50. package/dist/capabilities/metadata-search/tools.js.map +1 -1
  51. package/dist/capabilities/pdf-discovery/DownloadTier.d.ts +11 -0
  52. package/dist/capabilities/pdf-discovery/DownloadTier.d.ts.map +1 -1
  53. package/dist/capabilities/pdf-discovery/OpenAccessFallbackService.d.ts.map +1 -1
  54. package/dist/capabilities/pdf-discovery/OpenAccessFallbackService.js +31 -1
  55. package/dist/capabilities/pdf-discovery/OpenAccessFallbackService.js.map +1 -1
  56. package/dist/capabilities/pdf-discovery/arxiv.d.ts +4 -0
  57. package/dist/capabilities/pdf-discovery/arxiv.d.ts.map +1 -0
  58. package/dist/capabilities/pdf-discovery/arxiv.js +42 -0
  59. package/dist/capabilities/pdf-discovery/arxiv.js.map +1 -0
  60. package/dist/capabilities/pdf-discovery/batch.d.ts +1 -1
  61. package/dist/capabilities/pdf-discovery/batch.d.ts.map +1 -1
  62. package/dist/capabilities/pdf-discovery/batch.js +87 -15
  63. package/dist/capabilities/pdf-discovery/batch.js.map +1 -1
  64. package/dist/capabilities/pdf-discovery/carsi/CarsiAttempt.d.ts.map +1 -1
  65. package/dist/capabilities/pdf-discovery/carsi/CarsiAttempt.js +2 -0
  66. package/dist/capabilities/pdf-discovery/carsi/CarsiAttempt.js.map +1 -1
  67. package/dist/capabilities/pdf-discovery/carsi/CarsiMcpClient.d.ts.map +1 -1
  68. package/dist/capabilities/pdf-discovery/carsi/CarsiMcpClient.js +3 -3
  69. package/dist/capabilities/pdf-discovery/carsi/CarsiMcpClient.js.map +1 -1
  70. package/dist/capabilities/pdf-discovery/carsi/CarsiReadiness.d.ts +2 -1
  71. package/dist/capabilities/pdf-discovery/carsi/CarsiReadiness.d.ts.map +1 -1
  72. package/dist/capabilities/pdf-discovery/carsi/CarsiReadiness.js +3 -1
  73. package/dist/capabilities/pdf-discovery/carsi/CarsiReadiness.js.map +1 -1
  74. package/dist/capabilities/pdf-discovery/carsi/CarsiResultNormalizer.d.ts +1 -1
  75. package/dist/capabilities/pdf-discovery/carsi/CarsiResultNormalizer.d.ts.map +1 -1
  76. package/dist/capabilities/pdf-discovery/carsi/CarsiResultNormalizer.js +4 -4
  77. package/dist/capabilities/pdf-discovery/carsi/CarsiResultNormalizer.js.map +1 -1
  78. package/dist/capabilities/pdf-discovery/carsi/types.d.ts +2 -0
  79. package/dist/capabilities/pdf-discovery/carsi/types.d.ts.map +1 -1
  80. package/dist/capabilities/pdf-discovery/handler.d.ts.map +1 -1
  81. package/dist/capabilities/pdf-discovery/handler.js +3 -2
  82. package/dist/capabilities/pdf-discovery/handler.js.map +1 -1
  83. package/dist/capabilities/pdf-discovery/oaHosts.d.ts +1 -1
  84. package/dist/capabilities/pdf-discovery/oaHosts.d.ts.map +1 -1
  85. package/dist/capabilities/pdf-discovery/oaHosts.js +4 -4
  86. package/dist/capabilities/pdf-discovery/outputLayout.d.ts +29 -0
  87. package/dist/capabilities/pdf-discovery/outputLayout.d.ts.map +1 -0
  88. package/dist/capabilities/pdf-discovery/outputLayout.js +144 -0
  89. package/dist/capabilities/pdf-discovery/outputLayout.js.map +1 -0
  90. package/dist/capabilities/pdf-discovery/tiers/directPdfUrl.d.ts.map +1 -1
  91. package/dist/capabilities/pdf-discovery/tiers/directPdfUrl.js +6 -0
  92. package/dist/capabilities/pdf-discovery/tiers/directPdfUrl.js.map +1 -1
  93. package/dist/capabilities/pdf-discovery/tiers/entitledAccess.d.ts +2 -1
  94. package/dist/capabilities/pdf-discovery/tiers/entitledAccess.d.ts.map +1 -1
  95. package/dist/capabilities/pdf-discovery/tiers/entitledAccess.js +8 -1
  96. package/dist/capabilities/pdf-discovery/tiers/entitledAccess.js.map +1 -1
  97. package/dist/capabilities/pdf-discovery/tiers/preprint.d.ts +5 -0
  98. package/dist/capabilities/pdf-discovery/tiers/preprint.d.ts.map +1 -0
  99. package/dist/capabilities/pdf-discovery/tiers/preprint.js +80 -0
  100. package/dist/capabilities/pdf-discovery/tiers/preprint.js.map +1 -0
  101. package/dist/capabilities/pdf-discovery/tiers/primary.js +2 -0
  102. package/dist/capabilities/pdf-discovery/tiers/primary.js.map +1 -1
  103. package/dist/capabilities/pdf-discovery/tiers/unpaywall.d.ts.map +1 -1
  104. package/dist/capabilities/pdf-discovery/tiers/unpaywall.js +12 -3
  105. package/dist/capabilities/pdf-discovery/tiers/unpaywall.js.map +1 -1
  106. package/dist/capabilities/pdf-discovery/types.d.ts +6 -0
  107. package/dist/capabilities/pdf-discovery/types.d.ts.map +1 -1
  108. package/dist/core/handleToolCall.d.ts.map +1 -1
  109. package/dist/core/handleToolCall.js +12 -0
  110. package/dist/core/handleToolCall.js.map +1 -1
  111. package/dist/core/schemas.d.ts +26 -1
  112. package/dist/core/schemas.d.ts.map +1 -1
  113. package/dist/core/schemas.js +13 -0
  114. package/dist/core/schemas.js.map +1 -1
  115. package/dist/core/searchers.d.ts +2 -0
  116. package/dist/core/searchers.d.ts.map +1 -1
  117. package/dist/core/searchers.js.map +1 -1
  118. package/dist/core/tools.d.ts.map +1 -1
  119. package/dist/core/tools.js +30 -0
  120. package/dist/core/tools.js.map +1 -1
  121. package/dist/infrastructure/arxiv.d.ts +5 -0
  122. package/dist/infrastructure/arxiv.d.ts.map +1 -0
  123. package/dist/infrastructure/arxiv.js +36 -0
  124. package/dist/infrastructure/arxiv.js.map +1 -0
  125. package/dist/infrastructure/pdf/PdfDownload.d.ts +2 -0
  126. package/dist/infrastructure/pdf/PdfDownload.d.ts.map +1 -1
  127. package/dist/infrastructure/pdf/PdfDownload.js +14 -0
  128. package/dist/infrastructure/pdf/PdfDownload.js.map +1 -1
  129. package/dist/infrastructure/pdf/PdfFirstPage.cjs +4 -1
  130. package/dist/infrastructure/pdf/PdfFirstPage.cjs.map +1 -1
  131. package/dist/infrastructure/pdf/PdfFirstPage.d.cts.map +1 -1
  132. package/dist/infrastructure/runlog/RunLedger.d.ts.map +1 -1
  133. package/dist/infrastructure/runlog/RunLedger.js +17 -1
  134. package/dist/infrastructure/runlog/RunLedger.js.map +1 -1
  135. package/dist/management/carsi/installer.d.ts +1 -1
  136. package/dist/platforms/ArxivSearcher.d.ts +62 -0
  137. package/dist/platforms/ArxivSearcher.d.ts.map +1 -0
  138. package/dist/platforms/ArxivSearcher.js +544 -0
  139. package/dist/platforms/ArxivSearcher.js.map +1 -0
  140. package/dist/platforms/SemanticScholarSearcher.d.ts.map +1 -1
  141. package/dist/platforms/SemanticScholarSearcher.js +4 -4
  142. package/dist/platforms/SemanticScholarSearcher.js.map +1 -1
  143. package/dist/registry/platformFactories.d.ts.map +1 -1
  144. package/dist/registry/platformFactories.js +2 -0
  145. package/dist/registry/platformFactories.js.map +1 -1
  146. package/dist/registry/platformMetadata.d.ts +1 -1
  147. package/dist/registry/platformMetadata.d.ts.map +1 -1
  148. package/dist/registry/platformMetadata.js +16 -0
  149. package/dist/registry/platformMetadata.js.map +1 -1
  150. package/package.json +2 -2
  151. package/skills/paper-search/SKILL.md +7 -4
  152. package/skills/paper-search/references/browser-download.md +10 -6
  153. package/skills/paper-search/references/capability-routing.md +4 -3
  154. package/skills/paper-search/references/cli-contract.md +10 -3
  155. package/skills/paper-search/references/publisher-support.md +6 -4
  156. package/skills/paper-search/references/troubleshooting.md +1 -1
  157. 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
- run_dir = output_path.parent / ".browser_runs"
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 = "browser_unresponsive"
542
+ result.reason = result.state = watchdog.failure_code()
540
543
  if supplements_mode in ("with", "only"):
541
- if result.state == "browser_unresponsive":
542
- result.supplements = [{"status": "skipped", "reason": "browser_unresponsive",
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.events[written:])
621
- data["supplements"] = result.supplements
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
- download: Callable[[str], tuple[bytes, str]] | None = None) -> list[dict[str, Any]]:
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 index >= MAX_FILES:
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
- (target / "manifest.json").write_text(
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
- """``kill -USR1 <pid>`` appends every thread's Python stack to
33
- ``<data dir>/logs/stacks-<date>.txt``: a backend waiting forever on a browser
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 not hasattr(signal, "SIGUSR1") or os.environ.get("CARSI_FILE_LOG") == "0":
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
- faulthandler.register(signal.SIGUSR1, file=_stack_file, all_threads=True)
49
- except Exception:
50
- _stack_file = None
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) -> dict[str, Any]:
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(Wl - P, y1, text=count, fill=col["text"], font=self.f_count, anchor="e")
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 = str(state.get("phase") or ("处理中" if running else ""))
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
- c.tag_bind(tag, "<Button-1>", lambda e, k=key, d=current, s=state: self._decide(k, d, s))
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
- c.tag_bind(tag, "<Enter>", lambda e, k=key, i=label_id: self._hover_button(k, i))
498
- c.tag_bind(tag, "<Leave>", lambda e, i=label_id: self._hover_button("", i))
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
- c.tag_bind("report", "<Button-1>", lambda e, d=current, s=state: self._report_menu(e, d, s))
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
- c.tag_bind("outdir", "<Button-1>", lambda e: self._open_output_dir(state))
510
- c.tag_bind("outdir", "<Enter>", lambda e: c.itemconfigure(link, font=self._underlined()))
511
- c.tag_bind("outdir", "<Leave>", lambda e: c.itemconfigure(link, font=self.f_small))
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 = {"skip_item": "已跳过本篇,进入下一篇",
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))
@@ -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, carved out of scansci-pdf"
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" }