1378 lines
59 KiB
Python
1378 lines
59 KiB
Python
"""
|
|
Living Site Engine — Dynamic, self-learning authority website.
|
|
Each vertical gets its own instance with its own database, identity, and content.
|
|
"""
|
|
import os
|
|
import json
|
|
import sqlite3
|
|
import hashlib
|
|
import time
|
|
from pathlib import Path
|
|
from datetime import datetime, timedelta
|
|
from functools import wraps
|
|
from flask import Flask, request, jsonify, render_template_string, g, abort, Response
|
|
|
|
# ─── Config ────────────────────────────────────────────────────────
|
|
VERTICAL = os.environ.get("PUBLISHER_VERTICAL", "guides")
|
|
DOMAIN = f"{VERTICAL}.thetempleofdoom.com"
|
|
DB_PATH = Path(f"/var/lib/publisher/{VERTICAL}.db")
|
|
SECRET = os.environ.get("PUBLISHER_SECRET", "auto-publish-2026")
|
|
|
|
# Per-vertical identity
|
|
IDENTITIES = {
|
|
"ai": {
|
|
"name": "AI Insights",
|
|
"tagline": "Exploring machine intelligence, one breakthrough at a time.",
|
|
"primary": "#7c3aed",
|
|
"accent": "#a78bfa",
|
|
"bg": "#0f0720",
|
|
"card_bg": "#1a1030",
|
|
"font_heading": "'Space Grotesk', sans-serif",
|
|
"gradient": "linear-gradient(135deg, #7c3aed, #a78bfa)",
|
|
"description": "Deep dives into AI, machine learning, LLMs, and the future of intelligence.",
|
|
"author_name": "Dr. Soren Vale",
|
|
"author_title": "AI Research Director",
|
|
"author_bio": "Former research scientist at DeepMind. 15 years in machine learning. Believes the best AI writing explains concepts so clearly that anyone can understand them. Based in London.",
|
|
},
|
|
"tech": {
|
|
"name": "Tech Frontier",
|
|
"tagline": "The software, systems, and stacks shaping tomorrow.",
|
|
"primary": "#2563eb",
|
|
"accent": "#60a5fa",
|
|
"bg": "#0a1628",
|
|
"card_bg": "#112240",
|
|
"font_heading": "'JetBrains Mono', monospace",
|
|
"gradient": "linear-gradient(135deg, #2563eb, #06b6d4)",
|
|
"description": "Programming, cloud infrastructure, DevOps, and the tools that power the internet.",
|
|
"author_name": "Maya Chen",
|
|
"author_title": "Senior Software Architect",
|
|
"author_bio": "Staff engineer who's shipped products at Stripe and Vercel. Writes about the tools and patterns that actually work in production, not just in blog posts. Based in San Francisco.",
|
|
},
|
|
"science": {
|
|
"name": "Science Decoded",
|
|
"tagline": "The universe, explained clearly.",
|
|
"primary": "#059669",
|
|
"accent": "#34d399",
|
|
"bg": "#061a12",
|
|
"card_bg": "#0d2b1e",
|
|
"font_heading": "'Crimson Text', serif",
|
|
"gradient": "linear-gradient(135deg, #059669, #06b6d4)",
|
|
"description": "Physics, biology, chemistry, astronomy — the frontiers of human knowledge.",
|
|
"author_name": "Dr. James Aldrin",
|
|
"author_title": "Research Physicist & Science Writer",
|
|
"author_bio": "PhD in astrophysics from MIT. Left academia to make cutting-edge science accessible. Believes the universe is stranger than fiction and twice as interesting. Based in Cambridge, MA.",
|
|
},
|
|
"crypto": {
|
|
"name": "Crypto Compass",
|
|
"tagline": "Navigate the blockchain wilderness.",
|
|
"primary": "#f59e0b",
|
|
"accent": "#fbbf24",
|
|
"bg": "#1a1400",
|
|
"card_bg": "#261f00",
|
|
"font_heading": "'DM Mono', monospace",
|
|
"gradient": "linear-gradient(135deg, #f59e0b, #ef4444)",
|
|
"description": "Bitcoin, Ethereum, DeFi, and the technology rebuilding finance.",
|
|
"author_name": "Satoshi Lane",
|
|
"author_title": "Crypto Analyst & Security Engineer",
|
|
"author_bio": "Bitcoin since 2013. Self-custody maximalist. Previously led security at a major exchange. Now writes about the protocols, not the prices. Based nowhere in particular.",
|
|
},
|
|
"linux": {
|
|
"name": "Linux Lab",
|
|
"tagline": "Open source. Open mind. Open systems.",
|
|
"primary": "#f97316",
|
|
"accent": "#fb923c",
|
|
"bg": "#1a0e00",
|
|
"card_bg": "#261500",
|
|
"font_heading": "'Fira Code', monospace",
|
|
"gradient": "linear-gradient(135deg, #f97316, #ef4444)",
|
|
"description": "Linux, kernel hacking, system administration, and the FOSS ecosystem.",
|
|
"author_name": "Linus Koval",
|
|
"author_title": "Systems Engineer & Kernel Contributor",
|
|
"author_bio": "Linux user since Slackware 3.0. Has submitted patches to the kernel. Runs Arch on the desktop, Debian on the servers, and believes systemd was the right call. Based in Berlin.",
|
|
},
|
|
"gaming": {
|
|
"name": "Game Layer",
|
|
"tagline": "The art, tech, and culture of play.",
|
|
"primary": "#ec4899",
|
|
"accent": "#f472b6",
|
|
"bg": "#1a0010",
|
|
"card_bg": "#260018",
|
|
"font_heading": "'Press Start 2P', cursive",
|
|
"gradient": "linear-gradient(135deg, #ec4899, #8b5cf6)",
|
|
"description": "Video games, game development, esports, hardware, and gaming culture.",
|
|
"author_name": "Jules Park",
|
|
"author_title": "Game Designer & Critic",
|
|
"author_bio": "10 years in game dev across indie and AA studios. Shipped titles on Steam, Switch, and mobile. Now writes about why games work (or don't) with the depth they deserve. Based in Seoul.",
|
|
},
|
|
"diy": {
|
|
"name": "Maker Forge",
|
|
"tagline": "Build it yourself. Build it better.",
|
|
"primary": "#ef4444",
|
|
"accent": "#f87171",
|
|
"bg": "#1a0808",
|
|
"card_bg": "#261010",
|
|
"font_heading": "'Oswald', sans-serif",
|
|
"gradient": "linear-gradient(135deg, #ef4444, #f97316)",
|
|
"description": "3D printing, woodworking, electronics, home automation, and the maker movement.",
|
|
"author_name": "Rex Hardwick",
|
|
"author_title": "Master Maker & Fabricator",
|
|
"author_bio": "Former aerospace machinist turned full-time maker. Runs a 3,000 sq ft workshop. Believes the best tool is the one you know how to fix. Based in Portland, OR.",
|
|
},
|
|
"guides": {
|
|
"name": "Practical Guides",
|
|
"tagline": "Learn anything. Step by step.",
|
|
"primary": "#0891b2",
|
|
"accent": "#22d3ee",
|
|
"bg": "#061a1e",
|
|
"card_bg": "#0d2b30",
|
|
"font_heading": "'Inter', sans-serif",
|
|
"gradient": "linear-gradient(135deg, #0891b2, #06b6d4)",
|
|
"description": "Clear, practical tutorials and how-to guides for everything that matters.",
|
|
"author_name": "Nina Okonkwo",
|
|
"author_title": "Technical Educator",
|
|
"author_bio": "Taught 10,000+ students to code through bootcamps and online courses. Believes every skill can be taught if you break it down right. Based in Nairobi.",
|
|
},
|
|
}
|
|
|
|
NETWORK_SITES = [
|
|
{"name": "AI Insights", "domain": "ai.thetempleofdoom.com", "desc": "Machine learning & AI"},
|
|
{"name": "Tech Frontier", "domain": "tech.thetempleofdoom.com", "desc": "Software & infrastructure"},
|
|
{"name": "Science Decoded", "domain": "science.thetempleofdoom.com", "desc": "Physics, biology, space"},
|
|
{"name": "Crypto Compass", "domain": "crypto.thetempleofdoom.com", "desc": "Blockchain & DeFi"},
|
|
{"name": "Linux Lab", "domain": "linux.thetempleofdoom.com", "desc": "Open source & systems"},
|
|
{"name": "Game Layer", "domain": "gaming.thetempleofdoom.com", "desc": "Games & culture"},
|
|
{"name": "Maker Forge", "domain": "diy.thetempleofdoom.com", "desc": "3D printing & making"},
|
|
{"name": "Practical Guides", "domain": "guides.thetempleofdoom.com", "desc": "Tutorials & how-tos"},
|
|
]
|
|
|
|
IDENTITY = IDENTITIES.get(VERTICAL, IDENTITIES["guides"])
|
|
|
|
app = Flask(__name__)
|
|
|
|
|
|
# ─── Database ──────────────────────────────────────────────────────
|
|
def get_db():
|
|
if "db" not in g:
|
|
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
|
|
g.db = sqlite3.connect(str(DB_PATH))
|
|
g.db.row_factory = sqlite3.Row
|
|
g.db.execute("PRAGMA journal_mode=WAL")
|
|
g.db.execute("PRAGMA foreign_keys=ON")
|
|
return g.db
|
|
|
|
|
|
def init_db():
|
|
db = get_db()
|
|
db.executescript("""
|
|
CREATE TABLE IF NOT EXISTS articles (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
title TEXT NOT NULL,
|
|
slug TEXT UNIQUE NOT NULL,
|
|
content_html TEXT NOT NULL,
|
|
content_md TEXT NOT NULL DEFAULT '',
|
|
excerpt TEXT DEFAULT '',
|
|
seo_title TEXT,
|
|
seo_description TEXT,
|
|
og_image TEXT,
|
|
keywords TEXT DEFAULT '[]',
|
|
word_count INTEGER DEFAULT 0,
|
|
reading_time INTEGER DEFAULT 0,
|
|
status TEXT DEFAULT 'published',
|
|
published_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS pageviews (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
path TEXT NOT NULL,
|
|
article_id INTEGER,
|
|
referrer TEXT DEFAULT '',
|
|
ip_hash TEXT DEFAULT '',
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS topics (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
title TEXT NOT NULL,
|
|
score REAL DEFAULT 0,
|
|
status TEXT DEFAULT 'discovered',
|
|
article_id INTEGER,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS learning (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
metric TEXT NOT NULL,
|
|
value TEXT NOT NULL,
|
|
recorded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
CREATE VIRTUAL TABLE IF NOT EXISTS articles_fts USING fts5(
|
|
title, content_md, excerpt, keywords,
|
|
content='articles', content_rowid='id'
|
|
);
|
|
|
|
CREATE TRIGGER IF NOT EXISTS articles_ai AFTER INSERT ON articles BEGIN
|
|
INSERT INTO articles_fts(rowid, title, content_md, excerpt, keywords)
|
|
VALUES (new.id, new.title, new.content_md, new.excerpt, new.keywords);
|
|
END;
|
|
|
|
CREATE TRIGGER IF NOT EXISTS articles_ad AFTER DELETE ON articles BEGIN
|
|
INSERT INTO articles_fts(articles_fts, rowid, title, content_md, excerpt, keywords)
|
|
VALUES ('delete', old.id, old.title, old.content_md, old.excerpt, old.keywords);
|
|
END;
|
|
|
|
CREATE TRIGGER IF NOT EXISTS articles_au AFTER UPDATE ON articles BEGIN
|
|
INSERT INTO articles_fts(articles_fts, rowid, title, content_md, excerpt, keywords)
|
|
VALUES ('delete', old.id, old.title, old.content_md, old.excerpt, old.keywords);
|
|
INSERT INTO articles_fts(rowid, title, content_md, excerpt, keywords)
|
|
VALUES (new.id, new.title, new.content_md, new.excerpt, new.keywords);
|
|
END;
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_pageviews_path ON pageviews(path);
|
|
CREATE INDEX IF NOT EXISTS idx_pageviews_created ON pageviews(created_at);
|
|
CREATE INDEX IF NOT EXISTS idx_articles_published ON articles(published_at);
|
|
CREATE INDEX IF NOT EXISTS idx_articles_slug ON articles(slug);
|
|
|
|
CREATE TABLE IF NOT EXISTS subscribers (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
email TEXT UNIQUE NOT NULL,
|
|
vertical TEXT DEFAULT '',
|
|
confirmed INTEGER DEFAULT 0,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
""")
|
|
|
|
|
|
@app.teardown_appcontext
|
|
def close_db(exception):
|
|
db = g.pop("db", None)
|
|
if db:
|
|
db.close()
|
|
|
|
|
|
# ─── Helpers ───────────────────────────────────────────────────────
|
|
def record_view(path, article_id=None):
|
|
db = get_db()
|
|
ip = request.remote_addr or ""
|
|
ip_hash = hashlib.sha256(ip.encode()).hexdigest()[:16]
|
|
ref = request.headers.get("Referer", "")[:500]
|
|
db.execute(
|
|
"INSERT INTO pageviews (path, article_id, referrer, ip_hash) VALUES (?, ?, ?, ?)",
|
|
(path, article_id, ref, ip_hash)
|
|
)
|
|
db.commit()
|
|
|
|
|
|
def get_popular(limit=6):
|
|
db = get_db()
|
|
rows = db.execute("""
|
|
SELECT a.*, COUNT(p.id) as views
|
|
FROM articles a
|
|
LEFT JOIN pageviews p ON a.id = p.article_id
|
|
WHERE a.status = 'published'
|
|
GROUP BY a.id
|
|
ORDER BY views DESC
|
|
LIMIT ?
|
|
""", (limit,)).fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
def get_related(article_id, vertical=None, limit=4):
|
|
db = get_db()
|
|
# Simple: same vertical, different article, most recent
|
|
rows = db.execute("""
|
|
SELECT * FROM articles
|
|
WHERE id != ? AND status = 'published'
|
|
ORDER BY published_at DESC
|
|
LIMIT ?
|
|
""", (article_id, limit)).fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
def search_articles(query, limit=20):
|
|
db = get_db()
|
|
rows = db.execute("""
|
|
SELECT a.* FROM articles a
|
|
JOIN articles_fts fts ON a.id = fts.rowid
|
|
WHERE articles_fts MATCH ? AND a.status = 'published'
|
|
ORDER BY rank
|
|
LIMIT ?
|
|
""", (query, limit)).fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
def build_rss():
|
|
db = get_db()
|
|
articles = db.execute(
|
|
"SELECT * FROM articles WHERE status='published' ORDER BY published_at DESC LIMIT 20"
|
|
).fetchall()
|
|
|
|
items = ""
|
|
for a in articles:
|
|
a = dict(a)
|
|
items += f"""
|
|
<item>
|
|
<title>{_xml(a['title'])}</title>
|
|
<link>https://{DOMAIN}/articles/{a['slug']}</link>
|
|
<guid>https://{DOMAIN}/articles/{a['slug']}</guid>
|
|
<description>{_xml(a.get('excerpt', ''))}</description>
|
|
<pubDate>{a.get('published_at', '')}</pubDate>
|
|
</item>"""
|
|
|
|
return f"""<?xml version="1.0" encoding="UTF-8"?>
|
|
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
|
<channel>
|
|
<title>{IDENTITY['name']}</title>
|
|
<link>https://{DOMAIN}</link>
|
|
<description>{IDENTITY['description']}</description>
|
|
<language>en-us</language>
|
|
<lastBuildDate>{datetime.now().isoformat()}</lastBuildDate>
|
|
<atom:link href="https://{DOMAIN}/rss.xml" rel="self" type="application/rss+xml"/>
|
|
{items}
|
|
</channel>
|
|
</rss>"""
|
|
|
|
|
|
def build_sitemap_xml():
|
|
db = get_db()
|
|
articles = db.execute(
|
|
"SELECT slug, updated_at FROM articles WHERE status='published' ORDER BY published_at DESC"
|
|
).fetchall()
|
|
|
|
urls = f"""
|
|
<url>
|
|
<loc>https://{DOMAIN}/</loc>
|
|
<changefreq>daily</changefreq>
|
|
<priority>1.0</priority>
|
|
</url>
|
|
<url>
|
|
<loc>https://{DOMAIN}/search</loc>
|
|
<changefreq>weekly</changefreq>
|
|
<priority>0.6</priority>
|
|
</url>"""
|
|
|
|
for a in articles:
|
|
urls += f"""
|
|
<url>
|
|
<loc>https://{DOMAIN}/articles/{a['slug']}</loc>
|
|
<lastmod>{a['updated_at'][:10]}</lastmod>
|
|
<changefreq>weekly</changefreq>
|
|
<priority>0.8</priority>
|
|
</url>"""
|
|
|
|
return f"""<?xml version="1.0" encoding="UTF-8"?>
|
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">{urls}
|
|
</urlset>"""
|
|
|
|
|
|
def build_json_ld(article=None):
|
|
if article:
|
|
return {
|
|
"@context": "https://schema.org",
|
|
"@type": "Article",
|
|
"headline": article.get("title", ""),
|
|
"description": article.get("excerpt", ""),
|
|
"datePublished": article.get("published_at", ""),
|
|
"author": {"@type": "Organization", "name": IDENTITY["name"]},
|
|
"publisher": {"@type": "Organization", "name": IDENTITY["name"], "logo": {"@type": "ImageObject", "url": f"https://{DOMAIN}/assets/logo.png"}},
|
|
}
|
|
return {
|
|
"@context": "https://schema.org",
|
|
"@type": "WebSite",
|
|
"name": IDENTITY["name"],
|
|
"url": f"https://{DOMAIN}",
|
|
"description": IDENTITY["description"],
|
|
"potentialAction": {
|
|
"@type": "SearchAction",
|
|
"target": f"https://{DOMAIN}/search?q={{search_term_string}}",
|
|
"query-input": "required name=search_term_string",
|
|
},
|
|
}
|
|
|
|
|
|
def _xml(s):
|
|
return str(s).replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
|
|
|
|
|
# ─── Routes ────────────────────────────────────────────────────────
|
|
@app.route("/")
|
|
def home():
|
|
db = get_db()
|
|
articles = db.execute(
|
|
"SELECT * FROM articles WHERE status='published' ORDER BY published_at DESC LIMIT 20"
|
|
).fetchall()
|
|
articles = [dict(a) for a in articles]
|
|
popular = get_popular(6)
|
|
total = db.execute("SELECT COUNT(*) FROM articles WHERE status='published'").fetchone()[0]
|
|
total_views = db.execute("SELECT COUNT(*) FROM pageviews").fetchone()[0]
|
|
|
|
record_view("/")
|
|
return render_template_string(HOME_TEMPLATE, **IDENTITY,
|
|
articles=articles, popular=popular, total=total, total_views=total_views,
|
|
domain=DOMAIN, vertical=VERTICAL, json_ld=json.dumps(build_json_ld()),
|
|
network_sites=[s for s in NETWORK_SITES if s["domain"] != DOMAIN])
|
|
|
|
|
|
@app.route("/articles/<slug>")
|
|
def article(slug):
|
|
db = get_db()
|
|
row = db.execute("SELECT * FROM articles WHERE slug=? AND status='published'", (slug,)).fetchone()
|
|
if not row:
|
|
abort(404)
|
|
|
|
article = dict(row)
|
|
related = get_related(article["id"])
|
|
record_view(f"/articles/{slug}", article["id"])
|
|
|
|
return render_template_string(ARTICLE_TEMPLATE, **IDENTITY,
|
|
article=article, related=related, domain=DOMAIN, vertical=VERTICAL,
|
|
json_ld=json.dumps(build_json_ld(article)),
|
|
network_sites=[s for s in NETWORK_SITES if s["domain"] != DOMAIN])
|
|
|
|
|
|
@app.route("/search")
|
|
def search():
|
|
q = request.args.get("q", "").strip()
|
|
results = []
|
|
if q:
|
|
results = search_articles(q)
|
|
record_view("/search")
|
|
return render_template_string(SEARCH_TEMPLATE, **IDENTITY,
|
|
query=q, results=results, domain=DOMAIN, vertical=VERTICAL)
|
|
|
|
|
|
@app.route("/rss.xml")
|
|
def rss():
|
|
return Response(build_rss(), mimetype="application/rss+xml")
|
|
|
|
|
|
@app.route("/sitemap.xml")
|
|
def sitemap():
|
|
return Response(build_sitemap_xml(), mimetype="application/xml")
|
|
|
|
|
|
@app.route("/tag/<tag>")
|
|
def tag_page(tag):
|
|
"""Aggregate all articles with a given tag."""
|
|
db = get_db()
|
|
rows = db.execute("""
|
|
SELECT * FROM articles WHERE status='published' AND keywords LIKE ?
|
|
ORDER BY published_at DESC LIMIT 30
|
|
""", (f"%{tag}%",)).fetchall()
|
|
articles = [dict(r) for r in rows]
|
|
record_view(f"/tag/{tag}")
|
|
return render_template_string(TAG_TEMPLATE, **IDENTITY,
|
|
tag=tag, articles=articles, domain=DOMAIN, vertical=VERTICAL)
|
|
|
|
|
|
# ─── Analytics Collector ──────────────────────────────────────────
|
|
@app.route("/a/ping")
|
|
def analytics_ping():
|
|
"""Invisible tracking pixel."""
|
|
path = request.args.get("p", "/")
|
|
record_view(path)
|
|
# 1x1 transparent GIF
|
|
return Response(
|
|
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",
|
|
mimetype="image/gif",
|
|
headers={"Cache-Control": "no-cache, no-store, must-revalidate"},
|
|
)
|
|
|
|
|
|
# ─── Content API (for orchestrator) ────────────────────────────────
|
|
@app.route("/api/publish", methods=["POST"])
|
|
def api_publish():
|
|
"""Receive a new article from the orchestrator."""
|
|
auth = request.headers.get("Authorization", "")
|
|
if auth != f"Bearer {SECRET}":
|
|
return jsonify({"error": "unauthorized"}), 401
|
|
|
|
data = request.json
|
|
if not data:
|
|
return jsonify({"error": "no data"}), 400
|
|
|
|
db = get_db()
|
|
|
|
slug = data.get("slug", "")
|
|
title = data.get("title", "")
|
|
content_html = data.get("content_html", data.get("content_md", ""))
|
|
content_md = data.get("content_md", "")
|
|
excerpt = data.get("excerpt", data.get("seo_description", ""))
|
|
seo_title = data.get("seo_title", title)
|
|
seo_description = data.get("seo_description", "")
|
|
og_image = data.get("og_image", "")
|
|
keywords = json.dumps(data.get("keywords", []))
|
|
word_count = data.get("word_count", len(content_md.split()))
|
|
reading_time = data.get("reading_time", max(1, word_count // 200))
|
|
|
|
# Upsert
|
|
existing = db.execute("SELECT id FROM articles WHERE slug=?", (slug,)).fetchone()
|
|
if existing:
|
|
db.execute("""
|
|
UPDATE articles SET title=?, content_html=?, content_md=?, excerpt=?,
|
|
seo_title=?, seo_description=?, og_image=?, keywords=?,
|
|
word_count=?, reading_time=?, updated_at=CURRENT_TIMESTAMP
|
|
WHERE slug=?
|
|
""", (title, content_html, content_md, excerpt, seo_title, seo_description,
|
|
og_image, keywords, word_count, reading_time, slug))
|
|
else:
|
|
db.execute("""
|
|
INSERT INTO articles (title, slug, content_html, content_md, excerpt,
|
|
seo_title, seo_description, og_image, keywords, word_count, reading_time, status)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'published')
|
|
""", (title, slug, content_html, content_md, excerpt, seo_title, seo_description,
|
|
og_image, keywords, word_count, reading_time))
|
|
|
|
db.commit()
|
|
return jsonify({"status": "published", "slug": slug}), 201
|
|
|
|
|
|
@app.route("/api/articles")
|
|
def api_articles():
|
|
db = get_db()
|
|
limit = request.args.get("limit", 50)
|
|
rows = db.execute(
|
|
"SELECT * FROM articles WHERE status='published' ORDER BY published_at DESC LIMIT ?",
|
|
(limit,)
|
|
).fetchall()
|
|
return jsonify([dict(r) for r in rows])
|
|
|
|
|
|
@app.route("/api/stats")
|
|
def api_stats():
|
|
db = get_db()
|
|
return jsonify({
|
|
"total_articles": db.execute("SELECT COUNT(*) FROM articles WHERE status='published'").fetchone()[0],
|
|
"total_views": db.execute("SELECT COUNT(*) FROM pageviews").fetchone()[0],
|
|
"today_views": db.execute(
|
|
"SELECT COUNT(*) FROM pageviews WHERE created_at >= date('now')"
|
|
).fetchone()[0],
|
|
"popular": get_popular(5),
|
|
})
|
|
|
|
|
|
@app.route("/api/learn", methods=["POST"])
|
|
def api_learn():
|
|
"""Nightly learning: analyze performance and update internal models."""
|
|
db = get_db()
|
|
|
|
# Aggregate keyword performance
|
|
rows = db.execute("""
|
|
SELECT a.keywords, COUNT(p.id) as views
|
|
FROM articles a
|
|
LEFT JOIN pageviews p ON a.id = p.article_id
|
|
WHERE a.status = 'published'
|
|
GROUP BY a.id
|
|
ORDER BY views DESC
|
|
LIMIT 20
|
|
""").fetchall()
|
|
|
|
keyword_views = {}
|
|
for row in rows:
|
|
try:
|
|
kws = json.loads(row["keywords"]) if isinstance(row["keywords"], str) else (row["keywords"] or [])
|
|
except (json.JSONDecodeError, TypeError):
|
|
kws = []
|
|
for kw in kws:
|
|
keyword_views[kw] = keyword_views.get(kw, 0) + (row["views"] or 0)
|
|
|
|
top_keywords = sorted(keyword_views.items(), key=lambda x: x[1], reverse=True)[:15]
|
|
|
|
# Best performing word count range
|
|
wc_row = db.execute("""
|
|
SELECT AVG(a.word_count) as avg_wc, AVG(a.reading_time) as avg_rt
|
|
FROM articles a
|
|
LEFT JOIN pageviews p ON a.id = p.article_id
|
|
WHERE a.status = 'published'
|
|
GROUP BY a.id
|
|
HAVING COUNT(p.id) > 0
|
|
ORDER BY COUNT(p.id) DESC
|
|
LIMIT 10
|
|
""").fetchone()
|
|
|
|
# Store learning
|
|
db.execute("""
|
|
INSERT OR REPLACE INTO learning (metric, value, recorded_at)
|
|
VALUES ('top_keywords', ?, datetime('now'))
|
|
""", (json.dumps(top_keywords),))
|
|
|
|
db.commit()
|
|
|
|
return jsonify({
|
|
"status": "learned",
|
|
"top_keywords": [{"keyword": kw, "views": v} for kw, v in top_keywords[:10]],
|
|
"optimal_word_count": round(wc_row["avg_wc"]) if wc_row and wc_row["avg_wc"] else None,
|
|
"optimal_reading_time": round(wc_row["avg_rt"]) if wc_row and wc_row["avg_rt"] else None,
|
|
"total_articles_analyzed": db.execute("SELECT COUNT(*) FROM articles WHERE status='published'").fetchone()[0],
|
|
})
|
|
|
|
|
|
@app.route("/api/subscribe", methods=["POST"])
|
|
def api_subscribe():
|
|
"""Email newsletter signup."""
|
|
email = (request.json or {}).get("email", "").strip().lower()
|
|
if not email or "@" not in email:
|
|
return jsonify({"error": "invalid email"}), 400
|
|
db = get_db()
|
|
try:
|
|
db.execute("INSERT OR IGNORE INTO subscribers (email, vertical) VALUES (?, ?)",
|
|
(email, VERTICAL))
|
|
db.commit()
|
|
return jsonify({"status": "subscribed"})
|
|
except Exception as e:
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
@app.route("/health")
|
|
def health():
|
|
db = get_db()
|
|
count = db.execute("SELECT COUNT(*) FROM articles").fetchone()[0]
|
|
return jsonify({
|
|
"status": "healthy",
|
|
"vertical": VERTICAL,
|
|
"articles": count,
|
|
"timestamp": datetime.now().isoformat(),
|
|
})
|
|
|
|
|
|
# ─── Error Handlers ──────────────────────────────────────────────
|
|
@app.errorhandler(404)
|
|
def not_found(e):
|
|
return render_template_string(NOT_FOUND_TEMPLATE, **IDENTITY, domain=DOMAIN, vertical=VERTICAL), 404
|
|
|
|
|
|
# ─── Templates ─────────────────────────────────────────────────────
|
|
HOME_TEMPLATE = """<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>{{ name }} — {{ tagline }}</title>
|
|
<meta name="description" content="{{ description }}">
|
|
<meta property="og:title" content="{{ name }}">
|
|
<meta property="og:description" content="{{ tagline }}">
|
|
<meta property="og:type" content="website">
|
|
<meta name="twitter:card" content="summary_large_image">
|
|
<link rel="canonical" href="https://{{ domain }}/">
|
|
<link rel="alternate" type="application/rss+xml" title="{{ name }} RSS" href="/rss.xml">
|
|
<script type="application/ld+json">{{ json_ld }}</script>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&family=Space+Grotesk:wght@400;500;600;700&family=Crimson+Text:wght@400;600;700&family=DM+Mono:wght@400;500&family=Fira+Code:wght@400;500&family=Press+Start+2P&family=Oswald:wght@400;500;700&display=swap" rel="stylesheet">
|
|
<style>
|
|
:root {
|
|
--bg: {{ bg }};
|
|
--card-bg: {{ card_bg }};
|
|
--text: #e2e8f0;
|
|
--text-muted: #94a3b8;
|
|
--primary: {{ primary }};
|
|
--accent: {{ accent }};
|
|
--gradient: {{ gradient }};
|
|
--border: rgba(255,255,255,0.08);
|
|
--font-heading: {{ font_heading }};
|
|
--radius: 12px;
|
|
--max-width: 1000px;
|
|
}
|
|
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
|
html{font-size:17px;line-height:1.6;scroll-behavior:smooth}
|
|
body{
|
|
font-family:'Inter',-apple-system,BlinkMacSystemFont,sans-serif;
|
|
background:var(--bg);color:var(--text);
|
|
min-height:100vh;
|
|
-webkit-font-smoothing:antialiased;
|
|
}
|
|
|
|
/* ── Header ── */
|
|
header{
|
|
position:sticky;top:0;z-index:100;
|
|
background:rgba(15,7,32,0.85);backdrop-filter:blur(20px);
|
|
border-bottom:1px solid var(--border);
|
|
}
|
|
nav{
|
|
max-width:var(--max-width);margin:0 auto;
|
|
display:flex;justify-content:space-between;align-items:center;
|
|
padding:0.75rem 1.5rem;
|
|
}
|
|
.logo{
|
|
font-family:var(--font-heading);font-weight:700;font-size:1.3rem;
|
|
background:var(--gradient);-webkit-background-clip:text;
|
|
-webkit-text-fill-color:transparent;text-decoration:none;
|
|
}
|
|
.nav-links{display:flex;gap:1.5rem;align-items:center}
|
|
.nav-links a{
|
|
color:var(--text-muted);text-decoration:none;font-size:0.85rem;
|
|
font-weight:500;transition:color 0.2s;
|
|
}
|
|
.nav-links a:hover{color:var(--text)}
|
|
.search-box{display:flex;gap:0.25rem}
|
|
.search-box input{
|
|
background:var(--card-bg);border:1px solid var(--border);
|
|
color:var(--text);padding:0.4rem 0.75rem;border-radius:6px;
|
|
font-size:0.85rem;width:180px;
|
|
}
|
|
.search-box button{
|
|
background:var(--primary);color:white;border:none;
|
|
padding:0.4rem 0.75rem;border-radius:6px;cursor:pointer;
|
|
font-size:0.85rem;
|
|
}
|
|
|
|
/* ── Hero ── */
|
|
.hero{
|
|
max-width:var(--max-width);margin:0 auto;padding:4rem 1.5rem 3rem;
|
|
text-align:center;
|
|
}
|
|
.hero h1{
|
|
font-family:var(--font-heading);font-size:2.8rem;font-weight:700;
|
|
background:var(--gradient);-webkit-background-clip:text;
|
|
-webkit-text-fill-color:transparent;line-height:1.15;
|
|
margin-bottom:0.75rem;
|
|
}
|
|
.hero p{
|
|
color:var(--text-muted);font-size:1.15rem;max-width:600px;
|
|
margin:0 auto 1.5rem;
|
|
}
|
|
.hero-stats{
|
|
display:flex;gap:2rem;justify-content:center;
|
|
font-size:0.85rem;color:var(--text-muted);
|
|
}
|
|
.hero-stats strong{color:var(--accent);font-family:var(--font-heading)}
|
|
|
|
/* ── Article Grid ── */
|
|
.content{max-width:var(--max-width);margin:0 auto;padding:0 1.5rem 4rem}
|
|
.section-title{
|
|
font-family:var(--font-heading);font-size:1.3rem;margin-bottom:1rem;
|
|
color:var(--text);
|
|
}
|
|
.articles-grid{
|
|
display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));
|
|
gap:1.25rem;margin-bottom:2.5rem;
|
|
}
|
|
.card{
|
|
background:var(--card-bg);border:1px solid var(--border);
|
|
border-radius:var(--radius);padding:1.5rem;
|
|
transition:transform 0.2s,border-color 0.2s,box-shadow 0.2s;
|
|
text-decoration:none;display:block;color:inherit;
|
|
}
|
|
.card:hover{
|
|
transform:translateY(-2px);
|
|
border-color:var(--primary);
|
|
box-shadow:0 8px 30px rgba(0,0,0,0.3);
|
|
}
|
|
.card h3{
|
|
font-family:var(--font-heading);font-size:1.1rem;font-weight:600;
|
|
margin-bottom:0.35rem;line-height:1.3;color:var(--text);
|
|
}
|
|
.card .excerpt{color:var(--text-muted);font-size:0.88rem;line-height:1.5;margin-bottom:0.75rem}
|
|
.card .meta{
|
|
display:flex;gap:1rem;font-size:0.75rem;color:var(--text-muted);
|
|
}
|
|
.card .views{color:var(--accent);font-weight:600}
|
|
|
|
/* ── Popular Sidebar ── */
|
|
.layout{display:grid;grid-template-columns:1fr 280px;gap:1.5rem;align-items:start}
|
|
.sidebar .card{padding:1rem}
|
|
.sidebar .card h3{font-size:0.95rem}
|
|
|
|
/* ── Footer ── */
|
|
footer{
|
|
border-top:1px solid var(--border);padding:2rem 1.5rem;
|
|
text-align:center;color:var(--text-muted);font-size:0.8rem;
|
|
display:flex;justify-content:space-between;max-width:var(--max-width);
|
|
margin:0 auto;flex-wrap:wrap;gap:1rem;
|
|
}
|
|
footer a{color:var(--text-muted);text-decoration:none}
|
|
footer a:hover{color:var(--accent)}
|
|
|
|
@media(max-width:768px){
|
|
.hero h1{font-size:1.8rem}
|
|
.layout{grid-template-columns:1fr}
|
|
.sidebar{display:none}
|
|
.articles-grid{grid-template-columns:1fr}
|
|
.search-box input{width:120px}
|
|
.hero-stats{flex-wrap:wrap;gap:0.75rem}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<nav>
|
|
<a href="/" class="logo">{{ name }}</a>
|
|
<div class="nav-links">
|
|
<a href="/">Home</a>
|
|
<a href="/search">Search</a>
|
|
<a href="/rss.xml">RSS</a>
|
|
<form class="search-box" action="/search" method="GET">
|
|
<input type="text" name="q" placeholder="Search...">
|
|
<button type="submit">→</button>
|
|
</form>
|
|
</div>
|
|
</nav>
|
|
</header>
|
|
|
|
<section class="hero" style="background: linear-gradient(180deg, transparent 0%, var(--bg) 85%), url('/assets/hero.png') center/cover no-repeat; min-height: 420px; display: flex; flex-direction: column; justify-content: center; position: relative;">
|
|
<div style="padding: 2rem 0;">
|
|
<h1>{{ tagline }}</h1>
|
|
<p>{{ description }}</p>
|
|
<div class="hero-stats">
|
|
<span><strong>{{ total }}</strong> articles</span>
|
|
<span><strong>{{ total_views }}</strong> readers</span>
|
|
<span>Updated <strong>daily</strong></span>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<div class="content">
|
|
{% if popular %}
|
|
<h2 class="section-title">🔥 Trending Now</h2>
|
|
<div class="articles-grid">
|
|
{% for a in popular[:3] %}
|
|
<a href="/articles/{{ a.slug }}" class="card">
|
|
<h3>{{ a.title }}</h3>
|
|
<p class="excerpt">{{ a.excerpt[:160] }}{% if a.excerpt|length > 160 %}...{% endif %}</p>
|
|
<div class="meta">
|
|
<span class="views">{{ a.views }} reads</span>
|
|
<span>{{ a.reading_time }} min</span>
|
|
<span>{{ a.published_at[:10] }}</span>
|
|
</div>
|
|
</a>
|
|
{% endfor %}
|
|
</div>
|
|
{% endif %}
|
|
|
|
<h2 class="section-title">📚 Latest Articles</h2>
|
|
<div class="layout">
|
|
<div class="articles-grid" style="grid-template-columns:1fr">
|
|
{% for a in articles %}
|
|
<a href="/articles/{{ a.slug }}" class="card">
|
|
<h3>{{ a.title }}</h3>
|
|
<p class="excerpt">{{ a.excerpt[:200] }}{% if a.excerpt|length > 200 %}...{% endif %}</p>
|
|
<div class="meta">
|
|
<span>{{ a.reading_time }} min read</span>
|
|
<span>{{ a.word_count }} words</span>
|
|
<span>{{ a.published_at[:10] }}</span>
|
|
</div>
|
|
</a>
|
|
{% endfor %}
|
|
</div>
|
|
|
|
{% if popular[3:] %}
|
|
<aside class="sidebar">
|
|
<h3 style="font-family:var(--font-heading);margin-bottom:0.75rem;font-size:1rem">More Popular</h3>
|
|
{% for a in popular[3:6] %}
|
|
<a href="/articles/{{ a.slug }}" class="card" style="margin-bottom:0.5rem">
|
|
<h3>{{ a.title[:60] }}</h3>
|
|
<div class="meta"><span class="views">{{ a.views }} reads</span></div>
|
|
</a>
|
|
{% endfor %}
|
|
</aside>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
<footer>
|
|
<div class="footer-network">
|
|
<h4>Around the Network</h4>
|
|
<div class="network-grid">
|
|
{% for s in network_sites %}
|
|
<a href="https://{{ s.domain }}" class="network-link">{{ s.name }}<span>{{ s.desc }}</span></a>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
<div class="footer-bottom">
|
|
<span>© {{ domain }}</span>
|
|
<nav>
|
|
<a href="/rss.xml">RSS</a>
|
|
<a href="/sitemap.xml">Sitemap</a>
|
|
<a href="/search">Search</a>
|
|
</nav>
|
|
</div>
|
|
</footer>
|
|
<img src="/a/ping?p=/" alt="" width="1" height="1" style="display:none">
|
|
</body>
|
|
</html>"""
|
|
|
|
|
|
ARTICLE_TEMPLATE = """<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>{{ article.seo_title or article.title }}</title>
|
|
<meta name="description" content="{{ article.seo_description or article.excerpt }}">
|
|
<meta property="og:title" content="{{ article.seo_title or article.title }}">
|
|
<meta property="og:description" content="{{ article.seo_description or article.excerpt }}">
|
|
<meta property="og:type" content="article">
|
|
<meta property="og:image" content="{{ article.og_image or '' }}">
|
|
<meta name="twitter:card" content="summary_large_image">
|
|
<link rel="canonical" href="https://{{ domain }}/articles/{{ article.slug }}">
|
|
<script type="application/ld+json">{{ json_ld }}</script>
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&family=Space+Grotesk:wght@400;500;600;700&family=Crimson+Text:wght@400;600;700&family=DM+Mono:wght@400;500&family=Fira+Code:wght@400;500&family=Press+Start+2P&family=Oswald:wght@400;500;700&display=swap" rel="stylesheet">
|
|
<style>
|
|
:root {
|
|
--bg: {{ bg }};
|
|
--card-bg: {{ card_bg }};
|
|
--text: #e2e8f0;
|
|
--text-muted: #94a3b8;
|
|
--primary: {{ primary }};
|
|
--accent: {{ accent }};
|
|
--gradient: {{ gradient }};
|
|
--border: rgba(255,255,255,0.08);
|
|
--font-heading: {{ font_heading }};
|
|
--radius: 12px;
|
|
--max-width: 760px;
|
|
}
|
|
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
|
html{font-size:18px;line-height:1.75;scroll-behavior:smooth}
|
|
body{
|
|
font-family:'Inter',-apple-system,BlinkMacSystemFont,sans-serif;
|
|
background:var(--bg);color:var(--text);
|
|
min-height:100vh;-webkit-font-smoothing:antialiased;
|
|
}
|
|
header{
|
|
position:sticky;top:0;z-index:100;
|
|
background:rgba(15,7,32,0.85);backdrop-filter:blur(20px);
|
|
border-bottom:1px solid var(--border);
|
|
}
|
|
nav{
|
|
max-width:1000px;margin:0 auto;
|
|
display:flex;justify-content:space-between;align-items:center;
|
|
padding:0.75rem 1.5rem;
|
|
}
|
|
.logo{
|
|
font-family:var(--font-heading);font-weight:700;font-size:1.1rem;
|
|
background:var(--gradient);-webkit-background-clip:text;
|
|
-webkit-text-fill-color:transparent;text-decoration:none;
|
|
}
|
|
.nav-links{display:flex;gap:1rem}
|
|
.nav-links a{color:var(--text-muted);text-decoration:none;font-size:0.8rem;font-weight:500}
|
|
.nav-links a:hover{color:var(--text)}
|
|
|
|
main{max-width:var(--max-width);margin:0 auto;padding:3rem 1.5rem}
|
|
|
|
.article-header{margin-bottom:2.5rem;text-align:center}
|
|
.article-header h1{
|
|
font-family:var(--font-heading);font-size:2.2rem;font-weight:700;
|
|
line-height:1.2;margin-bottom:0.75rem;
|
|
}
|
|
.article-meta{
|
|
display:flex;gap:1rem;justify-content:center;
|
|
color:var(--text-muted);font-size:0.85rem;flex-wrap:wrap;
|
|
}
|
|
|
|
.article-content{font-size:1.05rem}
|
|
.article-content h2{
|
|
font-family:var(--font-heading);font-size:1.5rem;margin:2.5rem 0 0.75rem;
|
|
color:var(--accent);
|
|
}
|
|
.article-content h3{font-size:1.2rem;margin:1.5rem 0 0.5rem}
|
|
.article-content p{margin-bottom:1.3rem}
|
|
.article-content a{color:var(--accent);text-decoration:underline}
|
|
.article-content strong{color:var(--text);font-weight:600}
|
|
.article-content ul,.article-content ol{margin:0.5rem 0 1.3rem 1.5rem}
|
|
.article-content li{margin-bottom:0.4rem}
|
|
.article-content blockquote{
|
|
border-left:3px solid var(--primary);padding:0.75rem 1.5rem;
|
|
margin:1.5rem 0;background:var(--card-bg);border-radius:0 8px 8px 0;
|
|
font-style:italic;color:var(--text-muted);
|
|
}
|
|
.article-content pre{
|
|
background:#0d1117;padding:1.25rem;border-radius:8px;
|
|
overflow-x:auto;margin:1.5rem 0;font-family:'JetBrains Mono',monospace;
|
|
font-size:0.82rem;line-height:1.6;border:1px solid var(--border);
|
|
}
|
|
.article-content code{
|
|
font-family:'JetBrains Mono',monospace;font-size:0.85em;
|
|
background:var(--card-bg);padding:0.15em 0.4em;border-radius:4px;
|
|
}
|
|
.article-content pre code{background:none;padding:0}
|
|
.article-content hr{border:none;border-top:1px solid var(--border);margin:2rem 0}
|
|
.article-content img{max-width:100%;border-radius:8px;margin:1rem 0}
|
|
|
|
.article-footer{margin-top:3rem;padding-top:1.5rem;border-top:1px solid var(--border)}
|
|
.tags{display:flex;gap:0.5rem;flex-wrap:wrap;margin-bottom:1rem}
|
|
.tag{background:var(--card-bg);color:var(--text-muted);padding:0.2rem 0.6rem;border-radius:20px;font-size:0.75rem}
|
|
|
|
.related{margin-top:3rem}
|
|
.related h2{font-family:var(--font-heading);font-size:1.3rem;margin-bottom:1rem}
|
|
.related-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:0.75rem}
|
|
.related-card{
|
|
background:var(--card-bg);border:1px solid var(--border);border-radius:8px;
|
|
padding:1rem;text-decoration:none;color:inherit;transition:border-color 0.2s;
|
|
}
|
|
.related-card:hover{border-color:var(--primary)}
|
|
.related-card h4{font-size:0.9rem;font-weight:600;margin-bottom:0.25rem}
|
|
.related-card .meta{font-size:0.7rem;color:var(--text-muted)}
|
|
|
|
footer{
|
|
border-top:1px solid var(--border);padding:2rem 1.5rem;
|
|
text-align:center;color:var(--text-muted);font-size:0.8rem;
|
|
max-width:1000px;margin:0 auto;
|
|
display:flex;justify-content:space-between;
|
|
}
|
|
footer a{color:var(--text-muted);text-decoration:none}
|
|
|
|
@media(max-width:600px){
|
|
.article-header h1{font-size:1.5rem}
|
|
.related-grid{grid-template-columns:1fr}
|
|
}
|
|
|
|
/* Progress bar */
|
|
.progress-bar{position:fixed;top:0;left:0;height:3px;background:var(--gradient);z-index:200;width:0;transition:width 0.1s linear}
|
|
|
|
/* Table of Contents */
|
|
.toc-container{background:var(--card-bg);border:1px solid var(--border);border-radius:8px;padding:1rem 1.25rem;margin-bottom:2rem;font-size:0.9rem}
|
|
.toc-container h3{font-family:var(--font-heading);font-size:0.9rem;margin-bottom:0.5rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.05em}
|
|
.toc-container ul{list-style:none;margin:0}
|
|
.toc-container li{margin-bottom:0.3rem}
|
|
.toc-container a{color:var(--text-muted);text-decoration:none;transition:color 0.2s}
|
|
.toc-container a:hover{color:var(--accent)}
|
|
|
|
/* Author Box */
|
|
.author-box{display:flex;gap:1rem;align-items:flex-start;background:var(--card-bg);border:1px solid var(--border);border-radius:12px;padding:1.5rem;margin-top:2rem}
|
|
.author-name{font-family:var(--font-heading);font-weight:600;font-size:1.05rem;margin-bottom:0.15rem}
|
|
.author-title{color:var(--accent);font-size:0.8rem;margin-bottom:0.5rem}
|
|
.author-bio{color:var(--text-muted);font-size:0.85rem;line-height:1.5}
|
|
|
|
.site-footer{border-top:1px solid var(--border);padding:2.5rem 1.5rem;max-width:1000px;margin:0 auto}
|
|
.footer-main{display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:1rem;color:var(--text-muted);font-size:0.8rem}
|
|
.footer-main a{color:var(--text-muted);text-decoration:none}
|
|
|
|
/* Network Footer */
|
|
.footer-network{border-bottom:1px solid var(--border);padding-bottom:1.5rem;margin-bottom:1.5rem}
|
|
.footer-network h4{font-family:var(--font-heading);font-size:0.85rem;color:var(--text-muted);margin-bottom:0.75rem;text-transform:uppercase;letter-spacing:0.05em}
|
|
.network-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:0.5rem}
|
|
.network-link{display:block;background:var(--card-bg);border:1px solid var(--border);border-radius:6px;padding:0.5rem 0.75rem;text-decoration:none;color:var(--text);font-size:0.8rem;font-weight:500;transition:border-color 0.2s}
|
|
.network-link:hover{border-color:var(--primary)}
|
|
.network-link span{display:block;color:var(--text-muted);font-size:0.7rem;font-weight:400;margin-top:0.1rem}
|
|
.footer-bottom{display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:1rem;color:var(--text-muted);font-size:0.8rem}
|
|
.footer-bottom a{color:var(--text-muted);text-decoration:none}
|
|
|
|
/* Newsletter */
|
|
.newsletter-box{background:var(--card-bg);border:1px solid var(--border);border-radius:12px;padding:1.25rem;margin-top:2rem;text-align:center}
|
|
.newsletter-box h4{font-family:var(--font-heading);font-size:1rem;margin-bottom:0.75rem}
|
|
.subscribe-form{display:flex;gap:0.5rem;max-width:400px;margin:0 auto}
|
|
.subscribe-form input{flex:1;padding:0.6rem 0.75rem;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:0.9rem}
|
|
.subscribe-form button{background:var(--gradient);color:white;border:none;padding:0.6rem 1.25rem;border-radius:6px;cursor:pointer;font-weight:600;font-size:0.9rem}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<nav>
|
|
<a href="/" class="logo">{{ name }}</a>
|
|
<div class="nav-links">
|
|
<a href="/">Home</a>
|
|
<a href="/search">Search</a>
|
|
<a href="/rss.xml">RSS</a>
|
|
</div>
|
|
</nav>
|
|
</header>
|
|
|
|
<main>
|
|
<article>
|
|
<header class="article-header">
|
|
<h1>{{ article.title }}</h1>
|
|
<div class="article-meta">
|
|
<span>{{ article.published_at[:10] }}</span>
|
|
<span>{{ article.reading_time }} min read</span>
|
|
<span>{{ article.word_count }} words</span>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="toc-container" id="toc-container">
|
|
<h3>In This Article</h3>
|
|
<ul id="toc-list"></ul>
|
|
</div>
|
|
|
|
<div class="article-content">
|
|
{{ article.content_html|safe }}
|
|
</div>
|
|
|
|
<footer class="article-footer">
|
|
{% if article.keywords %}
|
|
<div class="tags">
|
|
{% for kw in article.keywords|from_json %}
|
|
<span class="tag">{{ kw }}</span>
|
|
{% endfor %}
|
|
</div>
|
|
{% endif %}
|
|
</footer>
|
|
</article>
|
|
<div class="author-box">
|
|
<div class="author-avatar" style="background:var(--gradient);width:64px;height:64px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-family:var(--font-heading);font-size:1.5rem;font-weight:700;color:white;flex-shrink:0">{{ author_name[:1] }}</div>
|
|
<div class="author-info">
|
|
<div class="author-name">{{ author_name }}</div>
|
|
<div class="author-title">{{ author_title }}</div>
|
|
<div class="author-bio">{{ author_bio }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="newsletter-box">
|
|
<h4>📬 Get new articles by email</h4>
|
|
<form class="subscribe-form" onsubmit="subscribe(event)">
|
|
<input type="email" id="sub-email" placeholder="your@email.com" required>
|
|
<button type="submit">Subscribe</button>
|
|
</form>
|
|
<div id="sub-msg" style="margin-top:0.5rem;font-size:0.8rem;display:none"></div>
|
|
<p style="font-size:0.7rem;color:var(--text-muted);margin-top:0.5rem">No spam. Just new articles from {{ name }}.</p>
|
|
</div>
|
|
|
|
{% if related %}
|
|
<section class="related">
|
|
<h2>Continue Reading</h2>
|
|
<div class="related-grid">
|
|
{% for r in related[:3] %}
|
|
<a href="/articles/{{ r.slug }}" class="related-card">
|
|
<h4>{{ r.title[:60] }}</h4>
|
|
<div class="meta">{{ r.reading_time }} min · {{ r.published_at[:10] }}</div>
|
|
</a>
|
|
{% endfor %}
|
|
</div>
|
|
</section>
|
|
{% endif %}
|
|
</main>
|
|
|
|
<footer class="site-footer">
|
|
<div class="footer-network">
|
|
<h4>Around the Network</h4>
|
|
<div class="network-grid">
|
|
{% for s in network_sites %}
|
|
<a href="https://{{ s.domain }}" class="network-link">{{ s.name }}<span>{{ s.desc }}</span></a>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
<div class="footer-bottom">
|
|
<span>© {{ domain }}</span>
|
|
<nav>
|
|
<a href="/rss.xml">RSS</a>
|
|
<a href="/sitemap.xml">Sitemap</a>
|
|
</nav>
|
|
</div>
|
|
</footer>
|
|
|
|
<div class="progress-bar" id="progress"></div>
|
|
|
|
<script>
|
|
// Reading progress bar
|
|
window.addEventListener('scroll', function(){
|
|
var winHeight = window.innerHeight;
|
|
var docHeight = document.documentElement.scrollHeight;
|
|
var scrollTop = window.scrollY;
|
|
var progress = Math.min((scrollTop / (docHeight - winHeight)) * 100, 100);
|
|
document.getElementById('progress').style.width = progress + '%';
|
|
});
|
|
|
|
// Auto-generate table of contents from h2 headings
|
|
(function(){
|
|
var toc = document.getElementById('toc-list');
|
|
if(!toc) return;
|
|
var headings = document.querySelectorAll('.article-content h2');
|
|
if(headings.length < 2){ document.getElementById('toc-container').style.display='none'; return; }
|
|
headings.forEach(function(h, i){
|
|
var id = 'section-' + i;
|
|
h.id = id;
|
|
var li = document.createElement('li');
|
|
var a = document.createElement('a');
|
|
a.href = '#' + id;
|
|
a.textContent = h.textContent;
|
|
li.appendChild(a);
|
|
toc.appendChild(li);
|
|
});
|
|
})();
|
|
|
|
// Scroll depth tracking
|
|
(function(){
|
|
var fired = {25:false,50:false,75:false,100:false};
|
|
window.addEventListener('scroll', function(){
|
|
var pct = Math.round((window.scrollY / (document.documentElement.scrollHeight - window.innerHeight)) * 100);
|
|
[25,50,75,100].forEach(function(threshold){
|
|
if(pct >= threshold && !fired[threshold]){
|
|
fired[threshold] = true;
|
|
new Image().src = '/a/ping?p=/articles/{{ article.slug }}&d=' + threshold;
|
|
}
|
|
});
|
|
});
|
|
// Time on page ping every 30s
|
|
setInterval(function(){
|
|
new Image().src = '/a/ping?p=/articles/{{ article.slug }}&t=1';
|
|
}, 30000);
|
|
})();
|
|
|
|
function subscribe(e){
|
|
e.preventDefault();
|
|
var email = document.getElementById('sub-email').value;
|
|
var msg = document.getElementById('sub-msg');
|
|
fetch('/api/subscribe', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email:email})})
|
|
.then(function(r){ return r.json(); })
|
|
.then(function(d){
|
|
msg.style.display = 'block';
|
|
msg.textContent = d.status === 'subscribed' ? '✅ Subscribed! Welcome.' : '❌ ' + (d.error || 'Error');
|
|
});
|
|
}
|
|
</script>
|
|
<img src="/a/ping?p=/articles/{{ article.slug }}" alt="" width="1" height="1" style="display:none">
|
|
</body>
|
|
</html>"""
|
|
|
|
|
|
SEARCH_TEMPLATE = """<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Search — {{ name }}</title>
|
|
<meta name="description" content="Search {{ name }} for articles and guides.">
|
|
<style>
|
|
:root {
|
|
--bg: {{ bg }};--card-bg: {{ card_bg }};--text: #e2e8f0;
|
|
--text-muted: #94a3b8;--primary: {{ primary }};--accent: {{ accent }};
|
|
--gradient: {{ gradient }};--border: rgba(255,255,255,0.08);
|
|
--font-heading: {{ font_heading }};
|
|
}
|
|
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
|
body{font-family:'Inter',sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
|
|
header{position:sticky;top:0;background:rgba(15,7,32,0.85);backdrop-filter:blur(20px);border-bottom:1px solid var(--border)}
|
|
nav{max-width:1000px;margin:0 auto;display:flex;justify-content:space-between;align-items:center;padding:0.75rem 1.5rem}
|
|
.logo{font-family:var(--font-heading);font-weight:700;font-size:1.3rem;background:var(--gradient);-webkit-background-clip:text;-webkit-text-fill-color:transparent;text-decoration:none}
|
|
main{max-width:700px;margin:0 auto;padding:3rem 1.5rem}
|
|
h1{font-family:var(--font-heading);font-size:1.8rem;margin-bottom:1.5rem}
|
|
.search-form{display:flex;gap:0.5rem;margin-bottom:2rem}
|
|
.search-form input{
|
|
flex:1;padding:0.75rem 1rem;background:var(--card-bg);border:1px solid var(--border);
|
|
color:var(--text);border-radius:8px;font-size:1rem;
|
|
}
|
|
.search-form button{
|
|
padding:0.75rem 1.5rem;background:var(--primary);color:white;
|
|
border:none;border-radius:8px;cursor:pointer;font-size:1rem;font-weight:600;
|
|
}
|
|
.result{
|
|
background:var(--card-bg);border:1px solid var(--border);border-radius:8px;
|
|
padding:1.25rem;margin-bottom:0.75rem;text-decoration:none;display:block;color:inherit;
|
|
}
|
|
.result:hover{border-color:var(--primary)}
|
|
.result h3{font-size:1.05rem;margin-bottom:0.25rem}
|
|
.result p{color:var(--text-muted);font-size:0.88rem}
|
|
footer{border-top:1px solid var(--border);padding:2rem 1.5rem;text-align:center;color:var(--text-muted);font-size:0.8rem}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<nav>
|
|
<a href="/" class="logo">{{ name }}</a>
|
|
</nav>
|
|
</header>
|
|
<main>
|
|
<h1>Search {{ name }}</h1>
|
|
<form class="search-form" action="/search" method="GET">
|
|
<input type="text" name="q" value="{{ query }}" placeholder="Search articles..." autofocus>
|
|
<button type="submit">Search</button>
|
|
</form>
|
|
{% if results %}
|
|
<p style="color:var(--text-muted);margin-bottom:1rem">{{ results|length }} results for "{{ query }}"</p>
|
|
{% for r in results %}
|
|
<a href="/articles/{{ r.slug }}" class="result">
|
|
<h3>{{ r.title }}</h3>
|
|
<p>{{ r.excerpt[:200] }}</p>
|
|
</a>
|
|
{% endfor %}
|
|
{% elif query %}
|
|
<p style="color:var(--text-muted)">No results for "{{ query }}"</p>
|
|
{% endif %}
|
|
</main>
|
|
<footer>© {{ domain }}</footer>
|
|
</body>
|
|
</html>"""
|
|
|
|
|
|
TAG_TEMPLATE = """<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>{{ tag }} — {{ name }}</title>
|
|
<style>
|
|
:root{--bg:{{ bg }};--card-bg:{{ card_bg }};--text:#e2e8f0;--text-muted:#94a3b8;--primary:{{ primary }};--accent:{{ accent }};--gradient:{{ gradient }};--border:rgba(255,255,255,0.08);--font-heading:{{ font_heading }}}
|
|
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
|
body{font-family:'Inter',sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
|
|
header{position:sticky;top:0;background:rgba(15,7,32,0.85);backdrop-filter:blur(20px);border-bottom:1px solid var(--border)}
|
|
nav{max-width:900px;margin:0 auto;display:flex;justify-content:space-between;align-items:center;padding:0.75rem 1.5rem}
|
|
.logo{font-family:var(--font-heading);font-weight:700;font-size:1.2rem;background:var(--gradient);-webkit-background-clip:text;-webkit-text-fill-color:transparent;text-decoration:none}
|
|
main{max-width:900px;margin:0 auto;padding:3rem 1.5rem}
|
|
h1{font-family:var(--font-heading);font-size:2rem;margin-bottom:0.5rem}
|
|
h1 span{background:var(--gradient);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
|
.count{color:var(--text-muted);margin-bottom:2rem}
|
|
.card{background:var(--card-bg);border:1px solid var(--border);border-radius:8px;padding:1.25rem;margin-bottom:0.75rem;text-decoration:none;display:block;color:inherit;transition:border-color 0.2s}
|
|
.card:hover{border-color:var(--primary)}
|
|
.card h3{font-size:1.05rem;margin-bottom:0.25rem}
|
|
.card p{color:var(--text-muted);font-size:0.88rem}
|
|
.card .meta{font-size:0.75rem;color:var(--text-muted);margin-top:0.5rem}
|
|
footer{border-top:1px solid var(--border);padding:2rem 1.5rem;text-align:center;color:var(--text-muted);font-size:0.8rem}
|
|
a{color:var(--accent)}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header><nav><a href="/" class="logo">{{ name }}</a></nav></header>
|
|
<main>
|
|
<h1>#<span>{{ tag }}</span></h1>
|
|
<p class="count">{{ articles|length }} article{% if articles|length != 1 %}s{% endif %}</p>
|
|
{% for a in articles %}
|
|
<a href="/articles/{{ a.slug }}" class="card">
|
|
<h3>{{ a.title }}</h3>
|
|
<p>{{ a.excerpt[:180] }}</p>
|
|
<div class="meta">{{ a.reading_time }} min read · {{ a.published_at[:10] }}</div>
|
|
</a>
|
|
{% endfor %}
|
|
{% if not articles %}
|
|
<p style="color:var(--text-muted)">No articles tagged "{{ tag }}" yet.</p>
|
|
{% endif %}
|
|
</main>
|
|
<footer>© {{ domain }}</footer>
|
|
</body>
|
|
</html>"""
|
|
|
|
NOT_FOUND_TEMPLATE = """<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>404 — {{ name }}</title>
|
|
<style>
|
|
:root{--bg:{{ bg }};--text:#e2e8f0;--text-muted:#94a3b8;--primary:{{ primary }};--gradient:{{ gradient }};--font-heading:{{ font_heading }}}
|
|
body{font-family:'Inter',sans-serif;background:var(--bg);color:var(--text);display:flex;align-items:center;justify-content:center;min-height:100vh;text-align:center}
|
|
h1{font-family:var(--font-heading);font-size:4rem;background:var(--gradient);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
|
p{color:var(--text-muted);margin:1rem 0}
|
|
a{color:var(--primary)}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div>
|
|
<h1>404</h1>
|
|
<p>This page doesn't exist yet. Maybe it will tomorrow.</p>
|
|
<a href="/">← Back to {{ name }}</a>
|
|
</div>
|
|
</body>
|
|
</html>"""
|
|
|
|
|
|
# ─── Jinja filter ──────────────────────────────────────────────────
|
|
@app.template_filter("from_json")
|
|
def from_json_filter(s):
|
|
try:
|
|
return json.loads(s)
|
|
except (json.JSONDecodeError, TypeError):
|
|
return []
|
|
|
|
|
|
# ─── Main ──────────────────────────────────────────────────────────
|
|
if __name__ == "__main__":
|
|
import argparse
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--port", type=int, default=5000)
|
|
ap.add_argument("--host", type=str, default="0.0.0.0")
|
|
args = ap.parse_args()
|
|
|
|
with app.app_context():
|
|
init_db()
|
|
|
|
print(f"🚀 {IDENTITY['name']} → http://{args.host}:{args.port}")
|
|
app.run(host=args.host, port=args.port, debug=False)
|