stemedb/latent/ingest-reddit/macros/reddit-app-setup/main.py
jordan b3e8a9a058 feat: Multi-application expansion with chaos testing and community UI
Major additions:
- Community Next.js app (port 18187) for browsing claims with API docs
- stemedb-chaos crate: Fault injection, chaos testing, CRDT properties
- Latent ingestion system: Reddit/FDA ingesters with ADK-Go agents
- Disputed claims handling: Manual review workflows and validation
- Aphoria security scanner: New extractors (SQL injection, command
  injection, weak crypto, TLS version), policy-based ignores, UAT reports
- Docker infrastructure: Dockerfile, docker-compose.yml for full stack
- VulnBank demo: Intentionally vulnerable multi-language test corpus

SDK & API enhancements:
- Source registry handlers for tracking data provenance
- Metrics endpoint
- Skeptic filtering improvements

Code quality:
- Split 14 large files (>500 lines) into focused modules
- All files now under 500-line limit per project guidelines

Documentation:
- Chaos testing guide, circuit breakers, observability docs
- Phase 7 UAT documentation updates
- Martin Kleppmann technical writer agent

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 01:24:14 -07:00

384 lines
14 KiB
Python

#!/usr/bin/env python3
"""
Macro: Reddit App Setup
Purpose: Create a Reddit API application for the Latent Signal ingestor
Justification: User's own account, no API alternative for app creation
"""
import asyncio
import os
import random
from datetime import datetime
from pathlib import Path
from patchright.async_api import async_playwright
# Paths
MACRO_DIR = Path(__file__).parent
SCREENSHOTS_DIR = MACRO_DIR / "screenshots"
ENV_FILE = MACRO_DIR.parent.parent / ".env"
SCREENSHOTS_DIR.mkdir(exist_ok=True)
# App configuration
APP_NAME = "LatentSignalBot"
APP_DESCRIPTION = "Latent signal detection for adverse event monitoring"
APP_REDIRECT_URI = os.getenv("REDDIT_REDIRECT_URI", "http://localhost:8080")
async def human_delay(min_ms=500, max_ms=1500):
"""Add human-like delay between actions."""
await asyncio.sleep(random.randint(min_ms, max_ms) / 1000)
async def screenshot(page, name: str):
"""Capture a timestamped screenshot."""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
path = SCREENSHOTS_DIR / f"{timestamp}_{name}.png"
await page.screenshot(path=str(path))
print(f"[Screenshot] {path.name}")
return path
async def human_type(page, selector: str, text: str):
"""Type text with human-like delays."""
await page.click(selector)
await human_delay(100, 300)
for char in text:
await page.keyboard.type(char)
await asyncio.sleep(random.uniform(0.03, 0.10))
await human_delay(200, 400)
async def wait_for_login(page):
"""Wait for user to complete login if needed."""
print("\n[*] Checking if login is required...")
await screenshot(page, "01_initial_load")
# Check if we're on a login page or need to log in
login_button = await page.query_selector('a[href*="login"], button:has-text("Log In")')
if login_button:
print("\n" + "=" * 50)
print("LOGIN REQUIRED")
print("=" * 50)
print("Please log in to Reddit in the browser window.")
print("")
print("TIP: Use Reddit username/password login, NOT Google OAuth")
print(" (Google detects automated browsers)")
print("")
print("The macro will continue automatically after login.")
print("=" * 50 + "\n")
# Wait for the apps page to load (indicates successful login)
# Poll instead of wait_for_url to be more resilient
for _ in range(600): # 10 minutes max
await asyncio.sleep(1)
url = page.url
if "/prefs/apps" in url:
print("[OK] Login detected!")
await human_delay(1000, 2000)
return
# Also check if we're now logged in on the current page
login_btn = await page.query_selector('a[href*="login"], button:has-text("Log In")')
if not login_btn:
# Logged in, navigate to apps page
print("[OK] Login detected, navigating to apps page...")
await page.goto("https://www.reddit.com/prefs/apps")
await page.wait_for_load_state("networkidle")
await human_delay(1000, 2000)
return
print("[!] Login timeout - please try again")
raise Exception("Login timeout")
async def create_app(page):
"""Create a new Reddit application."""
print("\n[*] Looking for 'create app' button...")
await screenshot(page, "02_apps_page")
# Look for the "create another app" or "are you a developer? create an app" link
create_button = await page.query_selector('button:has-text("create another app"), a:has-text("create another app"), button:has-text("create app"), a:has-text("create an app")')
if not create_button:
# Try alternative selectors for Reddit's UI
create_button = await page.query_selector('.create-app-button, [href*="create"], button[type="submit"]')
if not create_button:
await screenshot(page, "02_no_create_button")
print("[!] Could not find 'create app' button. Please check the screenshot.")
print("[*] Attempting to scroll and find it...")
# Scroll down to find it
await page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
await human_delay(500, 1000)
await screenshot(page, "02b_scrolled")
create_button = await page.query_selector('button:has-text("create"), a:has-text("create")')
if create_button:
print("[*] Found create button, clicking...")
await human_delay(300, 600)
await create_button.click()
await human_delay(1000, 1500)
await screenshot(page, "03_create_form")
else:
print("[!] Still can't find create button - manual intervention needed")
input("Press Enter after clicking 'create another app' manually...")
await screenshot(page, "03_manual_create_form")
async def fill_app_form(page):
"""Fill in the app creation form."""
print("\n[*] Filling app form...")
# Wait for form to be visible
await human_delay(500, 1000)
# Name field
name_field = await page.query_selector('input[name="name"], #app-name, input[placeholder*="name"]')
if name_field:
await human_type(page, 'input[name="name"]', APP_NAME)
print(f" Name: {APP_NAME}")
await human_delay(300, 600)
# Select "script" type
script_radio = await page.query_selector('input[value="script"], input[name="app_type"][value="script"]')
if script_radio:
await script_radio.click()
print(" Type: script")
else:
# Try clicking label
script_label = await page.query_selector('label:has-text("script")')
if script_label:
await script_label.click()
print(" Type: script (via label)")
await human_delay(300, 600)
# Description field
desc_field = await page.query_selector('textarea[name="description"], #app-description')
if desc_field:
await desc_field.click()
await page.keyboard.type(APP_DESCRIPTION)
print(f" Description: {APP_DESCRIPTION}")
await human_delay(300, 600)
# Redirect URI field
redirect_field = await page.query_selector('input[name="redirect_uri"], #app-redirect-uri')
if redirect_field:
await human_type(page, 'input[name="redirect_uri"]', APP_REDIRECT_URI)
print(f" Redirect URI: {APP_REDIRECT_URI}")
await screenshot(page, "04_form_filled")
# Submit the form
print("\n[*] Submitting form...")
await human_delay(500, 1000)
submit_button = await page.query_selector('button[type="submit"]:has-text("create app"), input[type="submit"]')
if submit_button:
await submit_button.click()
else:
# Try generic submit
await page.keyboard.press("Enter")
await human_delay(2000, 3000)
await screenshot(page, "05_after_submit")
async def extract_credentials(page):
"""Extract the client_id and client_secret from the page."""
print("\n[*] Extracting credentials...")
await screenshot(page, "06_credentials_page")
# Get page content to find credentials
content = await page.content()
# The client_id appears under the app name (it's a short alphanumeric string)
# The client_secret appears next to "secret"
client_id = None
client_secret = None
# Try to find the app we just created
app_info = await page.query_selector(f'div:has-text("{APP_NAME}")')
if app_info:
# Get all text in the app info section
app_text = await app_info.inner_text()
print(f"[DEBUG] App section text:\n{app_text[:500]}...")
# Look for the client_id (appears right after "personal use script")
# and secret
lines = app_text.split('\n')
for i, line in enumerate(lines):
line = line.strip()
if 'personal use script' in line.lower():
# Next non-empty line should be client_id
for next_line in lines[i+1:]:
next_line = next_line.strip()
if next_line and len(next_line) > 10 and len(next_line) < 30:
client_id = next_line
break
if 'secret' in line.lower():
# The secret might be on this line or next
parts = line.split()
for part in parts:
if len(part) > 20:
client_secret = part
break
# Alternative: use JavaScript to extract
if not client_id or not client_secret:
print("[*] Trying JavaScript extraction...")
# Reddit shows client_id in a specific element
try:
client_id = await page.evaluate("""
() => {
const apps = document.querySelectorAll('.app-details, .developed-app');
for (const app of apps) {
const text = app.innerText;
if (text.includes('LatentSignalBot')) {
// Find the ID (short string after "personal use script")
const match = text.match(/personal use script[\\s\\n]+([a-zA-Z0-9_-]+)/i);
if (match) return match[1];
}
}
return null;
}
""")
client_secret = await page.evaluate("""
() => {
const apps = document.querySelectorAll('.app-details, .developed-app');
for (const app of apps) {
const text = app.innerText;
if (text.includes('LatentSignalBot')) {
// Find secret
const match = text.match(/secret[:\\s]+([a-zA-Z0-9_-]+)/i);
if (match) return match[1];
}
}
return null;
}
""")
except Exception as e:
print(f"[!] JavaScript extraction failed: {e}")
return client_id, client_secret
async def save_credentials(client_id: str, client_secret: str):
"""Save credentials to .env file."""
print(f"\n[*] Saving credentials to {ENV_FILE}")
env_content = f"""REDDIT_CLIENT_ID={client_id}
REDDIT_CLIENT_SECRET={client_secret}
REDDIT_USER_AGENT=LatentSignalBot/0.1
"""
# Read existing .env if it exists
existing = {}
if ENV_FILE.exists():
with open(ENV_FILE) as f:
for line in f:
if '=' in line and not line.startswith('#'):
key, value = line.strip().split('=', 1)
existing[key] = value
# Update with new values
existing['REDDIT_CLIENT_ID'] = client_id
existing['REDDIT_CLIENT_SECRET'] = client_secret
existing['REDDIT_USER_AGENT'] = 'LatentSignalBot/0.1'
# Write back
with open(ENV_FILE, 'w') as f:
for key, value in existing.items():
f.write(f"{key}={value}\n")
print(f"[OK] Credentials saved!")
print(f" REDDIT_CLIENT_ID={client_id}")
print(f" REDDIT_CLIENT_SECRET={'*' * len(client_secret)}")
async def main():
print("=" * 50)
print("Reddit App Setup Macro")
print("=" * 50)
# Use copied Chrome profile to avoid conflicts with running Chrome
TEMP_PROFILE = Path("/tmp/chrome-jordanbot2000")
async with async_playwright() as p:
# Launch with copied profile
context = await p.chromium.launch_persistent_context(
user_data_dir=str(TEMP_PROFILE),
channel="chrome", # Use installed Chrome
headless=False,
viewport={"width": 1280, "height": 900},
locale="en-US",
timezone_id="America/New_York",
)
page = context.pages[0] if context.pages else await context.new_page()
try:
# Step 1: Navigate to Reddit apps page
print("\n[*] Navigating to Reddit apps page...")
await page.goto("https://www.reddit.com/prefs/apps")
await page.wait_for_load_state("networkidle")
await human_delay(1000, 2000)
# Step 2: Handle login if needed
await wait_for_login(page)
# Step 3: Create new app
await create_app(page)
# Step 4: Fill form
await fill_app_form(page)
# Step 5: Extract credentials
client_id, client_secret = await extract_credentials(page)
if client_id and client_secret:
# Step 6: Save to .env
await save_credentials(client_id, client_secret)
await screenshot(page, "99_success")
print("\n" + "=" * 50)
print("SUCCESS!")
print("=" * 50)
print(f"Credentials saved to: {ENV_FILE}")
print("You can now run the Reddit ingestor:")
print(" python main.py")
else:
await screenshot(page, "99_manual_needed")
print("\n" + "=" * 50)
print("MANUAL EXTRACTION NEEDED")
print("=" * 50)
print("Could not automatically extract credentials.")
print("Please copy them manually from the browser:")
print(" - Client ID: shown under 'personal use script'")
print(" - Secret: shown next to 'secret'")
print(f"\nThen add to: {ENV_FILE}")
# Keep browser open for manual copying
input("\nPress Enter when done copying credentials...")
except Exception as e:
await screenshot(page, "error")
print(f"\n[ERROR] {e}")
raise
finally:
print("\n[*] Closing browser...")
await context.close()
if __name__ == "__main__":
asyncio.run(main())