Founding customers get 50% off the first year. Claim it →
← all posts

How to build a daily news newsletter from feeds

October 9, 202616 min readFeedmarkdown#newsletter#google-news#n8n#deduplication

A morning inbox with a daily digest email built from several news feeds

A daily news newsletter needs five jobs done in order: choose sources, fetch stories, drop duplicates, get clean article text, and send the email. An LLM summary is an optional sixth. We built one for offshore wind news, ran it end to end on 2026-10-09, and this post shows what came out, including what broke.

This is a demonstration digest. We have no subscribers and no client behind it. We don't cover growth, monetising, picking an email platform or legal questions. The n8n workflow is downloadable: the daily digest. Every command below was run on the date shown.

What a daily news newsletter actually needs

Job What goes wrong if you skip it
Sources You send whatever one feed happens to have. Quiet days are empty, busy days overflow.
Fetch Google News gives you headlines and redirect links, not stories.
Deduplicate The same announcement appears three times under three headlines.
Clean text A summary written from a headline or from page navigation is wrong or empty.
Send Nothing arrives, or it arrives at a random hour.

Pipeline from sources to send: Google News sources, fetch, deduplicate, clean text, summarize, email

Two of these cost money per item: scraping the article and the LLM call. That sets the order. Anything you can drop using only the headline should be dropped before you scrape, and anything you can drop before the LLM should not reach it.

Pick the niche and the sources

We chose offshore wind. Trade outlets and wire copy both cover it, so one announcement tends to show up under several headlines. That makes duplicates easy to see. It is also slow enough that a daily digest is a dozen stories, not hundreds. We considered AI news and rejected it: too noisy to tell whether deduplication works.

Google News is the only source type in this build. It has no official API; the Google News API post compares the options, and the Google News RSS feed guide has the URL formats, so we won't repeat them. If you want to choose outlets with site: queries and count what each gives you, see how to pick sources for a niche news digest.

Turn the niche into queries

We tried five search queries, each with when:1d, on 2026-10-09 at 14:34 UTC. Here is the first one, with the item count:

curl -s "https://news.google.com/rss/search?q=offshore+wind+when:1d&hl=en-US&gl=US&ceid=US:en" > q.xml
grep -o "<item>" q.xml | wc -l

curl against a Google News RSS search showing items with news.google.com redirect links

Results for all five, with how many of each query's items also appeared in the broad query A:

Query Items in 24h Also in A
A: offshore wind 71 n/a
B: "offshore wind" project 52 45
C: offshore wind farm 57 56
D: offshore wind turbine installation 43 31
E: offshore wind auction OR lease 20 14

Query C added one item that A didn't have, so we dropped it. A, B, D and E together returned 92 unique items. All five returned 93. Volume was fine. Relevance was the problem: a lot of what matched was onshore wind, general energy policy and other countries' news, and you will see that in the final email.

Google News also has gaps. It may miss paywalled trade press and sites it doesn't index, and we didn't measure what it missed. If you need coverage Google News lacks, Google Alerts is a second source type. See the Google Alerts RSS feed post. Direct site sources are not available yet, so we didn't use them.

Fetch the stories

The raw feed gives you a headline, a snippet and a news.google.com redirect link. Three problems follow: the link doesn't point at the publisher, there is no article body, and the same story arrives many times. The feed guide covers each in detail.

We used Feedmarkdown for this part, through its API at api.feedmarkdown.com. First, one source per query. The key comes from an environment variable:

curl -s -X POST https://api.feedmarkdown.com/v1/source \
  -H "Authorization: Bearer $FEEDMARKDOWN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"kind":"google_news","name":"ow: offshore wind","config":{"query":"offshore wind when:1d","lang":"en","country":"US"}}'

Run on 2026-10-09. The response (trimmed) returned the source id:

{"id":"11a1a30b-91a7-4514-b609-15ddf65d97af","kind":"google_news","name":"ow: offshore wind","config":{"query":"offshore wind when:1d","lang":"en","country":"US"},"status":"active"}

