{
  "name": "Daily news digest (Feedmarkdown pull)",
  "nodes": [
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "days",
              "daysInterval": 1,
              "triggerAtHour": 7
            }
          ]
        }
      },
      "name": "Every day 07:00",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        0
      ]
    },
    {
      "parameters": {},
      "name": "Run now (testing)",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        0,
        200
      ]
    },
    {
      "parameters": {
        "jsCode": "return [{ json: { triggered_at: new Date().toISOString() } }];"
      },
      "name": "Record start time",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        260,
        100
      ]
    },
    {
      "parameters": {
        "method": "PUT",
        "url": "https://api.feedmarkdown.com/v1/feed/YOUR_FEED_ID/run",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {}
      },
      "name": "Run feed now",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        520,
        100
      ]
    },
    {
      "parameters": {
        "amount": 60,
        "unit": "seconds"
      },
      "name": "Wait 60s",
      "type": "n8n-nodes-base.wait",
      "typeVersion": 1.1,
      "position": [
        780,
        100
      ]
    },
    {
      "parameters": {
        "url": "https://api.feedmarkdown.com/v1/feed/YOUR_FEED_ID/runs",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {}
      },
      "name": "List runs",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1040,
        100
      ]
    },
    {
      "parameters": {
        "jsCode": "const triggered = $('Record start time').first().json.triggered_at;\nconst runs = $input.all().map((i) => i.json).filter((r) => r && r.id);\nconst run = runs\n  .filter((r) => r.created_at >= triggered)\n  .sort((a, b) => b.created_at.localeCompare(a.created_at))[0];\nconst finished = !!run && (run.status === 'done' || run.status === 'error');\nreturn [{ json: { finished, run: run || null } }];"
      },
      "name": "Newest run since trigger",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1300,
        100
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "c1",
              "leftValue": "={{ $json.finished || $runIndex >= 14 }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "name": "Run finished or out of tries?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1560,
        100
      ]
    },
    {
      "parameters": {
        "url": "https://api.feedmarkdown.com/v1/feed/YOUR_FEED_ID/json",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "page_size",
              "value": "100"
            }
          ]
        },
        "options": {
          "response": {
            "response": {
              "responseFormat": "json"
            }
          }
        }
      },
      "name": "Pull JSON Feed",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1820,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "const feed = $input.first().json;\nconst MAX_STORIES = 15, BODY_CAP = 3000, MIN_BODY = 600;\nconst since = Date.now() - 24 * 3600 * 1000;\n\n// Keep sentence-like lines, drop menus and link lists, unwrap links.\nfunction cleanBody(md) {\n  return md\n    .replace(/\\[([^\\]]*)\\]\\([^)]*\\)/g, '$1')\n    .split('\\n')\n    .map((l) => l.trim())\n    .filter((l) => l.length >= 80)\n    .join('\\n');\n}\n\nconst STOP = new Set(['Offshore Wind', 'Wind Farm', 'North Sea', 'Wind Power', 'Floating Wind', 'Green Energy', 'Wind Project']);\nfunction keys(title) {\n  const words = title.replace(/[^\\p{L}\\p{N}\\s]/gu, ' ').split(/\\s+/).filter(Boolean);\n  const out = [];\n  for (let i = 0; i < words.length - 1; i++) {\n    const a = words[i], b = words[i + 1], k = a + ' ' + b;\n    if (/^\\p{Lu}/u.test(a) && /^\\p{Lu}/u.test(b) && !STOP.has(k) && i > 0) out.push(k);\n  }\n  return out;\n}\n\nconst stories = feed.items\n  .filter((it) => Date.parse(it.date_published) >= since)\n  .map((it) => ({\n    title: it.title,\n    url: it.url,\n    outlet: it._feedmarkdown.source,\n    published: it.date_published,\n    body: cleanBody(it.content_text || ''),\n  }))\n  .filter((s) => s.body.length >= MIN_BODY)\n  .sort((a, b) => b.published.localeCompare(a.published));\n\n// Second pass: group stories that share a capitalised pair such as \"Five Estuaries\".\nconst groups = [];\nfor (const s of stories) {\n  const k = keys(s.title);\n  const g = groups.find((g) => k.some((x) => g.keys.has(x)));\n  if (g) { g.also.push({ outlet: s.outlet, url: s.url }); k.forEach((x) => g.keys.add(x)); }\n  else groups.push({ lead: s, keys: new Set(k), also: [] });\n}\nconst out = groups.slice(0, MAX_STORIES).map((g) => ({\n  ...g.lead, body: g.lead.body.slice(0, BODY_CAP), also_covered_by: g.also,\n}));\nreturn out.map((s) => ({ json: { ...s, prompt_input: `Headline: ${s.title}\\nOutlet: ${s.outlet}\\n\\n${s.body}` } }));\n"
      },
      "name": "Pick stories",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2080,
        0
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://openrouter.ai/api/v1/chat/completions",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "openRouterApi",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ model: 'openai/gpt-4o-mini', temperature: 0.2, max_tokens: 300, response_format: { type: 'json_object' }, usage: { include: true }, messages: [ { role: 'system', content: `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.` }, { role: 'user', content: $json.prompt_input } ] }) }}",
        "options": {}
      },
      "name": "Summarize with OpenRouter",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2340,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "const stories = $('Pick stories').all().map((i) => i.json);\nconst results = $input.all().map((i) => i.json);\nlet promptTokens = 0, completionTokens = 0, cost = 0;\nconst entries = [];\nresults.forEach((r, i) => {\n  const u = r.usage || {};\n  promptTokens += u.prompt_tokens || 0;\n  completionTokens += u.completion_tokens || 0;\n  cost += u.cost || 0;\n  let parsed;\n  try { parsed = JSON.parse(r.choices[0].message.content); } catch (e) { return; }\n  if (parsed.relevant === true) entries.push({ ...stories[i], ...parsed });\n});\n// Second pass: merge entries whose event descriptions overlap.\nconst STOPW = new Set(['offshore', 'wind', 'farm', 'farms', 'the', 'and', 'for', 'with']);\nconst toks = (s) => new Set((s || '').toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 2 && !STOPW.has(w)));\nconst overlap = (a, b) => { let n = 0; for (const x of a) if (b.has(x)) n++; return n / (Math.min(a.size, b.size) || 1); };\nconst merged = [];\nfor (const e of entries) {\n  const t = toks(e.event);\n  const g = merged.find((m) => m.t.size >= 2 && t.size >= 2 && overlap(m.t, t) >= 0.6);\n  if (g) g.e.also_covered_by.push({ outlet: e.outlet, url: e.url });\n  else merged.push({ t, e });\n}\nconst finalEntries = merged.map((m) => m.e);\nconst esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');\nconst day = new Date().toISOString().slice(0, 10);\nconst blocks = finalEntries.map((e) => {\n  const more = e.also_covered_by.length\n    ? `<br><span style=\"color:#666\">Also reported by ${e.also_covered_by.map((a) => esc(a.outlet)).join(', ')}</span>`\n    : '';\n  return `<h3 style=\"margin:24px 0 4px\"><a href=\"${e.url}\">${esc(e.title)}</a></h3>`\n    + `<div style=\"color:#666;font-size:13px\">${esc(e.outlet)}</div>`\n    + `<p>${esc(e.summary)} <em>${esc(e.why_it_matters)}</em>${more}</p>`;\n});\nconst html = `<div style=\"font-family:Arial,sans-serif;max-width:640px\">`\n  + `<h2>Offshore wind digest (demo), ${day}</h2>`\n  + `<p style=\"color:#666\">${finalEntries.length} stories from ${stories.length} candidates.</p>`\n  + blocks.join('') + `</div>`;\nreturn [{ json: {\n  subject: `Offshore wind digest (demo) ${day}`,\n  html,\n  stats: { candidates: stories.length, relevant: entries.length, kept: finalEntries.length, promptTokens, completionTokens, cost },\n} }];"
      },
      "name": "Build email",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2600,
        0
      ]
    },
    {
      "parameters": {
        "fromEmail": "Feedmarkdown demo <hello@sender.feedmarkdown.com>",
        "toEmail": "you@example.com",
        "subject": "={{ $json.subject }}",
        "emailFormat": "html",
        "html": "={{ $json.html }}",
        "options": {
          "appendAttribution": false
        }
      },
      "name": "Send digest",
      "type": "n8n-nodes-base.emailSend",
      "typeVersion": 2.1,
      "position": [
        2860,
        0
      ]
    },
    {
      "parameters": {
        "content": "## 1. Make the feed run now\nRecord the time, call PUT /run, then poll /runs until the run created after that time is done or errored (max 15 tries).",
        "height": 110,
        "width": 1000,
        "color": 4
      },
      "name": "Note 1",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        240,
        -70
      ]
    },
    {
      "parameters": {
        "content": "## 2. Pull and pick\nJSON Feed, last 24 hours, strip menus, group same-story headlines.",
        "height": 110,
        "width": 520,
        "color": 5
      },
      "name": "Note 2",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1780,
        -170
      ]
    },
    {
      "parameters": {
        "content": "## 3. Summarize and send\nOne LLM call per story, then one email.",
        "height": 110,
        "width": 780,
        "color": 6
      },
      "name": "Note 3",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        2300,
        -170
      ]
    }
  ],
  "connections": {
    "Every day 07:00": {
      "main": [
        [
          {
            "node": "Record start time",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Run now (testing)": {
      "main": [
        [
          {
            "node": "Record start time",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Record start time": {
      "main": [
        [
          {
            "node": "Run feed now",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Run feed now": {
      "main": [
        [
          {
            "node": "Wait 60s",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Wait 60s": {
      "main": [
        [
          {
            "node": "List runs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "List runs": {
      "main": [
        [
          {
            "node": "Newest run since trigger",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Newest run since trigger": {
      "main": [
        [
          {
            "node": "Run finished or out of tries?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Run finished or out of tries?": {
      "main": [
        [
          {
            "node": "Pull JSON Feed",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Wait 60s",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pull JSON Feed": {
      "main": [
        [
          {
            "node": "Pick stories",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pick stories": {
      "main": [
        [
          {
            "node": "Summarize with OpenRouter",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Summarize with OpenRouter": {
      "main": [
        [
          {
            "node": "Build email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build email": {
      "main": [
        [
          {
            "node": "Send digest",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1",
    "timezone": "Europe/Berlin"
  }
}
