magpie/app/services/engagement.py
Alan Weinstock dfdde692b8 feat(engagement): poll Reddit post metrics after posting (#6)
- Add RedditClient.fetch_stats() — fetches score/upvotes/comments/awards via by_id API
- Add Store.list_posts_needing_poll() — selects successful Reddit posts not checked within recheck window
- Add Store.list_posts() LEFT JOIN latest engagement snapshot (avoids N+1 on frontend)
- Add app/services/engagement.py — poll_recent_posts() async service with unauthenticated fallback
- Register hourly engagement poll job in APScheduler at startup
- Add POST /posts/poll-engagement for manual triggers
- Update Post interface with engagement fields (score, comment_count, awards, engagement_checked_at)
- Add Score/Comments columns and poll button to PostsView

Closes: #6
2026-06-13 22:02:07 -07:00

92 lines
3.4 KiB
Python

"""
Engagement polling service.
Periodically fetches score, comments, and awards for recent successful Reddit
posts and records snapshots in the engagement table.
"""
from __future__ import annotations
import asyncio
import logging
from app.db.store import Store
logger = logging.getLogger(__name__)
def _poll_sync(db_path: str, max_age_hours: int = 72, recheck_hours: int = 1) -> dict:
store = Store(db_path)
try:
posts = store.list_posts_needing_poll(max_age_hours, recheck_hours)
if not posts:
logger.debug("Engagement poll: no posts due for a check")
return {"polled": 0, "errors": 0}
# Import here to avoid circular deps; session validation deferred to first use.
from app.services.reddit.client import RedditClient
try:
client = RedditClient()
except Exception:
logger.warning(
"Reddit session unavailable — engagement poll will run without auth"
)
client = None
polled = errors = 0
for post in posts:
try:
if client is not None:
stats = client.fetch_stats(post["url"])
else:
# Unauthenticated fallback: still works for public posts.
import re
import httpx
match = re.search(r"/comments/([a-z0-9]+)/", post["url"])
if not match:
continue
resp = httpx.get(
f"https://www.reddit.com/by_id/t3_{match.group(1)}.json",
headers={"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) Chrome/124.0.0.0"},
timeout=15,
)
children = resp.json().get("data", {}).get("children", []) if resp.status_code == 200 else []
if not children:
continue
d = children[0].get("data", {})
stats = {
"score": d.get("score"),
"upvotes": d.get("ups"),
"comments": d.get("num_comments"),
"awards": d.get("total_awards_received", 0),
}
if stats:
store.record_engagement(
post["id"],
score=stats.get("score"),
upvotes=stats.get("upvotes"),
comments=stats.get("comments"),
awards=stats.get("awards", 0),
)
logger.debug(
"Post %d engagement: score=%s comments=%s",
post["id"], stats.get("score"), stats.get("comments"),
)
polled += 1
except Exception:
logger.exception("Failed to poll engagement for post %d", post["id"])
errors += 1
logger.info("Engagement poll done: %d polled, %d errors", polled, errors)
return {"polled": polled, "errors": errors}
finally:
store.close()
async def poll_recent_posts(
db_path: str, max_age_hours: int = 72, recheck_hours: int = 1
) -> dict:
"""Async wrapper for the scheduler and API trigger."""
return await asyncio.to_thread(_poll_sync, db_path, max_age_hours, recheck_hours)