259 lines
7.7 KiB
Python
259 lines
7.7 KiB
Python
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import logging
|
||
import re
|
||
import threading
|
||
import time
|
||
from typing import TYPE_CHECKING
|
||
|
||
from crossref.restful import Etiquette, Works
|
||
|
||
from app.services.domains.doi.normalize import normalize_doi
|
||
from app.settings import settings
|
||
|
||
if TYPE_CHECKING:
|
||
from app.services.domains.publications.types import PublicationListItem, UnreadPublicationItem
|
||
|
||
TOKEN_RE = re.compile(r"[a-z0-9]+")
|
||
NON_ALNUM_RE = re.compile(r"[^a-z0-9\\s]+")
|
||
STOP_WORDS = {"the", "and", "for", "with", "from", "method", "study", "analysis"}
|
||
_RATE_LOCK = threading.Lock()
|
||
_LAST_REQUEST_AT = 0.0
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def _rate_limit_wait(min_interval_seconds: float) -> None:
|
||
global _LAST_REQUEST_AT
|
||
interval = max(float(min_interval_seconds), 0.0)
|
||
with _RATE_LOCK:
|
||
elapsed = time.monotonic() - _LAST_REQUEST_AT
|
||
remaining = interval - elapsed
|
||
if remaining > 0:
|
||
time.sleep(remaining)
|
||
_LAST_REQUEST_AT = time.monotonic()
|
||
|
||
|
||
def _normalized_tokens(value: str) -> list[str]:
|
||
lowered = value.lower().replace("’", "'").replace("“", "\"").replace("”", "\"")
|
||
lowered = NON_ALNUM_RE.sub(" ", lowered)
|
||
return [token for token in TOKEN_RE.findall(lowered) if len(token) >= 3]
|
||
|
||
|
||
def _normalized_query(value: str) -> str:
|
||
tokens = [token for token in _normalized_tokens(value) if token not in STOP_WORDS]
|
||
if len(tokens) < 3:
|
||
tokens = _normalized_tokens(value)
|
||
if len(tokens) < 3:
|
||
return ""
|
||
return " ".join(tokens[:12]).strip()
|
||
|
||
|
||
def _query_author(value: str) -> str | None:
|
||
tokens = [token for token in value.strip().split() if token]
|
||
if len(tokens) < 2:
|
||
return None
|
||
return " ".join(tokens[:2])[:64]
|
||
|
||
|
||
def _author_surname(value: str) -> str | None:
|
||
tokens = [token for token in value.strip().split() if token]
|
||
if not tokens:
|
||
return None
|
||
return NON_ALNUM_RE.sub("", tokens[-1].lower()) or None
|
||
|
||
|
||
def _query_filters(year: int | None) -> list[tuple[str, str] | None]:
|
||
if year is None:
|
||
return [None]
|
||
return [
|
||
(f"{year - 1}-01-01", f"{year + 1}-12-31"),
|
||
(f"{year}-01-01", f"{year}-12-31"),
|
||
None,
|
||
]
|
||
|
||
|
||
def _candidate_title(item: dict) -> str:
|
||
titles = item.get("title")
|
||
if isinstance(titles, list) and titles:
|
||
return str(titles[0] or "")
|
||
return str(item.get("title") or "")
|
||
|
||
|
||
def _title_match_score(source: str, candidate: str) -> float:
|
||
source_tokens = {token for token in _normalized_tokens(source) if len(token) >= 3}
|
||
candidate_tokens = {token for token in _normalized_tokens(candidate) if len(token) >= 3}
|
||
if not source_tokens or not candidate_tokens:
|
||
return 0.0
|
||
return len(source_tokens & candidate_tokens) / float(len(source_tokens))
|
||
|
||
|
||
def _candidate_year(item: dict) -> int | None:
|
||
issued = item.get("issued")
|
||
if not isinstance(issued, dict):
|
||
return None
|
||
date_parts = issued.get("date-parts")
|
||
if not isinstance(date_parts, list) or not date_parts:
|
||
return None
|
||
first = date_parts[0]
|
||
if not isinstance(first, list) or not first:
|
||
return None
|
||
try:
|
||
return int(first[0])
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
def _candidate_author_match(item: dict, surname: str | None) -> bool:
|
||
if not surname:
|
||
return True
|
||
authors = item.get("author")
|
||
if not isinstance(authors, list):
|
||
return False
|
||
for author in authors:
|
||
if not isinstance(author, dict):
|
||
continue
|
||
family = NON_ALNUM_RE.sub("", str(author.get("family") or "").lower())
|
||
if family and family == surname:
|
||
return True
|
||
return False
|
||
|
||
|
||
def _candidate_rank(*, title: str, year: int | None, item: dict) -> tuple[float, str | None]:
|
||
doi = normalize_doi(str(item.get("DOI") or ""))
|
||
if doi is None:
|
||
return 0.0, None
|
||
score = _title_match_score(title, _candidate_title(item))
|
||
candidate_year = _candidate_year(item)
|
||
if year is not None and candidate_year is not None:
|
||
if abs(year - candidate_year) > 1:
|
||
return 0.0, None
|
||
score += 0.1
|
||
return score, doi
|
||
|
||
|
||
def _best_candidate_doi(
|
||
*,
|
||
title: str,
|
||
year: int | None,
|
||
items: list[dict],
|
||
author_surname: str | None,
|
||
) -> str | None:
|
||
best_score = 0.0
|
||
best_doi: str | None = None
|
||
best_year: int | None = None
|
||
for item in items:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
if not _candidate_author_match(item, author_surname):
|
||
continue
|
||
score, doi = _candidate_rank(title=title, year=year, item=item)
|
||
candidate_year = _candidate_year(item)
|
||
if doi is None or score < 0.75:
|
||
continue
|
||
if score > best_score:
|
||
best_score = score
|
||
best_doi = doi
|
||
best_year = candidate_year
|
||
continue
|
||
if abs(score - best_score) > 0.02:
|
||
continue
|
||
if best_year is None or candidate_year is None:
|
||
continue
|
||
if candidate_year < best_year:
|
||
best_doi = doi
|
||
best_year = candidate_year
|
||
return best_doi
|
||
|
||
|
||
def _works_client(email: str | None) -> Works:
|
||
if email:
|
||
etiquette = Etiquette(settings.app_name, "0.1.0", "https://scholarr.local", email)
|
||
return Works(etiquette=etiquette)
|
||
return Works()
|
||
|
||
|
||
def _fetch_items_sync(
|
||
*,
|
||
query: str,
|
||
author: str | None,
|
||
date_range: tuple[str, str] | None,
|
||
max_rows: int,
|
||
email: str | None,
|
||
min_interval_seconds: float,
|
||
) -> list[dict]:
|
||
_rate_limit_wait(min_interval_seconds)
|
||
works = _works_client(email)
|
||
params = {"bibliographic": query}
|
||
if author:
|
||
params["author"] = author
|
||
request = works.query(**params)
|
||
if date_range is not None:
|
||
from_date, until_date = date_range
|
||
request = request.filter(from_pub_date=from_date, until_pub_date=until_date)
|
||
request = request.select(["DOI", "title", "issued", "score", "author"])
|
||
items: list[dict] = []
|
||
for entry in request:
|
||
if isinstance(entry, dict):
|
||
items.append(entry)
|
||
if len(items) >= max(max_rows, 1):
|
||
break
|
||
return items
|
||
|
||
|
||
async def _fetch_items(
|
||
*,
|
||
query: str,
|
||
author: str | None,
|
||
date_range: tuple[str, str] | None,
|
||
max_rows: int,
|
||
email: str | None,
|
||
) -> list[dict]:
|
||
timeout = max(float(settings.crossref_timeout_seconds), 0.5)
|
||
try:
|
||
return await asyncio.wait_for(
|
||
asyncio.to_thread(
|
||
_fetch_items_sync,
|
||
query=query,
|
||
author=author,
|
||
date_range=date_range,
|
||
max_rows=max_rows,
|
||
email=email,
|
||
min_interval_seconds=settings.crossref_min_interval_seconds,
|
||
),
|
||
timeout=timeout,
|
||
)
|
||
except Exception:
|
||
return []
|
||
|
||
|
||
async def discover_doi_for_publication(
|
||
*,
|
||
item: PublicationListItem | UnreadPublicationItem,
|
||
max_rows: int = 10,
|
||
email: str | None = None,
|
||
) -> str | None:
|
||
title = (item.title or "").strip()
|
||
query = _normalized_query(title)
|
||
if not query:
|
||
return None
|
||
author = _query_author(item.scholar_label)
|
||
author_surname = _author_surname(item.scholar_label)
|
||
for date_range in _query_filters(item.year):
|
||
items = await _fetch_items(
|
||
query=query,
|
||
author=author,
|
||
date_range=date_range,
|
||
max_rows=max_rows,
|
||
email=email,
|
||
)
|
||
doi = _best_candidate_doi(
|
||
title=title,
|
||
year=item.year,
|
||
items=items,
|
||
author_surname=author_surname,
|
||
)
|
||
if doi:
|
||
logger.debug("crossref.doi_discovered", extra={"event": "crossref.doi_discovered"})
|
||
return doi
|
||
return None
|