DEV Community

9xj89gzrtw-hue
9xj89gzrtw-hue

Posted on Originally published at 9xj89gzrtw-hue.github.io

A daily digest from RSS and Telegram, built entirely in the browser

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. DOMParser handles 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();
}
Enter fullscreen mode Exit fullscreen mode

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
    };
  });
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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 localStorage if 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. Use textContent, 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)