Files
stack/src/prisma/fetch.py

855 lines
28 KiB
Python

"""PDF retrieval for PRISMA screening — Unpaywall → Semantic Scholar → PMC
→ fallback proxy.
Call graph::
pending_queue(db, project, client=...)
→ list of (zot_id, doi, pmid, pmcid) needing a PDF; with a client,
PMIDs lacking a PMCID are resolved through NCBI idconv (#723)
fetch_one(item, *, proxy=None)
→ tries each source in order, returns (source, path_on_disk) | None
(every Unpaywall oa_location, then Semantic Scholar's
openAccessPdf — which also lends a PMCID to the PMC tiers)
attach_pdf(db, zot_parent_id, path)
→ creates a Zotero attachment row and copies the file into
the Zotero storage directory
The last tier respects a SOCKS5/HTTPS proxy via ``PRISMA_FETCH_PROXY``
env var; set it to the tunnel endpoint after ``stack prisma vpn up``.
Unpaywall and PMC are legal/open and hit direct — no proxy needed.
Queue policy: pull PDFs only for items tagged ``screen:include`` or
``screen:uncertain``. Stage-2 excludes are terminal; we never fetch
for them. The filter lives in :func:`pending_queue`.
"""
from __future__ import annotations
import base64
import hashlib
import json as _json
import logging
import re
import shutil
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING
import httpx
from zot.db import FIELD_MAP, TYPE_MAP, generate_key, now_iso
if TYPE_CHECKING:
from zot.db import Db
log = logging.getLogger(__name__)
USER_AGENT = "stack-prisma-fetch/1.0 (mailto:{})"
_FALLBACK_MIRRORS = (
"https://sci-hub.ru",
"https://sci-hub.st",
"https://sci-hub.cat",
"https://sci-hub.box",
)
# Projects that must not use the mirror tier. Empty: the five LEAD
# reviews leave the fallback on, and any other project can be named here.
SCIHUB_OFF: frozenset[str] = frozenset()
def fallback_allowed(project: str) -> bool:
"""True when this project's fetch may use the mirror tier."""
return project not in SCIHUB_OFF
# The fallback tier shares one proxied client (altcha solves mutate its
# cookie jar) and one VPN exit — hold this across the whole tier.
_fallback_lock = threading.Lock()
@dataclass
class PendingItem:
"""One queue row needing a PDF."""
zot_id: int
doi: str
pmid: str
pmcid: str
title: str
# ── Queue ────────────────────────────────────────────────────────
def pending_queue(
db: Db,
project: str,
limit: int | None = None,
*,
client: httpx.Client | None = None,
email: str = "",
) -> list[PendingItem]:
"""Items in *project* that passed or deferred stage-2 screening and
don't yet have a PDF attached.
Definition:
- tagged ``project:<name>``
- tagged ``screen:include`` OR ``screen:uncertain``
- NOT tagged ``module:prisma`` (anchor guard)
- no child attachment with an existing PDF path
With *client*, items that carry a PMID but no PMCID are resolved in
one NCBI idconv batch and the answer is written back to ``extra`` as
a ``PMCID:`` line, so the PMC tiers get their chance and the next
run asks no question twice (#723). Without a client the queue is
built from the stored fields only — no network.
"""
rows = db.con.execute(
"""
SELECT DISTINCT i.itemID
FROM items i
JOIN itemTags itp ON i.itemID = itp.itemID
JOIN tags tp ON itp.tagID = tp.tagID AND tp.name = ?
JOIN itemTags its ON i.itemID = its.itemID
JOIN tags ts ON its.tagID = ts.tagID
AND ts.name IN ('screen:include', 'screen:uncertain')
WHERE i.itemID NOT IN (
SELECT itx.itemID FROM itemTags itx
JOIN tags tx ON itx.tagID = tx.tagID
WHERE tx.name = 'module:prisma'
)
AND NOT EXISTS (
SELECT 1
FROM itemAttachments ia
JOIN items att ON ia.itemID = att.itemID
WHERE ia.parentItemID = i.itemID
AND ia.path IS NOT NULL
AND ia.path LIKE 'storage:%.pdf'
)
ORDER BY i.itemID
"""
+ (f" LIMIT {int(limit)}" if limit else ""),
(f"project:{project}",),
).fetchall()
out: list[PendingItem] = []
for (zot_id,) in rows:
out.append(
PendingItem(
zot_id=zot_id,
doi=_field(db, zot_id, "DOI"),
pmid=_extra_value(db, zot_id, "PMID"),
pmcid=_extra_value(db, zot_id, "PMCID") or _from_url_pmcid(db, zot_id),
title=_field(db, zot_id, "title"),
)
)
if client is not None:
_resolve_queue_pmcids(db, out, client, email)
return out
def _resolve_queue_pmcids(
db: Db, items: list[PendingItem], client: httpx.Client, email: str
) -> None:
missing = [it for it in items if it.pmid and not it.pmcid]
if not missing:
return
found = resolve_pmcids(client, [it.pmid for it in missing], email)
if not found:
return
for it in missing:
pmcid = found.get(it.pmid.strip())
if not pmcid:
continue
it.pmcid = pmcid
extra = _field(db, it.zot_id, "extra")
db.set_fields(
it.zot_id,
{"extra": f"{extra}\nPMCID: {pmcid}" if extra else f"PMCID: {pmcid}"},
)
db.commit()
_IDCONV = "https://pmc.ncbi.nlm.nih.gov/tools/idconv/api/v1/articles/"
_IDCONV_BATCH = 200
def resolve_pmcids(
client: httpx.Client, pmids: list[str], email: str
) -> dict[str, str]:
"""PMID → PMCID via NCBI's idconv service, 200 ids per call (#723).
Only records idconv actually found are returned; ``status: error``
rows ("Identifier not found in PMC") and transport failures drop out.
"""
ids = [p for p in dict.fromkeys((p or "").strip() for p in pmids) if p.isdigit()]
out: dict[str, str] = {}
for i in range(0, len(ids), _IDCONV_BATCH):
chunk = ids[i : i + _IDCONV_BATCH]
try:
r = client.get(
_IDCONV,
params={
"ids": ",".join(chunk),
"idtype": "pmid",
"format": "json",
"tool": "stack-prisma",
"email": email,
},
timeout=30,
)
if r.status_code != 200:
continue
records = r.json().get("records") or []
except (httpx.HTTPError, ValueError):
continue
for rec in records:
pmcid = rec.get("pmcid")
if not pmcid or rec.get("status") == "error":
continue
out[str(rec.get("requested-id") or rec.get("pmid"))] = pmcid
return out
# ── Source fetchers ─────────────────────────────────────────────
def unpaywall_urls(client: httpx.Client, doi: str, email: str) -> list[str]:
"""Every OA URL Unpaywall knows for *doi*, best location first.
``best_oa_location`` is a ranking, not a guarantee — on the #650
pass it pointed at dead publisher links while a later
``oa_locations`` entry served the PDF (#723). Direct PDF URLs come
before landing pages across all locations; duplicates dropped.
"""
if not doi:
return []
try:
r = client.get(
f"https://api.unpaywall.org/v2/{doi}",
params={"email": email},
timeout=15,
)
if r.status_code != 200:
return []
data = r.json()
except (httpx.HTTPError, ValueError):
return []
locs = [data.get("best_oa_location") or {}, *(data.get("oa_locations") or [])]
out: list[str] = []
for key in ("url_for_pdf", "url"):
for loc in locs:
u = loc.get(key)
if u and u not in out:
out.append(u)
return out
def fetch_unpaywall(client: httpx.Client, doi: str, email: str) -> str | None:
"""Unpaywall → best PDF URL (or None). See :func:`unpaywall_urls`."""
urls = unpaywall_urls(client, doi, email)
return urls[0] if urls else None
_S2_BASE = "https://api.semanticscholar.org/graph/v1/paper"
_S2_MIN_INTERVAL = 1.0 # unauthenticated graph API: ~1 req/s shared pool
_S2_LAST = [0.0]
_s2_lock = threading.Lock()
@dataclass
class S2Hit:
"""What Semantic Scholar knows that helps the cascade."""
pdf_url: str
pmcid: str
def fetch_semantic_scholar(client: httpx.Client, doi: str) -> S2Hit | None:
"""Semantic Scholar graph API → ``openAccessPdf`` URL and/or a PMCID.
Found 4 of 30 items the rest of the cascade missed on the #650 pass
(#723). Its ``externalIds.PubMedCentral`` also fills in a PMCID the
Zotero record lacks, so the PMC tiers run for items Unpaywall only
knows by landing page. Rate-limited to ``_S2_MIN_INTERVAL`` across
threads; no API key needed.
"""
if not doi:
return None
with _s2_lock:
wait = _S2_MIN_INTERVAL - (time.monotonic() - _S2_LAST[0])
if wait > 0:
time.sleep(wait)
try:
r = client.get(
f"{_S2_BASE}/DOI:{doi}",
params={"fields": "openAccessPdf,externalIds"},
timeout=15,
)
except httpx.HTTPError:
return None
finally:
_S2_LAST[0] = time.monotonic()
if r.status_code != 200:
return None
try:
data = r.json()
except ValueError:
return None
pdf = (data.get("openAccessPdf") or {}).get("url") or ""
pmc = str((data.get("externalIds") or {}).get("PubMedCentral") or "")
if pmc and not pmc.startswith("PMC"):
pmc = f"PMC{pmc}"
if not pdf and not pmc:
return None
return S2Hit(pdf_url=pdf, pmcid=pmc)
def fetch_pmc(client: httpx.Client, pmcid: str) -> str | None:
"""PMC PDF URL via the OA Web Service.
The old ``/pmc/articles/<id>/pdf/`` guess URL now serves an HTML
bot-challenge to scripted clients (200, text/html — #657), so ask
the sanctioned OA service instead. Only articles in the OA subset
resolve; free-to-read author manuscripts return
``idIsNotOpenAccess`` and yield None. FTP hrefs are rewritten to
the HTTPS mirror.
"""
if not pmcid:
return None
pmc = pmcid if pmcid.startswith("PMC") else f"PMC{pmcid}"
try:
r = client.get(
"https://www.ncbi.nlm.nih.gov/pmc/utils/oa/oa.fcgi",
params={"id": pmc, "format": "pdf"},
follow_redirects=True,
timeout=15,
)
r.raise_for_status()
except httpx.HTTPError:
return None
m = re.search(r'<link[^>]*href="([^"]+\.pdf)"', r.text)
if not m:
return None
# NCBI moved /pub/pmc/ content under deprecated/ — the plain
# rewrite 404s on every article (#657).
return m.group(1).replace(
"ftp://ftp.ncbi.nlm.nih.gov/pub/pmc/",
"https://ftp.ncbi.nlm.nih.gov/pub/pmc/deprecated/",
)
_S3_BASE = "https://pmc-oa-opendata.s3.amazonaws.com"
def fetch_pmc_s3(client: httpx.Client, pmcid: str) -> str | None:
"""PMC OA PDF via the pmc-oa-opendata S3 bucket.
The current canonical OA route — the FTP tree moved to
``deprecated/`` (#657). Keys are ``PMC{n}.{ver}/PMC{n}.{ver}.pdf``;
pick the newest version. Uncapped and challenge-free, so it runs
before the OA Web Service.
"""
if not pmcid:
return None
pmc = pmcid if pmcid.startswith("PMC") else f"PMC{pmcid}"
try:
r = client.get(
f"{_S3_BASE}/",
params={"list-type": "2", "prefix": f"{pmc}.", "max-keys": "100"},
timeout=15,
)
if r.status_code != 200:
return None
except httpx.HTTPError:
return None
versions = [
int(m.group(1))
for m in re.finditer(rf"<Key>{pmc}\.(\d+)/{pmc}\.\1\.pdf</Key>", r.text)
]
if not versions:
return None
v = max(versions)
return f"{_S3_BASE}/{pmc}.{v}/{pmc}.{v}.pdf"
_EPMC_BREAKER = {"fails": 0}
_EPMC_MAX_FAILS = 3
def fetch_europepmc_download(
client: httpx.Client, pmcid: str, dest: Path
) -> Path | None:
"""Europe PMC render endpoint — the lawful full-text route for the
PMC author manuscripts the OA subset excludes (#657).
EBI outages happen, so three consecutive connection failures open a
circuit breaker that skips the tier for the rest of the run; any
success (even a non-PDF response) closes it.
"""
if not pmcid or _EPMC_BREAKER["fails"] >= _EPMC_MAX_FAILS:
return None
pmc = pmcid if pmcid.startswith("PMC") else f"PMC{pmcid}"
url = f"https://europepmc.org/backend/ptpmcrender.fcgi?accid={pmc}&blobtype=pdf"
try:
with client.stream(
"GET", url, timeout=httpx.Timeout(30, connect=5), follow_redirects=True
) as r:
_EPMC_BREAKER["fails"] = 0 # host reachable
if r.status_code != 200:
return None
with open(dest, "wb") as f:
for chunk in r.iter_bytes(1 << 16):
f.write(chunk)
except (httpx.ConnectError, httpx.ConnectTimeout):
_EPMC_BREAKER["fails"] += 1
return None
except (httpx.HTTPError, OSError):
return None
try:
with open(dest, "rb") as f:
head = f.read(4)
if dest.stat().st_size < 1024 or head != b"%PDF":
dest.unlink(missing_ok=True)
return None
except OSError:
return None
return dest
def resolve_doi_from_title(client: httpx.Client, title: str) -> str | None:
"""CrossRef bibliographic search → best-match DOI.
Only accept a hit when CrossRef's relevance score is strong enough
(>50) and the returned title meaningfully matches the query — the
API happily returns low-confidence fuzzy matches that would poison
the downstream cascade.
"""
if not title or len(title) < 10:
return None
try:
r = client.get(
"https://api.crossref.org/works",
params={"query.bibliographic": title, "rows": 3},
timeout=15,
)
if r.status_code != 200:
return None
items = (r.json().get("message") or {}).get("items") or []
except (httpx.HTTPError, ValueError):
return None
qnorm = _simplify(title)
for it in items:
doi = (it.get("DOI") or "").lower()
cand_title = " ".join(it.get("title") or []) or ""
score = it.get("score") or 0
if not doi or not cand_title:
continue
# Require high CrossRef relevance AND meaningful title overlap.
if score < 50:
continue
if _simplify(cand_title)[:80] == qnorm[:80]:
return doi
return None
def _simplify(s: str) -> str:
"""Lowercase + strip punctuation for crude title equality checks."""
return re.sub(r"[^a-z0-9]+", " ", (s or "").lower()).strip()
_ALTCHA_MAX_ITERATIONS = 2_000_000 # hashes; >1M is rare, stops runaway
def _solve_altcha_pow(
salt: str, challenge: str, maxnumber: int
) -> tuple[int, int] | None:
"""Brute-force the SHA-256 PoW. Returns (nonce, elapsed_ms) or None.
The challenge is solved when ``sha256(salt+str(n)).hexdigest() == challenge``.
Typical difficulty on the mirror is ~100k-500k iterations → <1s.
"""
target = challenge.lower()
t0 = time.monotonic()
cap = min(maxnumber, _ALTCHA_MAX_ITERATIONS)
for n in range(cap + 1):
if hashlib.sha256(f"{salt}{n}".encode()).hexdigest() == target:
return n, int((time.monotonic() - t0) * 1000)
return None
def _altcha_bootstrap(client: httpx.Client, page_url: str, html: str) -> bool:
"""Detect the altcha robot-check embedded in *html*, solve it, persist
the session cookie. Returns True on success, False if no challenge or
the solution was rejected.
"""
ch_match = re.search(r'challengeurl\s*=\s*["\']([^"\']+)["\']', html)
cid_match = re.search(r"/captcha/solution/(\d+)", html)
if not ch_match or not cid_match:
return False
origin = re.match(r"^(https?://[^/]+)", page_url)
if not origin:
return False
base = origin.group(1)
try:
chall = client.get(f"{base}{ch_match.group(1)}", timeout=20).json()
except (httpx.HTTPError, ValueError):
return False
maxnum = chall.get("maxNumber") or chall.get("maxnumber") or 1_000_000
solved = _solve_altcha_pow(chall["salt"], chall["challenge"], maxnum)
if not solved:
return False
nonce, elapsed = solved
payload = {
"algorithm": chall.get("algorithm", "SHA-256"),
"challenge": chall["challenge"],
"number": nonce,
"salt": chall["salt"],
"signature": chall["signature"],
"test": False,
"took": elapsed,
}
b64 = base64.b64encode(_json.dumps(payload).encode()).decode()
try:
r = client.post(
f"{base}/captcha/solution/{cid_match.group(1)}",
json={"captcha": b64},
headers={"Content-Type": "application/json"},
timeout=20,
)
except httpx.HTTPError:
return False
if r.status_code != 200:
return False
try:
return bool(r.json().get("success"))
except ValueError:
return False
def _extract_pdf_url(html: str, base: str) -> str | None:
"""Pull a PDF URL out of a mirror's citation page.
Mirror renders include ``<meta name="citation_pdf_url" content=…>``,
an ``<embed src=…pdf…>``, or a legacy ``location = "…pdf"`` script.
"""
patterns = (
r'<meta\s+name\s*=\s*["\']citation_pdf_url["\']\s+content\s*=\s*["\']([^"\']+)["\']',
r'<embed[^>]+src\s*=\s*["\']([^"\']+\.pdf[^"\']*)',
r'<iframe[^>]+src\s*=\s*["\']([^"\']+\.pdf[^"\']*)',
r'(?:src|location|href)\s*=\s*["\']([^"\']+\.pdf[^"\']*)',
)
for pat in patterns:
m = re.search(pat, html, re.IGNORECASE)
if not m:
continue
url = m.group(1)
if url.startswith("//"):
return "https:" + url
if url.startswith("/"):
return base + url
return url
return None
def fetch_fallback(client: httpx.Client, doi: str) -> str | None:
"""Last-resort fallback mirror lookup for a DOI.
Client should be configured with ``proxy=`` pointing at the VPN
tunnel — direct hits from residential/commercial US endpoints are
unreliable. The session is expected to be long-lived so altcha
solves persist across calls.
"""
if not doi:
return None
for mirror in _FALLBACK_MIRRORS:
url = f"{mirror}/{doi}"
try:
r = client.get(url, timeout=30)
except httpx.HTTPError:
continue
if r.status_code != 200:
continue
html = r.text
if "altcha-widget" in html or "captcha/challenge" in html:
if not _altcha_bootstrap(client, url, html):
continue
try:
r = client.get(url, timeout=30)
except httpx.HTTPError:
continue
if r.status_code != 200:
continue
html = r.text
pdf = _extract_pdf_url(html, mirror)
if pdf:
return pdf
return None
# ── Download + attach ────────────────────────────────────────────
def download(client: httpx.Client, url: str, dest: Path) -> Path | None:
"""Stream a PDF to disk; validate it actually is a PDF."""
try:
with client.stream("GET", url, timeout=60, follow_redirects=True) as r:
if r.status_code != 200:
return None
ctype = r.headers.get("content-type", "")
if "pdf" not in ctype.lower() and not url.lower().endswith(".pdf"):
pass
with open(dest, "wb") as f:
for chunk in r.iter_bytes(1 << 16):
f.write(chunk)
except (httpx.HTTPError, OSError):
return None
try:
if dest.stat().st_size < 1024:
dest.unlink(missing_ok=True)
return None
with open(dest, "rb") as f:
head = f.read(4)
if head != b"%PDF":
dest.unlink(missing_ok=True)
return None
except OSError:
return None
return dest
def fetch_one(
item: PendingItem,
*,
email: str,
scratch: Path,
client: httpx.Client,
client_proxied: httpx.Client | None = None,
allow_fallback: bool = True,
) -> tuple[str, Path] | None:
"""Run one item through the source cascade.
``client`` is the unproxied httpx client (Unpaywall, Semantic
Scholar, PMC). ``client_proxied`` (optional) routes the fallback
tier through the VPN; if None, the fallback is skipped.
Order: every Unpaywall location → Semantic Scholar openAccessPdf →
PMC S3 → PMC OA service → Europe PMC → fallback. A PMCID the item
lacks may arrive from Semantic Scholar before the PMC tiers run.
"""
filename = scratch / f"{item.zot_id}-{_hash(item.doi or item.title)}.pdf"
doi = item.doi
if not doi and item.title:
doi = resolve_doi_from_title(client, item.title) or ""
for url in unpaywall_urls(client, doi, email):
path = download(client, url, filename)
if path:
return "unpaywall", path
pmcid = item.pmcid
s2 = fetch_semantic_scholar(client, doi)
if s2:
if s2.pdf_url:
path = download(client, s2.pdf_url, filename)
if path:
return "s2", path
if not pmcid:
pmcid = s2.pmcid
url = fetch_pmc_s3(client, pmcid)
if url:
path = download(client, url, filename)
if path:
return "pmc", path
url = fetch_pmc(client, pmcid)
if url:
path = download(client, url, filename)
if path:
return "pmc", path
path = fetch_europepmc_download(client, pmcid, filename)
if path:
return "pmc", path
if allow_fallback and client_proxied is not None:
with _fallback_lock:
url = fetch_fallback(client_proxied, doi)
if url:
path = download(client_proxied, url, filename)
if path:
return "fallback", path
return None
def attach_pdf(
db: Db,
zot_parent_id: int,
path: Path,
storage_dir: Path,
*,
title: str = "",
) -> int:
"""Copy PDF into Zotero storage and register it as a child attachment.
Returns the attachment's itemID.
"""
att_key = generate_key()
target_dir = storage_dir / att_key
target_dir.mkdir(parents=True, exist_ok=True)
dest = target_dir / path.name
shutil.copy2(path, dest)
att_id = db.create_item(TYPE_MAP["attachment"], key=att_key, now=now_iso())
db.con.execute(
"""INSERT INTO itemAttachments
(itemID, parentItemID, linkMode, contentType, path)
VALUES (?, ?, ?, ?, ?)""",
(att_id, zot_parent_id, 1, "application/pdf", f"storage:{path.name}"),
)
if title:
db.set_fields(att_id, {"title": title})
return att_id
# ── Orchestrator ────────────────────────────────────────────────
def run(
db: Db,
*,
project: str,
storage_dir: Path,
scratch_dir: Path,
email: str,
fetch_proxy: str | None,
limit: int | None = None,
progress: bool = True,
workers: int = 8,
allow_fallback: bool = True,
) -> dict[str, int]:
"""Fetch PDFs for every non-excluded item in the project.
Attaches each successful download to its Zotero parent. Leaves
un-retrievable items untouched — stage 3 eligibility will flag them
as `screen:reason:unavailable-fulltext`.
"""
scratch_dir.mkdir(parents=True, exist_ok=True)
stats = {"unpaywall": 0, "s2": 0, "pmc": 0, "fallback": 0, "missed": 0, "errors": 0}
client = httpx.Client(
headers={"User-Agent": USER_AGENT.format(email)},
follow_redirects=True,
)
pending = pending_queue(db, project, limit=limit, client=client, email=email)
client_proxied: httpx.Client | None = None
if fetch_proxy:
# Mirrors behind altcha treat our generic UA as a bot; a real
# browser UA passes through the challenge reliably.
browser_ua = (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Firefox/122.0"
)
client_proxied = httpx.Client(
headers={"User-Agent": browser_ua},
follow_redirects=True,
proxy=fetch_proxy,
)
done = 0
try:
with ThreadPoolExecutor(max_workers=max(1, workers)) as pool:
futures = {
pool.submit(
fetch_one,
item,
email=email,
scratch=scratch_dir,
client=client,
client_proxied=client_proxied,
allow_fallback=allow_fallback,
): item
for item in pending
}
# All DB work stays on this thread — the sqlite connection
# must never be shared with the pool.
for fut in as_completed(futures):
item = futures[fut]
done += 1
try:
hit = fut.result()
except Exception as e: # noqa: BLE001
log.warning("fetch failed for item %s: %s", item.zot_id, e)
stats["errors"] += 1
continue
if not hit:
stats["missed"] += 1
else:
source, path = hit
try:
attach_pdf(db, item.zot_id, path, storage_dir, title=item.title)
db.commit()
path.unlink(missing_ok=True)
stats[source] += 1
except Exception as e: # noqa: BLE001
log.warning("attach failed for item %s: %s", item.zot_id, e)
stats["errors"] += 1
if progress and done % 25 == 0:
print(
f" fetched {done}/{len(pending)} "
f"(up={stats['unpaywall']} s2={stats['s2']} "
f"pmc={stats['pmc']} fb={stats['fallback']} "
f"miss={stats['missed']})"
)
finally:
client.close()
if client_proxied is not None:
client_proxied.close()
return stats
# ── Helpers ─────────────────────────────────────────────────────
def _field(db: Db, zot_id: int, field_name: str) -> str:
field_id = FIELD_MAP.get(field_name)
if field_id is None:
return ""
row = db.con.execute(
"""SELECT idv.value FROM itemData id
JOIN itemDataValues idv ON id.valueID = idv.valueID
WHERE id.itemID = ? AND id.fieldID = ?""",
(zot_id, field_id),
).fetchone()
return (row[0] if row else "") or ""
def _extra_value(db: Db, zot_id: int, key: str) -> str:
"""Scrape ``KEY: VALUE`` pairs out of the free-form ``extra`` field."""
extra = _field(db, zot_id, "extra")
if not extra:
return ""
m = re.search(rf"^{re.escape(key)}\s*:\s*(.+)$", extra, re.M | re.I)
return m.group(1).strip() if m else ""
def _from_url_pmcid(db: Db, zot_id: int) -> str:
url = _field(db, zot_id, "url")
m = re.search(r"PMC\d+", url or "")
return m.group(0) if m else ""
def _hash(s: str) -> str:
return hashlib.sha1((s or "").encode()).hexdigest()[:10] # noqa: S324 — not security