mirror of
https://github.com/usetrmnl/byos_fastapi.git
synced 2026-04-29 13:44:09 -07:00
147 lines
4.7 KiB
Python
147 lines
4.7 KiB
Python
import asyncio
|
|
import logging
|
|
import os
|
|
import re
|
|
from html import unescape
|
|
from html.parser import HTMLParser
|
|
from typing import List, Optional, Tuple
|
|
|
|
import feedparser
|
|
import httpx
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
|
|
from .base import PluginBase, PluginOutput
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class HNPlugin(PluginBase):
|
|
"""Render an e-ink friendly snapshot of Hacker News front page headlines."""
|
|
|
|
BASENAME = "hackernews"
|
|
OUTPUT_SUBDIR = "news"
|
|
REGISTRY_ORDER = 40
|
|
REFRESH_INTERVAL = 600
|
|
DISPLAY_NAME = "Hacker News"
|
|
|
|
FEED_URL = "https://hnrss.org/frontpage"
|
|
|
|
def get_content_ttl(self) -> int:
|
|
return 1800 # 30 minutes
|
|
|
|
async def run(self, **kwargs) -> Optional[PluginOutput]:
|
|
target_size = kwargs.get("target_size", (800, 480))
|
|
max_items = kwargs.get("max_items", 10)
|
|
output_dir = kwargs.get("output_dir", "web")
|
|
os.makedirs(output_dir, exist_ok=True)
|
|
|
|
try:
|
|
entries = await self._fetch_entries(max_items)
|
|
if not entries:
|
|
logger.warning("HN feed returned no entries")
|
|
return None
|
|
|
|
rendered = await asyncio.to_thread(self._render_entries, entries, target_size)
|
|
output = await asyncio.to_thread(self.save_assets, rendered, output_dir, 'hackernews')
|
|
logger.info(
|
|
"Saved Hacker News assets to %s and %s",
|
|
output.monochrome_path,
|
|
output.grayscale_path
|
|
)
|
|
return output
|
|
except Exception as exc:
|
|
logger.error("Failed to render HN feed: %s", exc)
|
|
raise
|
|
|
|
async def _fetch_entries(self, max_items: int) -> List[dict]:
|
|
async with httpx.AsyncClient(timeout=10) as client:
|
|
response = await client.get(self.FEED_URL)
|
|
response.raise_for_status()
|
|
payload = response.content
|
|
feed = feedparser.parse(payload)
|
|
entries = []
|
|
for entry in feed.entries[:max_items]:
|
|
points, comments = self._parse_metadata(entry)
|
|
entries.append({
|
|
'title': entry.get('title', 'Untitled'),
|
|
'points': points,
|
|
'comments': comments
|
|
})
|
|
return entries
|
|
|
|
def _parse_metadata(self, entry) -> Tuple[int, int]:
|
|
points = 0
|
|
comments = 0
|
|
summary = entry.get('summary', '')
|
|
text = self._strip_html(summary)
|
|
points_match = re.search(r'Points:\s*(\d+)', text)
|
|
comments_match = re.search(r'#\s*Comments:\s*(\d+)', text)
|
|
if points_match:
|
|
points = int(points_match.group(1))
|
|
if comments_match:
|
|
comments = int(comments_match.group(1))
|
|
return points, comments
|
|
|
|
def _strip_html(self, html: str) -> str:
|
|
parser = _HTMLStripper()
|
|
parser.feed(html or '')
|
|
return unescape(parser.get_text())
|
|
|
|
def _render_entries(self, entries: List[dict], target_size) -> Image.Image:
|
|
canvas = Image.new('L', target_size, color=255)
|
|
draw = ImageDraw.Draw(canvas)
|
|
|
|
title_font = self.load_font(28)
|
|
item_font = self.load_font(20)
|
|
meta_font = self.load_font(16)
|
|
|
|
draw.text((30, 20), "Hacker News", fill=0, font=title_font)
|
|
draw.line([(30, 60), (target_size[0] - 30, 60)], fill=0, width=1)
|
|
|
|
y = 80
|
|
line_height = item_font.size + 10
|
|
|
|
for idx, entry in enumerate(entries, start=1):
|
|
title = f"{entry['title']}"
|
|
wrapped = self._wrap_text(title, item_font, target_size[0] - 60)
|
|
for line in wrapped:
|
|
draw.text((40, y), line, fill=0, font=item_font)
|
|
y += line_height
|
|
|
|
meta = f"{entry['points']} points | {entry['comments']} comments"
|
|
draw.text((40, y), meta, fill=80, font=meta_font)
|
|
y += meta_font.size + 12
|
|
|
|
if y > target_size[1] - line_height:
|
|
break
|
|
|
|
return canvas
|
|
|
|
def _wrap_text(self, text: str, font: ImageFont.ImageFont, max_width: int) -> List[str]:
|
|
words = text.split()
|
|
lines: List[str] = []
|
|
current: List[str] = []
|
|
|
|
for word in words:
|
|
test_line = " ".join(current + [word]).strip()
|
|
if font.getlength(test_line) <= max_width:
|
|
current.append(word)
|
|
else:
|
|
if current:
|
|
lines.append(" ".join(current))
|
|
current = [word]
|
|
if current:
|
|
lines.append(" ".join(current))
|
|
return lines
|
|
|
|
|
|
class _HTMLStripper(HTMLParser):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.parts: List[str] = []
|
|
|
|
def handle_data(self, data: str) -> None:
|
|
self.parts.append(data)
|
|
|
|
def get_text(self) -> str:
|
|
return "\n".join(self.parts) |