Keeping up with search news means checking Google's blogs, Bing, and a handful of trade sites every morning. This script does the reading for you. It pulls each feed, keeps the last three days, scores every story by how much it matters to people who run websites, merges duplicate coverage, and writes a ranked brief.

It's a cut-down version of the watcher behind this site. How we use the full one is here. We ran this exact code against the live feeds before publishing it.

What you need

  • Node.js 18 or newer (it uses the built-in fetch)
  • One package to read RSS and Atom feeds
mkdir news-watcher && cd news-watcher
npm init -y
npm pkg set type=module
npm install fast-xml-parser

The script

watch.mjs
// A small SEO news watcher: read feeds, score stories, write a daily brief.
import fs from 'node:fs';
import { XMLParser } from 'fast-xml-parser';
 
const FEEDS = [
  { name: 'Google Search Central', url: 'https://developers.google.com/search/blog/feed.xml', weight: 3 },
  { name: 'Bing Webmaster Blog', url: 'https://blogs.bing.com/webmaster/feed', weight: 3 },
  { name: 'Search Engine Roundtable', url: 'https://www.seroundtable.com/index.xml', weight: 2 },
  { name: 'Search Engine Journal', url: 'https://www.searchenginejournal.com/feed/', weight: 2 },
];
 
// Topics that change what site owners do earn points; filler loses them.
const SIGNALS = [
  [4, /core update|spam update|algorithm update/i],
  [3, /AI Overviews?|AI Mode|ChatGPT|Perplexity|Gemini|Copilot/i],
  [3, /Search Console|Google Analytics|GA4/i],
  [2, /crawl|index|Googlebot|robots\.txt|sitemap|canonical/i],
  [-3, /webinar|podcast|sponsored|recap/i],
];
const DAYS = 3;
const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@_' });
 
const text = (v) => (v == null ? '' : typeof v === 'object' ? String(v['#text'] ?? '') : String(v));
const clean = (s) =>
  text(s)
    .replace(/<[^>]+>/g, ' ')
    .replace(/&#(\d+);/g, (_m, d) => String.fromCodePoint(Number(d)))
    .replace(/\s+via @\w+(,\s*@\w+)*\s*$/i, '') // author credits some feeds add to headlines
    .replace(/\s+/g, ' ')
    .trim();
 
async function readFeed(feed) {
  const res = await fetch(feed.url, {
    headers: { 'User-Agent': 'MyNewsWatcher/1.0 (+https://example.com/about)' },
    signal: AbortSignal.timeout(20000),
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  const doc = parser.parse(await res.text());
  const items = [].concat(doc?.rss?.channel?.item ?? doc?.feed?.entry ?? []);
  return items.map((it) => ({
    source: feed.name,
    weight: feed.weight,
    title: clean(it.title),
    url: text(it.link?.['@_href'] ?? it.link),
    date: new Date(text(it.pubDate ?? it.published ?? it.updated)),
    summary: clean(it.description ?? it.summary).slice(0, 240),
  }));
}
 
function score(item) {
  const hay = `${item.title} ${item.summary}`;
  const hoursOld = (Date.now() - item.date) / 3.6e6;
  const fresh = hoursOld < 24 ? 2 : hoursOld < 48 ? 1 : 0;
  return SIGNALS.reduce((s, [pts, re]) => s + (re.test(hay) ? pts : 0), item.weight + fresh);
}
 
// Two headlines are the same story when they share enough distinctive words.
const STOP = new Set('the a an and or for to of in on with how why what your you is are new google seo search'.split(' '));
const words = (s) => new Set(s.toLowerCase().replace(/[^a-z0-9 ]/g, ' ').split(' ').filter((w) => w.length > 2 && !STOP.has(w)));
const sameStory = (a, b) => [...a].filter((w) => b.has(w)).length >= 4;
 
const items = [];
for (const feed of FEEDS) {
  try {
    items.push(...(await readFeed(feed)));
  } catch (err) {
    console.error(`${feed.name}: ${err.message}`);
  }
}
 
const recent = items.filter((it) => Date.now() - it.date < DAYS * 864e5);
const stories = [];
for (const it of recent.sort((a, b) => score(b) - score(a))) {
  const w = words(it.title);
  const home = stories.find((s) => sameStory(w, s.words));
  if (home) home.items.push(it);
  else stories.push({ words: w, items: [it], score: score(it) });
}
for (const s of stories) s.score += 2 * (new Set(s.items.map((i) => i.source)).size - 1);
stories.sort((a, b) => b.score - a.score);
 
const today = new Date().toISOString().slice(0, 10);
const lines = [`# News brief, ${today}`, ''];
for (const [i, s] of stories.slice(0, 10).entries()) {
  lines.push(`## ${i + 1}. ${s.items[0].title}`, `Score ${s.score}`, '');
  for (const it of s.items) lines.push(`- [${it.source}](${it.url})`);
  lines.push('');
}
fs.writeFileSync(`brief-${today}.md`, lines.join('\n'));
console.log(`${recent.length} recent items, ${stories.length} stories, brief-${today}.md`);

Run it:

node watch.mjs

Our run on Nov. 9, 2026 printed 18 recent items, 18 stories and wrote a brief that began:

# News brief, 2026-11-09
 
## 1. OpenAI Working On ChatGPT Tools For Business For Analytics & Citations
Score 8
 
## 2. Brave Sells Its Search Index To AI, And Your Website Is Probably In It
Score 7
 
## 3. Cloudflare CEO Likens AI Agent Optimization To A New Version Of SEO
Score 6

How it decides what matters

  • Source weight. The company that made a change (Google, Bing) starts at 3 points; a trade publication at 2.
  • Signals. Each pattern in SIGNALS adds or removes points once. Edit these to match your niche: a local SEO desk might add Business Profile|local pack, an e-commerce desk Merchant Center|Shopping.
  • Freshness. Up to 2 points for stories from the last day.
  • Coverage. A story picked up by several sources gets 2 extra points per extra source.

Three traps we hit building the full version

  1. Author credits merge unrelated stories. One publication ends every headline with "via @sejournal, @author". Those shared words made unrelated stories look like the same one. The clean function strips them.
  2. Some sites block every automated reader, feeds included. A 403 or a "checking your browser" page means the publisher has chosen to block bots. Respect it and drop the feed; don't try to get around it.
  3. The score is a first sort, not the decision. Our first brief ranked a story about search engine index sales near the top, and one about a Search Console click spike near the bottom. We skipped the first and wrote the second, because only the second was something we could test ourselves.

Run it every morning

On Mac or Linux, add a cron line with crontab -e:

0 7 * * * cd /path/to/news-watcher && node watch.mjs >> watch.log 2>&1

On Windows, create a daily task in Task Scheduler that runs node watch.mjs, starting in the script's folder.

Make it yours

  • Add feeds by adding lines to FEEDS. Most news sites publish one at /feed or /rss.
  • Remember what you've seen by saving each run's links to a JSON file and skipping repeats, so the brief only shows new stories.
  • Only conditional requests: our full version sends If-None-Match and If-Modified-Since headers, so a feed is only downloaded again when it has changed. That's polite to the publishers and faster for you.