Initial commit: Thread Ripper containerized application
This commit is contained in:
commit
a648a59c0f
16 files changed
+2213
No files matched your search
@@ -0,0 +1,113 @@
|
||||
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")
|
||||
Reference in new issue
Block a user