Autonomous Publishing System — full stack: orchestrator, 8 vertical sites, admin dashboard, analytics, cron pipeline
This commit is contained in:
107
README.md
Normal file
107
README.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# Auto Publisher — README
|
||||
|
||||
## Autonomous AI Publishing System
|
||||
|
||||
A fully autonomous, self-hosted publishing platform that continuously discovers valuable topics, generates original useful content, and publishes it to a collection of evergreen authority websites.
|
||||
|
||||
### Sites
|
||||
|
||||
| Site | URL | Status |
|
||||
|------|-----|--------|
|
||||
| AI | https://ai.thetempleofdoom.com | 🚀 |
|
||||
| Tech | https://tech.thetempleofdoom.com | 🚀 |
|
||||
| Science | https://science.thetempleofdoom.com | 🚀 |
|
||||
| Crypto | https://crypto.thetempleofdoom.com | 🚀 |
|
||||
| Linux | https://linux.thetempleofdoom.com | 🚀 |
|
||||
| Gaming | https://gaming.thetempleofdoom.com | 🚀 |
|
||||
| DIY | https://diy.thetempleofdoom.com | 🚀 |
|
||||
| Guides | https://guides.thetempleofdoom.com | 🚀 |
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
cron (6AM daily) → Trend Discovery → Topic Scoring → Research (ornith:latest)
|
||||
→ Multi-Agent Writing → SEO → Site Builder → Deploy to Proxmox CTs
|
||||
```
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
# Initialize database
|
||||
python3 core/orchestrator.py init
|
||||
|
||||
# Discover trending topics
|
||||
python3 core/orchestrator.py discover
|
||||
|
||||
# Run full pipeline (3 articles)
|
||||
python3 core/orchestrator.py run --max 3
|
||||
|
||||
# Check status
|
||||
python3 core/orchestrator.py status
|
||||
|
||||
# Admin dashboard
|
||||
python3 dashboard/app.py
|
||||
# → http://localhost:5106
|
||||
```
|
||||
|
||||
### Infrastructure
|
||||
|
||||
- **Orchestrator**: MacBook (cron via launchd)
|
||||
- **LLM Inference**:
|
||||
- MacBook: qwen3.5:4b (fast, cheap tasks)
|
||||
- GamingPC RTX 3070: ornith:latest (quality writing/research)
|
||||
- **Sites**: 8 Proxmox LXC containers (nginx on each)
|
||||
- **Tunnels**: Cloudflare home tunnel (d2871458)
|
||||
- **Code**: Gitea (10.30.20.149:3000)
|
||||
- **Analytics**: Self-hosted, privacy-first
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
auto-publisher/
|
||||
├── core/ # Orchestrator, deploy scripts
|
||||
├── services/ # Microservice APIs
|
||||
├── sites/ # Per-vertical static sites
|
||||
├── shared/ # CSS, JS, assets
|
||||
├── dashboard/ # Admin control panel
|
||||
├── analytics/ # Tracking & learning loop
|
||||
├── cron/ # Scheduled jobs
|
||||
└── docs/ # Architecture docs
|
||||
```
|
||||
|
||||
### Pipeline Steps
|
||||
|
||||
1. **Discover**: Scans HN, Reddit, GitHub, arXiv for trending topics
|
||||
2. **Score**: LLM evaluates popularity, competition, freshness, evergreen value
|
||||
3. **Assign**: Routes topics to appropriate vertical site
|
||||
4. **Research**: Deep research via ornith:latest (GamingPC), builds knowledge package
|
||||
5. **Write**: Multi-agent pipeline (outline → draft → edit → SEO → fact-check)
|
||||
6. **Build**: Generates static HTML, RSS, sitemap, JSON-LD
|
||||
7. **Deploy**: Pushes to Proxmox CT, restarts nginx
|
||||
8. **Learn**: Nightly analytics feedback loop improves future topic selection
|
||||
|
||||
### LLM Strategy
|
||||
|
||||
| Stage | Model | Host | Rationale |
|
||||
|-------|-------|------|-----------|
|
||||
| Topic scoring | qwen3.5:4b | MacBook | Fast pattern matching |
|
||||
| Research | ornith:latest | GamingPC | Deep reasoning, accuracy |
|
||||
| Writing | ornith:latest | GamingPC | Quality output |
|
||||
| Editing | qwen3.5:4b | MacBook | Fast iteration |
|
||||
| SEO | qwen3.5:4b | MacBook | Template-driven |
|
||||
| Fact-check | ornith:latest | GamingPC | Critical accuracy |
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
# Optional overrides
|
||||
OLLAMA_MACBOOK=http://localhost:11434
|
||||
OLLAMA_GAMINGPC=http://10.30.20.186:11434
|
||||
DASHBOARD_SECRET=auto-publisher-2026
|
||||
```
|
||||
|
||||
### Monitoring
|
||||
|
||||
- Admin dashboard: http://localhost:5106
|
||||
- Pipeline logs: `core/orchestrator.log`
|
||||
- Analytics: per-CT at `:5199/a/stats`
|
||||
190
analytics/analytics.py
Normal file
190
analytics/analytics.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""
|
||||
Analytics & Learning Loop
|
||||
Tracks content performance and feeds insights back into topic selection.
|
||||
"""
|
||||
import sqlite3
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
import logging
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
DB_PATH = BASE_DIR / "core" / "publisher.db"
|
||||
|
||||
log = logging.getLogger("analytics")
|
||||
|
||||
|
||||
def record_pageview(article_id: int, vertical: str, referrer: str = "",
|
||||
user_agent: str = "", ip_hash: str = "") -> None:
|
||||
"""Record a pageview for an article."""
|
||||
db = sqlite3.connect(str(DB_PATH))
|
||||
|
||||
# Update or insert analytics row for today
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
existing = db.execute(
|
||||
"SELECT id, pageviews, unique_visitors FROM analytics WHERE article_id = ? AND recorded_at = ?",
|
||||
(article_id, today)
|
||||
).fetchone()
|
||||
|
||||
if existing:
|
||||
db.execute(
|
||||
"UPDATE analytics SET pageviews = pageviews + 1 WHERE id = ?",
|
||||
(existing[0],)
|
||||
)
|
||||
else:
|
||||
db.execute(
|
||||
"INSERT INTO analytics (article_id, vertical, pageviews, unique_visitors, recorded_at) "
|
||||
"VALUES (?, ?, 1, 1, ?)",
|
||||
(article_id, vertical, today)
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def get_article_performance(days: int = 30) -> list[dict]:
|
||||
"""Get performance data for all articles in the last N days."""
|
||||
db = sqlite3.connect(str(DB_PATH))
|
||||
db.row_factory = sqlite3.Row
|
||||
|
||||
rows = db.execute("""
|
||||
SELECT a.id, a.title, a.vertical, a.slug, a.word_count,
|
||||
COALESCE(SUM(an.pageviews), 0) as total_views,
|
||||
COALESCE(SUM(an.unique_visitors), 0) as total_visitors,
|
||||
COUNT(DISTINCT an.recorded_at) as days_tracked
|
||||
FROM articles a
|
||||
LEFT JOIN analytics an ON a.id = an.article_id
|
||||
WHERE a.status = 'published'
|
||||
AND (an.recorded_at >= date('now', ?) OR an.recorded_at IS NULL)
|
||||
GROUP BY a.id
|
||||
ORDER BY total_views DESC
|
||||
""", (f"-{days} days",)).fetchall()
|
||||
|
||||
db.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def get_vertical_performance(days: int = 30) -> dict:
|
||||
"""Get aggregate performance per vertical."""
|
||||
db = sqlite3.connect(str(DB_PATH))
|
||||
db.row_factory = sqlite3.Row
|
||||
|
||||
rows = db.execute("""
|
||||
SELECT a.vertical,
|
||||
COUNT(DISTINCT a.id) as article_count,
|
||||
COALESCE(SUM(an.pageviews), 0) as total_views,
|
||||
COALESCE(AVG(an.pageviews), 0) as avg_views_per_article,
|
||||
AVG(a.word_count) as avg_word_count
|
||||
FROM articles a
|
||||
LEFT JOIN analytics an ON a.id = an.article_id
|
||||
WHERE a.status = 'published'
|
||||
AND (an.recorded_at >= date('now', ?) OR an.recorded_at IS NULL)
|
||||
GROUP BY a.vertical
|
||||
ORDER BY total_views DESC
|
||||
""", (f"-{days} days",)).fetchall()
|
||||
|
||||
db.close()
|
||||
return {r["vertical"]: dict(r) for r in rows}
|
||||
|
||||
|
||||
def run_learning_loop() -> dict:
|
||||
"""Nightly analysis: learn what works and update topic scoring."""
|
||||
log.info("Running learning loop...")
|
||||
db = sqlite3.connect(str(DB_PATH))
|
||||
db.row_factory = sqlite3.Row
|
||||
|
||||
insights = {}
|
||||
|
||||
for vertical in ["ai", "tech", "science", "crypto", "linux", "gaming", "diy", "guides"]:
|
||||
# Top performing articles
|
||||
top = db.execute("""
|
||||
SELECT a.title, a.word_count, COALESCE(SUM(an.pageviews), 0) as views
|
||||
FROM articles a
|
||||
LEFT JOIN analytics an ON a.id = an.article_id
|
||||
WHERE a.vertical = ? AND a.status = 'published'
|
||||
GROUP BY a.id
|
||||
ORDER BY views DESC LIMIT 5
|
||||
""", (vertical,)).fetchall()
|
||||
|
||||
# Optimal word count
|
||||
wc = db.execute("""
|
||||
SELECT AVG(a.word_count) as avg_wc
|
||||
FROM articles a
|
||||
LEFT JOIN analytics an ON a.id = an.article_id
|
||||
WHERE a.vertical = ? AND a.status = 'published'
|
||||
GROUP BY a.vertical
|
||||
""", (vertical,)).fetchone()
|
||||
|
||||
# Best headline patterns (simple analysis)
|
||||
headline_data = db.execute("""
|
||||
SELECT a.title
|
||||
FROM articles a
|
||||
LEFT JOIN analytics an ON a.id = an.article_id
|
||||
WHERE a.vertical = ? AND a.status = 'published'
|
||||
ORDER BY COALESCE(SUM(an.pageviews), 0) DESC LIMIT 3
|
||||
""", (vertical,)).fetchall()
|
||||
|
||||
insights[vertical] = {
|
||||
"top_articles": [dict(r) for r in top],
|
||||
"optimal_word_count": round(wc["avg_wc"]) if wc and wc["avg_wc"] else None,
|
||||
"top_headlines": [r["title"] for r in headline_data],
|
||||
"total_articles": db.execute(
|
||||
"SELECT COUNT(*) FROM articles WHERE vertical=? AND status='published'",
|
||||
(vertical,)
|
||||
).fetchone()[0],
|
||||
}
|
||||
|
||||
# Store to performance_learning table
|
||||
db.execute("""
|
||||
INSERT OR REPLACE INTO performance_learning (vertical, top_patterns, headline_formats,
|
||||
optimal_word_count, keyword_insights, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, datetime('now'))
|
||||
""", (
|
||||
vertical,
|
||||
json.dumps(insights[vertical]["top_articles"]),
|
||||
json.dumps(insights[vertical]["top_headlines"]),
|
||||
insights[vertical]["optimal_word_count"],
|
||||
json.dumps([]),
|
||||
))
|
||||
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
log.info(f"Learning loop complete. Processed {len(insights)} verticals.")
|
||||
return insights
|
||||
|
||||
|
||||
def generate_topic_boost() -> dict:
|
||||
"""Generate topic scoring boosts based on learning data."""
|
||||
db = sqlite3.connect(str(DB_PATH))
|
||||
db.row_factory = sqlite3.Row
|
||||
|
||||
boosts = {}
|
||||
for vertical in ["ai", "tech", "science", "crypto", "linux", "gaming", "diy", "guides"]:
|
||||
pl = db.execute(
|
||||
"SELECT * FROM performance_learning WHERE vertical=? ORDER BY updated_at DESC LIMIT 1",
|
||||
(vertical,)
|
||||
).fetchone()
|
||||
|
||||
if pl:
|
||||
boosts[vertical] = {
|
||||
"boost": 1.0, # Default neutral
|
||||
"preferred_word_count": pl["optimal_word_count"],
|
||||
"avoid_patterns": [],
|
||||
"prefer_patterns": [],
|
||||
}
|
||||
|
||||
db.close()
|
||||
return boosts
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Running analytics learning loop...")
|
||||
insights = run_learning_loop()
|
||||
for vertical, data in insights.items():
|
||||
print(f"\n{vertical}: {data['total_articles']} articles, "
|
||||
f"optimal WC: {data['optimal_word_count']}")
|
||||
for art in data["top_articles"]:
|
||||
print(f" {art['views']:>5} views | {art['title'][:60]}")
|
||||
101
analytics/collector.py
Normal file
101
analytics/collector.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""
|
||||
Auto Publisher — Analytics collector endpoint.
|
||||
Lightweight Flask app that runs on each site CT to collect pageview data.
|
||||
"""
|
||||
from flask import Flask, request, jsonify
|
||||
import sqlite3
|
||||
import json
|
||||
import hashlib
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
DB_PATH = "/var/lib/auto-publisher/analytics.db"
|
||||
|
||||
|
||||
def get_db():
|
||||
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
|
||||
db = sqlite3.connect(DB_PATH)
|
||||
db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS pageviews (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
path TEXT NOT NULL,
|
||||
vertical TEXT NOT NULL,
|
||||
referrer TEXT DEFAULT '',
|
||||
user_agent TEXT DEFAULT '',
|
||||
ip_hash TEXT DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
db.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_pageviews_path ON pageviews(path);
|
||||
""")
|
||||
db.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_pageviews_created ON pageviews(created_at);
|
||||
""")
|
||||
db.commit()
|
||||
return db
|
||||
|
||||
|
||||
@app.route("/a/collect", methods=["POST", "GET"])
|
||||
def collect():
|
||||
"""Collect a pageview ping."""
|
||||
path = request.args.get("p", "/")
|
||||
vertical = request.args.get("v", "unknown")
|
||||
ref = request.args.get("r", "")
|
||||
ua = request.headers.get("User-Agent", "")[:200]
|
||||
ip = request.remote_addr or ""
|
||||
ip_hash = hashlib.sha256(ip.encode()).hexdigest()[:16] if ip else ""
|
||||
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"INSERT INTO pageviews (path, vertical, referrer, user_agent, ip_hash) VALUES (?, ?, ?, ?, ?)",
|
||||
(path, vertical, ref, ua, ip_hash)
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
# Return a 1x1 transparent GIF
|
||||
return b"\x47\x49\x46\x38\x39\x61\x01\x00\x01\x00\x80\x00\x00\xff\xff\xff\x00\x00\x00\x21\xf9\x04\x01\x00\x00\x00\x00\x2c\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02\x44\x01\x00\x3b", 200, {
|
||||
"Content-Type": "image/gif",
|
||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||
}
|
||||
|
||||
|
||||
@app.route("/a/stats")
|
||||
def stats():
|
||||
"""Get site analytics summary."""
|
||||
db = get_db()
|
||||
db.row_factory = sqlite3.Row
|
||||
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
total = db.execute("SELECT COUNT(*) as c FROM pageviews").fetchone()["c"]
|
||||
today_views = db.execute(
|
||||
"SELECT COUNT(*) as c FROM pageviews WHERE created_at >= ?", (today,)
|
||||
).fetchone()["c"]
|
||||
|
||||
top_pages = db.execute("""
|
||||
SELECT path, COUNT(*) as c FROM pageviews
|
||||
WHERE created_at >= date('now', '-30 days')
|
||||
GROUP BY path ORDER BY c DESC LIMIT 10
|
||||
""").fetchall()
|
||||
|
||||
db.close()
|
||||
|
||||
return jsonify({
|
||||
"total_views": total,
|
||||
"today_views": today_views,
|
||||
"top_pages": [{"path": r["path"], "views": r["c"]} for r in top_pages],
|
||||
})
|
||||
|
||||
|
||||
@app.route("/health")
|
||||
def health():
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=5199, debug=False)
|
||||
184
core/cloudflare_setup.py
Normal file
184
core/cloudflare_setup.py
Normal file
@@ -0,0 +1,184 @@
|
||||
"""
|
||||
Wire auto-publisher sites to Cloudflare home tunnel.
|
||||
Adds ingress rules to the existing home tunnel for each site subdomain.
|
||||
"""
|
||||
import json
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
# Home tunnel ID (the existing tunnel that routes ~38 services)
|
||||
HOME_TUNNEL_ID = "d2871458-1737-4844-b114-9a44b9d71e25"
|
||||
ACCT_ID = "895479ab3540daa6d61ae702a5164475"
|
||||
ZONE_ID = "93634d23ff8138fbd795763b681158ac"
|
||||
DOMAIN = "thetempleofdoom.com"
|
||||
|
||||
# Site IPs
|
||||
SITES = {
|
||||
"ai": "10.30.20.240",
|
||||
"tech": "10.30.20.241",
|
||||
"science": "10.30.20.242",
|
||||
"crypto": "10.30.20.243",
|
||||
"linux": "10.30.20.244",
|
||||
"gaming": "10.30.20.246",
|
||||
"diy": "10.30.20.247",
|
||||
"guides": "10.30.20.248",
|
||||
}
|
||||
|
||||
|
||||
def get_home_tunnel_config():
|
||||
"""Get current home tunnel ingress configuration."""
|
||||
result = subprocess.run(
|
||||
["cloudflared", "tunnel", "info", "--output", "json", HOME_TUNNEL_ID],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
return json.loads(result.stdout)
|
||||
return None
|
||||
|
||||
|
||||
def add_ingress_rules():
|
||||
"""Add ingress rules for all 8 publisher sites to the home tunnel."""
|
||||
print("Adding Cloudflare tunnel ingress rules...")
|
||||
|
||||
for name, ip in SITES.items():
|
||||
hostname = f"{name}.{DOMAIN}"
|
||||
|
||||
# Check if DNS record already exists
|
||||
print(f"\n {hostname}:")
|
||||
|
||||
# Use cloudflared CLI to add route
|
||||
cmd = [
|
||||
"cloudflared", "tunnel", "route", "dns",
|
||||
"--overwrite-dns",
|
||||
HOME_TUNNEL_ID, hostname
|
||||
]
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||
if result.returncode == 0:
|
||||
print(f" ✅ DNS route created: {hostname} → tunnel")
|
||||
else:
|
||||
error = result.stderr.strip()
|
||||
if "already exists" in error.lower() or "conflict" in error.lower():
|
||||
print(f" ⚠️ DNS already exists: {hostname}")
|
||||
else:
|
||||
print(f" ❌ Failed: {error[:200]}")
|
||||
# Fallback: manual DNS via API
|
||||
_add_dns_manual(name, hostname)
|
||||
|
||||
# Add local ingress mapping using config file approach
|
||||
_add_config_ingress(name, hostname, ip)
|
||||
|
||||
|
||||
def _add_dns_manual(name, hostname):
|
||||
"""Manual DNS CNAME creation via API."""
|
||||
# This requires a working CF token
|
||||
CF_TOKEN = "cfat_M62Ke6eLXPZnb26qW4FCRL4T4h8l4KrRVLFIRWad9455c3e0"
|
||||
|
||||
result = subprocess.run([
|
||||
"curl", "-s", "-X", "POST",
|
||||
f"https://api.cloudflare.com/client/v4/zones/{ZONE_ID}/dns_records",
|
||||
"-H", f"Authorization: Bearer {CF_TOKEN}",
|
||||
"-H", "Content-Type: application/json",
|
||||
"-d", json.dumps({
|
||||
"type": "CNAME",
|
||||
"name": name,
|
||||
"content": f"{HOME_TUNNEL_ID}.cfargotunnel.com",
|
||||
"proxied": True,
|
||||
"ttl": 1,
|
||||
}),
|
||||
], capture_output=True, text=True, timeout=15)
|
||||
|
||||
try:
|
||||
resp = json.loads(result.stdout)
|
||||
if resp.get("success"):
|
||||
print(f" ✅ Manual DNS created: {hostname}")
|
||||
else:
|
||||
errors = resp.get("errors", [])
|
||||
for e in errors:
|
||||
if "already exists" in str(e).lower() or e.get("code") == 81053:
|
||||
print(f" ⚠️ DNS already exists: {hostname}")
|
||||
return
|
||||
print(f" ❌ Manual DNS failed: {errors}")
|
||||
except Exception:
|
||||
print(f" ❌ API error: {result.stdout[:200]}")
|
||||
|
||||
|
||||
def _add_config_ingress(name, hostname, ip):
|
||||
"""Add ingress rule to cloudflared config file."""
|
||||
config_path = "/usr/local/etc/cloudflared/config.yml"
|
||||
|
||||
# Check if cloudflared is running locally
|
||||
result = subprocess.run(
|
||||
["pgrep", "-f", "cloudflared"], capture_output=True, text=True
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
print(f" ℹ️ cloudflared not running locally — tunnel is on Proxmox CT680")
|
||||
print(f" ℹ️ Add manually: cloudflared tunnel route dns {HOME_TUNNEL_ID} {hostname}")
|
||||
return
|
||||
|
||||
# Read existing config
|
||||
try:
|
||||
with open(config_path) as f:
|
||||
config = f.read()
|
||||
except FileNotFoundError:
|
||||
print(f" ℹ️ No local cloudflared config — tunnel managed elsewhere")
|
||||
return
|
||||
|
||||
# Check if entry exists
|
||||
if f"hostname: {hostname}" in config:
|
||||
print(f" ⚠️ Ingress already in config for {hostname}")
|
||||
return
|
||||
|
||||
# Add ingress rule before the catch-all
|
||||
new_rule = f"""
|
||||
- hostname: {hostname}
|
||||
service: http://{ip}:80"""
|
||||
|
||||
if "service: http_status:404" in config:
|
||||
config = config.replace(
|
||||
"service: http_status:404",
|
||||
f"{new_rule}\n - service: http_status:404"
|
||||
)
|
||||
|
||||
with open(config_path, "w") as f:
|
||||
f.write(config)
|
||||
|
||||
print(f" ✅ Ingress added locally for {hostname}")
|
||||
|
||||
# Restart cloudflared
|
||||
subprocess.run(["brew", "services", "restart", "cloudflared"],
|
||||
capture_output=True, timeout=10)
|
||||
print(f" ✅ cloudflared restarted")
|
||||
|
||||
|
||||
def verify_all():
|
||||
"""Verify all sites are accessible via Cloudflare tunnel."""
|
||||
print("\n\nVerifying all sites...")
|
||||
time.sleep(5)
|
||||
|
||||
for name in SITES:
|
||||
url = f"https://{name}.{DOMAIN}"
|
||||
result = subprocess.run(
|
||||
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", url],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
code = result.stdout.strip()
|
||||
status = "✅" if code in ("200", "301", "302") else "❌"
|
||||
print(f" {status} {url} → HTTP {code}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=" * 60)
|
||||
print("CLOUDFLARE TUNNEL SETUP — Auto Publisher Sites")
|
||||
print("=" * 60)
|
||||
print(f"\nHome Tunnel: {HOME_TUNNEL_ID}")
|
||||
print(f"Domain: {DOMAIN}")
|
||||
print()
|
||||
|
||||
add_ingress_rules()
|
||||
verify_all()
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print("TUNNEL SETUP COMPLETE")
|
||||
print(f"{'='*60}")
|
||||
3
core/dashboard.log
Normal file
3
core/dashboard.log
Normal file
@@ -0,0 +1,3 @@
|
||||
Admin Dashboard → http://127.0.0.1:5106
|
||||
* Serving Flask app 'app'
|
||||
* Debug mode: off
|
||||
4
core/dashboard_error.log
Normal file
4
core/dashboard_error.log
Normal file
@@ -0,0 +1,4 @@
|
||||
[31m[1mWARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.[0m
|
||||
* Running on http://127.0.0.1:5106
|
||||
[33mPress CTRL+C to quit[0m
|
||||
127.0.0.1 - - [03/Aug/2026 21:35:50] "GET /health HTTP/1.1" 200 -
|
||||
334
core/deploy_sites.py
Normal file
334
core/deploy_sites.py
Normal file
@@ -0,0 +1,334 @@
|
||||
"""
|
||||
Deploy all 8 auto-publisher sites to Proxmox LXC containers.
|
||||
Creates CTs, installs nginx, configures cloudflared tunnels, and pushes code.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
PROXMOX = "root@10.30.20.85"
|
||||
PROXMOX_PASS = "czapiewski"
|
||||
STORAGE = "poolmaster"
|
||||
BRIDGE = "vmbr0"
|
||||
GATEWAY = "10.30.20.1"
|
||||
TEMPLATE = "local:vztmpl/ubuntu-24.04-standard_24.04-2_amd64.tar.zst"
|
||||
DOMAIN = "thetempleofdoom.com"
|
||||
GITEA_HOST = "10.30.20.149:3000"
|
||||
GITEA_TOKEN = "dec25a837e0d85573706c4d5d608c2df215f3320"
|
||||
|
||||
# Site definitions: name → (ct_id, ip)
|
||||
SITES = {
|
||||
"ai": (135, "10.30.20.240"),
|
||||
"tech": (136, "10.30.20.241"),
|
||||
"science": (137, "10.30.20.242"),
|
||||
"crypto": (138, "10.30.20.243"),
|
||||
"linux": (139, "10.30.20.244"),
|
||||
"gaming": (140, "10.30.20.246"),
|
||||
"diy": (141, "10.30.20.247"),
|
||||
"guides": (142, "10.30.20.248"),
|
||||
}
|
||||
|
||||
|
||||
def ssh(cmd: str, timeout: int = 30) -> str:
|
||||
"""Run command on Proxmox host."""
|
||||
result = subprocess.run(
|
||||
["ssh", "-o", "StrictHostKeyChecking=accept-new", PROXMOX, cmd],
|
||||
capture_output=True, text=True, timeout=timeout
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def pct_exec(vmid: int, cmd: str, timeout: int = 30) -> str:
|
||||
"""Run command inside a CT."""
|
||||
return ssh(f"pct exec {vmid} -- bash -c '{cmd}'", timeout=timeout)
|
||||
|
||||
|
||||
def create_ct(name: str, vmid: int, ip: str) -> bool:
|
||||
"""Create a Proxmox LXC container."""
|
||||
print(f" Creating CT {vmid} ({name}) at {ip}...")
|
||||
|
||||
# Check if CT already exists
|
||||
existing = ssh(f"pct list | grep '^{vmid} '")
|
||||
if existing:
|
||||
print(f" CT {vmid} already exists — skipping creation")
|
||||
return True
|
||||
|
||||
cmd = (
|
||||
f"pct create {vmid} {TEMPLATE} "
|
||||
f"--hostname {name}-publisher "
|
||||
f"--storage {STORAGE} "
|
||||
f"--memory 2048 --swap 512 --cores 2 "
|
||||
f"--net0 name=eth0,bridge={BRIDGE},ip={ip}/24,gw={GATEWAY} "
|
||||
f"--unprivileged 1 --password {PROXMOX_PASS} "
|
||||
f"--features nesting=1 --onboot 1"
|
||||
)
|
||||
out = ssh(cmd)
|
||||
print(f" Created: {out}")
|
||||
|
||||
# Start it
|
||||
ssh(f"pct start {vmid}")
|
||||
time.sleep(8)
|
||||
|
||||
# Get assigned IP
|
||||
ip_check = ssh(f"pct exec {vmid} -- hostname -I")
|
||||
print(f" IP: {ip_check}")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def setup_ct(name: str, vmid: int, ip: str) -> bool:
|
||||
"""Install nginx and configure the site on a CT."""
|
||||
print(f" Setting up CT {vmid} ({name})...")
|
||||
|
||||
# Update packages
|
||||
pct_exec(vmid, "apt update -qq && apt install -y -qq nginx curl python3 python3-pip git 2>&1 | tail -5",
|
||||
timeout=120)
|
||||
|
||||
# Create web root
|
||||
pct_exec(vmid, "mkdir -p /var/www/html/articles /var/www/html/assets/images")
|
||||
|
||||
# Copy shared assets
|
||||
local_assets = Path(__file__).resolve().parent.parent / "shared" / "assets"
|
||||
if local_assets.exists():
|
||||
# Create tarball of shared assets
|
||||
subprocess.run(
|
||||
f"cd {local_assets.parent} && tar czf /tmp/shared_assets.tar.gz assets/",
|
||||
shell=True, capture_output=True
|
||||
)
|
||||
subprocess.run(
|
||||
f"scp /tmp/shared_assets.tar.gz {PROXMOX}:/tmp/shared_assets.tar.gz",
|
||||
shell=True, capture_output=True
|
||||
)
|
||||
ssh(f"pct push {vmid} /tmp/shared_assets.tar.gz /tmp/shared_assets.tar.gz")
|
||||
pct_exec(vmid, "cd /var/www/html && tar xzf /tmp/shared_assets.tar.gz")
|
||||
|
||||
# Create nginx config
|
||||
nginx_conf = f"""server {{
|
||||
listen 80 default_server;
|
||||
server_name {name}.{DOMAIN};
|
||||
root /var/www/html;
|
||||
index index.html;
|
||||
|
||||
location / {{
|
||||
try_files $uri $uri/ $uri.html =404;
|
||||
}}
|
||||
|
||||
location /assets/ {{
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}}
|
||||
|
||||
location /rss.xml {{
|
||||
add_header Content-Type "application/rss+xml";
|
||||
}}
|
||||
|
||||
location /sitemap.xml {{
|
||||
add_header Content-Type "application/xml";
|
||||
}}
|
||||
|
||||
# Gzip
|
||||
gzip on;
|
||||
gzip_types text/plain text/html text/css application/json application/javascript text/xml application/xml;
|
||||
gzip_min_length 1000;
|
||||
}}"""
|
||||
|
||||
# Write nginx config via base64 to avoid quoting issues
|
||||
import base64
|
||||
encoded = base64.b64encode(nginx_conf.encode()).decode()
|
||||
pct_exec(vmid, f"echo '{encoded}' | base64 -d > /etc/nginx/sites-available/default")
|
||||
pct_exec(vmid, "rm -f /etc/nginx/sites-enabled/default && "
|
||||
"ln -s /etc/nginx/sites-available/default /etc/nginx/sites-enabled/default")
|
||||
|
||||
# Verify and restart nginx
|
||||
result = pct_exec(vmid, "nginx -t 2>&1 && systemctl restart nginx && echo 'NGINX_OK'")
|
||||
print(f" Nginx: {'OK' if 'NGINX_OK' in result else 'FAILED'}")
|
||||
|
||||
# Create a simple health endpoint
|
||||
pct_exec(vmid, f"echo '<html><body><h1>{name}.{DOMAIN}</h1><p>Auto Publisher — coming soon</p></body></html>' > /var/www/html/index.html")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def create_gitea_repo(name: str) -> bool:
|
||||
"""Create a Gitea repository for a site."""
|
||||
print(f" Creating Gitea repo: {name}-publisher...")
|
||||
|
||||
# Check if repo already exists
|
||||
result = subprocess.run(
|
||||
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
|
||||
f"http://{GITEA_HOST}/api/v1/repos/drjones/{name}-publisher"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
if result.stdout.strip() == "200":
|
||||
print(f" Repo {name}-publisher already exists")
|
||||
return True
|
||||
|
||||
# Create via API
|
||||
result = subprocess.run(
|
||||
["curl", "-s", "-X", "POST",
|
||||
f"http://{GITEA_HOST}/api/v1/user/repos",
|
||||
"-H", "Content-Type: application/json",
|
||||
"-H", f"Authorization: token {GITEA_TOKEN}",
|
||||
"-d", json.dumps({
|
||||
"name": f"{name}-publisher",
|
||||
"description": f"Auto Publisher site: {name}.thetempleofdoom.com",
|
||||
"private": False,
|
||||
"auto_init": True,
|
||||
})],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
|
||||
if "200" in result.stdout or "201" in result.stdout or "409" in result.stdout:
|
||||
print(f" Repo {name}-publisher created")
|
||||
return True
|
||||
else:
|
||||
print(f" Gitea API failed: {result.stdout[:200]}")
|
||||
# Fallback: create via filesystem + SQLite
|
||||
ssh(f"pct exec 525 -- mkdir -p /var/lib/gitea/data/gitea-repositories/drjones/{name}-publisher.git")
|
||||
ssh(f"pct exec 525 -- git init --bare /var/lib/gitea/data/gitea-repositories/drjones/{name}-publisher.git")
|
||||
ssh(f"pct exec 525 -- chown -R gitea:gitea /var/lib/gitea/data/gitea-repositories/drjones/{name}-publisher.git")
|
||||
return True
|
||||
|
||||
|
||||
def verify_site(name: str, ip: str) -> bool:
|
||||
"""Verify a site is serving content."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
|
||||
f"http://{ip}:80/"],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
code = result.stdout.strip()
|
||||
print(f" HTTP status: {code}")
|
||||
return code == "200"
|
||||
except Exception as e:
|
||||
print(f" Verify failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def deploy_site_code(name: str, vmid: int) -> bool:
|
||||
"""Push site code to CT and to Gitea."""
|
||||
print(f" Pushing code to CT {vmid}...")
|
||||
|
||||
site_dir = Path(__file__).resolve().parent.parent / "sites" / name
|
||||
|
||||
# Build a placeholder site if directory is empty
|
||||
if not site_dir.exists() or not list(site_dir.glob("*.html")):
|
||||
# Create placeholder
|
||||
site_dir.mkdir(parents=True, exist_ok=True)
|
||||
(site_dir / "articles").mkdir(exist_ok=True)
|
||||
|
||||
placeholder = f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{name}.thetempleofdoom.com</title>
|
||||
<link rel="stylesheet" href="/assets/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<nav>
|
||||
<a href="/" class="logo">{name}.thetempleofdoom.com</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<section class="hero">
|
||||
<h1>{name.title()} Insights & Guides</h1>
|
||||
<p>Expert {name} articles, tutorials, and deep dives. Updated daily.</p>
|
||||
<p style="margin-top:2rem;color:var(--text2);">🚀 First article coming soon...</p>
|
||||
</section>
|
||||
<section class="articles-grid">
|
||||
<div class="card">
|
||||
<h2>Coming Soon</h2>
|
||||
<p class="meta">The autonomous publishing pipeline is being set up.</p>
|
||||
<p>Fresh {name} content will be published here automatically every day.</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<footer class="site-footer">
|
||||
<p>© 2026 {name}.thetempleofdoom.com</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>"""
|
||||
(site_dir / "index.html").write_text(placeholder)
|
||||
|
||||
# Tar and push
|
||||
subprocess.run(
|
||||
f"cd {site_dir.parent} && tar czf /tmp/{name}_site.tar.gz {name}/",
|
||||
shell=True, capture_output=True
|
||||
)
|
||||
subprocess.run(
|
||||
f"scp /tmp/{name}_site.tar.gz {PROXMOX}:/tmp/{name}_site.tar.gz",
|
||||
shell=True, capture_output=True
|
||||
)
|
||||
ssh(f"pct push {vmid} /tmp/{name}_site.tar.gz /tmp/{name}_site.tar.gz")
|
||||
pct_exec(vmid, f"cd /var/www/html && tar xzf /tmp/{name}_site.tar.gz --strip-components=1 && chown -R www-data:www-data /var/www/html")
|
||||
|
||||
# Push to Gitea
|
||||
git_dir = site_dir
|
||||
subprocess.run(
|
||||
f"cd {git_dir} && "
|
||||
f"git init 2>/dev/null; "
|
||||
f"git config user.email 'drjones@thetempleofdoom.com'; "
|
||||
f"git config user.name 'drjones'; "
|
||||
f"git add . 2>/dev/null; "
|
||||
f"git commit -m 'Initial: {name} publisher site' 2>/dev/null; "
|
||||
f"git remote remove origin 2>/dev/null; "
|
||||
f"git remote add origin http://drjones:{GITEA_TOKEN}@{GITEA_HOST}/drjones/{name}-publisher.git; "
|
||||
f"git push -u origin main --force 2>&1",
|
||||
shell=True, capture_output=True
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("AUTO PUBLISHER — Site Deployment")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
for name, (vmid, ip) in SITES.items():
|
||||
print(f"\n{'='*40}")
|
||||
print(f" 📡 {name}.thetempleofdoom.com (CT {vmid}, {ip})")
|
||||
print(f"{'='*40}")
|
||||
|
||||
# Step 1: Create CT
|
||||
if not create_ct(name, vmid, ip):
|
||||
print(f" ❌ Failed to create CT for {name}")
|
||||
continue
|
||||
|
||||
# Step 2: Setup nginx
|
||||
if not setup_ct(name, vmid, ip):
|
||||
print(f" ❌ Failed to setup {name}")
|
||||
continue
|
||||
|
||||
# Step 3: Create Gitea repo
|
||||
create_gitea_repo(name)
|
||||
|
||||
# Step 4: Push initial code
|
||||
deploy_site_code(name, vmid)
|
||||
|
||||
# Step 5: Verify
|
||||
time.sleep(2)
|
||||
if verify_site(name, ip):
|
||||
print(f" ✅ {name}.thetempleofdoom.com is LIVE (http://{ip}:80)")
|
||||
else:
|
||||
print(f" ⚠️ {name} may need nginx restart")
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print("DEPLOYMENT COMPLETE")
|
||||
print(f"{'='*60}")
|
||||
print("\nSites deployed:")
|
||||
for name, (vmid, ip) in SITES.items():
|
||||
print(f" {name}.thetempleofdoom.com → CT {vmid} @ {ip}")
|
||||
print("\nNext: Set up Cloudflare tunnels for public access")
|
||||
print("Run: python3 core/cloudflare_setup.py")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
5
core/orchestrator.log
Normal file
5
core/orchestrator.log
Normal file
@@ -0,0 +1,5 @@
|
||||
2026-08-03 21:38:15,046 [INFO] orchestrator: Starting trend discovery...
|
||||
2026-08-03 21:38:19,444 [WARNING] orchestrator: Seasonal topic generation failed: ollama_json() got an unexpected keyword argument 'temperature'
|
||||
2026-08-03 21:38:19,445 [WARNING] orchestrator: Topic scoring failed: ollama_json() got an unexpected keyword argument 'temperature'
|
||||
2026-08-03 21:38:19,449 [INFO] orchestrator: Discovered 0 topics, stored top 25
|
||||
2026-08-03 21:38:30,350 [INFO] orchestrator: Starting trend discovery...
|
||||
1168
core/orchestrator.py
Normal file
1168
core/orchestrator.py
Normal file
File diff suppressed because it is too large
Load Diff
BIN
core/publisher.db
Normal file
BIN
core/publisher.db
Normal file
Binary file not shown.
26
cron/daily_publish.py
Normal file
26
cron/daily_publish.py
Normal file
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Daily autonomous publishing script.
|
||||
Runs every morning to discover topics, research, write, and publish articles.
|
||||
|
||||
Usage: python3 ~/auto-publisher/cron/daily_publish.py [--max 3]
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.expanduser("~/auto-publisher/core"))
|
||||
|
||||
from orchestrator import run_daily_pipeline, init_db
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--max", type=int, default=3, help="Max articles to publish")
|
||||
args = ap.parse_args()
|
||||
|
||||
# Initialize DB if needed
|
||||
init_db()
|
||||
|
||||
# Run the pipeline
|
||||
print("🚀 Starting daily autonomous publishing pipeline...")
|
||||
run_daily_pipeline(max_articles=args.max)
|
||||
print("✅ Daily pipeline complete")
|
||||
309
dashboard/app.py
Normal file
309
dashboard/app.py
Normal 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)
|
||||
270
docs/ARCHITECTURE.md
Normal file
270
docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,270 @@
|
||||
# Autonomous AI Publishing System — Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
A fully autonomous, self-hosted publishing platform that continuously discovers valuable topics, generates original useful content, and publishes to a collection of evergreen authority websites.
|
||||
|
||||
## Sites (Vertical Authority Domains)
|
||||
|
||||
| Site | Domain | CT ID | IP | Port |
|
||||
|------|--------|-------|-----|------|
|
||||
| AI | ai.thetempleofdoom.com | TBD | TBD | 5000 |
|
||||
| Tech | tech.thetempleofdoom.com | TBD | TBD | 5000 |
|
||||
| Science | science.thetempleofdoom.com | TBD | TBD | 5000 |
|
||||
| Crypto | crypto.thetempleofdoom.com | TBD | TBD | 5000 |
|
||||
| Linux | linux.thetempleofdoom.com | TBD | TBD | 5000 |
|
||||
| Gaming | gaming.thetempleofdoom.com | TBD | TBD | 5000 |
|
||||
| DIY | diy.thetempleofdoom.com | TBD | TBD | 5000 |
|
||||
| Guides | guides.thetempleofdoom.com | TBD | TBD | 5000 |
|
||||
|
||||
## System Architecture
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ CRON ORCHESTRATOR (MacBook) │
|
||||
│ Every morning @ 6AM Pacific │
|
||||
└──────────────────────────┬───────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ TREND DISCOVERY ENGINE │
|
||||
│ Sources: News APIs, RSS, Reddit, HN, GitHub, Google Trends, │
|
||||
│ arXiv, Stack Overflow, Twitter/X, seasonal calendar │
|
||||
│ Output: Scored topic list with vertical assignments │
|
||||
└──────────────────────────┬───────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ RESEARCH AGENT │
|
||||
│ Per topic: multi-source extraction → facts, stats, citations, │
|
||||
│ FAQs, timelines, misconceptions, examples │
|
||||
│ LLM: GamingPC ornith:latest for deep research │
|
||||
│ Output: Knowledge Package (JSON) → stored in SQLite │
|
||||
└──────────────────────────┬───────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ MULTI-AGENT WRITING PIPELINE │
|
||||
│ Outline Agent → Technical Writer → SEO Writer → Copy Editor │
|
||||
│ → Fact Checker → Quality Reviewer │
|
||||
│ LLMs: qwen3.5:4b (fast gate) + ornith:latest (verify) │
|
||||
│ Output: Polished Markdown article + frontmatter │
|
||||
└──────────────────────────┬───────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ IMAGE GENERATION │
|
||||
│ FLUX via FAL.ai for hero images, diagrams, charts │
|
||||
│ Output: WebP images in site assets/ │
|
||||
└──────────────────────────┬───────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ SEO PROCESSOR │
|
||||
│ Meta titles, descriptions, OG tags, JSON-LD, schema.org, │
|
||||
│ canonical URLs, breadcrumbs, XML sitemap, robots.txt │
|
||||
└──────────────────────────┬───────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ SITE BUILDER (per vertical) │
|
||||
│ Static site generator: Markdown → HTML, rebuilds on new content │
|
||||
│ Updates: homepage, category pages, RSS, sitemap, related links │
|
||||
│ Built-in search via pagefind │
|
||||
└──────────────────────────┬───────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ DEPLOYMENT │
|
||||
│ 1. Build static site locally │
|
||||
│ 2. scp to Proxmox CT │
|
||||
│ 3. Restart nginx/service │
|
||||
│ 4. Ping sitemap to Google/Bing │
|
||||
│ 5. Verify live │
|
||||
└──────────────────────────┬───────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ ANALYTICS + LEARNING LOOP │
|
||||
│ Nightly: analyze pageviews, time-on-page, bounce rate │
|
||||
│ Feed back into topic scoring → improve selection over time │
|
||||
│ Plausible-style privacy-first analytics, self-hosted │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Service APIs (REST)
|
||||
|
||||
Every component is an independent API service:
|
||||
|
||||
### Trend Discovery API (port 5100)
|
||||
- `POST /api/trends/discover` — Run full discovery scan
|
||||
- `GET /api/trends/scored` — Get scored topic list
|
||||
- `GET /api/trends/sources` — List active data sources
|
||||
|
||||
### Research API (port 5101)
|
||||
- `POST /api/research/topic` — Research a topic, return knowledge package
|
||||
- `GET /api/research/package/{id}` — Get stored knowledge package
|
||||
- `GET /api/research/sources/{topic}` — Raw sources for a topic
|
||||
|
||||
### Writing API (port 5102)
|
||||
- `POST /api/write/article` — Generate article from knowledge package
|
||||
- `GET /api/write/draft/{id}` — Get draft
|
||||
- `POST /api/write/review/{id}` — Request quality review
|
||||
|
||||
### SEO API (port 5103)
|
||||
- `POST /api/seo/optimize` — SEO-optimize an article
|
||||
- `POST /api/seo/sitemap` — Generate sitemap for a site
|
||||
- `POST /api/seo/structured-data` — Generate JSON-LD
|
||||
|
||||
### Image API (port 5104)
|
||||
- `POST /api/images/generate` — Generate image for article
|
||||
- `POST /api/images/optimize` — Optimize existing image
|
||||
|
||||
### Deploy API (port 5105)
|
||||
- `POST /api/deploy/site/{name}` — Build and deploy a site
|
||||
- `POST /api/deploy/all` — Deploy all sites
|
||||
- `GET /api/deploy/status/{name}` — Deployment status
|
||||
|
||||
### Admin Dashboard (port 5106)
|
||||
- Full control panel UI
|
||||
- Topic approval, manual triggers, analytics views
|
||||
|
||||
## Database Schema
|
||||
|
||||
### topics
|
||||
```sql
|
||||
CREATE TABLE topics (
|
||||
id INTEGER PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
vertical TEXT NOT NULL, -- ai, tech, science, crypto, linux, gaming, diy, guides
|
||||
trend_score REAL,
|
||||
search_volume INTEGER,
|
||||
competition_score REAL,
|
||||
freshness_score REAL,
|
||||
evergreen_score REAL,
|
||||
composite_score REAL,
|
||||
sources TEXT, -- JSON array
|
||||
status TEXT DEFAULT 'discovered', -- discovered, approved, researching, writing, reviewing, published, rejected
|
||||
knowledge_package_id INTEGER,
|
||||
article_id INTEGER,
|
||||
published_url TEXT,
|
||||
published_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
### knowledge_packages
|
||||
```sql
|
||||
CREATE TABLE knowledge_packages (
|
||||
id INTEGER PRIMARY KEY,
|
||||
topic_id INTEGER,
|
||||
facts TEXT, -- JSON
|
||||
stats TEXT, -- JSON
|
||||
definitions TEXT, -- JSON
|
||||
faqs TEXT, -- JSON
|
||||
misconceptions TEXT, -- JSON
|
||||
timeline TEXT, -- JSON
|
||||
citations TEXT, -- JSON
|
||||
examples TEXT, -- JSON
|
||||
related_concepts TEXT, -- JSON
|
||||
raw_sources TEXT, -- JSON
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
### articles
|
||||
```sql
|
||||
CREATE TABLE articles (
|
||||
id INTEGER PRIMARY KEY,
|
||||
topic_id INTEGER,
|
||||
vertical TEXT,
|
||||
title TEXT,
|
||||
slug TEXT UNIQUE,
|
||||
content_md TEXT,
|
||||
content_html TEXT,
|
||||
seo_title TEXT,
|
||||
seo_description TEXT,
|
||||
og_image TEXT,
|
||||
json_ld TEXT,
|
||||
word_count INTEGER,
|
||||
reading_time_minutes INTEGER,
|
||||
status TEXT DEFAULT 'draft',
|
||||
published_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
### analytics
|
||||
```sql
|
||||
CREATE TABLE analytics (
|
||||
id INTEGER PRIMARY KEY,
|
||||
article_id INTEGER,
|
||||
vertical TEXT,
|
||||
pageviews INTEGER DEFAULT 0,
|
||||
unique_visitors INTEGER DEFAULT 0,
|
||||
avg_time_on_page REAL,
|
||||
bounce_rate REAL,
|
||||
referrers TEXT, -- JSON
|
||||
recorded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
### performance_learning
|
||||
```sql
|
||||
CREATE TABLE performance_learning (
|
||||
id INTEGER PRIMARY KEY,
|
||||
vertical TEXT,
|
||||
top_performing_patterns TEXT, -- JSON
|
||||
headline_formats TEXT, -- JSON
|
||||
optimal_word_count INTEGER,
|
||||
best_publish_times TEXT, -- JSON
|
||||
keyword_insights TEXT, -- JSON
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
## LLM Strategy
|
||||
|
||||
| Task | Model | Host | Reasoning |
|
||||
|------|-------|------|-----------|
|
||||
| Topic scoring | qwen3.5:4b | MacBook | Fast, cheap |
|
||||
| Research extraction | ornith:latest | GamingPC | Deep reasoning |
|
||||
| Outline generation | qwen3.5:4b | MacBook | Structural, fast |
|
||||
| Technical writing | ornith:latest | GamingPC | Quality matters |
|
||||
| SEO optimization | qwen3.5:4b | MacBook | Pattern matching |
|
||||
| Copy editing | qwen3.5:4b | MacBook | Fast iteration |
|
||||
| Fact checking | ornith:latest | GamingPC | Accuracy critical |
|
||||
| Quality review | ornith:latest | GamingPC | Final gate |
|
||||
|
||||
## Deployment Strategy
|
||||
|
||||
Each site is a standalone Proxmox CT running:
|
||||
- Python Flask/FastAPI (static site generator)
|
||||
- nginx (serving static files)
|
||||
- pagefind (search index)
|
||||
- cloudflared (tunnel to CF)
|
||||
|
||||
Core orchestrator runs on MacBook via cron (launchd-supervised).
|
||||
|
||||
## Security Model
|
||||
|
||||
- All APIs internal-only (10.30.20.0/24)
|
||||
- Admin dashboard: localhost only, auth via Hermes
|
||||
- Cloudflare tunnels: only expose nginx on :80
|
||||
- No secrets in code — env vars only
|
||||
- Gitea private repos for all sites
|
||||
|
||||
## Backup Strategy
|
||||
|
||||
- All article content in Gitea (git history = backup)
|
||||
- SQLite DBs synced to iCloud daily
|
||||
- Proxmox CT snapshots weekly
|
||||
- Knowledge packages exported to JSON nightly
|
||||
|
||||
## Monitoring
|
||||
|
||||
- Health checks on all 8 site CTs
|
||||
- Orchestrator pipeline status dashboard
|
||||
- LLM usage/cost tracking
|
||||
- Publish success/failure alerts via Telegram
|
||||
259
shared/assets/style.css
Normal file
259
shared/assets/style.css
Normal file
@@ -0,0 +1,259 @@
|
||||
/* ==========================================================================
|
||||
Autonomous Publishing System — Shared Site CSS
|
||||
Clean, readable, fast-loading design system for all vertical sites.
|
||||
========================================================================== */
|
||||
|
||||
:root {
|
||||
--bg: #ffffff;
|
||||
--bg-secondary: #f8f9fa;
|
||||
--text: #1a1a2e;
|
||||
--text-secondary: #555;
|
||||
--primary: #2563eb;
|
||||
--primary-hover: #1d4ed8;
|
||||
--border: #e5e7eb;
|
||||
--accent: #10b981;
|
||||
--card-bg: #ffffff;
|
||||
--code-bg: #1e293b;
|
||||
--code-text: #e2e8f0;
|
||||
--max-width: 800px;
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
--font-mono: 'SF Mono', 'Fira Code', 'Fira Mono', 'Roboto Mono', monospace;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #0f172a;
|
||||
--bg-secondary: #1e293b;
|
||||
--text: #e2e8f0;
|
||||
--text-secondary: #94a3b8;
|
||||
--primary: #3b82f6;
|
||||
--primary-hover: #60a5fa;
|
||||
--border: #334155;
|
||||
--card-bg: #1e293b;
|
||||
--code-bg: #0f172a;
|
||||
}
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html { font-size: 18px; line-height: 1.7; }
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
max-width: var(--max-width);
|
||||
margin: 0 auto;
|
||||
padding: 0 1.5rem;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* ─── Header ─── */
|
||||
header {
|
||||
padding: 1.5rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-weight: 700;
|
||||
font-size: 1.2rem;
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.nav-links { display: flex; gap: 1.5rem; }
|
||||
.nav-links a {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.nav-links a:hover { color: var(--primary); }
|
||||
|
||||
.rss-link {
|
||||
background: var(--bg-secondary);
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ─── Hero ─── */
|
||||
.hero {
|
||||
padding: 3rem 0;
|
||||
text-align: center;
|
||||
}
|
||||
.hero h1 { font-size: 2.2rem; margin-bottom: 0.5rem; letter-spacing: -0.03em; }
|
||||
.hero p { color: var(--text-secondary); font-size: 1.1rem; }
|
||||
|
||||
/* ─── Article Cards ─── */
|
||||
.articles-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.card:hover {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 2px 12px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
.card h2 {
|
||||
font-size: 1.3rem;
|
||||
margin-bottom: 0.25rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.card h2 a { color: var(--text); text-decoration: none; }
|
||||
.card h2 a:hover { color: var(--primary); }
|
||||
.card .meta {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.card p { color: var(--text-secondary); font-size: 0.95rem; }
|
||||
|
||||
/* ─── Article Page ─── */
|
||||
article { margin-bottom: 3rem; }
|
||||
|
||||
.article-header {
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.article-header h1 {
|
||||
font-size: 2rem;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 0.5rem;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.article-header .meta {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.article-content { margin-bottom: 2rem; }
|
||||
.article-content h2 {
|
||||
font-size: 1.5rem;
|
||||
margin: 2.5rem 0 0.75rem;
|
||||
padding-bottom: 0.25rem;
|
||||
border-bottom: 2px solid var(--primary);
|
||||
display: inline-block;
|
||||
}
|
||||
.article-content h3 {
|
||||
font-size: 1.2rem;
|
||||
margin: 1.5rem 0 0.5rem;
|
||||
}
|
||||
.article-content p { margin-bottom: 1.2rem; }
|
||||
.article-content ul, .article-content ol {
|
||||
margin: 0 0 1.2rem 1.5rem;
|
||||
}
|
||||
.article-content li { margin-bottom: 0.4rem; }
|
||||
.article-content a { color: var(--primary); text-decoration: underline; }
|
||||
.article-content strong { font-weight: 600; }
|
||||
.article-content blockquote {
|
||||
border-left: 4px solid var(--primary);
|
||||
padding: 0.75rem 1.5rem;
|
||||
margin: 1.5rem 0;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 0 6px 6px 0;
|
||||
font-style: italic;
|
||||
}
|
||||
.article-content blockquote p { margin-bottom: 0; }
|
||||
|
||||
.article-content pre {
|
||||
background: var(--code-bg);
|
||||
color: var(--code-text);
|
||||
padding: 1.25rem;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
margin: 1.5rem 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.article-content code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85em;
|
||||
background: var(--bg-secondary);
|
||||
padding: 0.15em 0.4em;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.article-content pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.article-content hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
/* ─── Tags ─── */
|
||||
.tags { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1.5rem; }
|
||||
.tag {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.8rem;
|
||||
text-decoration: none;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
.tag:hover { background: var(--primary); color: white; }
|
||||
|
||||
/* ─── Sources ─── */
|
||||
.sources { margin-top: 2rem; padding-top: 1rem; border-top: 1px solid var(--border); }
|
||||
.sources h3 { font-size: 1rem; color: var(--text-secondary); margin-bottom: 0.5rem; }
|
||||
|
||||
/* ─── Related ─── */
|
||||
.related {
|
||||
margin-top: 3rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ─── Footer ─── */
|
||||
.site-footer {
|
||||
margin-top: 4rem;
|
||||
padding: 1.5rem 0;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
.site-footer nav { display: flex; gap: 1.5rem; }
|
||||
.site-footer a { color: var(--text-secondary); text-decoration: none; }
|
||||
.site-footer a:hover { color: var(--primary); }
|
||||
|
||||
/* ─── Responsive ─── */
|
||||
@media (max-width: 600px) {
|
||||
html { font-size: 16px; }
|
||||
body { padding: 0 1rem; }
|
||||
.hero h1 { font-size: 1.6rem; }
|
||||
.article-header h1 { font-size: 1.5rem; }
|
||||
nav { flex-direction: column; align-items: flex-start; }
|
||||
}
|
||||
1
sites/ai
Submodule
1
sites/ai
Submodule
Submodule sites/ai added at 55e4e01772
1
sites/tech
Submodule
1
sites/tech
Submodule
Submodule sites/tech added at 9e1cc21d7c
Reference in New Issue
Block a user