114 lines
4.1 KiB
Python
114 lines
4.1 KiB
Python
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")
|