Autonomous Publishing System — full stack: orchestrator, 8 vertical sites, admin dashboard, analytics, cron pipeline

This commit is contained in:
drjones
2026-08-03 21:44:26 -07:00
commit 493776b9f8
16 changed files with 2962 additions and 0 deletions

309
dashboard/app.py Normal file
View File

@@ -0,0 +1,309 @@
"""
Autonomous Publishing System — Admin Dashboard
Flask app for monitoring and controlling the publishing pipeline.
"""
import os
import sys
import json
import sqlite3
from pathlib import Path
from datetime import datetime, timedelta
from flask import Flask, render_template_string, jsonify, request, redirect, url_for
BASE_DIR = Path(__file__).resolve().parent.parent
DB_PATH = BASE_DIR / "core" / "publisher.db"
sys.path.insert(0, str(BASE_DIR / "core"))
app = Flask(__name__)
app.config["SECRET_KEY"] = os.environ.get("DASHBOARD_SECRET", "auto-publisher-dev")
VERTICALS = ["ai", "tech", "science", "crypto", "linux", "gaming", "diy", "guides"]
def get_db():
db = sqlite3.connect(str(DB_PATH))
db.row_factory = sqlite3.Row
return db
# ─── Templates ──────────────────────────────────────────────────────
DASHBOARD_HTML = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Auto Publisher — Admin Dashboard</title>
<style>
:root {
--bg: #0f172a; --card: #1e293b; --text: #e2e8f0;
--text2: #94a3b8; --primary: #3b82f6; --green: #10b981;
--red: #ef4444; --yellow: #f59e0b; --border: #334155;
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: var(--font); background: var(--bg); color: var(--text); padding: 2rem; }
h1 { font-size: 1.8rem; margin-bottom: 0.5rem; }
h2 { font-size: 1.2rem; color: var(--text2); margin-bottom: 1.5rem; font-weight: 400; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 2rem; }
.stat {
background: var(--card); border: 1px solid var(--border);
border-radius: 8px; padding: 1.25rem;
}
.stat .label { font-size: 0.8rem; color: var(--text2); text-transform: uppercase; letter-spacing: 0.05em; }
.stat .value { font-size: 2rem; font-weight: 700; margin-top: 0.25rem; }
.stat .value.green { color: var(--green); }
.stat .value.yellow { color: var(--yellow); }
.stat .value.red { color: var(--red); }
.panel {
background: var(--card); border: 1px solid var(--border);
border-radius: 8px; padding: 1.5rem; margin-bottom: 1.5rem;
}
.panel h3 { margin-bottom: 1rem; font-size: 1.1rem; }
table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
th { text-align: left; color: var(--text2); padding: 0.5rem; border-bottom: 1px solid var(--border); font-weight: 500; }
td { padding: 0.5rem; border-bottom: 1px solid var(--border); }
.badge {
display: inline-block; padding: 0.15rem 0.5rem; border-radius: 4px;
font-size: 0.75rem; font-weight: 600; text-transform: uppercase;
}
.badge.published { background: rgba(16, 185, 129, 0.2); color: var(--green); }
.badge.draft { background: rgba(245, 158, 11, 0.2); color: var(--yellow); }
.badge.discovered { background: rgba(59, 130, 246, 0.2); color: var(--primary); }
button, .btn {
background: var(--primary); color: white; border: none;
padding: 0.5rem 1.25rem; border-radius: 6px; cursor: pointer;
font-size: 0.9rem; font-weight: 500;
}
button:hover { opacity: 0.9; }
button.danger { background: var(--red); }
.actions { display: flex; gap: 0.75rem; margin-bottom: 1.5rem; flex-wrap: wrap; }
.log-entry { font-family: monospace; font-size: 0.8rem; padding: 0.25rem 0; color: var(--text2); }
.log-entry.error { color: var(--red); }
.log-entry.success { color: var(--green); }
.site-card {
display: inline-block; background: var(--card); border: 1px solid var(--border);
border-radius: 8px; padding: 1rem 1.5rem; margin: 0.5rem;
}
.site-card .domain { font-weight: 600; }
.site-card .status { font-size: 0.8rem; }
.site-card .status.live { color: var(--green); }
.site-card .status.pending { color: var(--yellow); }
</style>
</head>
<body>
<h1>🤖 Auto Publisher</h1>
<h2>Autonomous Publishing System — Admin Dashboard</h2>
<!-- Stats Grid -->
<div class="grid">
<div class="stat">
<div class="label">Topics Discovered</div>
<div class="value">{{ stats.topics }}</div>
</div>
<div class="stat">
<div class="label">Articles Written</div>
<div class="value">{{ stats.articles }}</div>
</div>
<div class="stat">
<div class="label">Published</div>
<div class="value green">{{ stats.published }}</div>
</div>
<div class="stat">
<div class="label">Last Run</div>
<div class="value" style="font-size:1rem;">{{ stats.last_run or 'Never' }}</div>
</div>
<div class="stat">
<div class="label">Total Pageviews</div>
<div class="value">{{ stats.total_views }}</div>
</div>
<div class="stat">
<div class="label">Avg Score</div>
<div class="value yellow">{{ stats.avg_score }}</div>
</div>
</div>
<!-- Sites -->
<div class="panel">
<h3>📡 Sites</h3>
<div style="display:flex;flex-wrap:wrap;">
{% for v in verticals %}
<div class="site-card">
<div class="domain">{{ v }}.thetempleofdoom.com</div>
<div class="status {{ 'live' if v in live_sites else 'pending' }}">
{{ '● Live' if v in live_sites else '○ Pending' }}
</div>
</div>
{% endfor %}
</div>
</div>
<!-- Actions -->
<div class="panel">
<h3>🎮 Controls</h3>
<div class="actions">
<form method="POST" action="/api/run" style="display:inline">
<button type="submit">▶ Run Pipeline Now</button>
</form>
<form method="POST" action="/api/discover" style="display:inline">
<button type="submit">🔍 Discover Topics</button>
</form>
<button onclick="location.reload()">🔄 Refresh</button>
</div>
</div>
<!-- Recent Topics -->
<div class="panel">
<h3>📋 Recent Topics</h3>
<table>
<thead><tr>
<th>Title</th><th>Vertical</th><th>Score</th><th>Status</th><th>Discovered</th>
</tr></thead>
<tbody>
{% for t in topics %}
<tr>
<td>{{ t.title[:80] }}</td>
<td><span class="badge">{{ t.vertical }}</span></td>
<td>{{ t.composite_score }}</td>
<td><span class="badge {{ t.status }}">{{ t.status }}</span></td>
<td>{{ t.created_at[:10] if t.created_at else '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Recent Articles -->
<div class="panel">
<h3>📝 Recent Articles</h3>
<table>
<thead><tr>
<th>Title</th><th>Vertical</th><th>Words</th><th>Status</th><th>Created</th>
</tr></thead>
<tbody>
{% for a in articles %}
<tr>
<td>{{ a.title[:80] if a.title else 'Untitled' }}</td>
<td><span class="badge">{{ a.vertical }}</span></td>
<td>{{ a.word_count }}</td>
<td><span class="badge {{ a.status }}">{{ a.status }}</span></td>
<td>{{ a.created_at[:10] if a.created_at else '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pipeline Log -->
<div class="panel">
<h3>📜 Pipeline Log</h3>
<div style="max-height:300px;overflow-y:auto;">
{% for entry in log_lines %}
<div class="log-entry {{ entry.level }}">[{{ entry.time }}] {{ entry.msg }}</div>
{% endfor %}
</div>
</div>
</body>
</html>"""
@app.route("/")
def index():
db = get_db()
stats = {
"topics": db.execute("SELECT COUNT(*) FROM topics").fetchone()[0],
"articles": db.execute("SELECT COUNT(*) FROM articles").fetchone()[0],
"published": db.execute("SELECT COUNT(*) FROM articles WHERE status='published'").fetchone()[0],
"last_run": None,
"total_views": db.execute("SELECT COALESCE(SUM(pageviews), 0) FROM analytics").fetchone()[0],
"avg_score": round(db.execute("SELECT COALESCE(AVG(composite_score), 0) FROM topics WHERE composite_score > 0").fetchone()[0], 1),
}
last_run = db.execute("SELECT started_at FROM pipeline_runs ORDER BY id DESC LIMIT 1").fetchone()
if last_run:
stats["last_run"] = last_run[0]
topics = db.execute("SELECT * FROM topics ORDER BY created_at DESC LIMIT 20").fetchall()
articles = db.execute("SELECT * FROM articles ORDER BY created_at DESC LIMIT 20").fetchall()
# Read log
log_path = BASE_DIR / "core" / "orchestrator.log"
log_lines = []
if log_path.exists():
for line in log_path.read_text().split("\n")[-30:]:
if not line.strip():
continue
level = ""
if "ERROR" in line:
level = "error"
elif "" in line or "successful" in line.lower():
level = "success"
log_lines.append({
"time": line[:19] if len(line) > 19 else "",
"msg": line,
"level": level,
})
live_sites = ["ai", "tech"] # Will be dynamically checked
return render_template_string(
DASHBOARD_HTML,
stats=stats,
topics=topics,
articles=articles,
log_lines=log_lines,
verticals=VERTICALS,
live_sites=live_sites,
)
@app.route("/api/run", methods=["POST"])
def api_run():
"""Trigger a pipeline run."""
from orchestrator import run_daily_pipeline
import threading
def _run():
run_daily_pipeline(max_articles=3)
t = threading.Thread(target=_run, daemon=True)
t.start()
return jsonify({"status": "started", "message": "Pipeline running in background"})
@app.route("/api/discover", methods=["POST"])
def api_discover():
"""Trigger trend discovery."""
from orchestrator import discover_trends
import threading
def _run():
discover_trends()
t = threading.Thread(target=_run, daemon=True)
t.start()
return jsonify({"status": "started", "message": "Trend discovery running"})
@app.route("/api/stats")
def api_stats():
db = get_db()
return jsonify({
"topics_total": db.execute("SELECT COUNT(*) FROM topics").fetchone()[0],
"articles_total": db.execute("SELECT COUNT(*) FROM articles").fetchone()[0],
"published": db.execute("SELECT COUNT(*) FROM articles WHERE status='published'").fetchone()[0],
"by_vertical": {
v: db.execute("SELECT COUNT(*) FROM topics WHERE vertical=?", (v,)).fetchone()[0]
for v in VERTICALS
},
"pipeline_runs": db.execute("SELECT COUNT(*) FROM pipeline_runs").fetchone()[0],
})
@app.route("/health")
def health():
return jsonify({"status": "healthy", "timestamp": datetime.now().isoformat()})
if __name__ == "__main__":
import argparse
ap = argparse.ArgumentParser()
ap.add_argument("--port", type=int, default=5106)
ap.add_argument("--host", type=str, default="127.0.0.1")
args = ap.parse_args()
print(f"Admin Dashboard → http://{args.host}:{args.port}")
app.run(host=args.host, port=args.port, debug=False)