magpie/app/api/endpoints/subs.py
Alan Weinstock f39f36e258 feat(discovery): subreddit discovery and rule classification (#2)
- Add app/services/reddit/discovery.py:
  - search_subs(): searches /subreddits/search.json by keyword
  - analyze_sub(): fetches /about.json + /about/rules.json per sub
  - _classify_rules(): keyword-pattern classifier for promo policy
    (banned / conditional / unknown; hard to positively confirm allowed)
  - search_and_analyze(): combined search + per-sub analysis entry point
  - Unauthenticated-friendly (uses auth cookies when available)
- Add POST /subs/discover endpoint: returns candidate list with
  promo_allowed, flair_required, subscriber count, notes excerpt,
  and already_tracked flag. Nothing stored until user imports.
- Add SubDiscoveryResult interface and api.subs.discover() in api.ts
- Rework SubRulesView: slide-in discovery panel (right drawer),
  per-row Import button, auto-marks already-tracked subs, immutable
  result update on import

Closes: #2
2026-06-13 22:17:53 -07:00

87 lines
2.7 KiB
Python

from __future__ import annotations
import asyncio
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from app.core.config import get_settings
from app.db.store import Store
router = APIRouter(prefix="/subs", tags=["subs"])
class DiscoverBody(BaseModel):
keyword: str
limit: int = 15
def _in_thread(fn):
store = Store(get_settings().db_path)
try:
return fn(store)
finally:
store.close()
class SubRulesUpsert(BaseModel):
flair_required: bool = False
flair_to_use: str | None = None
promo_allowed: bool | None = None # None = unknown
rule_warning: bool = False
notes: str | None = None
post_url: str | None = None # override link for Copy & Post (e.g. megathread)
@router.get("")
async def list_sub_rules(platform: str = "reddit"):
return await asyncio.to_thread(_in_thread, lambda s: s.list_sub_rules(platform))
@router.get("/{sub}")
async def get_sub_rules(sub: str, platform: str = "reddit"):
result = await asyncio.to_thread(_in_thread, lambda s: s.get_sub_rules(sub, platform))
if result is None:
raise HTTPException(404, f"No rules on record for r/{sub}")
return result
@router.put("/{sub}")
async def upsert_sub_rules(sub: str, body: SubRulesUpsert, platform: str = "reddit"):
fields = body.model_dump()
# Convert bool | None to int | None for SQLite
if fields.get("promo_allowed") is not None:
fields["promo_allowed"] = 1 if fields["promo_allowed"] else 0
fields["flair_required"] = 1 if fields["flair_required"] else 0
fields["rule_warning"] = 1 if fields["rule_warning"] else 0
return await asyncio.to_thread(
_in_thread, lambda s: s.upsert_sub_rules(sub, platform, **fields)
)
@router.post("/discover")
async def discover_subs(body: DiscoverBody):
"""
Search Reddit for subreddits matching a keyword and analyze their posting rules.
Returns a list of candidates with promo classification. Nothing is stored —
the caller decides which subs to import via PUT /subs/{sub}.
"""
from app.services.reddit.discovery import search_and_analyze
def _run(store: Store):
# Collect already-tracked sub names so the UI can flag them
existing = {r["sub"].lower() for r in store.list_sub_rules("reddit")}
try:
from app.services.reddit.client import RedditClient
cookies = RedditClient().cookies
except Exception:
cookies = None
return search_and_analyze(
keyword=body.keyword,
limit=body.limit,
cookies=cookies,
known_subs=existing,
)
return await asyncio.to_thread(_in_thread, _run)