'use strict';
// RSS/Atom headline ticker -> ScreenTinker PiP. Polls a feed, extracts headlines,
// and pushes a persistent scrolling strip overlay to a device/group. Refreshes the
// strip on each poll (player single-slot, last-show-wins) and clears on exit.
//
// node news.js [path/to/config.json]
//
// Node 18+ (global fetch). Needs an st_ API token with the 'full' scope.
// Zero dependencies — the feed parser is hand-rolled and tolerant of RSS and Atom.
const fs = require('fs');
const path = require('path');
// ---- pure helpers (exported for the offline test) -------------------------
// Decode CDATA sections and the handful of XML entities feeds actually use.
function decodeText(s) {
if (s == null) return '';
let t = String(s);
// pull CDATA payloads out verbatim
t = t.replace(//g, '$1');
// strip any stray tags (some feeds put markup in titles)
t = t.replace(/<[^>]+>/g, '');
// named + numeric entities
t = t
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(parseInt(h, 16)))
.replace(/(\d+);/g, (_, d) => String.fromCodePoint(parseInt(d, 10)))
.replace(/&/g, '&'); // ampersand last, so < -> < not <
return t.replace(/\s+/g, ' ').trim();
}
// Grab the first
… inside a block (RSS item / Atom entry).
function firstTitle(block) {
const m = block.match(/]*>([\s\S]*?)<\/title>/i);
return m ? decodeText(m[1]) : '';
}
// Tolerant headline extraction. Handles RSS (- ) and Atom (); falls back
// gracefully if a feed is malformed. Returns up to maxItems non-empty titles in order.
function parseHeadlines(xml, maxItems = 12) {
const text = String(xml || '');
let blocks = text.match(/
- /gi);
if (!blocks || blocks.length === 0) blocks = text.match(//gi);
const out = [];
for (const b of blocks || []) {
const title = firstTitle(b);
if (title) out.push(title);
if (out.length >= maxItems) break;
}
return out;
}
// Feed channel/source title, used as the left-hand chip label when present.
function feedLabel(xml) {
const text = String(xml || '');
// RSS: channel > title (the first before any
- )
const beforeItem = text.split(/
- ]*>([\s\S]*?)<\/title>/i);
if (ch) return decodeText(ch[1]);
return '';
}
function buildOverlayUri(base, { text, label, sep }) {
const q = new URLSearchParams({ text: text || '', label: label || '', sep: sep || ' • ' });
return `${base}${base.includes('?') ? '&' : '?'}${q.toString()}`;
}
// ---- live runner ----------------------------------------------------------
function loadConfig() {
const configPath = process.argv[2] || path.join(__dirname, 'config.json');
let cfg;
try { cfg = JSON.parse(fs.readFileSync(configPath, 'utf8')); }
catch (e) { console.error(`Could not read config at ${configPath}: ${e.message}`); process.exit(1); }
if (!cfg.api_base || !cfg.api_token || !cfg.overlay_base_url || !cfg.device_id || !cfg.feed_url) {
console.error('config must set api_base, api_token, overlay_base_url, device_id, and feed_url.');
process.exit(1);
}
return cfg;
}
async function pipShow(cfg, uri) {
const base = cfg.api_base.replace(/\/$/, '');
const body = {
device_id: cfg.device_id,
type: 'web',
uri,
position: cfg.position || 'bottom-right',
width: cfg.width || 1200,
height: cfg.height || 90,
duration: 0, // persistent until we clear it
border_radius: cfg.border_radius != null ? cfg.border_radius : 12,
opacity: cfg.opacity != null ? cfg.opacity : 1,
};
const res = await fetch(`${base}/api/pip`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${cfg.api_token}` },
body: JSON.stringify(body),
});
const json = await res.json().catch(() => ({}));
if (!res.ok || !json.pip_id) throw new Error(`pip show failed (${res.status}): ${json.error || 'unknown'}`);
return json.pip_id;
}
async function pipClear(cfg, pipId) {
const base = cfg.api_base.replace(/\/$/, '');
await fetch(`${base}/api/pip/clear`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${cfg.api_token}` },
body: JSON.stringify({ device_id: cfg.device_id, pip_id: pipId }),
}).catch(() => {});
}
async function main() {
const cfg = loadConfig();
const maxItems = cfg.max_items || 12;
const sep = cfg.separator || ' • ';
const pollSec = cfg.poll_interval_sec || 300;
let currentPip = null;
console.log(`News ticker starting — feed=${cfg.feed_url}`);
console.log(` poll: every ${pollSec}s max headlines: ${maxItems} target: ${cfg.device_id}`);
async function tick() {
let xml;
try {
const res = await fetch(cfg.feed_url, { headers: { Accept: 'application/rss+xml, application/atom+xml, application/xml, text/xml' } });
if (!res.ok) throw new Error(`feed HTTP ${res.status}`);
xml = await res.text();
} catch (e) {
console.error(`[${new Date().toISOString()}] feed fetch error: ${e.message}`);
return;
}
const headlines = parseHeadlines(xml, maxItems);
if (headlines.length === 0) { console.error(`[${new Date().toISOString()}] no headlines parsed`); return; }
const label = cfg.label || feedLabel(xml) || 'NEWS';
const text = headlines.join(sep);
const uri = buildOverlayUri(cfg.overlay_base_url, { text, label, sep });
try {
currentPip = await pipShow(cfg, uri);
console.log(`[${new Date().toISOString()}] SHOW ${headlines.length} headline(s) pip=${currentPip}`);
} catch (e) {
console.error(`[${new Date().toISOString()}] show error: ${e.message}`);
}
}
await tick();
const timer = setInterval(tick, pollSec * 1000);
async function shutdown() {
clearInterval(timer);
if (currentPip) { console.log('\nclearing ticker before exit...'); await pipClear(cfg, currentPip); }
process.exit(0);
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
}
if (require.main === module) main();
module.exports = { decodeText, firstTitle, parseHeadlines, feedLabel, buildOverlayUri };