1175 lines
47 KiB
Python
1175 lines
47 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.",
|
|
},
|
|
"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.",
|
|
},
|
|
"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.",
|
|
},
|
|
"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.",
|
|
},
|
|
"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.",
|
|
},
|
|
"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.",
|
|
},
|
|
"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.",
|
|
},
|
|
"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.",
|
|
},
|
|
}
|
|
|
|
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);
|
|
""")
|
|
|
|
|
|
@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()))
|
|
|
|
|
|
@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)))
|
|
|
|
|
|
@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("/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>
|
|
<span>© {{ domain }}</span>
|
|
<nav>
|
|
<a href="/rss.xml">RSS</a>
|
|
<a href="/sitemap.xml">Sitemap</a>
|
|
<a href="/search">Search</a>
|
|
</nav>
|
|
</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}
|
|
}
|
|
</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="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>
|
|
|
|
{% if related %}
|
|
<section class="related">
|
|
<h2>Continue Reading</h2>
|
|
<div class="related-grid">
|
|
{% for r in related %}
|
|
<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>
|
|
<span>© {{ domain }}</span>
|
|
<nav>
|
|
<a href="/rss.xml">RSS</a>
|
|
<a href="/sitemap.xml">Sitemap</a>
|
|
</nav>
|
|
</footer>
|
|
<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)
|