- Fix 71 invalid-syntax files (class-body newline-broken assignments) - Add from/None chain to 307 B904 raise-without-from sites - Add B008 ignore to ruff.toml (already in pyproject.toml) - Noqa F401 on __init__.py re-exports (137 sites) - Noqa E402 on deferred imports (63 sites) - Bulk-add stdlib/FastAPI/project imports for F821 (127 sites) - Replace ×→x, –→-, …→... in docstrings (4093 chars) - Manual refactor of 5 SIM103/SIM116 patterns Tests: 791 passed (66 deselected due to pre-existing Redis issues in test_rag.py) Co-authored-by: opencode <opencode@rugmunch.io>
305 lines
11 KiB
Python
305 lines
11 KiB
Python
"""
|
|
Ghost Content Platform - @CryptoRugMunch Feed engine.
|
|
|
|
Post types:
|
|
micro - Short posts (≤4000 chars, like X posts)
|
|
thread - Connected series of posts (X-style threads)
|
|
article - Standard longform blog post
|
|
dev - Development updates
|
|
announcement - Platform announcements
|
|
newsletter - Weekly deep-dive newsletter
|
|
briefing - Daily intelligence briefing (auto-generated)
|
|
paid - Premium gated content
|
|
|
|
Auth: Session cookie from owner login. Cookie refreshed on 401.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
GHOST_API = "https://blog.rugmunch.io"
|
|
GHOST_CONTENT_KEY = os.getenv("GHOST_CONTENT_API_KEY", "")
|
|
GHOST_SESSION_COOKIE = "/home/dev/rmi/backend/.secrets/ghost_session_cookies"
|
|
|
|
PROFILE = {
|
|
"name": "Rug Munch Intelligence",
|
|
"handle": "@CryptoRugMunch",
|
|
"avatar": "https://rugmunch.io/logo.png",
|
|
"bio": "Crypto security platform. Real-time scam detection, cross-chain forensics, AI-powered investigation. Free. No signup. MCP-native.",
|
|
"website": "https://rugmunch.io",
|
|
}
|
|
|
|
POST_TYPES = {
|
|
"micro": {"tag": "micro", "label": "Post", "icon": "💬", "limit": 4000},
|
|
"thread": {"tag": "thread", "label": "Thread", "icon": "🧵", "limit": 4000},
|
|
"article": {"tag": "article", "label": "Longform", "icon": "📄", "limit": 50000},
|
|
"dev": {"tag": "dev", "label": "Dev Update", "icon": "⚡", "limit": 4000},
|
|
"announcement": {"tag": "announcement", "label": "Announcement", "icon": "📢", "limit": 4000},
|
|
"newsletter": {"tag": "newsletter", "label": "Newsletter", "icon": "📰", "limit": 50000},
|
|
"briefing": {"tag": "briefing", "label": "Briefing", "icon": "🔍", "limit": 50000},
|
|
}
|
|
|
|
TYPE_LIMITS = {k: v["limit"] for k, v in POST_TYPES.items()}
|
|
|
|
|
|
def _get_session_cookie() -> str | None:
|
|
"""Read saved session cookie for Ghost admin auth."""
|
|
try:
|
|
with open(GHOST_SESSION_COOKIE) as f:
|
|
for line in f:
|
|
if "ghost-admin-api-session" in line:
|
|
return line.strip()
|
|
except FileNotFoundError:
|
|
pass
|
|
return None
|
|
|
|
|
|
async def create_post(
|
|
content: str,
|
|
post_type: str = "micro",
|
|
title: str = "",
|
|
tags: list[str] | None = None,
|
|
status: str = "published",
|
|
visibility: str = "public",
|
|
) -> dict[str, Any]:
|
|
"""Create any type of post on Ghost."""
|
|
|
|
tags = (tags or []) + [POST_TYPES[post_type]["tag"]]
|
|
limit = TYPE_LIMITS.get(post_type, 4000)
|
|
if len(content) > limit:
|
|
content = content[: limit - 3] + "..."
|
|
|
|
if not title:
|
|
title = content[:120] + ("..." if len(content) > 120 else "")
|
|
|
|
# Convert to Ghost lexical format
|
|
paragraphs = content.split("\n\n")
|
|
children = []
|
|
for para in paragraphs:
|
|
para = para.strip()
|
|
if not para:
|
|
continue
|
|
children.append(
|
|
{
|
|
"children": [
|
|
{
|
|
"detail": 0,
|
|
"format": 0,
|
|
"mode": "normal",
|
|
"style": "",
|
|
"text": para.replace("\n", " "),
|
|
"type": "text",
|
|
"version": 1,
|
|
}
|
|
],
|
|
"direction": "ltr",
|
|
"format": "",
|
|
"indent": 0,
|
|
"type": "paragraph",
|
|
"version": 1,
|
|
}
|
|
)
|
|
|
|
lexical = json.dumps(
|
|
{
|
|
"root": {
|
|
"children": children,
|
|
"direction": "ltr",
|
|
"format": "",
|
|
"indent": 0,
|
|
"type": "root",
|
|
"version": 1,
|
|
}
|
|
}
|
|
)
|
|
|
|
payload = {
|
|
"posts": [
|
|
{
|
|
"title": title,
|
|
"lexical": lexical,
|
|
"status": status,
|
|
"visibility": visibility,
|
|
"tags": [{"name": t} for t in tags],
|
|
"feature_image": PROFILE["avatar"],
|
|
}
|
|
]
|
|
}
|
|
|
|
session = _get_session_cookie()
|
|
if not session:
|
|
return {"error": "No Ghost session cookie", "help": "Login at /ghost first"}
|
|
|
|
try:
|
|
cookie_header = session.split("\t")
|
|
cookie = f"{cookie_header[5]}={cookie_header[6]}" if len(cookie_header) > 6 else session
|
|
async with httpx.AsyncClient(timeout=15) as client:
|
|
resp = await client.post(
|
|
f"{GHOST_API}/ghost/api/admin/posts/",
|
|
headers={"Content-Type": "application/json", "Origin": GHOST_API, "Cookie": cookie},
|
|
json=payload,
|
|
)
|
|
if resp.status_code in (200, 201):
|
|
p = resp.json()["posts"][0]
|
|
return {
|
|
"id": p["id"],
|
|
"title": p["title"],
|
|
"url": p.get("url", f"{GHOST_API}/{p.get('slug', '')}"),
|
|
"status": p["status"],
|
|
"created_at": p["created_at"],
|
|
"type": post_type,
|
|
"tags": [t["name"] for t in p.get("tags", [])],
|
|
"feature_image": p.get("feature_image", PROFILE["avatar"]),
|
|
"excerpt": p.get("custom_excerpt") or content[:140],
|
|
}
|
|
elif resp.status_code == 401:
|
|
return {"error": "Session expired - re-login at /ghost"}
|
|
return {"error": f"Ghost: HTTP {resp.status_code}", "detail": resp.text[:300]}
|
|
except Exception as e:
|
|
return {"error": str(e)}
|
|
|
|
|
|
async def get_feed(page: int = 1, limit: int = 20, tag: str = "", include: str = "tags,authors") -> dict[str, Any]:
|
|
"""Get feed via Ghost Content API."""
|
|
if not GHOST_CONTENT_KEY:
|
|
return {"posts": [], "error": "content key not configured"}
|
|
|
|
params = {
|
|
"key": GHOST_CONTENT_KEY,
|
|
"limit": limit,
|
|
"page": page,
|
|
"include": include,
|
|
"order": "published_at DESC",
|
|
"formats": "lexical",
|
|
}
|
|
if tag:
|
|
params["filter"] = f"tag:{tag}"
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10) as client:
|
|
resp = await client.get(
|
|
f"{GHOST_API}/ghost/api/content/posts/",
|
|
params=params,
|
|
headers={"Accept-Version": "v5.0"},
|
|
)
|
|
if resp.status_code == 200:
|
|
data = resp.json()
|
|
posts = []
|
|
for p in data.get("posts", []):
|
|
ptags = [t["name"] for t in p.get("tags", [])]
|
|
post_type = next((t for t in POST_TYPES if POST_TYPES[t]["tag"] in ptags), "article")
|
|
posts.append(
|
|
{
|
|
"id": p["id"],
|
|
"title": p["title"],
|
|
"excerpt": (p.get("custom_excerpt") or p.get("excerpt") or "")[:200],
|
|
"url": p.get("url", ""),
|
|
"feature_image": p.get("feature_image", PROFILE["avatar"]),
|
|
"created_at": p.get("published_at") or p["created_at"],
|
|
"reading_time": p.get("reading_time", 0),
|
|
"tags": ptags,
|
|
"type": post_type,
|
|
"type_label": POST_TYPES.get(post_type, {}).get("label", "Post"),
|
|
"type_icon": POST_TYPES.get(post_type, {}).get("icon", "📝"),
|
|
}
|
|
)
|
|
return {
|
|
"posts": posts,
|
|
"page": page,
|
|
"total": data.get("meta", {}).get("pagination", {}).get("total", 0),
|
|
"profile": PROFILE,
|
|
"post_types": dict(POST_TYPES.items()),
|
|
}
|
|
return {"posts": [], "error": f"HTTP {resp.status_code}"}
|
|
except Exception as e:
|
|
return {"posts": [], "error": str(e)}
|
|
|
|
|
|
async def create_thread(posts: list[str], post_type: str = "thread") -> dict[str, Any]:
|
|
"""Create a thread - multiple connected posts."""
|
|
results = []
|
|
for i, content in enumerate(posts):
|
|
title = f"Thread {i + 1}/{len(posts)}" if len(posts) > 1 else ""
|
|
result = await create_post(content, post_type=post_type, title=title)
|
|
results.append(result)
|
|
return {
|
|
"thread": results,
|
|
"total": len(results),
|
|
"url": results[0].get("url") if results else None,
|
|
}
|
|
|
|
|
|
async def generate_briefing() -> dict[str, Any]:
|
|
"""Generate daily intelligence briefing from RAG + news sources."""
|
|
content_parts = [f"# 🕵️ Daily Intelligence Briefing\n\n*{datetime.now(UTC).strftime('%B %d, %Y')}*\n"]
|
|
|
|
# Pull from RAG
|
|
try:
|
|
from app.rag_service import three_pillar_search
|
|
|
|
for topic, query in [
|
|
("Scam Alerts", "rug pull honeypot scam today"),
|
|
("Exploit Watch", "defi exploit hack attack today"),
|
|
("Market Intel", "crypto market movement whale alert"),
|
|
]:
|
|
results = await three_pillar_search(
|
|
query=query,
|
|
collections=["known_scams", "forensic_reports", "market_intel"],
|
|
limit=2,
|
|
)
|
|
if results.get("results"):
|
|
content_parts.append(f"\n## {topic}")
|
|
for r in results["results"][:2]:
|
|
snippet = (r.get("content") or "")[:300]
|
|
if snippet:
|
|
content_parts.append(f"- {snippet}")
|
|
except Exception:
|
|
content_parts.append("\n*Automated briefing - full RAG analysis pending*")
|
|
|
|
content = "\n".join(content_parts)
|
|
return await create_post(
|
|
content=content,
|
|
post_type="briefing",
|
|
title=f"Daily Briefing - {datetime.now(UTC).strftime('%B %d, %Y')}",
|
|
)
|
|
|
|
|
|
async def generate_newsletter() -> dict[str, Any]:
|
|
"""Generate weekly newsletter with top stories and analysis."""
|
|
content_parts = [f"# 📰 Weekly Intelligence Report\n\n*Week of {datetime.now(UTC).strftime('%B %d, %Y')}*\n"]
|
|
|
|
try:
|
|
from app.rag_service import three_pillar_search
|
|
|
|
for section, query in [
|
|
("🔴 Top Threats", "major scam exploit hack this week"),
|
|
("🟡 Emerging Patterns", "new scam technique pattern emerging"),
|
|
("🟢 Market Overview", "crypto market weekly summary"),
|
|
("🔬 Deep Dive", "smart contract vulnerability analysis forensic"),
|
|
]:
|
|
results = await three_pillar_search(
|
|
query=query,
|
|
collections=["known_scams", "forensic_reports", "market_intel", "token_analysis"],
|
|
limit=3,
|
|
)
|
|
if results.get("results"):
|
|
content_parts.append(f"\n## {section}")
|
|
for r in results["results"][:3]:
|
|
snippet = (r.get("content") or "")[:400]
|
|
if snippet:
|
|
content_parts.append(f"- {snippet}")
|
|
except Exception:
|
|
content_parts.append("\n*Full RAG analysis in next edition*")
|
|
|
|
content = "\n".join(content_parts)
|
|
return await create_post(
|
|
content=content,
|
|
post_type="newsletter",
|
|
title=f"Weekly Intelligence - {datetime.now(UTC).strftime('%B %d, %Y')}",
|
|
)
|