- 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
92 lines
3.4 KiB
Python
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)
|