magpie/app/services/reddit/client.py
Alan Weinstock bd58f9f54e feat: scaffold Magpie — campaign scheduler + social posting platform
FastAPI backend (SQLite + APScheduler), Vue 3 frontend, MCP server for
Claude integration, and Docker Compose stack. Includes campaign data model
(campaigns → variants → subs), post history, sub rules, and Playwright-based
Reddit posting layer migrated from claude-bridge/reddit-poster.

Also seeds legacy campaigns (6) and sub rules (14) from reddit-poster history.

Closes #1 (scaffold), resolves migration from claude-bridge.
2026-04-21 16:51:33 -07:00

122 lines
4.2 KiB
Python

"""
RedditClient: thin wrapper around the Playwright post.py script.
Migrated from claude-bridge/reddit-poster/reddit.py.
Posting goes via xvfb-run + Playwright (avoids Reddit API bot detection).
Comment/delete go via httpx with saved session cookies.
"""
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
import httpx
from app.core.config import get_settings
from app.services.reddit.session import ensure_valid_session, load_cookies
_POST_SCRIPT = Path(__file__).parent / "post.py"
USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) Chrome/124.0.0.0"
class RedditClient:
def __init__(self, session_file: Path | None = None) -> None:
settings = get_settings()
self._session_file = session_file or Path(settings.reddit_session_file)
ensure_valid_session(self._session_file)
self.cookies = load_cookies(self._session_file)
self.headers = {"User-Agent": USER_AGENT}
self._modhash: str | None = None
@property
def modhash(self) -> str:
if self._modhash is None:
resp = httpx.get(
"https://www.reddit.com/api/me.json",
cookies=self.cookies,
headers=self.headers,
timeout=15,
)
self._modhash = resp.json().get("data", {}).get("modhash", "")
return self._modhash
def post(self, sub: str, title: str, body: str, flair: str | None = None) -> str:
"""Submit a text post via Playwright (xvfb-run). Returns the permalink."""
settings = get_settings()
cmd = [
"xvfb-run", "--auto-servernum",
sys.executable, str(_POST_SCRIPT),
"--sub", sub,
"--title", title,
"--body", body,
"--yes",
]
if flair:
cmd += ["--flair", flair]
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=180,
env={
**__import__("os").environ,
"REDDIT_SESSION_FILE": str(self._session_file),
},
)
if result.returncode != 0:
raise RuntimeError(
f"Post to r/{sub} failed (exit {result.returncode}):\n"
f"STDOUT:\n{result.stdout}\n"
f"STDERR:\n{result.stderr}"
)
for line in result.stdout.splitlines():
if line.startswith("Posted:"):
url = line.split("Posted:", 1)[-1].strip()
return url
raise RuntimeError(
f"Post to r/{sub} may have failed — no 'Posted:' line in output.\n"
f"Full stdout:\n{result.stdout}"
)
def comment(self, thread_id: str, body: str) -> str:
"""Post a top-level comment to a thread. Returns the permalink."""
resp = httpx.post(
"https://www.reddit.com/api/comment",
cookies=self.cookies,
headers={**self.headers, "X-Modhash": self.modhash},
data={
"api_type": "json",
"thing_id": f"t3_{thread_id}",
"text": body,
},
timeout=15,
)
result = resp.json()
errors = result.get("json", {}).get("errors", [])
if errors:
raise RuntimeError(f"Comment failed: {errors}")
things = result.get("json", {}).get("data", {}).get("things", [])
permalink = (
"https://reddit.com" + things[0]["data"].get("permalink", "") if things else ""
)
return permalink
def delete(self, post_url: str) -> None:
"""Delete a post by URL."""
import re
match = re.search(r"/comments/([a-z0-9]+)/", post_url)
if not match:
raise ValueError(f"Cannot extract post ID from: {post_url}")
post_id = match.group(1)
resp = httpx.post(
"https://www.reddit.com/api/del",
cookies=self.cookies,
headers={**self.headers, "X-Modhash": self.modhash},
data={"id": f"t3_{post_id}"},
timeout=15,
)
if resp.status_code != 200:
raise RuntimeError(f"Delete failed ({resp.status_code}): {resp.text[:200]}")