"""Day-wise collection log for bioRxiv (JSON-API collected). Mirrors arxiv_auto but scoped to its OWN collection, `collected_data_biorxiv`, and campaign source `biorxiv`. Run state lives in `biorxiv_auto_runs`; email counts are aggregated live from the collection. """ from datetime import datetime, timedelta, 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 ( AutoRunFinish, AutoRunOut, AutoRunStart, CampaignOut, CampaignResult, CollectedBatch, CollectedPreprintOut, CollectedResult, DisciplineCount, Page, PendingDates, ProcessRequest, ProcessResult, ) from ..processing import create_campaigns, process_day from .arxiv_auto import ( BACKFILL_DAYS, REASONS, _reason_group, _with_remaining, apply_filters, as_day, today_utc, ) from .collected import as_bson_date, valid_email router = APIRouter(prefix="/biorxiv-auto", tags=["biorxiv auto"]) COLLECTED = "collected_data_biorxiv" RUNS = "biorxiv_auto_runs" SOURCE_COLL = "collected_data_biorxiv" SOURCE_TAG = "biorxiv" def collected(): return get_db()[COLLECTED] def runs(): return get_db()[RUNS] async def counts_by_day() -> dict[datetime, dict]: """published_on -> {emails, , remaining}.""" group = {"_id": "$published_on", **_reason_group()} rows = await (await collected().aggregate([ {"$match": {"published_on": {"$type": "date"}}}, {"$group": group}, ])).to_list(length=None) return {as_day(r["_id"]): _with_remaining(r) for r in rows} # ------------------------------------------------------------ save harvested @router.post("/collected", response_model=CollectedResult, summary="Save a batch of bioRxiv contacts") async def save_batch(payload: CollectedBatch): if not payload.rows: return CollectedResult(received=0, inserted=0, skipped=0) now = datetime.now(timezone.utc) ops = [] seen: 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() if not title or not valid_email(email): continue doc["email"], doc["title"] = email, title published = as_bson_date(doc.get("published_on")) if published is not None: doc["published_on"] = published else: doc.pop("published_on", None) if (title, email) in seen: continue seen.add((title, email)) doc["collected_on"] = now ops.append(UpdateOne({"title": title, "email": email}, {"$setOnInsert": doc}, upsert=True)) if not ops: return CollectedResult(received=len(payload.rows), inserted=0, skipped=len(payload.rows)) result = await collected().bulk_write(ops, ordered=False) inserted = result.upserted_count return CollectedResult(received=len(payload.rows), inserted=inserted, skipped=len(payload.rows) - inserted, updated=0) # ------------------------------------------------------------ day-wise log @router.get("/runs", response_model=list[AutoRunOut], summary="Day-wise log report") async def list_runs(limit: Annotated[int, Query(ge=1, le=365)] = 60): per_day = await counts_by_day() saved_days = {as_day(d) for d in await get_db()["collected_data_de"].distinct( "published_on", {"source": SOURCE_TAG})} campaigned_days = {as_day(d) for d in await get_db()["cd_campaigns"].distinct( "published_on", {"source": SOURCE_TAG})} recorded = await runs().find().sort("date", DESCENDING).to_list(length=None) by_date: dict[datetime, dict] = {} for run in recorded: day = as_day(run.get("date")) if day is not None: by_date[day] = run report: list[AutoRunOut] = [] for day in sorted(set(by_date) | set(per_day), reverse=True)[:limit]: run = by_date.get(day, {}) stats = per_day.get(day, {"emails": 0, "remaining": 0}) report.append(AutoRunOut( date=day, status=run.get("status") or ("completed" if stats.get("emails") else "not started"), saved=day in saved_days, campaigned=day in campaigned_days, disciplines=run.get("disciplines") or [], started_on=run.get("started_on"), collected_on=run.get("completed_on"), user_id=run.get("user_id"), error=run.get("error"), **{k: stats.get(k, 0) for k in ("emails", "remaining", *REASONS)}, )) return report @router.get("/runs/{day}/disciplines", response_model=list[DisciplineCount], summary="Discipline-wise summary for one day") async def disciplines_for(day: str): target = as_day(day) if target is None: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="date must be YYYY-MM-DD") pipeline = [ {"$match": {"published_on": target}}, {"$group": {"_id": "$discipline", "emails": {"$sum": 1}}}, {"$sort": {"emails": -1}}, ] rows = await (await collected().aggregate(pipeline)).to_list(length=None) return [DisciplineCount(discipline=r["_id"] or "—", emails=r["emails"], remaining=r["emails"]) for r in rows] @router.get("/collected", response_model=Page[CollectedPreprintOut], summary="Records for a day (optionally one discipline)") async def list_collected( published_on: Annotated[Optional[str], Query()] = None, discipline: Annotated[Optional[str], Query()] = None, limit: Annotated[int, Query(ge=1, le=500)] = 200, sort_dir: Annotated[str, Query(pattern="^(asc|desc)$")] = "desc", ): query: dict[str, Any] = {} 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 discipline: query["discipline"] = discipline col = collected() total = await col.count_documents(query) cursor = col.find(query).sort("collected_on", ASCENDING if sort_dir == "asc" else DESCENDING).limit(limit) return Page[CollectedPreprintOut](total=total, skip=0, limit=limit, items=await cursor.to_list(length=limit)) # ------------------------------------------------------------ run tracking @router.get("/pending", response_model=PendingDates, summary="Which days still need collecting (never today)") async def pending(days: Annotated[int, Query(ge=1, le=90)] = BACKFILL_DAYS): today = today_utc() yesterday = today - timedelta(days=1) done = {as_day(r["date"]) for r in await runs().find( {"status": "completed"}, {"date": 1}).to_list(length=None)} last = max((d for d in done if d is not None), default=None) wanted = [yesterday] + [yesterday - timedelta(days=n) for n in range(1, days + 1)] todo = [d for d in wanted if d not in done] return PendingDates(last_completed=last, yesterday=yesterday, pending=todo) @router.post("/runs/start", response_model=AutoRunOut, summary="Mark a day as running") async def start_run(payload: AutoRunStart): day = as_day(payload.date) if day is None: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="date must be YYYY-MM-DD") now = datetime.now(timezone.utc) await runs().update_one( {"date": day}, {"$set": {"status": "running", "started_on": now, "disciplines": payload.disciplines, "user_id": payload.user_id, "error": None, "completed_on": None}}, upsert=True) return AutoRunOut(date=day, status="running", started_on=now, disciplines=payload.disciplines, user_id=payload.user_id) @router.post("/runs/finish", response_model=AutoRunOut, summary="Mark a day finished") async def finish_run(payload: AutoRunFinish): day = as_day(payload.date) if day is None: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="date must be YYYY-MM-DD") now = datetime.now(timezone.utc) await runs().update_one( {"date": day}, {"$set": {"status": payload.status, "completed_on": now, "error": payload.error}}, upsert=True) stats = (await counts_by_day()).get(day, {"emails": 0, "remaining": 0}) doc = await runs().find_one({"date": day}) or {} return AutoRunOut(date=day, status=payload.status, started_on=doc.get("started_on"), collected_on=now, disciplines=doc.get("disciplines") or [], user_id=doc.get("user_id"), error=payload.error, emails=stats.get("emails", 0), remaining=stats.get("remaining", 0)) # ------------------------------------------------- process + campaigns @router.post("/filters", summary="Run every block-list filter over the bioRxiv contacts") async def run_filters(): return await apply_filters(collected()) @router.post("/process", response_model=ProcessResult, summary="Save one day's rows into collected_data_de (biorxiv source)") async def process(payload: ProcessRequest): day = as_day(payload.date) if day is None: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="date must be YYYY-MM-DD") await apply_filters(collected(), {"published_on": day}) result = await process_day(payload.user_id, day, SOURCE_COLL, SOURCE_TAG) return ProcessResult(**result) @router.post("/campaigns", response_model=CampaignResult, summary="Create publisher-wise campaigns for a bioRxiv day") async def create_campaigns_route(payload: ProcessRequest): day = as_day(payload.date) if day is None: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="date must be YYYY-MM-DD") result = await create_campaigns(payload.user_id, day, SOURCE_TAG) return CampaignResult(**result) @router.get("/campaigns", response_model=list[CampaignOut], summary="bioRxiv campaigns for one day") async def campaigns_for(date: str): day = as_day(date) if day is None: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="date must be YYYY-MM-DD") rows = await get_db()["cd_campaigns"].find( {"published_on": day, "source": SOURCE_TAG} ).sort("total_emails", DESCENDING).to_list(length=None) return [CampaignOut(**r) for r in rows]