harden domain architecture and add lazy Crossref+Unpaywall PDF enrichment with publications workspace refactor

This commit is contained in:
Justin Visser 2026-02-20 22:56:52 +01:00
parent a527e7a535
commit 01454162bb
62 changed files with 2562 additions and 688 deletions

View file

@ -1,3 +0,0 @@
from __future__ import annotations
from app.services.domains.ingestion.queue import *

View file

@ -0,0 +1,5 @@
from __future__ import annotations
from app.services.domains.crossref.application import discover_doi_for_publication
__all__ = ["discover_doi_for_publication"]

View file

@ -0,0 +1,259 @@
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

View file

@ -0,0 +1,23 @@
from __future__ import annotations
import re
from urllib.parse import unquote
DOI_RE = re.compile(r"10\.\d{4,9}/[-._;()/:A-Z0-9]+", re.I)
def normalize_doi(value: str | None) -> str | None:
if not value:
return None
match = DOI_RE.search(unquote(value))
if not match:
return None
return match.group(0).rstrip(" .;,)").lower()
def first_doi_from_texts(*values: str | None) -> str | None:
for value in values:
doi = normalize_doi(value)
if doi:
return doi
return None

View file

@ -28,6 +28,7 @@ from app.services.domains.ingestion.constants import (
RESUMABLE_PARTIAL_REASONS,
RUN_LOCK_NAMESPACE,
)
from app.services.domains.doi.normalize import first_doi_from_texts
from app.services.domains.ingestion.fingerprints import (
_build_body_excerpt,
_dedupe_publication_candidates,
@ -55,6 +56,7 @@ from app.services.domains.scholar.parser import (
ParseState,
ParsedProfilePage,
PublicationCandidate,
ScholarParserError,
parse_profile_page,
)
from app.services.domains.scholar.source import FetchResult, ScholarSource
@ -1431,7 +1433,7 @@ class ScholarIngestionService:
cstart=cstart,
page_size=page_size,
)
parsed_page = parse_profile_page(fetch_result)
parsed_page = self._parse_profile_page_or_layout_error(fetch_result=fetch_result)
attempt_log.append(
self._attempt_log_entry(
attempt=attempt_index + 1,
@ -1458,6 +1460,38 @@ class ScholarIngestionService:
raise RuntimeError("Fetch-and-parse retry loop produced no result.")
return fetch_result, parsed_page, attempt_log
def _parse_profile_page_or_layout_error(
self,
*,
fetch_result: FetchResult,
) -> ParsedProfilePage:
try:
return parse_profile_page(fetch_result)
except ScholarParserError as exc:
return self._parsed_page_from_parser_error(
fetch_result=fetch_result,
code=exc.code,
)
@staticmethod
def _parsed_page_from_parser_error(
*,
fetch_result: FetchResult,
code: str,
) -> ParsedProfilePage:
return ParsedProfilePage(
state=ParseState.LAYOUT_CHANGED,
state_reason=code,
profile_name=None,
profile_image_url=None,
publications=[],
marker_counts={},
warnings=[code],
has_show_more_button=False,
has_operation_error_banner=False,
articles_range=None,
)
@staticmethod
def _page_log_entry(
*,
@ -1942,7 +1976,8 @@ class ScholarIngestionService:
author_text=candidate.authors_text,
venue_text=candidate.venue_text,
pub_url=build_publication_url(candidate.title_url),
pdf_url=build_publication_url(candidate.pdf_url),
doi=first_doi_from_texts(candidate.title_url, candidate.venue_text, candidate.title),
pdf_url=None,
)
db_session.add(publication)
await db_session.flush()
@ -1976,8 +2011,9 @@ class ScholarIngestionService:
publication.venue_text = candidate.venue_text
if candidate.title_url:
publication.pub_url = build_publication_url(candidate.title_url)
if candidate.pdf_url:
publication.pdf_url = build_publication_url(candidate.pdf_url)
local_doi = first_doi_from_texts(candidate.title_url, candidate.venue_text, candidate.title)
if local_doi and not publication.doi:
publication.doi = local_doi
async def _resolve_publication(
self,

View file

@ -34,6 +34,7 @@ def _serialize_export_publication(row: tuple[Any, ...]) -> dict[str, Any]:
author_text,
venue_text,
pub_url,
doi,
pdf_url,
is_read,
) = row
@ -47,6 +48,7 @@ def _serialize_export_publication(row: tuple[Any, ...]) -> dict[str, Any]:
"author_text": author_text,
"venue_text": venue_text,
"pub_url": pub_url,
"doi": doi,
"pdf_url": pdf_url,
"is_read": bool(is_read),
}
@ -73,6 +75,7 @@ async def export_user_data(
Publication.author_text,
Publication.venue_text,
Publication.pub_url,
Publication.doi,
Publication.pdf_url,
ScholarPublication.is_read,
)

View file

@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import Publication, ScholarProfile, ScholarPublication
from app.services.domains.ingestion.application import build_publication_url, normalize_title
from app.services.domains.doi.normalize import normalize_doi
from app.services.domains.portability.normalize import (
_normalize_citation_count,
_normalize_optional_text,
@ -70,6 +71,7 @@ def _apply_imported_publication_values(
author_text: str | None,
venue_text: str | None,
pub_url: str | None,
doi: str | None,
pdf_url: str | None,
cluster_id: str | None,
) -> bool:
@ -96,6 +98,9 @@ def _apply_imported_publication_values(
if pub_url and publication.pub_url != pub_url:
publication.pub_url = pub_url
updated = True
if doi and publication.doi != doi:
publication.doi = doi
updated = True
if pdf_url and publication.pdf_url != pdf_url:
publication.pdf_url = pdf_url
updated = True
@ -112,6 +117,7 @@ def _new_publication(
author_text: str | None,
venue_text: str | None,
pub_url: str | None,
doi: str | None,
pdf_url: str | None,
) -> Publication:
return Publication(
@ -124,6 +130,7 @@ def _new_publication(
author_text=author_text,
venue_text=venue_text,
pub_url=pub_url,
doi=doi,
pdf_url=pdf_url,
)
@ -226,6 +233,7 @@ def _build_imported_publication_input(
venue_text=venue_text,
cluster_id=_normalize_optional_text(item.get("cluster_id")),
pub_url=build_publication_url(_normalize_optional_text(item.get("pub_url"))),
doi=normalize_doi(_normalize_optional_text(item.get("doi"))),
pdf_url=build_publication_url(_normalize_optional_text(item.get("pdf_url"))),
fingerprint=_resolve_fingerprint(
title=title,
@ -277,6 +285,7 @@ async def _create_import_publication(
author_text=payload.author_text,
venue_text=payload.venue_text,
pub_url=payload.pub_url,
doi=payload.doi,
pdf_url=payload.pdf_url,
)
db_session.add(publication)
@ -297,6 +306,7 @@ def _update_import_publication(
author_text=payload.author_text,
venue_text=payload.venue_text,
pub_url=payload.pub_url,
doi=payload.doi,
pdf_url=payload.pdf_url,
cluster_id=payload.cluster_id,
)

View file

@ -19,6 +19,7 @@ class ImportedPublicationInput:
venue_text: str | None
cluster_id: str | None
pub_url: str | None
doi: str | None
pdf_url: str | None
fingerprint: str
is_read: bool

View file

@ -8,8 +8,12 @@ from app.services.domains.publications.counts import (
from app.services.domains.publications.listing import (
list_for_user,
list_new_for_latest_run_for_user,
retry_pdf_for_user,
list_unread_for_user,
)
from app.services.domains.publications.enrichment import (
schedule_missing_pdf_enrichment_for_user,
)
from app.services.domains.publications.modes import (
MODE_ALL,
MODE_LATEST,
@ -20,6 +24,7 @@ from app.services.domains.publications.modes import (
)
from app.services.domains.publications.queries import (
get_latest_completed_run_id_for_user,
get_publication_item_for_user,
publications_query,
)
from app.services.domains.publications.read_state import (
@ -39,9 +44,12 @@ __all__ = [
"resolve_mode",
"get_latest_completed_run_id_for_user",
"publications_query",
"get_publication_item_for_user",
"list_for_user",
"list_unread_for_user",
"list_new_for_latest_run_for_user",
"retry_pdf_for_user",
"schedule_missing_pdf_enrichment_for_user",
"count_for_user",
"count_unread_for_user",
"count_latest_for_user",

View file

@ -0,0 +1,135 @@
from __future__ import annotations
import asyncio
import logging
import time
from app.db.session import get_session_factory
from app.services.domains.publications.listing import (
missing_pdf_items,
resolve_and_persist_oa_metadata,
)
from app.services.domains.publications.types import PublicationListItem
from app.settings import settings
logger = logging.getLogger(__name__)
_enrichment_lock = asyncio.Lock()
_inflight_publications: set[tuple[int, int]] = set()
_recent_attempt_seconds: dict[tuple[int, int], float] = {}
_scheduled_tasks: set[asyncio.Task[None]] = set()
def _cooldown_seconds() -> float:
return max(float(settings.unpaywall_retry_cooldown_seconds), 1.0)
def _prune_recent_attempts(now_seconds: float, *, cooldown_seconds: float) -> None:
expiry = cooldown_seconds * 3
stale_keys = [
key for key, attempted_seconds in _recent_attempt_seconds.items()
if now_seconds - attempted_seconds >= expiry
]
for key in stale_keys:
_recent_attempt_seconds.pop(key, None)
async def _claim_items(
*,
user_id: int,
items: list[PublicationListItem],
max_items: int,
) -> list[PublicationListItem]:
candidates = missing_pdf_items(items, limit=max_items)
if not candidates:
return []
now_seconds = time.monotonic()
cooldown_seconds = _cooldown_seconds()
claimed: list[PublicationListItem] = []
async with _enrichment_lock:
_prune_recent_attempts(now_seconds, cooldown_seconds=cooldown_seconds)
for item in candidates:
key = (user_id, item.publication_id)
attempted_seconds = _recent_attempt_seconds.get(key)
if key in _inflight_publications:
continue
if attempted_seconds is not None and now_seconds - attempted_seconds < cooldown_seconds:
continue
_inflight_publications.add(key)
_recent_attempt_seconds[key] = now_seconds
claimed.append(item)
return claimed
async def _release_claims(*, user_id: int, publication_ids: list[int]) -> None:
async with _enrichment_lock:
for publication_id in publication_ids:
_inflight_publications.discard((user_id, publication_id))
def _on_task_done(task: asyncio.Task[None]) -> None:
_scheduled_tasks.discard(task)
try:
task.result()
except Exception:
logger.exception(
"publications.enrichment.task_failed",
extra={"event": "publications.enrichment.task_failed"},
)
async def _run_enrichment(
*,
user_id: int,
request_email: str | None,
items: list[PublicationListItem],
) -> None:
publication_ids = [item.publication_id for item in items]
try:
session_factory = get_session_factory()
async with session_factory() as db_session:
await resolve_and_persist_oa_metadata(
db_session,
rows=items,
unpaywall_email=request_email,
)
finally:
await _release_claims(user_id=user_id, publication_ids=publication_ids)
logger.info(
"publications.enrichment.completed",
extra={
"event": "publications.enrichment.completed",
"user_id": user_id,
"publication_count": len(items),
},
)
async def schedule_missing_pdf_enrichment_for_user(
*,
user_id: int,
request_email: str | None,
items: list[PublicationListItem],
max_items: int,
) -> int:
claimed_items = await _claim_items(user_id=user_id, items=items, max_items=max_items)
if not claimed_items:
return 0
task = asyncio.create_task(
_run_enrichment(
user_id=user_id,
request_email=request_email,
items=claimed_items,
)
)
_scheduled_tasks.add(task)
task.add_done_callback(_on_task_done)
logger.info(
"publications.enrichment.scheduled",
extra={
"event": "publications.enrichment.scheduled",
"user_id": user_id,
"publication_count": len(claimed_items),
},
)
return len(claimed_items)

View file

@ -10,11 +10,100 @@ from app.services.domains.publications.modes import (
)
from app.services.domains.publications.queries import (
get_latest_completed_run_id_for_user,
get_publication_item_for_user,
publication_list_item_from_row,
publications_query,
unread_item_from_row,
)
from app.services.domains.publications.types import PublicationListItem, UnreadPublicationItem
from app.services.domains.unpaywall.application import resolve_publication_oa_metadata
from sqlalchemy import update
from app.db.models import Publication
def _with_oa_overrides(
rows: list[PublicationListItem],
oa_data: dict[int, tuple[str | None, str | None]],
) -> list[PublicationListItem]:
return [
PublicationListItem(
publication_id=row.publication_id,
scholar_profile_id=row.scholar_profile_id,
scholar_label=row.scholar_label,
title=row.title,
year=row.year,
citation_count=row.citation_count,
venue_text=row.venue_text,
pub_url=row.pub_url,
doi=(oa_data.get(row.publication_id) or (None, None))[0] or row.doi,
pdf_url=(oa_data.get(row.publication_id) or (None, None))[1] or row.pdf_url,
is_read=row.is_read,
first_seen_at=row.first_seen_at,
is_new_in_latest_run=row.is_new_in_latest_run,
)
for row in rows
]
def _resolved_fields(
*,
row: PublicationListItem,
resolved: tuple[str | None, str | None] | None,
) -> tuple[str | None, str | None]:
if resolved is None:
return row.doi, row.pdf_url
resolved_doi, resolved_pdf = resolved
return resolved_doi or row.doi, resolved_pdf or row.pdf_url
async def _persist_resolved_metadata(
db_session: AsyncSession,
*,
rows: list[PublicationListItem],
oa_data: dict[int, tuple[str | None, str | None]],
) -> None:
by_id = {row.publication_id: row for row in rows}
updates: list[tuple[int, str | None, str | None]] = []
for publication_id, resolved in oa_data.items():
existing = by_id.get(publication_id)
if existing is None:
continue
next_doi, next_pdf = _resolved_fields(row=existing, resolved=resolved)
if next_doi == existing.doi and next_pdf == existing.pdf_url:
continue
updates.append((publication_id, next_doi, next_pdf))
for publication_id, doi, pdf_url in updates:
await db_session.execute(
update(Publication)
.where(Publication.id == publication_id)
.values(doi=doi, pdf_url=pdf_url)
)
if updates:
await db_session.commit()
def missing_pdf_items(
rows: list[PublicationListItem],
*,
limit: int,
) -> list[PublicationListItem]:
bounded_limit = max(0, int(limit))
if bounded_limit == 0:
return []
return [row for row in rows if not row.pdf_url][:bounded_limit]
async def resolve_and_persist_oa_metadata(
db_session: AsyncSession,
*,
rows: list[PublicationListItem],
unpaywall_email: str | None = None,
) -> dict[int, tuple[str | None, str | None]]:
if not rows:
return {}
oa_data = await resolve_publication_oa_metadata(rows, request_email=unpaywall_email)
await _persist_resolved_metadata(db_session, rows=rows, oa_data=oa_data)
return oa_data
async def list_for_user(
@ -36,10 +125,35 @@ async def list_for_user(
limit=limit,
)
)
return [
rows = [
publication_list_item_from_row(row, latest_run_id=latest_run_id)
for row in result.all()
]
return rows
async def retry_pdf_for_user(
db_session: AsyncSession,
*,
user_id: int,
scholar_profile_id: int,
publication_id: int,
unpaywall_email: str | None = None,
) -> PublicationListItem | None:
item = await get_publication_item_for_user(
db_session,
user_id=user_id,
scholar_profile_id=scholar_profile_id,
publication_id=publication_id,
)
if item is None:
return None
oa_data = await resolve_and_persist_oa_metadata(
db_session,
rows=[item],
unpaywall_email=unpaywall_email,
)
return _with_oa_overrides([item], oa_data)[0]
async def list_unread_for_user(
@ -73,17 +187,19 @@ async def list_new_for_latest_run_for_user(
scholar_profile_id=None,
limit=limit,
)
return [
UnreadPublicationItem(
publication_id=row.publication_id,
scholar_profile_id=row.scholar_profile_id,
scholar_label=row.scholar_label,
title=row.title,
year=row.year,
citation_count=row.citation_count,
venue_text=row.venue_text,
pub_url=row.pub_url,
pdf_url=row.pdf_url,
)
for row in rows
]
return [_to_unread_item(row) for row in rows]
def _to_unread_item(row: PublicationListItem) -> UnreadPublicationItem:
return UnreadPublicationItem(
publication_id=row.publication_id,
scholar_profile_id=row.scholar_profile_id,
scholar_label=row.scholar_label,
title=row.title,
year=row.year,
citation_count=row.citation_count,
venue_text=row.venue_text,
pub_url=row.pub_url,
doi=row.doi,
pdf_url=row.pdf_url,
)

View file

@ -50,6 +50,7 @@ def publications_query(
Publication.citation_count,
Publication.venue_text,
Publication.pub_url,
Publication.doi,
Publication.pdf_url,
ScholarPublication.is_read,
ScholarPublication.first_seen_run_id,
@ -72,6 +73,61 @@ def publications_query(
return stmt
def publication_query_for_user(
*,
user_id: int,
scholar_profile_id: int,
publication_id: int,
) -> Select[tuple]:
return (
select(
Publication.id,
ScholarProfile.id,
ScholarProfile.display_name,
ScholarProfile.scholar_id,
Publication.title_raw,
Publication.year,
Publication.citation_count,
Publication.venue_text,
Publication.pub_url,
Publication.doi,
Publication.pdf_url,
ScholarPublication.is_read,
ScholarPublication.first_seen_run_id,
ScholarPublication.created_at,
)
.join(ScholarPublication, ScholarPublication.publication_id == Publication.id)
.join(ScholarProfile, ScholarProfile.id == ScholarPublication.scholar_profile_id)
.where(
ScholarProfile.user_id == user_id,
ScholarProfile.id == scholar_profile_id,
Publication.id == publication_id,
)
.limit(1)
)
async def get_publication_item_for_user(
db_session: AsyncSession,
*,
user_id: int,
scholar_profile_id: int,
publication_id: int,
) -> PublicationListItem | None:
latest_run_id = await get_latest_completed_run_id_for_user(db_session, user_id=user_id)
result = await db_session.execute(
publication_query_for_user(
user_id=user_id,
scholar_profile_id=scholar_profile_id,
publication_id=publication_id,
)
)
row = result.one_or_none()
if row is None:
return None
return publication_list_item_from_row(row, latest_run_id=latest_run_id)
def publication_list_item_from_row(
row: tuple,
*,
@ -87,6 +143,7 @@ def publication_list_item_from_row(
citation_count,
venue_text,
pub_url,
doi,
pdf_url,
is_read,
first_seen_run_id,
@ -101,6 +158,7 @@ def publication_list_item_from_row(
citation_count=_normalized_citation_count(citation_count),
venue_text=venue_text,
pub_url=pub_url,
doi=doi,
pdf_url=pdf_url,
is_read=bool(is_read),
first_seen_at=created_at,
@ -121,6 +179,7 @@ def unread_item_from_row(row: tuple) -> UnreadPublicationItem:
citation_count,
venue_text,
pub_url,
doi,
pdf_url,
_is_read,
_first_seen_run_id,
@ -135,5 +194,6 @@ def unread_item_from_row(row: tuple) -> UnreadPublicationItem:
citation_count=_normalized_citation_count(citation_count),
venue_text=venue_text,
pub_url=pub_url,
doi=doi,
pdf_url=pdf_url,
)

View file

@ -14,6 +14,7 @@ class PublicationListItem:
citation_count: int
venue_text: str | None
pub_url: str | None
doi: str | None
pdf_url: str | None
is_read: bool
first_seen_at: datetime
@ -30,4 +31,5 @@ class UnreadPublicationItem:
citation_count: int
venue_text: str | None
pub_url: str | None
doi: str | None
pdf_url: str | None

View file

@ -11,14 +11,12 @@ from app.services.domains.scholar.parser_types import (
ParsedAuthorSearchPage,
ParsedProfilePage,
PublicationCandidate,
ScholarDomInvariantError,
ScholarMalformedDataError,
ScholarParserError,
ScholarSearchCandidate,
)
from app.services.domains.scholar.parser_utils import (
attr_class,
attr_href,
attr_src,
build_absolute_scholar_url,
normalize_space,
strip_tags,
)
from app.services.domains.scholar.profile_rows import (
@ -44,6 +42,55 @@ from app.services.domains.scholar.state_detection import (
)
def _raise_dom_error(code: str, message: str) -> None:
raise ScholarDomInvariantError(code=code, message=message)
def _assert_profile_dom_invariants(
*,
fetch_result: FetchResult,
marker_counts: dict[str, int],
publications: list[PublicationCandidate],
warnings: list[str],
has_show_more_button_flag: bool,
articles_range: str | None,
) -> None:
if fetch_result.status_code is None:
return
final_url = (fetch_result.final_url or "").lower()
if "accounts.google.com" in final_url or "sorry/index" in final_url:
return
if any(code.startswith("layout_") for code in warnings):
reason = next(code for code in warnings if code.startswith("layout_"))
_raise_dom_error(reason, f"Detected layout warning: {reason}")
if has_show_more_button_flag and not articles_range:
_raise_dom_error(
"layout_show_more_without_articles_range",
"Show-more control exists without an articles range marker.",
)
if marker_counts.get("gsc_a_tr", 0) > 0 and marker_counts.get("gsc_a_at", 0) <= 0:
_raise_dom_error(
"layout_missing_publication_title_anchor",
"Publication rows were present but title anchors were absent.",
)
if not publications:
has_profile_markers = marker_counts.get("gsc_prf_in", 0) > 0
has_table_markers = marker_counts.get("gsc_a_tr", 0) > 0 or marker_counts.get("gsc_a_at", 0) > 0
if not has_profile_markers and not has_table_markers:
_raise_dom_error("layout_markers_missing", "Expected scholar profile markers were absent.")
for publication in publications:
if not publication.title.strip():
raise ScholarMalformedDataError(
code="malformed_publication_title",
message="Encountered a publication candidate with an empty title.",
)
if publication.citation_count is not None and int(publication.citation_count) < 0:
raise ScholarMalformedDataError(
code="malformed_publication_negative_citations",
message="Encountered a publication candidate with negative citations.",
)
def parse_profile_page(fetch_result: FetchResult) -> ParsedProfilePage:
publications, warnings = parse_publications(fetch_result.body)
marker_counts = count_markers(fetch_result.body)
@ -59,6 +106,14 @@ def parse_profile_page(fetch_result: FetchResult) -> ParsedProfilePage:
warnings.append("operation_error_banner_present")
warnings = sorted(set(warnings))
_assert_profile_dom_invariants(
fetch_result=fetch_result,
marker_counts=marker_counts,
publications=publications,
warnings=warnings,
has_show_more_button_flag=show_more,
articles_range=articles_range,
)
state, state_reason = detect_state(
fetch_result,

View file

@ -12,6 +12,22 @@ class ParseState(StrEnum):
NETWORK_ERROR = "network_error"
class ScholarParserError(RuntimeError):
code: str
def __init__(self, *, code: str, message: str) -> None:
super().__init__(message)
self.code = code
class ScholarDomInvariantError(ScholarParserError):
pass
class ScholarMalformedDataError(ScholarParserError):
pass
@dataclass(frozen=True)
class PublicationCandidate:
title: str

View file

@ -2,13 +2,10 @@ from __future__ import annotations
import re
from html.parser import HTMLParser
from typing import Any
from urllib.parse import parse_qs, urlparse
from app.services.domains.scholar.parser_constants import (
MARKER_KEYS,
PROFILE_ROW_DIRECT_LABEL_TOKENS,
PROFILE_ROW_PARSER_DIRECT_MARKERS,
SHOW_MORE_BUTTON_RE,
)
from app.services.domains.scholar.parser_types import PublicationCandidate
@ -26,7 +23,6 @@ class ScholarRowParser(HTMLParser):
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.title_href: str | None = None
self.direct_download_href: str | None = None
self.title_parts: list[str] = []
self.citation_parts: list[str] = []
self.year_parts: list[str] = []
@ -35,14 +31,7 @@ class ScholarRowParser(HTMLParser):
self._title_depth = 0
self._citation_depth = 0
self._year_depth = 0
self._gray_stack: list[dict[str, Any]] = []
self._direct_marker_depth = 0
self._aux_link_stack: list[dict[str, Any]] = []
@staticmethod
def _contains_direct_marker(classes: str) -> bool:
lowered = classes.lower()
return any(marker in lowered for marker in PROFILE_ROW_PARSER_DIRECT_MARKERS)
self._gray_stack: list[dict[str, object]] = []
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
if self._title_depth > 0:
@ -53,14 +42,8 @@ class ScholarRowParser(HTMLParser):
self._year_depth += 1
if self._gray_stack:
self._gray_stack[-1]["depth"] += 1
if self._direct_marker_depth > 0:
self._direct_marker_depth += 1
if self._aux_link_stack:
self._aux_link_stack[-1]["depth"] += 1
classes = attr_class(attrs)
if tag in {"div", "span"} and self._contains_direct_marker(classes):
self._direct_marker_depth = 1
if tag == "a" and "gsc_a_at" in classes:
self._title_depth = 1
@ -79,17 +62,6 @@ class ScholarRowParser(HTMLParser):
self._gray_stack.append({"depth": 1, "parts": []})
return
if tag == "a":
href = attr_href(attrs)
self._aux_link_stack.append(
{
"depth": 1,
"classes": classes,
"href": href,
"parts": [],
}
)
def handle_data(self, data: str) -> None:
if self._title_depth > 0:
self.title_parts.append(data)
@ -99,23 +71,6 @@ class ScholarRowParser(HTMLParser):
self.year_parts.append(data)
if self._gray_stack:
self._gray_stack[-1]["parts"].append(data)
if self._aux_link_stack:
self._aux_link_stack[-1]["parts"].append(data)
def _capture_direct_download_href(self, link_info: dict[str, Any]) -> None:
if self.direct_download_href:
return
href = link_info.get("href")
if not href:
return
classes = str(link_info.get("classes") or "")
label = normalize_space("".join(link_info.get("parts") or [])).lower()
if "gs_ggsd" in classes or "gs_ggs" in classes or "gs_ggsa" in classes:
self.direct_download_href = href
return
label_match = any(token in label for token in PROFILE_ROW_DIRECT_LABEL_TOKENS)
if self._direct_marker_depth > 0 and label_match:
self.direct_download_href = href
def handle_endtag(self, tag: str) -> None:
if self._title_depth > 0:
@ -131,13 +86,6 @@ class ScholarRowParser(HTMLParser):
if text_value:
self.gray_texts.append(text_value)
self._gray_stack.pop()
if self._aux_link_stack:
self._aux_link_stack[-1]["depth"] -= 1
if self._aux_link_stack[-1]["depth"] == 0:
link_info = self._aux_link_stack.pop()
self._capture_direct_download_href(link_info)
if self._direct_marker_depth > 0:
self._direct_marker_depth -= 1
def extract_rows(html: str) -> list[str]:
@ -223,7 +171,7 @@ def _parse_publication_row(row_html: str) -> tuple[PublicationCandidate | None,
citation_count=citation_count,
authors_text=authors_text,
venue_text=venue_text,
pdf_url=build_absolute_scholar_url(parser.direct_download_href),
pdf_url=None,
),
warnings,
)

View file

@ -18,6 +18,7 @@ from app.db.models import AuthorSearchCacheEntry, AuthorSearchRuntimeState, Scho
from app.services.domains.scholar.parser import (
ParseState,
ParsedAuthorSearchPage,
ScholarParserError,
parse_author_search_page,
parse_profile_page,
)
@ -635,7 +636,16 @@ async def _fetch_author_search_with_retries(
retry_scheduled_count = 0
for attempt_index in range(max_attempts):
fetch_result = await source.fetch_author_search_html(normalized_query, start=0)
parsed = parse_author_search_page(fetch_result)
try:
parsed = parse_author_search_page(fetch_result)
except ScholarParserError as exc:
parsed = ParsedAuthorSearchPage(
state=ParseState.LAYOUT_CHANGED,
state_reason=exc.code,
candidates=[],
marker_counts={},
warnings=[exc.code],
)
if parsed.state != ParseState.NETWORK_ERROR or attempt_index >= max_attempts - 1:
break
retry_warnings.append("network_retry_scheduled_for_author_search")
@ -873,7 +883,10 @@ async def hydrate_profile_metadata(
source: ScholarSource,
) -> ScholarProfile:
fetch_result = await source.fetch_profile_html(profile.scholar_id)
parsed_page = parse_profile_page(fetch_result)
try:
parsed_page = parse_profile_page(fetch_result)
except ScholarParserError:
return profile
if parsed_page.profile_name and not (profile.display_name or "").strip():
profile.display_name = parsed_page.profile_name

View file

@ -0,0 +1,5 @@
from __future__ import annotations
from app.services.domains.unpaywall.application import resolve_publication_pdf_urls
__all__ = ["resolve_publication_pdf_urls"]

View file

@ -0,0 +1,218 @@
from __future__ import annotations
import logging
import re
from urllib.parse import unquote
from app.services.domains.crossref.application import discover_doi_for_publication
from app.services.domains.publications.types import PublicationListItem, UnreadPublicationItem
from app.services.domains.doi.normalize import normalize_doi
from app.settings import settings
DOI_PATTERN = re.compile(r"10\.\d{4,9}/[-._;()/:A-Z0-9]+", re.I)
DOI_PREFIX_RE = re.compile(r"\bdoi\s*[:=]\s*(10\.\d{4,9}/[-._;()/:A-Z0-9]+)", re.I)
DOI_URL_RE = re.compile(r"(?:https?://)?(?:dx\.)?doi\.org/(10\.\d{4,9}/[-._;()/:A-Z0-9]+)", re.I)
UNPAYWALL_URL_TEMPLATE = "https://api.unpaywall.org/v2/{doi}"
logger = logging.getLogger(__name__)
def _extract_doi_candidate(text: str | None) -> str | None:
if not text:
return None
decoded = unquote(text)
match = DOI_PATTERN.search(decoded)
if not match:
return None
return match.group(0).rstrip(" .;,)")
def _extract_explicit_doi(text: str | None) -> str | None:
if not text:
return None
decoded = unquote(text)
url_match = DOI_URL_RE.search(decoded)
if url_match:
return normalize_doi(url_match.group(1))
prefix_match = DOI_PREFIX_RE.search(decoded)
if prefix_match:
return normalize_doi(prefix_match.group(1))
return None
def _publication_doi(item: PublicationListItem | UnreadPublicationItem) -> str | None:
stored = normalize_doi(item.doi)
if stored:
in_metadata = any(
normalize_doi(_extract_explicit_doi(value)) == stored
for value in (item.pub_url, item.venue_text)
)
if in_metadata:
return stored
pub_url_doi = _extract_doi_candidate(item.pub_url)
if pub_url_doi:
return normalize_doi(pub_url_doi)
return (
_extract_explicit_doi(item.pub_url)
or _extract_explicit_doi(item.venue_text)
)
def _payload_pdf_url(payload: dict) -> str | None:
best = payload.get("best_oa_location")
if isinstance(best, dict):
pdf_url = best.get("url_for_pdf")
if isinstance(pdf_url, str) and pdf_url.strip():
return pdf_url.strip()
locations = payload.get("oa_locations")
if not isinstance(locations, list):
return None
for location in locations:
if not isinstance(location, dict):
continue
pdf_url = location.get("url_for_pdf")
if isinstance(pdf_url, str) and pdf_url.strip():
return pdf_url.strip()
return None
async def _fetch_unpaywall_payload_by_doi(
*,
client,
doi: str,
email: str,
) -> dict | None:
response = await client.get(
UNPAYWALL_URL_TEMPLATE.format(doi=doi),
params={"email": email},
)
if response.status_code != 200:
return None
payload = response.json()
if not isinstance(payload, dict):
return None
return payload
def _email_for_request(request_email: str | None) -> str | None:
email = (request_email or "").strip() or settings.unpaywall_email.strip()
return email or None
def _log_resolution_summary(
*,
publication_count: int,
doi_input_count: int,
search_attempt_count: int,
resolved_pdf_count: int,
email: str,
) -> None:
logger.info(
"unpaywall.resolve_completed",
extra={
"event": "unpaywall.resolve_completed",
"publication_count": publication_count,
"doi_input_count": doi_input_count,
"search_attempt_count": search_attempt_count,
"resolved_pdf_count": resolved_pdf_count,
"email_domain": email.split("@", 1)[-1] if "@" in email else None,
},
)
async def _resolve_item_payload(
*,
client,
item: PublicationListItem,
email: str,
allow_crossref: bool,
) -> tuple[dict | None, bool]:
doi = _publication_doi(item)
payload: dict | None = None
if doi:
payload = await _fetch_unpaywall_payload_by_doi(client=client, doi=doi, email=email)
if payload is not None and _payload_pdf_url(payload):
return payload, False
if not allow_crossref or not settings.crossref_enabled:
return payload, False
crossref_doi = await discover_doi_for_publication(
item=item,
max_rows=settings.crossref_max_rows,
email=email,
)
if crossref_doi is None or crossref_doi == doi:
return payload, crossref_doi is not None
crossref_payload = await _fetch_unpaywall_payload_by_doi(
client=client,
doi=crossref_doi,
email=email,
)
if crossref_payload is not None:
return crossref_payload, True
return payload, True
def _doi_and_pdf_from_payload(payload: dict) -> tuple[str | None, str | None]:
doi = normalize_doi(str(payload.get("doi") or ""))
return doi, _payload_pdf_url(payload)
def _resolution_targets(items: list[PublicationListItem]) -> list[PublicationListItem]:
return [item for item in items if not item.pdf_url]
def _crossref_budget_value() -> int:
return max(int(settings.crossref_max_lookups_per_request), 0)
async def resolve_publication_oa_metadata(
items: list[PublicationListItem],
*,
request_email: str | None = None,
) -> dict[int, tuple[str | None, str | None]]:
if not settings.unpaywall_enabled:
return {}
email = _email_for_request(request_email)
if email is None:
logger.debug("unpaywall.resolve_skipped_missing_email")
return {}
import httpx
timeout_seconds = max(float(settings.unpaywall_timeout_seconds), 0.5)
resolved: dict[int, tuple[str | None, str | None]] = {}
crossref_budget = _crossref_budget_value()
crossref_lookups = 0
targets = _resolution_targets(items)[: max(int(settings.unpaywall_max_items_per_request), 0)]
async with httpx.AsyncClient(timeout=timeout_seconds) as client:
for item in targets:
allow_crossref = crossref_budget > 0 and crossref_lookups < crossref_budget
payload, used_crossref = await _resolve_item_payload(
client=client,
item=item,
email=email,
allow_crossref=allow_crossref,
)
if used_crossref:
crossref_lookups += 1
if not isinstance(payload, dict):
continue
resolved[item.publication_id] = _doi_and_pdf_from_payload(payload)
resolved_count = sum(1 for _doi, pdf in resolved.values() if pdf)
doi_input_count = len([item for item in items if _publication_doi(item)])
target_doi_count = len([item for item in targets if _publication_doi(item)])
_log_resolution_summary(
publication_count=len(items),
doi_input_count=doi_input_count,
search_attempt_count=max(0, len(targets) - target_doi_count),
resolved_pdf_count=resolved_count,
email=email,
)
return resolved
async def resolve_publication_pdf_urls(
items: list[PublicationListItem],
*,
request_email: str | None = None,
) -> dict[int, str | None]:
resolved = await resolve_publication_oa_metadata(items, request_email=request_email)
return {publication_id: pdf for publication_id, (_doi, pdf) in resolved.items()}

View file

@ -1,3 +0,0 @@
from __future__ import annotations
from app.services.domains.portability.application import *

View file

@ -1,3 +0,0 @@
from __future__ import annotations
from app.services.domains.ingestion.application import *

View file

@ -1,3 +0,0 @@
from __future__ import annotations
from app.services.domains.publications.application import *

View file

@ -1,3 +0,0 @@
from __future__ import annotations
from app.services.domains.ingestion.safety import *

View file

@ -1,3 +0,0 @@
from __future__ import annotations
from app.services.domains.runs.application import *

View file

@ -1,3 +0,0 @@
from __future__ import annotations
from app.services.domains.ingestion.scheduler import *

View file

@ -1,3 +0,0 @@
from __future__ import annotations
from app.services.domains.scholar.parser import *

View file

@ -1,4 +0,0 @@
from __future__ import annotations
from app.services.domains.scholar.source import *
from app.services.domains.scholar.source import _build_profile_url

View file

@ -1,3 +0,0 @@
from __future__ import annotations
from app.services.domains.scholars.application import *

View file

@ -1,3 +0,0 @@
from __future__ import annotations
from app.services.domains.settings.application import *

View file

@ -1,3 +0,0 @@
from __future__ import annotations
from app.services.domains.users.application import *