We created four sources this way, for queries A, B, D and E. Then the feed:

curl -s -X POST https://api.feedmarkdown.com/v1/feed \
  -H "Authorization: Bearer $FEEDMARKDOWN_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Offshore wind daily digest (demo)",
    "source_ids": ["11a1a30b-91a7-4514-b609-15ddf65d97af", "f5b1a9e8-71c3-4950-bcce-e1b9cee3db31", "084e0d9a-37ea-495a-9939-314567d2f57e", "14937d54-0ff6-4cbb-bc42-a762245b2029"],
    "delivery": "pull",
    "interval_minutes": 360,
    "items_limit": 15,
    "dedup_scope": "feed",
    "dedup_window_hours": 72
  }'

Why these settings:

  • delivery: pull. A digest needs the day's stories together. A webhook sends one POST per article, so you would need your own store to batch them.
  • interval_minutes: 360. The Starter plan checks every 6 hours, per the pricing section, so we picked a value any plan can use.
  • items_limit: 15. At most 15 candidates per source per run, newest first. Four sources gives at most 60.
  • dedup_scope: feed and dedup_window_hours: 72. Compare against this feed's history for the last three days.

The feed started its first run as soon as it was created. PUT /v1/feed/:id/run triggers a run on demand, and it is asynchronous: it answers 202 {"status":"scheduled"} with no counters. The counters live on the run record, so you poll GET /v1/feed/:id/runs until status is done or error:

curl -s -H "Authorization: Bearer $FEEDMARKDOWN_KEY" \
  https://api.feedmarkdown.com/v1/feed/FEED_ID/runs \
  | jq '.[-1] | {status, items_found, items_new, items_deduped, items_scraped, items_errors, cost}'

Feed run record showing found, new, deduped, scraped and error counts

The first run, on 2026-10-09 starting 14:35:07 UTC, took 2 minutes 10 seconds. It found 60 items, kept 42 as new, counted 17 as deduped, scraped 41 and had 1 error. The cost field read 80000, which the server records in micro-dollars.

Remove duplicates before you pay to read them

Without deduplication a digest repeats itself. Feedmarkdown checks in three levels, cheapest first:

Check Catches Misses Needs the body?
Exact URL or ID repeat The same item seen twice, by two queries or two polls Anything with a different URL No
Headline similarity The same story with a near-identical headline Rewritten headlines No
Body similarity Near-identical text under a new headline Stories rewritten from scratch Yes, after scraping

The first two run before any scrape. The third runs after, so you pay for the scrape either way. For how often repeats show up in Google News feeds, see how often a Google News RSS feed repeats the same story. What this run showed is less tidy than that table suggests. To choose how many hours of past issues to compare against, see how to stop sending subscribers the same story twice.

What the 17 deduped items were

We went back to the four feeds we had saved at 14:34 UTC and took the newest 15 items from each, the same cut the feed makes. That gave 60 candidates and 43 unique items. 60 minus 43 is 17, which matches the run counter. Of the 43, 27 appeared in one query, 15 in two and 1 in three. So all 17 deduped items were exact repeats: the same Google News item returned by more than one query. Headline matching and body matching merged nothing in this run. This reconstruction is ours, from the saved feeds. The API doesn't list which items were dropped.

A real cluster that wasn't merged

One story did appear under three headlines. RWE sold its stake in the Five Estuaries wind farm to EDF, on 2026-10-09:

Headline Outlet URL What dedupe did
RWE completes sale of shares in Five Estuaries Offshore Wind Farm RWE rwe.com/en/press/rwe-offshore-wind-gmbh/2026-10-08-rwe-completes-sale-of-shares-in-five-estuaries-offshore-wind-farm/ Kept as new
RWE confirms Five Estuaries stake sale reNEWS renews.biz/offshore-wind/rwe-confirms-five-estuaries-stake-sale/ Kept as new
RWE sells 33.33% stake in Five Estuaries offshore wind project to EDF Renewables Now renewablesnow.com/news/rwe-sells-33-33-percent-stake-in-five-estuaries-offshore-wind-project-to-edf-1302763/ Kept as new

