Add sci-fi blog design and RSS feed
This commit is contained in:
parent
b7e417636e
commit
04e10a57ce
4 changed files with 173 additions and 35 deletions
|
|
@ -9,6 +9,8 @@ HA_TOKEN="paste_your_long_lived_access_token_here"
|
|||
DATA_DIR="./data"
|
||||
REPORT_DIR="./reports"
|
||||
WEB_DIR="./web"
|
||||
SITE_BASE_PATH="/"
|
||||
SITE_URL="http://localhost"
|
||||
|
||||
# Extra owner directions appended to the 05:00 AI prompt
|
||||
PROMPT_FILE="./llm_instructions.md"
|
||||
|
|
|
|||
25
README.md
25
README.md
|
|
@ -4,7 +4,7 @@ Cron-friendly Home Assistant observer:
|
|||
|
||||
- every 30 minutes: collect compact Home Assistant snapshots into `./data`
|
||||
- every day at 05:00: send the last day of snapshots to AI
|
||||
- publish a funny local blog at `./web/index.html` with daily article archive links
|
||||
- publish a funny local blog with daily article archive links
|
||||
- save Markdown AI reports in `./reports`
|
||||
|
||||
## Setup
|
||||
|
|
@ -105,12 +105,26 @@ Run the 05:00-style analysis/publishing step:
|
|||
Open the blog served by nginx:
|
||||
|
||||
```text
|
||||
http://localhost/haobserver/
|
||||
http://localhost/
|
||||
```
|
||||
|
||||
Daily articles are written under `web/articles/YYYY-MM-DD.html`, and `index.html` links to the archive. New articles include context from previous reports from the last `ARTICLE_CONTEXT_DAYS` days.
|
||||
This instance publishes to the web root with:
|
||||
|
||||
This uses a symlink from `/var/www/html/haobserver` to the project's `./web` directory.
|
||||
```bash
|
||||
WEB_DIR="/var/www/html"
|
||||
SITE_BASE_PATH="/"
|
||||
SITE_URL="http://piagent"
|
||||
```
|
||||
|
||||
For a subdirectory install, use for example:
|
||||
|
||||
```bash
|
||||
WEB_DIR="/var/www/html/haobserver"
|
||||
SITE_BASE_PATH="/haobserver"
|
||||
SITE_URL="http://piagent"
|
||||
```
|
||||
|
||||
Daily articles are written under `articles/YYYY-MM-DD.html` inside `WEB_DIR`, and `index.html` links to the archive. An RSS feed is published at `rss.xml`, and a sci-fi favicon is published at `favicon.svg`. New articles include context from previous reports from the last `ARTICLE_CONTEXT_DAYS` days.
|
||||
|
||||
## Install cron jobs
|
||||
|
||||
|
|
@ -135,8 +149,7 @@ Manual crontab equivalent:
|
|||
```text
|
||||
/home/hbrain/haobserver/data/ 30-minute JSON snapshots
|
||||
/home/hbrain/haobserver/reports/ daily Markdown AI reports
|
||||
/home/hbrain/haobserver/web/ local funny blog, index.html and articles/*.html
|
||||
/var/www/html/haobserver symlink to web/ for nginx
|
||||
/var/www/html/ local funny blog, index.html, rss.xml, favicon.svg, and articles/*.html
|
||||
/home/hbrain/haobserver/cron.log cron logs
|
||||
```
|
||||
|
||||
|
|
|
|||
179
ha_observer.py
179
ha_observer.py
|
|
@ -19,6 +19,7 @@ import re
|
|||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from email.utils import format_datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
|
@ -30,6 +31,8 @@ HA_TOKEN = os.environ.get("HA_TOKEN", "")
|
|||
DATA_DIR = Path(os.environ.get("DATA_DIR", "./data"))
|
||||
REPORT_DIR = Path(os.environ.get("REPORT_DIR", "./reports"))
|
||||
WEB_DIR = Path(os.environ.get("WEB_DIR", "./web"))
|
||||
SITE_BASE_PATH = os.environ.get("SITE_BASE_PATH", "/").strip() or "/"
|
||||
SITE_URL = os.environ.get("SITE_URL", "http://localhost").rstrip("/")
|
||||
PROMPT_FILE = Path(os.environ.get("PROMPT_FILE", "./llm_instructions.md"))
|
||||
HISTORY_HOURS = int(os.environ.get("HISTORY_HOURS", "24"))
|
||||
MAX_HISTORY_PER_ENTITY = int(os.environ.get("MAX_HISTORY_PER_ENTITY", "20"))
|
||||
|
|
@ -348,31 +351,80 @@ def markdownish_to_html(text: str) -> str:
|
|||
|
||||
|
||||
BLOG_CSS = """
|
||||
:root { color-scheme: dark; }
|
||||
body { margin:0; font-family: Georgia, 'Times New Roman', serif; background:#101018; color:#eeeef6; line-height:1.65; }
|
||||
header { border-bottom:1px solid #303044; background:linear-gradient(135deg,#18182a,#25193a); }
|
||||
.wrap { max-width:980px; margin:0 auto; padding:1.5rem; }
|
||||
.masthead { padding:2.4rem 1.5rem; }
|
||||
.kicker { color:#fbbf24; text-transform:uppercase; letter-spacing:.14em; font:700 .78rem system-ui,sans-serif; }
|
||||
h1 { margin:.2rem 0; font-size:clamp(2.2rem,6vw,4.8rem); line-height:1; }
|
||||
h2,h3 { color:#fde68a; line-height:1.2; }
|
||||
article { background:#181827; border:1px solid #33334a; border-radius:22px; padding:clamp(1rem,3vw,2rem); box-shadow:0 18px 45px #0007; }
|
||||
article p, article li { font-size:1.05rem; }
|
||||
.layout { display:grid; grid-template-columns:minmax(0,1fr) 280px; gap:1.25rem; align-items:start; }
|
||||
aside { background:#171724; border:1px solid #303044; border-radius:18px; padding:1rem; position:sticky; top:1rem; }
|
||||
:root { color-scheme: dark; --cyan:#00f5ff; --blue:#2777ff; --violet:#8b5cf6; --amber:#fbbf24; --panel:#07111fcc; --line:#1de7ff66; }
|
||||
* { box-sizing:border-box; }
|
||||
body {
|
||||
margin:0; min-height:100vh; color:#dff9ff; line-height:1.7;
|
||||
font-family:'Rajdhani','Orbitron','Eurostile',system-ui,sans-serif;
|
||||
background:
|
||||
radial-gradient(circle at 16% 10%, #1746ff55 0 12rem, transparent 28rem),
|
||||
radial-gradient(circle at 82% 4%, #00f5ff30 0 10rem, transparent 24rem),
|
||||
radial-gradient(circle at 50% 100%, #6d28d955 0 15rem, transparent 34rem),
|
||||
linear-gradient(135deg,#02040a 0%,#07111f 48%,#030712 100%);
|
||||
overflow-x:hidden;
|
||||
}
|
||||
body::before {
|
||||
content:""; position:fixed; inset:0; pointer-events:none; opacity:.34;
|
||||
background-image:
|
||||
linear-gradient(#00f5ff16 1px, transparent 1px),
|
||||
linear-gradient(90deg,#00f5ff16 1px, transparent 1px),
|
||||
linear-gradient(115deg, transparent 0 48%, #7dd3fc22 50%, transparent 52% 100%);
|
||||
background-size:54px 54px,54px 54px,180px 180px;
|
||||
mask-image:linear-gradient(to bottom,#000 0%,#000 55%,transparent 100%);
|
||||
}
|
||||
body::after {
|
||||
content:""; position:fixed; inset:0; pointer-events:none; opacity:.14;
|
||||
background:repeating-linear-gradient(to bottom, transparent 0 3px, #ffffff 4px 5px);
|
||||
mix-blend-mode:screen;
|
||||
}
|
||||
header { position:relative; border-bottom:1px solid var(--line); background:linear-gradient(90deg,#020617dd,#051b33bb,#020617dd); box-shadow:0 0 42px #00d9ff22; }
|
||||
header::before, header::after { content:""; position:absolute; top:0; bottom:0; width:18vw; border-color:var(--cyan); opacity:.65; pointer-events:none; }
|
||||
header::before { left:0; border-top:2px solid; border-left:2px solid; clip-path:polygon(0 0,100% 0,35% 100%,0 100%); }
|
||||
header::after { right:0; border-top:2px solid; border-right:2px solid; clip-path:polygon(0 0,100% 0,100% 100%,65% 100%); }
|
||||
.wrap { max-width:1180px; margin:0 auto; padding:1.5rem; position:relative; }
|
||||
.masthead { padding:3rem 1.5rem 2.6rem; text-align:center; }
|
||||
.kicker { color:var(--cyan); text-transform:uppercase; letter-spacing:.28em; font:800 .78rem system-ui,sans-serif; text-shadow:0 0 14px #00f5ff; }
|
||||
h1 { margin:.35rem 0; font-size:clamp(2.4rem,7vw,6rem); line-height:.9; text-transform:uppercase; letter-spacing:.05em; color:#f8feff; text-shadow:0 0 12px #00f5ff,0 0 38px #2777ff; }
|
||||
h2,h3 { color:#c8fbff; line-height:1.15; text-transform:uppercase; letter-spacing:.06em; text-shadow:0 0 12px #00f5ff88; }
|
||||
article, aside {
|
||||
position:relative; background:linear-gradient(180deg,#071827d9,#050914e6); border:1px solid var(--line);
|
||||
clip-path:polygon(0 18px,18px 0,100% 0,100% calc(100% - 18px),calc(100% - 18px) 100%,0 100%);
|
||||
box-shadow:0 0 0 1px #2777ff22 inset,0 0 34px #00d9ff18,0 24px 60px #000b;
|
||||
}
|
||||
article::before, aside::before { content:""; position:absolute; inset:0; pointer-events:none; border:1px solid #ffffff12; clip-path:inherit; }
|
||||
article { padding:clamp(1.1rem,3vw,2.2rem); }
|
||||
article p, article li { font-size:1.06rem; color:#e6fbff; }
|
||||
article h1 { font-size:clamp(1.8rem,4vw,3.5rem); text-align:left; }
|
||||
.layout { display:grid; grid-template-columns:minmax(0,1fr) 310px; gap:1.35rem; align-items:start; }
|
||||
aside { padding:1.1rem; position:sticky; top:1rem; }
|
||||
.archive { list-style:none; margin:0; padding:0; }
|
||||
.archive li { border-bottom:1px solid #2b2b3f; padding:.55rem 0; }
|
||||
.archive li { border-bottom:1px solid #22d3ee33; padding:.7rem 0; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; }
|
||||
.archive li::before { content:"▸ "; color:var(--cyan); text-shadow:0 0 10px var(--cyan); }
|
||||
.archive li:last-child { border-bottom:0; }
|
||||
a { color:#93c5fd; text-decoration:none; }
|
||||
a:hover { text-decoration:underline; }
|
||||
.meta { color:#b7b7c8; font: .95rem system-ui,sans-serif; }
|
||||
details { margin-top:1.5rem; }
|
||||
pre { white-space:pre-wrap; background:#0b0b11; color:#d1d5db; padding:1rem; border-radius:12px; overflow:auto; font-size:.85rem; }
|
||||
footer { color:#9999aa; text-align:center; padding:2rem; font: .9rem system-ui,sans-serif; }
|
||||
@media (max-width:800px) { .layout { grid-template-columns:1fr; } aside { position:static; } }
|
||||
a { color:#67e8f9; text-decoration:none; text-shadow:0 0 9px #00f5ff77; }
|
||||
a:hover { color:white; text-decoration:none; filter:drop-shadow(0 0 8px var(--cyan)); }
|
||||
.meta { color:#9eeaff; font:.95rem ui-monospace,SFMono-Regular,Menlo,monospace; letter-spacing:.04em; }
|
||||
details { margin-top:1.5rem; border-top:1px solid #22d3ee33; padding-top:1rem; }
|
||||
summary { cursor:pointer; color:var(--amber); text-transform:uppercase; letter-spacing:.08em; }
|
||||
pre { white-space:pre-wrap; background:#01040acc; color:#bff8ff; padding:1rem; border:1px solid #22d3ee44; border-radius:0; overflow:auto; font-size:.82rem; box-shadow:0 0 22px #00d9ff11 inset; }
|
||||
footer { color:#7dd3fc; text-align:center; padding:2rem; font:.82rem ui-monospace,SFMono-Regular,Menlo,monospace; text-transform:uppercase; letter-spacing:.12em; }
|
||||
@media (max-width:850px) { .layout { grid-template-columns:1fr; } aside { position:static; } .masthead { text-align:left; } }
|
||||
"""
|
||||
|
||||
|
||||
def site_href(relative_path: str = "") -> str:
|
||||
base = SITE_BASE_PATH
|
||||
if not base.startswith("/"):
|
||||
base = f"/{base}"
|
||||
if not base.endswith("/"):
|
||||
base = f"{base}/"
|
||||
return f"{base}{relative_path.lstrip('/')}"
|
||||
|
||||
|
||||
def site_url(relative_path: str = "") -> str:
|
||||
return f"{SITE_URL}{site_href(relative_path)}"
|
||||
|
||||
|
||||
def article_links() -> str:
|
||||
articles_dir = WEB_DIR / "articles"
|
||||
if not articles_dir.exists():
|
||||
|
|
@ -384,24 +436,90 @@ def article_links() -> str:
|
|||
label = datetime.strptime(path.stem, "%Y-%m-%d").strftime("%A, %B %-d, %Y")
|
||||
except ValueError:
|
||||
pass
|
||||
links.append(f'<li><a href="articles/{html.escape(path.name)}">{html.escape(label)}</a></li>')
|
||||
href = site_href(f"articles/{path.name}")
|
||||
links.append(f'<li><a href="{html.escape(href)}">{html.escape(label)}</a></li>')
|
||||
return "\n".join(links) or "<li>No articles yet. The raccoon newsroom is warming up.</li>"
|
||||
|
||||
|
||||
def blog_shell(title: str, subtitle: str, main_content: str, archive_links: str, article_href_prefix: str = "") -> str:
|
||||
archive = archive_links.replace('href="articles/', f'href="{article_href_prefix}articles/')
|
||||
def write_favicon() -> Path:
|
||||
favicon = f"""<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<defs>
|
||||
<radialGradient id="g" cx="50%" cy="45%" r="70%">
|
||||
<stop offset="0" stop-color="#67e8f9"/>
|
||||
<stop offset="0.45" stop-color="#2777ff"/>
|
||||
<stop offset="1" stop-color="#020617"/>
|
||||
</radialGradient>
|
||||
<filter id="glow"><feGaussianBlur stdDeviation="1.8" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
|
||||
</defs>
|
||||
<rect width="64" height="64" rx="12" fill="#020617"/>
|
||||
<path d="M8 32h48M32 8v48M14 18l36 28M50 18L14 46" stroke="#00f5ff" stroke-width="1.3" opacity=".45"/>
|
||||
<circle cx="32" cy="32" r="18" fill="url(#g)" stroke="#9effff" stroke-width="2" filter="url(#glow)"/>
|
||||
<circle cx="25" cy="28" r="3" fill="#020617"/>
|
||||
<circle cx="39" cy="28" r="3" fill="#020617"/>
|
||||
<path d="M23 39c6 4 12 4 18 0" stroke="#020617" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M7 32c10-16 40-16 50 0-10 16-40 16-50 0Z" fill="none" stroke="#fbbf24" stroke-width="2" opacity=".9"/>
|
||||
</svg>
|
||||
"""
|
||||
path = WEB_DIR / "favicon.svg"
|
||||
path.write_text(favicon, encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def write_rss_feed() -> Path:
|
||||
articles_dir = WEB_DIR / "articles"
|
||||
items = []
|
||||
for path in sorted(articles_dir.glob("*.html"), reverse=True)[:20]:
|
||||
title = path.stem
|
||||
try:
|
||||
title = datetime.strptime(path.stem, "%Y-%m-%d").strftime("Smart Home Briefing - %A, %B %-d, %Y")
|
||||
except ValueError:
|
||||
title = f"Smart Home Briefing - {path.stem}"
|
||||
content = path.read_text(encoding="utf-8", errors="ignore")
|
||||
description = re.sub(r"<[^>]+>", " ", content)
|
||||
description = re.sub(r"\s+", " ", html.unescape(description)).strip()[:500]
|
||||
pub_dt = datetime.fromtimestamp(path.stat().st_mtime, timezone.utc)
|
||||
url = site_url(f"articles/{path.name}")
|
||||
items.append(f"""
|
||||
<item>
|
||||
<title>{html.escape(title)}</title>
|
||||
<link>{html.escape(url)}</link>
|
||||
<guid isPermaLink="true">{html.escape(url)}</guid>
|
||||
<pubDate>{format_datetime(pub_dt, usegmt=True)}</pubDate>
|
||||
<description>{html.escape(description)}</description>
|
||||
</item>""")
|
||||
now = format_datetime(datetime.now(timezone.utc), usegmt=True)
|
||||
feed = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>Smart Home Gossip Gazette</title>
|
||||
<link>{html.escape(site_url())}</link>
|
||||
<description>Daily Home Assistant smart-home briefings from the orbital raccoon telemetry desk.</description>
|
||||
<language>en</language>
|
||||
<lastBuildDate>{now}</lastBuildDate>
|
||||
{''.join(items)}
|
||||
</channel>
|
||||
</rss>
|
||||
"""
|
||||
path = WEB_DIR / "rss.xml"
|
||||
path.write_text(feed, encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def blog_shell(title: str, subtitle: str, main_content: str, archive_links: str) -> str:
|
||||
return f"""<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{html.escape(title)}</title>
|
||||
<link rel="alternate" type="application/rss+xml" title="Smart Home Gossip Gazette RSS" href="{html.escape(site_href('rss.xml'))}">
|
||||
<link rel="icon" href="{html.escape(site_href('favicon.svg'))}" type="image/svg+xml">
|
||||
<style>{BLOG_CSS}</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="wrap masthead">
|
||||
<div class="kicker">🦝 Smart Home Gossip Gazette</div>
|
||||
<div class="kicker">◇ orbital home telemetry // raccoon intelligence unit ◇</div>
|
||||
<h1>{html.escape(title)}</h1>
|
||||
<p class="meta">{html.escape(subtitle)}</p>
|
||||
</div>
|
||||
|
|
@ -409,11 +527,12 @@ def blog_shell(title: str, subtitle: str, main_content: str, archive_links: str,
|
|||
<main class="wrap layout">
|
||||
<section>{main_content}</section>
|
||||
<aside>
|
||||
<h2>Article archive</h2>
|
||||
<ul class="archive">{archive}</ul>
|
||||
<h2>Transmission archive</h2>
|
||||
<p class="meta"><a href="{html.escape(site_href('rss.xml'))}">RSS feed</a></p>
|
||||
<ul class="archive">{archive_links}</ul>
|
||||
</aside>
|
||||
</main>
|
||||
<footer>Generated by Home Assistant Observer · Served locally by nginx</footer>
|
||||
<footer>Generated by Home Assistant Observer · Local nginx uplink active</footer>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
|
@ -438,13 +557,13 @@ def publish_webpage(conclusions: str, raw_summary: str) -> Path:
|
|||
</article>
|
||||
"""
|
||||
article_path = articles_dir / article_name
|
||||
article_path.touch(exist_ok=True)
|
||||
article_path.write_text(
|
||||
blog_shell(
|
||||
"Smart Home Gossip Gazette",
|
||||
f"Daily home intelligence briefing · Generated {now}",
|
||||
article_content,
|
||||
article_links(),
|
||||
article_href_prefix="../",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
|
@ -453,7 +572,7 @@ def publish_webpage(conclusions: str, raw_summary: str) -> Path:
|
|||
<article>
|
||||
<p class="meta">Latest article · {html.escape(now)}</p>
|
||||
{body}
|
||||
<p><a href="articles/{html.escape(article_name)}">Permanent link for this article →</a></p>
|
||||
<p><a href="{html.escape(site_href(f'articles/{article_name}'))}">Permanent link for this article →</a></p>
|
||||
</article>
|
||||
"""
|
||||
index_path = WEB_DIR / "index.html"
|
||||
|
|
@ -461,6 +580,8 @@ def publish_webpage(conclusions: str, raw_summary: str) -> Path:
|
|||
blog_shell("Smart Home Gossip Gazette", "A daily blog of your Home Assistant household signals", featured, article_links()),
|
||||
encoding="utf-8",
|
||||
)
|
||||
write_favicon()
|
||||
write_rss_feed()
|
||||
return article_path
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ Suggested directions:
|
|||
- If data is missing or ambiguous, say so instead of pretending.
|
||||
- Avoid being creepy about personal habits; summarize respectfully.
|
||||
- Prefer concise bullet points over long paragraphs.
|
||||
- entities marked smb_ are located in different house in Samobor, Croatia, others are in Sonderborg Denmark
|
||||
- people FJR and Megane are my motorcycle and car not persons at home
|
||||
|
||||
Optional custom questions to answer:
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue