import os from contextlib import asynccontextmanager from fastapi import FastAPI, HTTPException, Response from fastapi.staticfiles import StaticFiles from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, PlainTextResponse, HTMLResponse, JSONResponse from .models import RipRequest, RippedThread from .scraper import ThreadScraper from .storage import ThreadStorage DATA_DIR = os.environ.get("DATA_DIR", "data") storage = ThreadStorage(DATA_DIR) scraper = ThreadScraper(headless=True) @asynccontextmanager async def lifespan(app: FastAPI): yield await scraper.close() app = FastAPI( title="Thread Ripper", description="Containerized Thread Ripper for Meta Threads. Takes Threads links and stitches threads into single posts.", version="1.0.0", lifespan=lifespan ) app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) @app.post("/api/rip", response_model=RippedThread) async def rip_endpoint(req: RipRequest): """Rip a Threads post and its chained continuation posts.""" if not req.url or "threads." not in req.url: raise HTTPException(status_code=400, detail="Invalid Threads URL. Please provide a valid threads.net or threads.com link.") try: ripped = await scraper.rip(url=req.url, mode=req.mode, max_scrolls=req.max_scrolls) storage.save(ripped) return ripped except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to rip thread: {str(e)}") @app.get("/api/history") def get_history(): """Retrieve history of saved ripped threads.""" return storage.list_history() @app.get("/api/history/{thread_id}", response_model=RippedThread) def get_thread_by_id(thread_id: str): """Get complete ripped thread by ID.""" thread = storage.get(thread_id) if not thread: raise HTTPException(status_code=404, detail="Thread not found") return thread @app.delete("/api/history/{thread_id}") def delete_thread_by_id(thread_id: str): """Delete thread from history.""" deleted = storage.delete(thread_id) if not deleted: raise HTTPException(status_code=404, detail="Thread not found") return {"status": "success", "deleted_id": thread_id} @app.get("/api/download/{thread_id}/{format}") def download_thread(thread_id: str, format: str): """Download thread in chosen format (.md, .txt, .html, .json).""" thread = storage.get(thread_id) if not thread: raise HTTPException(status_code=404, detail="Thread not found") safe_title = "".join(c for c in thread.author_handle if c.isalnum() or c in "-_") filename_base = f"thread_{safe_title}_{thread_id}" if format == "md" or format == "markdown": return Response( content=thread.stitched_markdown, media_type="text/markdown; charset=utf-8", headers={"Content-Disposition": f'attachment; filename="{filename_base}.md"'} ) elif format == "txt" or format == "text": return Response( content=thread.stitched_text, media_type="text/plain; charset=utf-8", headers={"Content-Disposition": f'attachment; filename="{filename_base}.txt"'} ) elif format == "html": return Response( content=thread.stitched_html, media_type="text/html; charset=utf-8", headers={"Content-Disposition": f'attachment; filename="{filename_base}.html"'} ) elif format == "json": return Response( content=thread.model_dump_json(indent=2), media_type="application/json; charset=utf-8", headers={"Content-Disposition": f'attachment; filename="{filename_base}.json"'} ) else: raise HTTPException(status_code=400, detail="Supported formats: md, txt, html, json") @app.get("/api/health") def health_check(): return {"status": "ok", "app": "Thread Ripper", "version": "1.0.0"} # Mount static frontend directory static_dir = os.path.join(os.path.dirname(__file__), "static") if os.path.exists(static_dir): app.mount("/", StaticFiles(directory=static_dir, html=True), name="static")