One offshore wind story reported by three outlets under three headlines, kept as three items by the feed and merged into one entry by a second pass in n8n

All three have different URLs, the headlines share little wording, and the bodies are a press release and two news write-ups. None of the checks fired. The feed merged nothing here, so we saw no false merges, only misses. The same happened with a Germany site-plan story (three outlets) and the Utsira Nord floating wind coverage (four outlets). In this sample, treat cross-outlet rewrites as something your own pipeline still has to handle.

We wrote our own second pass in an n8n Code node, described below. It's a rough heuristic and it gets things wrong.

Get clean text for each story

The pull output is a JSON Feed 1.1 document. The article is Markdown in the standard content_text field. Feed, source and author data sit in a _feedmarkdown extension. page_size goes up to 100, and there is no date filter, so you filter by date_published yourself.

curl -s -H "Authorization: Bearer $FEEDMARKDOWN_KEY" \
  "https://api.feedmarkdown.com/v1/feed/FEED_ID/json?page_size=100"

One item, trimmed (run on 2026-10-09):

{
  "id": "f97596e8-36b5-4b89-93ee-c5b116ac1894",
  "url": "https://www.offshorewind.biz/2026/10/09/germany-to-auction-4-gw-of-offshore-wind-capacity-in-2027-under-revised-site-plan",
  "title": "Germany to Auction 4 GW of Offshore Wind Capacity in 2027 Under Revised Site Plan",
  "content_text": "2nd Amendment to the Site Development Plan 2025; Source: BSH\n\n**Germany will auction 4 GW of offshore wind capacity across five sites in the North Sea in 2027, following changes to the country’s devel...",
  "date_published": "2026-10-09T09:28:30Z",
  "_feedmarkdown": { "feed_id": "6b6eee66-3e8b-4b97-8435-0c0303528e22", "source_id": "084e0d9a-37ea-495a-9939-314567d2f57e", "source": "Offshore Wind" }
}

How much smaller is Markdown than the page? We fetched the raw HTML of three articles with curl and a desktop browser User-Agent, and compared it to content_text. Tokens are counted with the cl100k_base tokenizer. The HTML is the static response, not a rendered page.

Article HTML chars HTML tokens Markdown chars Markdown tokens
RWE press release 137,355 37,144 2,645 656
Windtech International, Germany site plan 132,627 34,597 14,751 4,722
Offshore Wind, Germany auction 174,899 57,219 3,321 754

Three other outlets returned a short page of about 5,500 to 5,900 characters that wasn't the article, so we left them out of the table.

What didn't come out clean

  • Navigation inside the body. The Renewables Now item is 23,046 characters, and the top of it is the site menu, including a list of every country. The Offshore Wind item ends in a newsletter sign-up block.
  • Bot walls. The CEEnergynews item is 41 characters: "Verifying that you are not a robot...". Three TGS | 4C Offshore items are 20 characters of empty link markup. The run counted these as new, not as errors. The one item the run counted as an error was a separate failure.
  • A paywall flag we could not find. The pricing FAQ says truncated paywalled bodies are flagged in the payload. We saw no such field in the JSON Feed items, and we didn't test a known paywalled article. If you rely on that, check it yourself before you do.

We handle this in n8n: unwrap links, keep only lines of 80 characters or more, which drops menus, and skip any story with fewer than 600 characters left. Of the 42 stories in the feed, 35 were published in the last 24 hours and passed that filter.

Summarize and write the email in n8n

The workflow

n8n canvas with the daily digest workflow: schedule, trigger feed run, wait for it, pull feed, code, LLM, code, email

