A daily digest from RSS and Telegram, built entirely in the browser
Every content channel eventually needs the same thing: a short daily post that collects what happened in its niche. The usual answer is a server, a cron job, and an API key. That is a lot of moving parts for something that runs once a day and produces four paragraphs.
This is a walkthrough of the version I built without any of it: a static page that fetches public sources, parses them in the tab, removes duplicates, and formats a post that fits the platform's limit. No backend, no database, no keys required.
The working tool is at NewsFlow Studio if you want to see the result before reading the code. I built it, so treat the last section as a disclosure, not a review.
1. What "no backend" really means
Three things have to be true for this to work:
- The sources are public. RSS feeds and the
t.me/s/<channel>preview pages qualify. Private chats and the Bot API do not. - The parsing runs in the browser.
DOMParserhandles RSS, Atom and HTML, so no XML library is needed. - State lives in
localStorage. Source lists, drafts and usage counters survive a reload and never leave the device.
The one thing that does not work out of the box is CORS. Most feeds do not send Access-Control-Allow-Origin, so fetch() fails before you see a single byte. The fix is a public reader that fetches the URL server-side and returns it with CORS headers.
const READER = "https://r.jina.ai/";
async function fetchText(url, asHtml) {
try {
const res = await fetch(url, { mode: "cors" });
if (res.ok) return await res.text();
} catch (e) {
// no CORS header, fall through
}
const res = await fetch(READER + url, {
headers: { "x-respond-with": asHtml ? "html" : "text" }
});
if (!res.ok) throw new Error("reader returned " + res.status);
return await res.text();
}
Two details matter here. x-respond-with: text returns the raw XML for a feed instead of converting it to markdown, and html returns the original HTML, which is what the Telegram parser needs. The reader is rate-limited on the free tier (roughly 20 requests a minute), so requests have to be spaced out. For a handful of sources that is invisible; for fifty it is a progress bar.
2. Parsing RSS and Atom with DOMParser
RSS and Atom disagree about almost everything, including where the link lives. RSS puts it in the element text; Atom puts it in an attribute.
function parseFeed(xml, sourceName) {
const doc = new DOMParser().parseFromString(xml, "text/xml");
if (doc.querySelector("parsererror")) throw new Error("not a feed");
const nodes = [...doc.querySelectorAll("item, entry")];
return nodes.map((node) => {
const link =
node.querySelector('link[rel="alternate"]')?.getAttribute("href") ||
node.querySelector("link")?.getAttribute("href") ||
node.querySelector("link")?.textContent ||
"";
const raw =
node.querySelector("description")?.textContent ||
node.querySelector("summary")?.textContent ||
node.querySelector("content")?.textContent ||
"";
return {
title: stripHtml(node.querySelector("title")?.textContent || ""),
url: link.trim(),
summary: stripHtml(raw).slice(0, 320),
published:
node.querySelector("pubDate")?.textContent ||
node.querySelector("published")?.textContent ||
node.querySelector("updated")?.textContent ||
"",
source: sourceName
};
});
}
stripHtml is a three-line helper that lets the browser do the work:
function stripHtml(value) {
const div = document.createElement("div");
div.innerHTML = value;
return (div.textContent || "").replace(/\s+/g, " ").trim();
}
Feeds love to stuff tracking pixels and double spaces into descriptions. This removes both.
One more thing worth handling: some feeds put boilerplate in <title>. Habr, for example, sends Пост @user — Блог компании X (+4) — N/P and keeps the real headline in the first paragraph of the description. When the title matches that pattern, I take the first paragraph instead. It is six lines and it changes the digest from unreadable to usable.
3. Telegram without a bot
The t.me/s/<channel> page is the public web preview of a channel. It needs no token and no login, and it contains the last twenty posts with timestamps. The markup is stable enough to parse:
function parseTelegram(html, sourceName) {
const doc = new DOMParser().parseFromString(html, "text/html");
return [...doc.querySelectorAll(".tgme_widget_message_wrap")].map((node) => {
const textEl = node.querySelector(".tgme_widget_message_text");
const timeEl = node.querySelector("time");
const linkEl = node.querySelector(".tgme_widget_message_date a");
const text = stripHtml(
(textEl?.innerHTML || "").replace(/<br\s*\/?>/gi, "\n").replace(/\n/g, " ")
);
return {
title: text.split(/(?<=[.!?])\s/)[0].slice(0, 150),
url: linkEl?.getAttribute("href") || "",
summary: text.slice(0, 320),
published: timeEl?.getAttribute("datetime") || "",
source: sourceName
};
}).reverse();
}
Channel posts rarely have a title, so the first sentence becomes one. Reversing the list puts the newest post first.
4. Duplicates and noise
The same story arrives from three feeds with three different URLs. Two checks catch most of it: a canonical URL (tracking parameters removed) and a normalized title.
function canonical(url) {
const u = new URL(url);
["utm_source", "utm_medium", "utm_campaign", "ref"].forEach((p) => u.searchParams.delete(p));
return (u.origin + u.pathname + u.search).replace(/\/$/, "").toLowerCase();
}
const titleKey = (title) =>
title.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "").slice(0, 70);
The u flag matters for Cyrillic: without it, \p{L} does not match.
Promotional posts are short, link-heavy and loud. A small score is enough to drop them without touching real articles:
const AD = /(subscribe|promo code|giveaway|casino|betting|click the link|sponsored)/i;
function isNoise(item) {
let score = 0;
if (item.title.length < 12) score += 2;
if (AD.test(item.title + " " + item.summary)) score += 3;
if ((item.summary.match(/https?:\/\//g) || []).length > 1) score += 2;
if (item.title.length > 20 && item.title === item.title.toUpperCase()) score += 1;
return score >= 3;
}
In practice this removes about one item in twenty, and almost all of them are ads.
5. Formatting for the platform
Telegram caps a message at 4096 characters. VK is similar but does not render Markdown. A short video script is not a post at all, but it is built from the same four stories.
The safe approach is to build the post, then drop items from the end until it fits:
function buildTelegram(items, limit = 4096) {
const head = "Daily digest · " + new Date().toLocaleDateString("en-GB", {
day: "numeric", month: "long"
}) + "\n\n";
const blocks = items.map((item, i) =>
`${i + 1}. ${item.title}\n${firstSentence(item.summary, 180)}\n${item.url}`
);
let out = head + blocks.join("\n\n");
while (out.length > limit && blocks.length > 1) {
blocks.pop();
out = head + blocks.join("\n\n") + "\n\nMore in the full issue.";
}
return out;
}
Truncating in the middle of a URL produces a dead link, so whole items go instead of characters.
6. Shipping it
The whole thing is three files and a localStorage schema. index.html holds the layout, app.js holds the pipeline, base.css holds the design tokens. GitHub Pages serves it, and there is nothing to keep alive.
A few practical notes from building it:
- Cache the parsed items in memory and re-render from there. Fetching on every keystroke is the fastest way to hit a rate limit.
- Keep the digest in a
<textarea>, not a<div>. Users edit the text before posting, and a textarea gives them undo, spellcheck and copy for free. - Count usage per calendar day in
localStorageif you need a free tier. It is trivially bypassable, and that is fine: the point is honesty, not enforcement. - Never put a source URL into
innerHTML. Feed titles are third-party text. UsetextContent, always.
What I built with this
NewsFlow Studio is the working version of the code above. It loads three sources on open (Hacker News, dev.to and Habr), lets you add RSS or t.me/s/ addresses, and produces a Telegram post, a VK post or a short video outline. Copying a post is free five times a day. A one-time $12 payment in USDC on Base unlocks fifty sources, scheduled sending through a bot token you own, and a content plan export. The payment check reads a public Base RPC in the browser, which is also why there is no account.
The honest limits:
- It only reads public pages. It cannot read a private channel, and it will not try.
- Scheduled sending only runs while the tab is open. There is no server to run it for you, by design.
- The public reader has a rate limit, so a fifty-source run takes a few minutes.
- If a site blocks the reader, that source fails and the rest continue.
If you need reliability at a larger scale, put the fetching on a small worker with a queue and a cache. The parsing, dedupe and formatting code stays the same.
Top comments (0)