import re from datetime import date, datetime, timezone from typing import Annotated, Any, Optional from fastapi import APIRouter, HTTPException, Query, status from pymongo import ASCENDING, DESCENDING, UpdateOne from ..db import get_db from ..models import ( CollectedBatch, CollectedPreprintOut, CollectedResult, Page, ) router = APIRouter(prefix="/collected-preprints", tags=["collected preprints"]) COLLECTION = "collected_data_preprints_v2" SORTABLE = {"collected_on", "title", "name", "email", "country", "discipline", "published_on"} def collected(): return get_db()[COLLECTION] # Mirrors the desktop's validation, as a backstop -- an invalid address must not # reach the collection whichever client sends it. EMAIL_OK = re.compile( r"^[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+(\.[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+)*" r"@[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?" r"(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+$" ) COMMON_TLDS = { "com", "org", "net", "edu", "gov", "mil", "int", "info", "biz", "name", "pro", "io", "ai", "co", "me", "dev", "app", "cloud", "tech", "online", "site", "xyz", "eu", "asia", "one", "science", "academy", "institute", "university", "email", } PLACEHOLDER_DOMAINS = { "example.com", "example.org", "example.net", "example.edu", "domain.com", "yourdomain.com", "email.com", "mail.com", "test.com", "sample.com", "institution.edu", "university.edu", "somewhere.com", "xxx.com", "abc.com", } PLACEHOLDER_LOCALS = { "email", "e-mail", "name", "yourname", "your-name", "firstname", "lastname", "firstname.lastname", "author", "authors", "corresponding", "user", "username", "xxx", "xxxx", "abc", "someone", "somebody", "example", "test", "noreply", "no-reply", "donotreply", } def valid_email(address: str) -> bool: if not address or not (5 <= len(address) <= 254) or not EMAIL_OK.match(address): return False local, _, domain = address.partition("@") if local in PLACEHOLDER_LOCALS or domain in PLACEHOLDER_DOMAINS: return False tld = address.rsplit(".", 1)[-1] if not tld.isalpha(): return False return len(tld) == 2 or tld in COMMON_TLDS def as_bson_date(value: Any) -> Optional[datetime]: """BSON has no date-only type, and PyMongo cannot encode `datetime.date` at all -- it must become a `datetime`. Midnight UTC keeps a day a day.""" if value is None: return None if isinstance(value, datetime): return value if value.tzinfo else value.replace(tzinfo=timezone.utc) if isinstance(value, date): return datetime(value.year, value.month, value.day, tzinfo=timezone.utc) try: parsed = date.fromisoformat(str(value)[:10]) except ValueError: return None return datetime(parsed.year, parsed.month, parsed.day, tzinfo=timezone.utc) @router.post("", response_model=CollectedResult, summary="Save a batch of harvested contacts") async def save_batch(payload: CollectedBatch): if not payload.rows: return CollectedResult(received=0, inserted=0, skipped=0) now = datetime.now(timezone.utc) operations = [] seen_in_batch: set[tuple[str, str]] = set() for row in payload.rows: doc = row.model_dump(exclude_none=True) email = (doc.get("email") or "").strip().lower() title = (doc.get("title") or "").strip() # Skipped rather than 422: one malformed address must not throw away the # rest of a harvested batch. if not title or not valid_email(email): continue doc["email"] = email doc["title"] = title published = as_bson_date(doc.get("published_on")) if published is not None: doc["published_on"] = published else: doc.pop("published_on", None) # A batch can carry the same contact twice (two articles, same author). if (title, email) in seen_in_batch: continue seen_in_batch.add((title, email)) doc["collected_on"] = now # title + email is the identity. Everything goes in $setOnInsert, so a # contact already held is left exactly as it was -- re-running a day # adds only what is genuinely new and never rewrites existing rows. operations.append( UpdateOne({"title": title, "email": email}, {"$setOnInsert": doc}, upsert=True) ) if not operations: return CollectedResult(received=len(payload.rows), inserted=0, skipped=len(payload.rows)) result = await collected().bulk_write(operations, ordered=False) inserted = result.upserted_count return CollectedResult( received=len(payload.rows), inserted=inserted, skipped=len(payload.rows) - inserted, updated=0, ) @router.get("", response_model=Page[CollectedPreprintOut], summary=f"List {COLLECTION}") async def list_collected( q: Annotated[Optional[str], Query(description="Search title, name, email, country")] = None, discipline: Annotated[Optional[str], Query()] = None, published_on: Annotated[ Optional[str], Query(description="Exact publication day, YYYY-MM-DD") ] = None, country: Annotated[Optional[str], Query()] = None, collected_by: Annotated[Optional[str], Query(description="User _id who harvested")] = None, has_email: Annotated[Optional[bool], Query()] = None, sort_by: Annotated[str, Query()] = "collected_on", sort_dir: Annotated[str, Query(pattern="^(asc|desc)$")] = "desc", skip: Annotated[int, Query(ge=0)] = 0, limit: Annotated[int, Query(ge=1, le=500)] = 50, ): if sort_by not in SORTABLE: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"sort_by must be one of: {', '.join(sorted(SORTABLE))}", ) query: dict[str, Any] = {} if q: rx = {"$regex": re.escape(q), "$options": "i"} query["$or"] = [{"title": rx}, {"name": rx}, {"email": rx}, {"country": rx}, {"relatedtopics": rx}] if discipline: query["discipline"] = discipline if published_on: day = as_bson_date(published_on) if day is None: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="published_on must be YYYY-MM-DD") query["published_on"] = day if country: query["country"] = country if collected_by: query["collected_by"] = collected_by if has_email is not None: query["email"] = {"$nin": [None, ""]} if has_email else {"$in": [None, ""]} col = collected() total = await col.count_documents(query) cursor = ( col.find(query) .sort(sort_by, ASCENDING if sort_dir == "asc" else DESCENDING) .skip(skip) .limit(limit) ) return Page[CollectedPreprintOut]( total=total, skip=skip, limit=limit, items=await cursor.to_list(length=limit) ) @router.delete("", summary="Delete collected rows (all, or one harvester)") async def clear_collected( collected_by: Annotated[Optional[str], Query(description="Omit to clear everything")] = None, ): query = {"collected_by": collected_by} if collected_by else {} result = await collected().delete_many(query) return {"deleted": result.deleted_count}