WIP before Manual-Headline-Insertion v1 edits (clickability 14d lifecycle pending)
This commit is contained in:
+185
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Render Athena entries into a static news site (two-layer: Top News + aging Stack).
|
||||
|
||||
Read-only against oracle.db. Writes static HTML to the preprod3 webroot.
|
||||
Designed for a 20-min no_agent cron.
|
||||
|
||||
Usage:
|
||||
python3 render_site.py # write to WEBROOT
|
||||
python3 render_site.py --dry-run # print stats, write to ./_preview.html
|
||||
"""
|
||||
import argparse, html, os, json, sqlite3, datetime, re
|
||||
from collections import OrderedDict
|
||||
|
||||
import clickability as cb
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
WEBROOT = "/var/www/preprod3"
|
||||
DB_PATH = os.path.join(HERE, "oracle.db")
|
||||
TOP_N = 8
|
||||
HALF_LIFE_H = 18.0
|
||||
|
||||
|
||||
def _clean_summary(raw):
|
||||
"""summary is stored as JSON {one_liner, key_technical_point, potential_use_case}.
|
||||
Pull the most readable field; fall back to the raw text if it isn't JSON.
|
||||
Strips markdown/latex noise so the card reads clean on the page."""
|
||||
if not raw:
|
||||
return ""
|
||||
try:
|
||||
d = json.loads(raw)
|
||||
if isinstance(d, dict):
|
||||
for k in ("one_liner", "key_technical_point", "potential_use_case"):
|
||||
v = d.get(k)
|
||||
if isinstance(v, str) and v.strip():
|
||||
return re.sub(r"\\+|_|`", "", v).strip()
|
||||
except Exception:
|
||||
pass
|
||||
return re.sub(r"\\+|_|`", "", raw).strip()
|
||||
|
||||
|
||||
def _fmt_time(first_seen):
|
||||
if not first_seen:
|
||||
return ""
|
||||
try:
|
||||
dt = datetime.datetime.strptime(first_seen, "%Y-%m-%dT%H:%M:%SZ")
|
||||
return dt.strftime("%H:%M")
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _card(it, big=False):
|
||||
title = html.escape(it["title"] or "(untitled)")
|
||||
url = html.escape(it["url"] or "#")
|
||||
src = html.escape(it["source"])
|
||||
sig = it.get("signal_score") or 0
|
||||
t = _fmt_time(it.get("first_seen"))
|
||||
summary_raw = _clean_summary(it.get("summary") or "")
|
||||
summary = html.escape(summary_raw[:200])
|
||||
cls = "card big" if big else "card"
|
||||
summary_html = ('<p class="summary">{0}</p>'.format(summary)) if (summary and big) else ""
|
||||
return f"""
|
||||
<article class="{cls}" data-src="{src}">
|
||||
<div class="meta"><span class="src">{src}</span>
|
||||
<span class="time">{t}</span>
|
||||
<span class="sig">sig {sig:.1f}</span>
|
||||
<span class="score">\U0001f525 {it['clickability_decayed']:.2f}</span></div>
|
||||
<h3><a href="{url}" target="_blank" rel="noopener">{title}</a></h3>
|
||||
{summary_html}
|
||||
</article>"""
|
||||
|
||||
|
||||
def build_html(items):
|
||||
now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||
ranked = sorted(items, key=lambda x: x["clickability_decayed"], reverse=True)
|
||||
# Top News = fresh items only (ingested today, UTC). Yesterday's viral
|
||||
# leftovers sink into the Stack instead of dominating the front page.
|
||||
fresh = [it for it in ranked if it.get("fresh")]
|
||||
top = fresh[:TOP_N]
|
||||
stack = [it for it in ranked if it not in top]
|
||||
|
||||
# group stack by day (first_seen date)
|
||||
by_day = OrderedDict()
|
||||
for it in stack:
|
||||
day = (it.get("first_seen") or "")[:10] or "unknown"
|
||||
by_day.setdefault(day, []).append(it)
|
||||
|
||||
top_html = "".join(_card(it, big=True) for it in top)
|
||||
|
||||
stack_html = ""
|
||||
for day, rows in by_day.items():
|
||||
rows.sort(key=lambda x: x["clickability_decayed"], reverse=True)
|
||||
cards = "".join(_card(it) for it in rows)
|
||||
stack_html += f"""
|
||||
<h3 class="day">\U0001f4c5 {html.escape(day)}</h3>
|
||||
<div class="stack">{cards}</div>"""
|
||||
|
||||
return f"""<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Athena AI News — Ranked by Clickability</title>
|
||||
<style>
|
||||
:root {{ --bg:#0b0e14; --card:#141925; --fg:#e6e9ef; --mut:#8b93a7; --acc:#5b8cff; }}
|
||||
* {{ box-sizing:border-box; }}
|
||||
body {{ margin:0; background:var(--bg); color:var(--fg);
|
||||
font:15px/1.5 -apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif; }}
|
||||
header {{ padding:28px 20px 14px; border-bottom:1px solid #1f2533; text-align:center; }}
|
||||
header h1 {{ margin:0; font-size:28px; letter-spacing:.5px; }}
|
||||
header .sub {{ color:var(--mut); font-size:13px; margin-top:6px; }}
|
||||
main {{ max-width:1000px; margin:0 auto; padding:20px; }}
|
||||
h2.sech {{ font-size:18px; margin:26px 0 12px; border-left:3px solid var(--acc); padding-left:10px; }}
|
||||
.grid {{ display:grid; grid-template-columns:repeat(auto-fill,minmax(320px,1fr)); gap:14px; }}
|
||||
.card {{ background:var(--card); border:1px solid #1f2533; border-radius:12px; padding:16px; }}
|
||||
.card.big {{ grid-column:1/-1; }}
|
||||
.meta {{ display:flex; gap:10px; align-items:center; font-size:12px; color:var(--mut); }}
|
||||
.src {{ background:#1f2533; padding:2px 8px; border-radius:20px; text-transform:uppercase; }}
|
||||
.score {{ color:#ff9d5b; font-weight:600; margin-left:auto; }}
|
||||
.card h3 {{ font-size:16px; margin:10px 0 8px; line-height:1.35; }}
|
||||
.card.big h3 {{ font-size:20px; }}
|
||||
.card h3 a {{ color:var(--fg); text-decoration:none; }}
|
||||
.card h3 a:hover {{ color:var(--acc); }}
|
||||
.summary {{ color:var(--mut); font-size:13px; margin:0; }}
|
||||
.day {{ font-size:15px; color:var(--mut); margin:28px 0 10px; border-bottom:1px solid #1f2533; padding-bottom:6px; }}
|
||||
.stack {{ display:grid; grid-template-columns:repeat(auto-fill,minmax(320px,1fr)); gap:12px; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Athena AI News</h1>
|
||||
<div class="sub">Auto-ranked by Clickability Index · decays with age so the stack flows top → bottom · generated {now} · {len(items)} stories</div>
|
||||
</header>
|
||||
<main>
|
||||
<h2 class="sech">\U0001f534 Top News</h2>
|
||||
<div class="grid">{top_html}</div>
|
||||
<h2 class="sech">\U0001f4f0 The Stack</h2>
|
||||
{stack_html}
|
||||
</main>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--dry-run", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
items = cb.fetch_items(conn)
|
||||
conn.close()
|
||||
items = cb.compute_index(items)
|
||||
items = cb.decay_index(items, HALF_LIFE_H)
|
||||
page = build_html(items)
|
||||
|
||||
if args.dry_run:
|
||||
out = os.path.join(HERE, "_preview.html")
|
||||
with open(out, "w") as f:
|
||||
f.write(page)
|
||||
fresh = [it for it in items if it.get("fresh")]
|
||||
top = sorted(fresh, key=lambda x: x["clickability_decayed"], reverse=True)[:TOP_N]
|
||||
print(f"[dry-run] wrote {out} ({len(items)} items, {len(fresh)} fresh today)")
|
||||
print(f"TOP {TOP_N} FRESH (today only) by decayed clickability:")
|
||||
for i, it in enumerate(top, 1):
|
||||
print(f" {i}. [{it['clickability_decayed']:.2f} | age {it['age_hours']:.0f}h] {it['source']:10} {it['title'][:55]}")
|
||||
return
|
||||
|
||||
# Write to webroot if it exists (deployed); otherwise fall back to a
|
||||
# user-owned dir so the no_agent cron never errors pre-deploy.
|
||||
fallback = os.path.join(HERE, "site")
|
||||
target = WEBROOT if os.path.isdir(WEBROOT) else fallback
|
||||
os.makedirs(target, exist_ok=True)
|
||||
with open(os.path.join(target, "index.html"), "w") as f:
|
||||
f.write(page)
|
||||
with open(os.path.join(target, "feed.json"), "w") as f:
|
||||
json.dump([
|
||||
{"title": i["title"], "url": i["url"], "source": i["source"],
|
||||
"clickability_decayed": i["clickability_decayed"], "age_hours": i["age_hours"],
|
||||
"first_seen": i.get("first_seen")}
|
||||
for i in sorted(items, key=lambda x: x["clickability_decayed"], reverse=True)
|
||||
], f, indent=2)
|
||||
where = "WEBROOT" if target == WEBROOT else "fallback(~oracle/site)"
|
||||
print(f"[render] wrote {target}/index.html ({len(items)} items) -> {where}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user