Files
thread-ripper/thread_ripper/server.py
T

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")