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>
384 lines
14 KiB
Python
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())
|