orakzai-io
V2
d57e0f9
Raw History Blame Contribute Delete
5.32 kB
import os
import asyncio
import uuid
import random
from app.scraper_manager import ScraperManager
from fastapi import APIRouter, BackgroundTasks, HTTPException
from fastapi.responses import FileResponse, Response
from pathlib import Path
from pydantic import BaseModel
from typing import List
from loguru import logger
router = APIRouter()
log = logger.bind(context="API")
jobs = {}
download_lock = asyncio.Lock()
class ScrapeRequest(BaseModel):
urls: List[str]
async def process_url(
job_id: str,
url_id: str,
url: str,
delay: float = 0.0,
limit: asyncio.Semaphore = asyncio.Semaphore(10),
):
async with limit:
jobs[job_id]["urls"][url_id]["status"] = "processing"
if delay > 0:
await asyncio.sleep(delay)
manager = ScraperManager()
try:
result = await manager.scrape(url)
jobs[job_id]["urls"][url_id]["status"] = "completed"
return result
except Exception as e:
log.exception(f"Error scraping {url}: {e}")
if "Bot Blocked" in str(e):
jobs[job_id]["urls"][url_id]["status"] = "Bot Blocked"
elif "Wrong URL" in str(e):
jobs[job_id]["urls"][url_id]["status"] = "Wrong URL"
elif "LLM" in str(e):
jobs[job_id]["urls"][url_id]["status"] = "LLM limit exceeded"
else:
jobs[job_id]["urls"][url_id]["status"] = "Failed"
return {"error": jobs[job_id]["urls"][url_id]["status"], "url": url}
finally:
await manager.close()
async def run(job_id: str, urls: List[str], url_tracking: dict):
jobs[job_id]["status"] = "processing"
results = []
limit = asyncio.Semaphore(10)
random_delay = random.uniform(0.0, 2.0)
# Create tasks as before...
tasks = [
process_url(job_id=job_id, url_id=url_id, url=info["url"], delay=random_delay, limit=limit)
for url_id, info in url_tracking.items()
]
# Use as_completed to update progress one by one
for task in asyncio.as_completed(tasks):
result = await task
results.append(result)
jobs[job_id]["status"] = "completed"
# Wait until the end to store the heavy result data
jobs[job_id]["data"] = results
@router.post("/scrape")
async def trigger_scrape(request: ScrapeRequest, background_tasks: BackgroundTasks):
"""
Accepts a list of URLs, generates a job ID, and starts scraping in the background.
"""
if not request.urls:
log.warning("Received scrape request with no URLs")
raise HTTPException(status_code=400, detail="No URLs provided")
log.success(f"Received scrape request for {len(request.urls)} URLs")
job_id = str(uuid.uuid4())
# Store tracking by index so duplicate URLs don't overwrite each other
url_tracking = {str(i): {"url": url, "status": "pending"} for i, url in enumerate(request.urls)}
jobs[job_id] = {
"status": "pending",
"urls": url_tracking,
"data": None,
"error": None,
}
# Send the scraping task to the background
background_tasks.add_task(run, job_id, request.urls, url_tracking)
return {
"job_id": job_id,
"status": "pending",
"message": f"Started scraping {len(request.urls)} URLs in the background.",
}
@router.get("/results")
async def get_all_results():
"""
Return a summary of ALL jobs for the frontend to poll once
"""
return {
job_id: {
"status": info["status"],
"urls": info.get("urls", {}),
"error": info["error"],
}
for job_id, info in jobs.items()
}
DATA_DIR = Path(__file__).resolve().parent
@router.get("/download")
async def download_jsonl():
"""
Returns the data.jsonl file as a downloadable attachment and deletes it after.
Handles double-clicks gracefully by returning 204 if state was just cleared.
"""
async with download_lock:
file_path = DATA_DIR / "data.jsonl"
if not file_path.exists():
# If file is gone but jobs are cleared, it's likely a double-click
if not jobs:
log.info("Download requested but data already cleared. Returning 204.")
return Response(status_code=204)
raise HTTPException(
status_code=404, detail="No data file found. Run a scrape job first."
)
log.success("Downloading data.jsonl")
# Read the file into memory first to avoid race conditions with FileResponse
content = file_path.read_bytes()
is_any_job_active = any(
job["status"] in ["pending", "processing"] for job in jobs.values()
)
if not is_any_job_active:
file_path.unlink(missing_ok=True)
jobs.clear()
log.info("File deleted & jobs cleared: No active jobs found.")
return Response(
content=content,
media_type="application/x-ndjson",
headers={"Content-Disposition": "attachment; filename=data.jsonl"},
)