We used core n8n nodes only. We didn't use any Feedmarkdown-specific node, and we haven't confirmed that one exists. Steps in order:

  1. Schedule Trigger. Daily at 07:00, Europe/Berlin. A manual trigger feeds the same path for testing.
  2. Code: record start time. Saves the time so we can tell our run from older ones.
  3. HTTP Request: PUT /v1/feed/:id/run. Makes the feed due now. Without it, stories published after the last scheduled run slip to tomorrow's email. The worker creates the run record when it claims the feed, not when you call PUT, so "newest run" isn't enough.
  4. Wait 60 seconds, HTTP Request GET /runs, Code, IF. Finds the newest run created after the start time. If its status is done or error, continue. Otherwise go back to Wait, with a cap of 15 tries so a slow run delays the email instead of cancelling it.
  5. HTTP Request: GET /json?page_size=100. Pulls the feed. The key sits in an n8n Header Auth credential.
  6. Code: pick stories. Last 24 hours, cleaned text, 3,000 characters per story, newest first, at most 15.
  7. HTTP Request to OpenRouter. One call per story.
  8. Code: build email. Subject, HTML body, token and cost totals.
  9. Send Email (SMTP). One recipient, our own test address.

n8n HTTP Request node configured to pull the Feedmarkdown JSON feed

n8n Code node that keeps the last 24 hours of items and trims article text

The pick step also has a second dedupe pass. It earns its place: the first email listed the Azerbaijan grant story twice, once per outlet. It groups headlines that share a capitalised pair of words, such as "Five Estuaries". After the LLM step, another step merges entries whose event descriptions overlap. Merged outlets show as "Also reported by". It's wrong in both directions sometimes. During development it merged unrelated stories on "Green Energy" and "Floating Wind" until we added those pairs to a stop list.

The summary prompt

We used openai/gpt-4o-mini through OpenRouter, with response_format set to JSON. We called it from an HTTP Request node rather than an LLM node because the response carries token counts and cost, which we wanted in the workflow.

n8n node with the summary prompt in the request body

The first prompt, version 1, asked only for relevant, summary and why_it_matters, and said to set relevant to false if the story wasn't mainly about offshore wind. Its output was too generous. Of 15 candidates, 14 came back relevant. They included an Azerbaijan grant story twice, once per outlet, a South African grid story and a Philippines energy-policy story. The first email, sent 2026-10-09 at about 14:48 UTC, had 14 entries.

Version 2 lists what counts as offshore wind and what doesn't, and adds an event field naming the underlying event so a later step can merge outlets. The full text:

You write one entry for a daily offshore wind news digest read by industry professionals. You get a headline, the outlet and the article text. Reply with JSON only: {"relevant": boolean, "event": "3 to 6 lowercase words naming the underlying event", "summary": "2 to 3 plain sentences saying what happened, using names and numbers from the text only", "why_it_matters": "one sentence"}. Set relevant to true only if the story is mainly about offshore wind: offshore or floating wind farms, turbines, substations, cables, vessels and ports serving them, auctions, leases and offshore wind policy. Set it to false for onshore wind, solar, grid or energy policy in general, and pages that are mostly navigation or an error. For event, name the thing that happened, not the outlet, so that two outlets reporting the same news give the same words (for example "rwe sells five estuaries stake"). Do not add facts that are not in the text.

Run and result

The final execution, run on 2026-10-09 with prompt version 2, started at 14:50:19 UTC and finished in 67 seconds. It sent the email at 14:51 UTC, 16:51 in Berlin.

Successful n8n execution with item counts on each node

The run it triggered found 60 items and added 0 new ones, with 17 deduped, because the earlier runs had already taken the day's stories. So the digest was built from stories stored by earlier runs. That is the normal case after the first day, and our test didn't exercise a run that finds a batch of new stories. The feed's own run on the first day took 2 minutes 10 seconds. The runs our workflow triggered took 6 to 9 seconds, so the first poll always found the run finished. The loop-back path never ran.

