Add Reddit monitoring bot — backend, frontend, and Docker config
Python/FastAPI backend with PostgreSQL for collecting Reddit data via public .json endpoints. React/Vite dashboard for analytics. Docker Compose setup with API and worker services connecting to shared PostgreSQL. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
0
backend/routers/__init__.py
Normal file
0
backend/routers/__init__.py
Normal file
61
backend/routers/analytics.py
Normal file
61
backend/routers/analytics.py
Normal file
@@ -0,0 +1,61 @@
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.services import analytics_service
|
||||
|
||||
router = APIRouter(prefix="/analytics", tags=["analytics"])
|
||||
|
||||
|
||||
@router.get("/engagement")
|
||||
async def engagement(
|
||||
subreddit_id: int | None = None,
|
||||
granularity: str = Query("day", pattern="^(hour|day|week)$"),
|
||||
since: datetime | None = None,
|
||||
until: datetime | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await analytics_service.get_engagement(db, subreddit_id, granularity, since, until)
|
||||
|
||||
|
||||
@router.get("/top-posts")
|
||||
async def top_posts(
|
||||
subreddit_id: int | None = None,
|
||||
metric: str = Query("score", pattern="^(score|num_comments)$"),
|
||||
since: datetime | None = None,
|
||||
until: datetime | None = None,
|
||||
limit: int = Query(10, ge=1, le=50),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await analytics_service.get_top_posts(db, subreddit_id, metric, since, until, limit)
|
||||
|
||||
|
||||
@router.get("/top-authors")
|
||||
async def top_authors(
|
||||
subreddit_id: int | None = None,
|
||||
since: datetime | None = None,
|
||||
until: datetime | None = None,
|
||||
limit: int = Query(10, ge=1, le=50),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await analytics_service.get_top_authors(db, subreddit_id, since, until, limit)
|
||||
|
||||
|
||||
@router.get("/subreddit-summary")
|
||||
async def subreddit_summary(
|
||||
since: datetime | None = None,
|
||||
until: datetime | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await analytics_service.get_subreddit_summary(db, since, until)
|
||||
|
||||
|
||||
@router.get("/flair-distribution")
|
||||
async def flair_distribution(
|
||||
subreddit_id: int = Query(...),
|
||||
since: datetime | None = None,
|
||||
until: datetime | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await analytics_service.get_flair_distribution(db, subreddit_id, since, until)
|
||||
39
backend/routers/authors.py
Normal file
39
backend/routers/authors.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.services import author_service
|
||||
|
||||
router = APIRouter(prefix="/authors", tags=["authors"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_authors(
|
||||
subreddit_id: int | None = None,
|
||||
sort_by: str = Query("total_comments", pattern="^(total_posts|total_comments)$"),
|
||||
sort_order: str = Query("desc", pattern="^(asc|desc)$"),
|
||||
since: datetime | None = None,
|
||||
until: datetime | None = None,
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(25, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
authors, total = await author_service.list_authors(
|
||||
db, subreddit_id, sort_by, sort_order, since, until, page, per_page
|
||||
)
|
||||
return {
|
||||
"data": authors,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"pages": (total + per_page - 1) // per_page if per_page else 0,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{author_id}")
|
||||
async def get_author(author_id: int, db: AsyncSession = Depends(get_db)):
|
||||
author = await author_service.get_author(db, author_id)
|
||||
if not author:
|
||||
raise HTTPException(status_code=404, detail="Author not found")
|
||||
return author
|
||||
33
backend/routers/comments.py
Normal file
33
backend/routers/comments.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.services import comment_service
|
||||
|
||||
router = APIRouter(prefix="/comments", tags=["comments"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_comments(
|
||||
post_id: int | None = None,
|
||||
subreddit_id: int | None = None,
|
||||
author: str | None = None,
|
||||
sort_by: str = Query("created_utc", pattern="^(created_utc|score)$"),
|
||||
sort_order: str = Query("desc", pattern="^(asc|desc)$"),
|
||||
since: datetime | None = None,
|
||||
until: datetime | None = None,
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(25, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
comments, total = await comment_service.list_comments(
|
||||
db, post_id, subreddit_id, author, sort_by, sort_order, since, until, page, per_page
|
||||
)
|
||||
return {
|
||||
"data": comments,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"pages": (total + per_page - 1) // per_page if per_page else 0,
|
||||
}
|
||||
54
backend/routers/digests.py
Normal file
54
backend/routers/digests.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.models.daily_digest import DailyDigest
|
||||
from backend.models.subreddit import MonitoredSubreddit
|
||||
|
||||
router = APIRouter(prefix="/digests", tags=["digests"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_digests(
|
||||
subreddit_id: int | None = None,
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(25, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
stmt = (
|
||||
select(DailyDigest, MonitoredSubreddit.name)
|
||||
.join(MonitoredSubreddit)
|
||||
.order_by(DailyDigest.digest_date.desc())
|
||||
)
|
||||
if subreddit_id:
|
||||
stmt = stmt.where(DailyDigest.subreddit_id == subreddit_id)
|
||||
|
||||
stmt = stmt.offset((page - 1) * per_page).limit(per_page)
|
||||
result = await db.execute(stmt)
|
||||
|
||||
digests = []
|
||||
for digest, sub_name in result.all():
|
||||
data = {c.name: getattr(digest, c.name) for c in digest.__table__.columns}
|
||||
data["subreddit_name"] = sub_name
|
||||
digests.append(data)
|
||||
|
||||
return {"data": digests, "page": page, "per_page": per_page}
|
||||
|
||||
|
||||
@router.get("/{digest_id}")
|
||||
async def get_digest(digest_id: int, db: AsyncSession = Depends(get_db)):
|
||||
stmt = (
|
||||
select(DailyDigest, MonitoredSubreddit.name)
|
||||
.join(MonitoredSubreddit)
|
||||
.where(DailyDigest.id == digest_id)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
row = result.first()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Digest not found")
|
||||
|
||||
digest, sub_name = row
|
||||
data = {c.name: getattr(digest, c.name) for c in digest.__table__.columns}
|
||||
data["subreddit_name"] = sub_name
|
||||
return data
|
||||
16
backend/routers/health.py
Normal file
16
backend/routers/health.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.database import get_db
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health_check(db: AsyncSession = Depends(get_db)):
|
||||
try:
|
||||
await db.execute(text("SELECT 1"))
|
||||
return {"status": "ok", "db": "connected"}
|
||||
except Exception:
|
||||
return {"status": "degraded", "db": "disconnected"}
|
||||
64
backend/routers/posts.py
Normal file
64
backend/routers/posts.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.schemas.post import PostResponse, PostDetailResponse
|
||||
from backend.services import post_service
|
||||
from backend.models.metric_snapshot import MetricSnapshot
|
||||
from sqlalchemy import select
|
||||
|
||||
router = APIRouter(prefix="/posts", tags=["posts"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_posts(
|
||||
subreddit_id: int | None = None,
|
||||
author: str | None = None,
|
||||
flair: str | None = None,
|
||||
sort_by: str = Query("created_utc", pattern="^(created_utc|score|num_comments)$"),
|
||||
sort_order: str = Query("desc", pattern="^(asc|desc)$"),
|
||||
since: datetime | None = None,
|
||||
until: datetime | None = None,
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(25, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
posts, total = await post_service.list_posts(
|
||||
db, subreddit_id, author, flair, sort_by, sort_order, since, until, page, per_page
|
||||
)
|
||||
return {
|
||||
"data": posts,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"pages": (total + per_page - 1) // per_page if per_page else 0,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{post_id}")
|
||||
async def get_post(post_id: int, db: AsyncSession = Depends(get_db)):
|
||||
post = await post_service.get_post(db, post_id)
|
||||
if not post:
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
return post
|
||||
|
||||
|
||||
@router.get("/{post_id}/snapshots")
|
||||
async def get_post_snapshots(post_id: int, db: AsyncSession = Depends(get_db)):
|
||||
stmt = (
|
||||
select(MetricSnapshot)
|
||||
.where(MetricSnapshot.post_id == post_id)
|
||||
.order_by(MetricSnapshot.snapshot_at.asc())
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
snapshots = result.scalars().all()
|
||||
return [
|
||||
{
|
||||
"score": s.score,
|
||||
"num_comments": s.num_comments,
|
||||
"upvote_ratio": s.upvote_ratio,
|
||||
"snapshot_at": s.snapshot_at,
|
||||
}
|
||||
for s in snapshots
|
||||
]
|
||||
47
backend/routers/subreddits.py
Normal file
47
backend/routers/subreddits.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.schemas.subreddit import SubredditCreate, SubredditUpdate, SubredditResponse
|
||||
from backend.services import subreddit_service
|
||||
|
||||
router = APIRouter(prefix="/subreddits", tags=["subreddits"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[SubredditResponse])
|
||||
async def list_subreddits(db: AsyncSession = Depends(get_db)):
|
||||
return await subreddit_service.list_subreddits(db)
|
||||
|
||||
|
||||
@router.post("", response_model=SubredditResponse, status_code=201)
|
||||
async def create_subreddit(body: SubredditCreate, db: AsyncSession = Depends(get_db)):
|
||||
sub = await subreddit_service.create_subreddit(db, body.name)
|
||||
data = {c.name: getattr(sub, c.name) for c in sub.__table__.columns}
|
||||
data["post_count"] = 0
|
||||
return data
|
||||
|
||||
|
||||
@router.get("/{subreddit_id}", response_model=SubredditResponse)
|
||||
async def get_subreddit(subreddit_id: int, db: AsyncSession = Depends(get_db)):
|
||||
sub = await subreddit_service.get_subreddit(db, subreddit_id)
|
||||
if not sub:
|
||||
raise HTTPException(status_code=404, detail="Subreddit not found")
|
||||
return sub
|
||||
|
||||
|
||||
@router.patch("/{subreddit_id}", response_model=SubredditResponse)
|
||||
async def update_subreddit(
|
||||
subreddit_id: int, body: SubredditUpdate, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
sub = await subreddit_service.update_subreddit(db, subreddit_id, body.is_active)
|
||||
if not sub:
|
||||
raise HTTPException(status_code=404, detail="Subreddit not found")
|
||||
result = await subreddit_service.get_subreddit(db, subreddit_id)
|
||||
return result
|
||||
|
||||
|
||||
@router.delete("/{subreddit_id}", status_code=204)
|
||||
async def delete_subreddit(subreddit_id: int, db: AsyncSession = Depends(get_db)):
|
||||
deleted = await subreddit_service.delete_subreddit(db, subreddit_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Subreddit not found")
|
||||
13
backend/routers/summaries.py
Normal file
13
backend/routers/summaries.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter(prefix="/summaries", tags=["summaries"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_summaries():
|
||||
return {"data": [], "message": "AI summaries not yet configured"}
|
||||
|
||||
|
||||
@router.get("/{summary_id}")
|
||||
async def get_summary(summary_id: int):
|
||||
return {"detail": "AI summaries not yet configured"}
|
||||
Reference in New Issue
Block a user