The LLM step made 15 calls: 11,653 prompt tokens and 1,874 completion tokens, and 12 of the 15 were marked relevant. After merging outlets, 10 entries were left: "10 stories from 15 candidates", as the email says. OpenRouter reported a cost of $0.00287 in the API response. That is the response figure, not a figure we reconciled against the provider dashboard.

The finished daily news digest as received in an inbox

Honestly, it isn't a good newsletter yet. One of the ten entries is a Philippines energy-policy story that mentions offshore wind in passing. We didn't check the summaries line by line against the articles.

What broke

  • The JSON Feed arrived as one string. The first execution, at 14:44 UTC, failed in the Pick stories node: Cannot read properties of undefined (reading 'filter'). n8n's HTTP Request node parsed the response as text, not JSON, so the whole feed sat in a data string. The fix is to set the response format to JSON in the node's options.
  • The first prompt was too permissive. See above.
  • The Wait loop is untested. It looks right on the canvas, and no run was slow enough to use it.

The first execution failing in the Pick stories node

Webhook instead of pull

Pull is right for a daily batch. A webhook is right when you want each story the moment it lands. Feedmarkdown sends one POST per new article. The first six fields are the stable contract: title, url, source, author, published_at and markdown. The body we received had a few more fields (id, feed_id, source_id) that we wouldn't build on.

Each item gets up to three attempts, waiting 2 seconds and then 10 seconds between them. After three consecutive failures, the run stops delivering and leaves the rest undelivered. POST /v1/feed/:id/webhook/resend sends the run's undelivered items; add "all": true to resend everything.

We built it as a second n8n workflow, using the Webhook node, and pointed a second feed at it: one source, items_limit 3, delivery: webhook. Three POSTs arrived and the workflow ran for each. Resending the run with POST /v1/feed/:id/webhook/resend and "all": true delivered them again. Want the same webhook in Slack instead of email? See n8n RSS feed to Slack with full article text.

What this costs per month

Item Cost
Feedmarkdown plan Starter $19 (10 sources, checked every 6 hours), Pro $49 (50 sources, hourly), Agency $149 (200 sources, every 15 minutes). This demo has 4 sources. Prices as on the pricing page on 2026-10-09.
LLM $0.00287 for the run above, as reported by OpenRouter. Times 30 days it is about $0.09, if every day looks like this one. For other volumes and models, see what it costs to summarize 1,000 news articles.
n8n hosting Not measured. We used an existing instance.
Email sending Not measured. We used an existing SMTP account.

For the same volume priced as separate tools, see what a newsletter's news stack costs per month.

The run record also has a cost field. The first run read 80000 micro-dollars, or $0.08, for the scrapes. The pricing page describes flat plan prices, so we don't add that to the plan price, and we haven't checked how the field maps to a bill.

What we didn't test

  • Subscriber growth, deliverability and spam placement. We sent one email to one inbox.
  • Running unattended for a week. The schedule trigger was never left to fire on its own.
  • A run that finds a large batch of new stories in the middle of the workflow, and the Wait loop.
  • Other niches, and languages other than English.
  • Checking the summaries against the articles.

At higher volume, the first things we would expect to hurt are the one-call-per-story LLM step and the unmerged duplicates.

Running this for more than one newsletter

An agency running digests for clients would create one feed per client. Sources are the billable unit on the pricing page, and feeds are not limited, so two clients can share a source. The full setup, with naming, plan limits and the dedup setting to avoid, is in running news digests for several clients.

Where Feedmarkdown fits

In this build, Feedmarkdown replaced the redirect-link resolution, the scraping and the first-pass deduplication: a Google News query goes in, and Markdown articles come out by pull as JSON Feed or Atom, or by webhook. n8n, the LLM step, the second dedupe pass and the email send are all outside it. It supports Google News and Google Alerts sources today, not direct site sources or plain RSS. See the pricing and plans or the home page for more.