Take-home assignment

    I designed the workflow as a modular pipeline that separates data ingestion, validation, enrichment, aggregation, summarization, and email delivery into independent stages, making each component easier to test and maintain. RSS articles are normalized and deduplicated first, then only articles with missing excerpts are routed through an enrichment loop that attempts to generate excerpts (LLM's context) while gracefully handling retrieval failures without interrupting the workflow. After enrichment, all articles are aggregated into a single structured dataset, which is used to generate either a weekly AI summary or an empty digest when no qualifying articles are available. This approach minimizes unnecessary LLM calls, improves resilience through fallback handling, and keeps the workflow extensible for additional news sources or enrichment steps in the future. I have used ChatGPT 5 mini throughout the workflow.

    Shared 7/20/2026

    2 views

    Visual Workflow

    JSON Code

    {
      "id": "WdsYDKL7HiAceVAY",
      "meta": {
        "instanceId": "fb34a7e805d9636b58c4ce28503e7ba8ec307e0c602654a9925674c108123fb3"
      },
      "name": "My workflow",
      "tags": [],
      "nodes": [
        {
          "id": "30d9ca58-a136-4e85-ad49-1fd75595dd93",
          "name": "Fetch OpenAI RSS",
          "type": "n8n-nodes-base.httpRequest",
          "position": [
            -2576,
            272
          ],
          "parameters": {
            "url": "https://openai.com/news/rss.xml",
            "options": {
              "response": {
                "response": {
                  "responseFormat": "text"
                }
              }
            }
          },
          "typeVersion": 4.4
        },
        {
          "id": "78468fe2-7d10-474c-9192-449dcad8eb77",
          "name": "XML: Parse OpenAI Feed",
          "type": "n8n-nodes-base.xml",
          "position": [
            -2352,
            272
          ],
          "parameters": {
            "options": {}
          },
          "typeVersion": 1
        },
        {
          "id": "f812c3b6-e360-4496-9959-37f26f120929",
          "name": "Code: Extract OpenAI Articles",
          "type": "n8n-nodes-base.code",
          "position": [
            -2128,
            272
          ],
          "parameters": {
            "jsCode": "const input = $input.first().json;\nconst config = $(\"Workflow Config\").first().json;\n\nconst lookbackDays = Number(config.lookbackDays ?? 8);\nconst maxExcerptCharacters = Number(\n  config.maxExcerptCharacters ?? 4000\n);\n\n// This is a per-source safety cap, not the final combined limit.\nconst maxArticlesPerSource = Number(\n  config.maxArticlesPerSource ?? 30\n);\n\nif (!Number.isFinite(lookbackDays) || lookbackDays < 1) {\n  throw new Error(\n    `\"lookbackDays\" must be a positive number. Received: ${config.lookbackDays}`\n  );\n}\n\nconst cutoff = new Date();\ncutoff.setUTCDate(cutoff.getUTCDate() - lookbackDays);\n\nfunction scalar(value) {\n  if (Array.isArray(value)) {\n    return scalar(value[0]);\n  }\n\n  if (value !== null && typeof value === \"object\") {\n    return value._ ?? value[\"#text\"] ?? \"\";\n  }\n\n  return value;\n}\n\nfunction cleanString(value) {\n  const result = scalar(value);\n\n  if (result === null || result === undefined) {\n    return \"\";\n  }\n\n  return String(result).trim();\n}\n\nfunction parseDate(value) {\n  const cleanedValue = cleanString(value);\n\n  if (!cleanedValue) {\n    return null;\n  }\n\n  const parsedDate = new Date(cleanedValue);\n\n  if (Number.isNaN(parsedDate.getTime())) {\n    return null;\n  }\n\n  return parsedDate;\n}\n\n// Parsed XML structure:\n// rss → channel → item[]\nconst channel = input.rss?.channel;\nconst rawArticles = channel?.item ?? [];\n\nconst articles = Array.isArray(rawArticles)\n  ? rawArticles\n  : [rawArticles];\n\nreturn articles\n  .map((article) => {\n    const title = cleanString(article.title);\n\n    const url = cleanString(\n      article.link ??\n      article.guid\n    );\n\n    const publishedDate = parseDate(\n      article.pubDate ??\n      article.published ??\n      article.updated\n    );\n\n    const excerpt = cleanString(\n      article.description ??\n      article[\"content:encoded\"] ??\n      article.content\n    ).slice(0, maxExcerptCharacters);\n\n    return {\n      title,\n      url,\n      publishedDate,\n      excerpt,\n      source: \"OpenAI\",\n    };\n  })\n\n  // Reject malformed entries.\n  .filter((article) => {\n    return Boolean(\n      article.title &&\n      article.url &&\n      article.publishedDate\n    );\n  })\n\n  // Reject historical articles before creating n8n output items.\n  .filter((article) => {\n    return article.publishedDate >= cutoff;\n  })\n\n  // Newest articles first.\n  .sort((a, b) => {\n    return (\n      b.publishedDate.getTime() -\n      a.publishedDate.getTime()\n    );\n  })\n\n  // Protect against an unexpectedly large feed response.\n  .slice(0, maxArticlesPerSource)\n\n  .map((article) => ({\n    json: {\n      title: article.title,\n      url: article.url,\n      publishedAt: article.publishedDate.toISOString(),\n      excerpt: article.excerpt,\n      source: article.source,\n    },\n  }));"
          },
          "typeVersion": 2
        },
        {
          "id": "b3b4c03f-27ff-4f58-a4c2-82f050613a55",
          "name": "Fetch Google RSS",
          "type": "n8n-nodes-base.httpRequest",
          "position": [
            -2576,
            464
          ],
          "parameters": {
            "url": "https://blog.google/rss/",
            "options": {
              "response": {
                "response": {
                  "responseFormat": "text"
                }
              }
            }
          },
          "typeVersion": 4.4
        },
        {
          "id": "fbea1fcf-187c-4a80-b25c-6bd66aee3309",
          "name": "XML: Parse Google Feed",
          "type": "n8n-nodes-base.xml",
          "position": [
            -2352,
            464
          ],
          "parameters": {
            "options": {}
          },
          "typeVersion": 1
        },
        {
          "id": "a7600b9f-a040-4cd0-821a-32c39bf384b3",
          "name": "Merge Google and OpenAI articles",
          "type": "n8n-nodes-base.merge",
          "position": [
            -1904,
            368
          ],
          "parameters": {},
          "typeVersion": 3.2
        },
        {
          "id": "b6672949-0975-42dc-b48f-b9ec6f7cfb13",
          "name": "Workflow Config",
          "type": "n8n-nodes-base.set",
          "position": [
            -2800,
            368
          ],
          "parameters": {
            "mode": "raw",
            "options": {},
            "jsonOutput": "{\n  \"lookbackDays\": 8,\n  \"maxArticles\": 20,\n  \"maxExcerptCharacters\": 4000,\n  \"maxArticlesPerSource\": 30,\n  \"minimumExcerptCharacters\": 30,\n  \"sendEmptyDigest\": true,\n  \"keywords\": [\n    \"artificial intelligence\",\n    \"generative ai\",\n    \"machine learning\",\n    \"deep learning\",\n    \"large language model\",\n    \"foundation model\",\n    \"gemini\",\n    \"deepmind\",\n    \"google ai\",\n    \"vertex ai\",\n    \"google cloud ai\",\n    \"gemma\",\n    \"notebooklm\",\n    \"ai studio\",\n    \"ai agent\",\n    \"ai agents\",\n    \"agentic\",\n    \"multimodal\",\n    \"reasoning model\",\n    \"computer vision\",\n    \"natural language processing\",\n    \"image generation\",\n    \"video generation\",\n    \"ai assistant\"\n     ]\n}"
          },
          "typeVersion": 3.4
        },
        {
          "id": "873113f5-485e-4822-90be-58d592e54186",
          "name": "Code: Extract + Filter Google Articles",
          "type": "n8n-nodes-base.code",
          "position": [
            -2128,
            464
          ],
          "parameters": {
            "jsCode": "const input = $input.first().json;\nconst config = $(\"Workflow Config\").first().json;\n\nconst configuredKeywords = Array.isArray(config.keywords)\n  ? config.keywords\n  : [];\n\nconst maxExcerptCharacters = Number(\n  config.maxExcerptCharacters ?? 4000\n);\n\nif (configuredKeywords.length === 0) {\n  throw new Error(\n    'Workflow Config must contain a non-empty \"keywords\" array.'\n  );\n}\n\nconst keywords = configuredKeywords\n  .map((keyword) => String(keyword).trim().toLowerCase())\n  .filter(Boolean);\n\nfunction scalar(value) {\n  if (Array.isArray(value)) {\n    return scalar(value[0]);\n  }\n\n  if (\n    value !== null &&\n    typeof value === \"object\"\n  ) {\n    return value._ ?? value[\"#text\"] ?? \"\";\n  }\n\n  return value;\n}\n\nfunction cleanText(value = \"\") {\n  const normalized = scalar(value);\n\n  if (normalized === null || normalized === undefined) {\n    return \"\";\n  }\n\n  return String(normalized)\n    .replace(/<script[\\s\\S]*?<\\/script>/gi, \" \")\n    .replace(/<style[\\s\\S]*?<\\/style>/gi, \" \")\n    .replace(/<[^>]+>/g, \" \")\n    .replace(/&nbsp;/gi, \" \")\n    .replace(/&amp;/gi, \"&\")\n    .replace(/&quot;/gi, '\"')\n    .replace(/&#39;/gi, \"'\")\n    .replace(/\\s+/g, \" \")\n    .trim();\n}\n\nfunction extractCategories(value) {\n  if (!value) {\n    return [];\n  }\n\n  const categories = Array.isArray(value)\n    ? value\n    : [value];\n\n  return categories\n    .map((category) => cleanText(category))\n    .filter(Boolean);\n}\n\nfunction matchesKeyword(searchableText, keyword) {\n  if (keyword === \"ai\") {\n    return /\\bai\\b/i.test(searchableText);\n  }\n\n  if (keyword === \"llm\") {\n    return /\\bllms?\\b/i.test(searchableText);\n  }\n\n  return searchableText.includes(keyword);\n}\n\n// Parsed Google RSS structure:\n// rss → channel → item[]\nconst channel = input.rss?.channel;\nconst rawArticles = channel?.item ?? [];\n\nconst articles = Array.isArray(rawArticles)\n  ? rawArticles\n  : [rawArticles];\n\nif (articles.length === 0) {\n  return [];\n}\n\nreturn articles\n  .map((article) => {\n    const title = cleanText(article.title);\n\n    const url = cleanText(\n      article.link ??\n      article.guid\n    );\n\n    const publishedAt = cleanText(\n      article.pubDate ??\n      article.published ??\n      article.updated\n    ) || null;\n\n    const excerpt = cleanText(\n      article.description ??\n      article[\"content:encoded\"] ??\n      article.content\n    ).slice(0, maxExcerptCharacters);\n\n    const categories = extractCategories(\n      article.category ??\n      article.categories\n    );\n\n    return {\n      title,\n      url,\n      publishedAt,\n      excerpt,\n      categories,\n      source: \"Google\",\n    };\n  })\n  .filter((article) => {\n    if (!article.title || !article.url) {\n      return false;\n    }\n\n    const searchableText = [\n      article.title,\n      article.excerpt,\n      article.url,\n      article.source,\n      article.categories.join(\" \"),\n    ]\n      .filter(Boolean)\n      .join(\" \")\n      .toLowerCase();\n\n    return keywords.some((keyword) =>\n      matchesKeyword(searchableText, keyword)\n    );\n  })\n  .map((article) => ({\n    json: article,\n  }));"
          },
          "typeVersion": 2
        },
        {
          "id": "40fdd24f-f7d8-4d9f-9af3-c04cfb10b904",
          "name": "Send a message",
          "type": "n8n-nodes-base.gmail",
          "position": [
            -208,
            -64
          ],
          "webhookId": "776abed7-f607-404c-958e-d9dbb974ad13",
          "parameters": {
            "sendTo": "samy.sharmaa@gmail.com",
            "message": "={{$json.plainText}}",
            "options": {},
            "subject": "={{$json.subject}}",
            "emailType": "text"
          },
          "credentials": {
            "gmailOAuth2": {
              "id": "2GMhPKbuU7xJruPv",
              "name": "Gmail account"
            }
          },
          "typeVersion": 2.2
        },
        {
          "id": "1e0fc950-2352-4d97-be92-ae6224dc2990",
          "name": "If: Send Empty Digest?",
          "type": "n8n-nodes-base.if",
          "position": [
            -720,
            16
          ],
          "parameters": {
            "options": {},
            "conditions": {
              "options": {
                "version": 3,
                "leftValue": "",
                "caseSensitive": true,
                "typeValidation": "strict"
              },
              "combinator": "and",
              "conditions": [
                {
                  "id": "86d49f89-8f8d-48e1-96bf-11bb0f8f5785",
                  "operator": {
                    "type": "boolean",
                    "operation": "true",
                    "singleValue": true
                  },
                  "leftValue": "={{ $(\"Workflow Config\").first().json.sendEmptyDigest }}",
                  "rightValue": ""
                }
              ]
            }
          },
          "typeVersion": 2.3
        },
        {
          "id": "7158eba8-ab27-43ad-9cee-ba03ec76e4b0",
          "name": "Code: Format Empty Digest",
          "type": "n8n-nodes-base.code",
          "position": [
            -432,
            -64
          ],
          "parameters": {
            "jsCode": "const config =\n  $(\"Workflow Config\").first().json;\n\nfunction cleanText(value) {\n  return String(value ?? \"\")\n    .replace(/\\s+/g, \" \")\n    .trim();\n}\n\nfunction formatDate(value) {\n  if (!value) {\n    return \"Date unavailable\";\n  }\n\n  const date = new Date(value);\n\n  if (Number.isNaN(date.getTime())) {\n    return cleanText(value) || \"Date unavailable\";\n  }\n\n  return new Intl.DateTimeFormat(\"en\", {\n    year: \"numeric\",\n    month: \"short\",\n    day: \"numeric\",\n    timeZone: \"UTC\",\n  }).format(date);\n}\n\nconst lookbackDays = Number(\n  config.lookbackDays ?? 8\n);\n\nif (\n  !Number.isFinite(lookbackDays) ||\n  lookbackDays < 1\n) {\n  throw new Error(\n    `\"lookbackDays\" must be a positive number. Received: ${config.lookbackDays}`\n  );\n}\n\nconst now = new Date();\n\nconst periodFromDate =\n  new Date(now);\n\nperiodFromDate.setUTCDate(\n  periodFromDate.getUTCDate() -\n  lookbackDays\n);\n\nconst aggregate = {\n  generatedAt:\n    now.toISOString(),\n\n  period: {\n    from:\n      periodFromDate.toISOString(),\n\n    to:\n      now.toISOString(),\n  },\n\n  articleCount: 0,\n  sourceCount: 0,\n  sources: [],\n  articles: [],\n};\n\nconst periodFrom = formatDate(\n  aggregate.period.from\n);\n\nconst periodTo = formatDate(\n  aggregate.period.to\n);\n\nconst digestTitle =\n  \"AI & Automation Weekly Digest\";\n\nconst emptyDigestMessage =\n  \"No new articles matched the digest criteria during this reporting period.\";\n\nconst subject =\n  `${digestTitle} — No new articles — ${periodTo}`;\n\nconst plainText = [\n  digestTitle.toUpperCase(),\n  `Reporting period: ${periodFrom} to ${periodTo}`,\n  \"\",\n  emptyDigestMessage,\n].join(\"\\n\");\n\nconst markdown = [\n  `# ${digestTitle}`,\n  `**Reporting period:** ${periodFrom} to ${periodTo}`,\n  \"\",\n  emptyDigestMessage,\n].join(\"\\n\");\n\nreturn [\n  {\n    json: {\n      subject,\n      plainText,\n      markdown,\n\n      digest: {\n        digestTitle,\n        digest:\n          emptyDigestMessage,\n        sources: [],\n      },\n\n      metadata: {\n        generatedAt:\n          aggregate.generatedAt,\n\n        period:\n          aggregate.period,\n\n        articleCount: 0,\n        sentenceCount: 1,\n        sourceCount: 0,\n        isEmptyDigest: true,\n      },\n    },\n  },\n];"
          },
          "typeVersion": 2
        },
        {
          "id": "92261f15-3e3e-4bb4-9334-16bc6476e4fb",
          "name": "Code: Format LLM Output",
          "type": "n8n-nodes-base.code",
          "position": [
            -432,
            256
          ],
          "parameters": {
            "jsCode": "const input = $input.first().json;\nconst aggregate = $(\"Code: Aggregate Articles\").first().json;\n\n/*\nExpected OpenAI response:\n\n{\n  \"output\": [\n    {\n      \"content\": [\n        {\n          \"type\": \"output_text\",\n          \"text\": \"{ ...JSON string... }\"\n        }\n      ]\n    }\n  ]\n}\n*/\n\nfunction findOutputText(data) {\n  if (!data || typeof data !== \"object\") {\n    return \"\";\n  }\n\n  const outputItems = Array.isArray(data.output)\n    ? data.output\n    : [];\n\n  for (const outputItem of outputItems) {\n    const contentItems = Array.isArray(outputItem.content)\n      ? outputItem.content\n      : [];\n\n    for (const contentItem of contentItems) {\n      if (\n        contentItem?.type === \"output_text\" &&\n        typeof contentItem.text === \"string\"\n      ) {\n        return contentItem.text;\n      }\n    }\n  }\n\n  return \"\";\n}\n\nfunction cleanText(value) {\n  return String(value ?? \"\")\n    .replace(/\\s+/g, \" \")\n    .trim();\n}\n\nfunction normalizeUrl(value) {\n  const url = cleanText(value);\n\n  if (!url) {\n    return \"\";\n  }\n\n  try {\n    return new URL(url).toString();\n  } catch {\n    return \"\";\n  }\n}\n\nfunction formatDate(value) {\n  const date = new Date(value);\n\n  if (Number.isNaN(date.getTime())) {\n    return cleanText(value) || \"Date unavailable\";\n  }\n\n  return new Intl.DateTimeFormat(\"en\", {\n    year: \"numeric\",\n    month: \"short\",\n    day: \"numeric\",\n    timeZone: \"UTC\",\n  }).format(date);\n}\n\n/*\nExtract and parse OpenAI output.\n*/\nlet rawOutput = findOutputText(input)\n  .replace(/^```json\\s*/i, \"\")\n  .replace(/^```\\s*/i, \"\")\n  .replace(/\\s*```$/i, \"\")\n  .trim();\n\nif (!rawOutput) {\n  throw new Error(\n    \"No output_text value was found in the OpenAI response.\"\n  );\n}\n\nlet digest;\n\ntry {\n  digest = JSON.parse(rawOutput);\n} catch (error) {\n  throw new Error(\n    `OpenAI returned invalid JSON: ${error.message}\\n\\nRaw output:\\n${rawOutput}`\n  );\n}\n\n/*\nValidate digest fields.\n*/\nconst digestTitle = cleanText(digest.digestTitle);\nconst digestText = cleanText(digest.digest);\n\nif (!digestTitle) {\n  throw new Error(\n    'OpenAI response is missing \"digestTitle\".'\n  );\n}\n\nif (!digestText) {\n  throw new Error(\n    'OpenAI response is missing \"digest\".'\n  );\n}\n\nif (!Array.isArray(digest.sources)) {\n  throw new Error(\n    'OpenAI response is missing the \"sources\" array.'\n  );\n}\n\n/*\nCheck digest length.\n*/\nconst sentenceCount = (\n  digestText.match(/[.!?]+(?:\\s|$)/g) ?? []\n).length;\n\nif (sentenceCount < 3 || sentenceCount > 5) {\n  throw new Error(\n    `Expected a 3–5 sentence digest, but detected ${sentenceCount} sentences.`\n  );\n}\n\n/*\nClean and deduplicate sources returned by OpenAI.\n\nSupports either:\n{\n  \"articleId\": \"...\",\n  \"publisher\": \"...\",\n  \"title\": \"...\",\n  \"url\": \"...\"\n}\n\nor:\n{\n  \"articleId\": \"...\",\n  \"publisher\": \"...\",\n  \"articleTitle\": \"...\",\n  \"url\": \"...\"\n}\n*/\nconst seenSourceKeys = new Set();\n\nconst sources = digest.sources\n  .map((source) => {\n    const articleId = cleanText(source.articleId);\n\n    const publisher =\n      cleanText(source.publisher) ||\n      \"Unknown source\";\n\n    const title =\n      cleanText(\n        source.title ??\n        source.articleTitle\n      ) || \"Read article\";\n\n    const url = normalizeUrl(source.url);\n\n    return {\n      articleId,\n      publisher,\n      title,\n      url,\n    };\n  })\n  .filter((source) => {\n    return (\n      source.articleId ||\n      source.title ||\n      source.url\n    );\n  })\n  .filter((source) => {\n    const dedupeKey =\n      source.articleId ||\n      source.url ||\n      `${source.publisher}:${source.title}`.toLowerCase();\n\n    if (seenSourceKeys.has(dedupeKey)) {\n      return false;\n    }\n\n    seenSourceKeys.add(dedupeKey);\n    return true;\n  })\n  .slice(0, 8);\n\nif (sources.length === 0) {\n  throw new Error(\n    \"No usable sources were returned by OpenAI.\"\n  );\n}\n\n/*\nBuild reporting-period values.\n*/\nconst periodFrom = formatDate(\n  aggregate.period?.from\n);\n\nconst periodTo = formatDate(\n  aggregate.period?.to\n);\n\nconst subject =\n  `${digestTitle} — ${periodTo}`;\n\n/*\nFormat source label.\n\nExamples:\n[google-123] NotebookLM is now Gemini Notebook\nNotebookLM is now Gemini Notebook\n*/\nfunction formatSourceTitle(source) {\n  if (source.articleId) {\n    return `[${source.articleId}] ${source.title}`;\n  }\n\n  return source.title;\n}\n\n/*\nBuild compact plain-text output for Gmail.\n*/\nconst plainTextSources = sources\n  .map((source) => {\n    const sourceTitle =\n      formatSourceTitle(source);\n\n    const lines = [\n      `• ${source.publisher} — ${sourceTitle}`,\n    ];\n\n    if (source.url) {\n      lines.push(source.url);\n    }\n\n    return lines.join(\"\\n\");\n  })\n  .join(\"\\n\");\n\nconst plainText = [\n  digestTitle.toUpperCase(),\n  `Reporting period: ${periodFrom} to ${periodTo}`,\n  \"\",\n  digestText,\n  \"\",\n  \"Sources\",\n  plainTextSources,\n].join(\"\\n\");\n\n/*\nBuild Markdown output.\n*/\nconst markdownSources = sources\n  .map((source) => {\n    const sourceTitle =\n      formatSourceTitle(source);\n\n    if (source.url) {\n      return `- [${sourceTitle}](${source.url}) — ${source.publisher}`;\n    }\n\n    return `- ${sourceTitle} — ${source.publisher}`;\n  })\n  .join(\"\\n\");\n\nconst markdown = [\n  `# ${digestTitle}`,\n  `**Reporting period:** ${periodFrom} to ${periodTo}`,\n  \"\",\n  digestText,\n  \"\",\n  \"## Sources\",\n  markdownSources,\n].join(\"\\n\");\n\n/*\nReturn one formatted item.\n*/\nreturn [\n  {\n    json: {\n      subject,\n      plainText,\n      markdown,\n\n      digest: {\n        digestTitle,\n        digest: digestText,\n        sources,\n      },\n\n      metadata: {\n        generatedAt:\n          aggregate.generatedAt ?? null,\n\n        period:\n          aggregate.period ?? null,\n\n        articleCount:\n          Number(\n            aggregate.articleCount ?? 0\n          ),\n\n        sentenceCount,\n        sourceCount:\n          sources.length,\n      },\n    },\n  },\n];"
          },
          "typeVersion": 2
        },
        {
          "id": "7ca165a7-81c9-4767-8def-99cd8106eb21",
          "name": "No Operation, do nothing",
          "type": "n8n-nodes-base.noOp",
          "position": [
            16,
            16
          ],
          "parameters": {},
          "typeVersion": 1
        },
        {
          "id": "2e4f1747-8751-4b8f-a939-f1d3d20e969c",
          "name": "Code: Prepare DIgest Data",
          "type": "n8n-nodes-base.code",
          "position": [
            -1680,
            368
          ],
          "parameters": {
            "jsCode": "const config = $(\"Workflow Config\").first().json;\n\n/*\nConfiguration\n*/\nconst lookbackDays = Number(\n  config.lookbackDays ?? 8\n);\n\nconst maxArticles = Number(\n  config.maxArticles ?? 20\n);\n\nconst maxExcerptCharacters = Number(\n  config.maxExcerptCharacters ?? 4000\n);\n\nconst minimumExcerptCharacters = Number(\n  config.minimumExcerptCharacters ?? 30\n);\n\n/*\nValidate configuration\n*/\nif (\n  !Number.isFinite(lookbackDays) ||\n  lookbackDays < 1\n) {\n  throw new Error(\n    `\"lookbackDays\" must be a positive number. Received: ${config.lookbackDays}`\n  );\n}\n\nif (\n  !Number.isFinite(maxArticles) ||\n  maxArticles < 1\n) {\n  throw new Error(\n    `\"maxArticles\" must be a positive number. Received: ${config.maxArticles}`\n  );\n}\n\nif (\n  !Number.isFinite(maxExcerptCharacters) ||\n  maxExcerptCharacters < 1\n) {\n  throw new Error(\n    `\"maxExcerptCharacters\" must be a positive number. Received: ${config.maxExcerptCharacters}`\n  );\n}\n\nif (\n  !Number.isFinite(minimumExcerptCharacters) ||\n  minimumExcerptCharacters < 0\n) {\n  throw new Error(\n    `\"minimumExcerptCharacters\" must be zero or greater. Received: ${config.minimumExcerptCharacters}`\n  );\n}\n\n/*\nReporting cutoff\n*/\nconst now = new Date();\n\nconst cutoff = new Date(now);\ncutoff.setUTCDate(\n  cutoff.getUTCDate() - lookbackDays\n);\n\n/*\nHelpers\n*/\nfunction scalar(value) {\n  if (Array.isArray(value)) {\n    return scalar(value[0]);\n  }\n\n  if (\n    value !== null &&\n    typeof value === \"object\"\n  ) {\n    return (\n      value._ ??\n      value[\"#text\"] ??\n      \"\"\n    );\n  }\n\n  return value;\n}\n\nfunction decodeBasicHtmlEntities(value = \"\") {\n  return String(value)\n    .replace(/&nbsp;/gi, \" \")\n    .replace(/&amp;/gi, \"&\")\n    .replace(/&quot;/gi, '\"')\n    .replace(/&#39;/gi, \"'\")\n    .replace(/&apos;/gi, \"'\")\n    .replace(/&lt;/gi, \"<\")\n    .replace(/&gt;/gi, \">\");\n}\n\nfunction cleanText(value = \"\") {\n  const normalized = scalar(value);\n\n  if (\n    normalized === null ||\n    normalized === undefined\n  ) {\n    return \"\";\n  }\n\n  return decodeBasicHtmlEntities(normalized)\n    .replace(\n      /<script[\\s\\S]*?<\\/script>/gi,\n      \" \"\n    )\n    .replace(\n      /<style[\\s\\S]*?<\\/style>/gi,\n      \" \"\n    )\n    .replace(/<[^>]+>/g, \" \")\n    .replace(/\\s+/g, \" \")\n    .trim();\n}\n\nfunction normalizeUrl(rawUrl = \"\") {\n  const cleanedUrl = cleanText(rawUrl);\n\n  if (!cleanedUrl) {\n    return \"\";\n  }\n\n  try {\n    const url = new URL(cleanedUrl);\n\n    url.hash = \"\";\n\n    const trackingParameters = [\n      \"utm_source\",\n      \"utm_medium\",\n      \"utm_campaign\",\n      \"utm_term\",\n      \"utm_content\",\n      \"gclid\",\n      \"fbclid\",\n      \"mc_cid\",\n      \"mc_eid\",\n    ];\n\n    for (\n      const parameter of trackingParameters\n    ) {\n      url.searchParams.delete(parameter);\n    }\n\n    if (url.pathname !== \"/\") {\n      url.pathname =\n        url.pathname.replace(/\\/+$/, \"\");\n    }\n\n    return url.toString();\n  } catch {\n    return cleanedUrl;\n  }\n}\n\nfunction normalizeTitle(title = \"\") {\n  return cleanText(title)\n    .toLowerCase()\n    .replace(\n      /[^\\p{L}\\p{N}\\s]/gu,\n      \" \"\n    )\n    .replace(/\\s+/g, \" \")\n    .trim();\n}\n\nfunction normalizeCategories(value) {\n  if (!value) {\n    return [];\n  }\n\n  const categories = Array.isArray(value)\n    ? value\n    : [value];\n\n  return [\n    ...new Set(\n      categories\n        .map((category) =>\n          cleanText(category)\n        )\n        .filter(Boolean)\n    ),\n  ];\n}\n\nfunction parsePublishedDate(value) {\n  const cleanedValue = cleanText(value);\n\n  if (!cleanedValue) {\n    return null;\n  }\n\n  const parsedDate = new Date(cleanedValue);\n\n  if (\n    Number.isNaN(\n      parsedDate.getTime()\n    )\n  ) {\n    return null;\n  }\n\n  return parsedDate;\n}\n\n/*\nPrepare and validate articles\n*/\nconst preparedArticles = [];\n\nconst processing = {\n  inputArticleCount: $input.all().length,\n  acceptedBeforeDeduplication: 0,\n  outputArticleCount: 0,\n\n  rejectionCounts: {\n    missingTitleOrUrl: 0,\n    invalidPublicationDate: 0,\n    outsideLookbackWindow: 0,\n    duplicateUrl: 0,\n    duplicateTitle: 0,\n    overArticleLimit: 0,\n  },\n\n  excerptCounts: {\n    usableExcerpt: 0,\n    needsExcerpt: 0,\n  },\n};\n\nfor (const item of $input.all()) {\n  const rawArticle = item.json ?? {};\n\n  const title = cleanText(\n    rawArticle.title\n  );\n\n  const url = normalizeUrl(\n    rawArticle.url ??\n    rawArticle.link ??\n    rawArticle.guid\n  );\n\n  const excerpt = cleanText(\n    rawArticle.excerpt ??\n    rawArticle.description ??\n    rawArticle.content\n  ).slice(\n    0,\n    maxExcerptCharacters\n  );\n\n  const source =\n    cleanText(rawArticle.source) ||\n    \"Unknown\";\n\n  const categories =\n    normalizeCategories(\n      rawArticle.categories ??\n      rawArticle.category\n    );\n\n  if (!title || !url) {\n    processing\n      .rejectionCounts\n      .missingTitleOrUrl += 1;\n\n    continue;\n  }\n\n  const publishedDate =\n    parsePublishedDate(\n      rawArticle.publishedAt ??\n      rawArticle.pubDate ??\n      rawArticle.published ??\n      rawArticle.updated\n    );\n\n  if (!publishedDate) {\n    processing\n      .rejectionCounts\n      .invalidPublicationDate += 1;\n\n    continue;\n  }\n\n  if (publishedDate < cutoff) {\n    processing\n      .rejectionCounts\n      .outsideLookbackWindow += 1;\n\n    continue;\n  }\n \n  const needsExcerpt =\n    excerpt.length <\n    minimumExcerptCharacters;\n\n  \n  // const forceTestCount = 4; // Set to 0 when done testing\n\n  // const needsExcerpt =\n  // preparedArticles.length < forceTestCount\n  //   ? true\n  //   : excerpt.length < minimumExcerptCharacters;  \n\n  if (needsExcerpt) {\n    processing\n      .excerptCounts\n      .needsExcerpt += 1;\n  } else {\n    processing\n      .excerptCounts\n      .usableExcerpt += 1;\n  }\n\n  preparedArticles.push({\n    articleId: url,\n    title,\n    normalizedTitle:\n      normalizeTitle(title),\n    url,\n    publishedAt:\n      publishedDate.toISOString(),\n    excerpt,\n    needsExcerpt,\n    source,\n    categories,\n  });\n}\n\nprocessing.acceptedBeforeDeduplication =\n  preparedArticles.length;\n\n/*\nSort newest first\n*/\npreparedArticles.sort((a, b) => {\n  return (\n    new Date(\n      b.publishedAt\n    ).getTime() -\n    new Date(\n      a.publishedAt\n    ).getTime()\n  );\n});\n\n/*\nDeduplicate and apply article limit\n*/\nconst seenUrls = new Set();\nconst seenTitles = new Set();\nconst articles = [];\n\nfor (const article of preparedArticles) {\n  const urlKey =\n    article.url.toLowerCase();\n\n  const titleKey =\n    article.normalizedTitle;\n\n  if (seenUrls.has(urlKey)) {\n    processing\n      .rejectionCounts\n      .duplicateUrl += 1;\n\n    continue;\n  }\n\n  if (\n    titleKey &&\n    seenTitles.has(titleKey)\n  ) {\n    processing\n      .rejectionCounts\n      .duplicateTitle += 1;\n\n    continue;\n  }\n\n  if (\n    articles.length >= maxArticles\n  ) {\n    processing\n      .rejectionCounts\n      .overArticleLimit += 1;\n\n    continue;\n  }\n\n  seenUrls.add(urlKey);\n\n  if (titleKey) {\n    seenTitles.add(titleKey);\n  }\n\n  articles.push({\n    articleId:\n      article.articleId,\n\n    title:\n      article.title,\n\n    url:\n      article.url,\n\n    publishedAt:\n      article.publishedAt,\n\n    excerpt:\n      article.excerpt,\n\n    needsExcerpt:\n      article.needsExcerpt,\n\n    source:\n      article.source,\n\n    categories:\n      article.categories,\n  });\n}\n\nprocessing.outputArticleCount =\n  articles.length;\n\nprocessing.rejectedArticleCount =\n  Object.values(\n    processing.rejectionCounts\n  ).reduce(\n    (total, count) =>\n      total + count,\n    0\n  );\n\n/*\nReturn one item per article.\n\nThe IF node after this can evaluate:\n{{ $json.needsExcerpt }}\n*/\nreturn articles.map((article) => ({\n  json: {\n    ...article,\n\n    preparationMetadata: {\n      preparedAt:\n        now.toISOString(),\n\n      lookbackDays,\n\n      minimumExcerptCharacters,\n\n      hasUsableExcerpt:\n        article.needsExcerpt === false,\n    },\n  },\n}));"
          },
          "typeVersion": 2,
          "alwaysOutputData": false
        },
        {
          "id": "0ae803ec-1aa2-42bd-8ba8-5dcec53bd7c1",
          "name": "IF: Excerpt Missing?",
          "type": "n8n-nodes-base.if",
          "position": [
            -1008,
            544
          ],
          "parameters": {
            "options": {},
            "conditions": {
              "options": {
                "version": 3,
                "leftValue": "",
                "caseSensitive": true,
                "typeValidation": "strict"
              },
              "combinator": "and",
              "conditions": [
                {
                  "id": "caf1548f-71df-4a65-ab8d-3de4b10710da",
                  "operator": {
                    "type": "boolean",
                    "operation": "true",
                    "singleValue": true
                  },
                  "leftValue": "={{ $json.needsExcerpt }}",
                  "rightValue": ""
                }
              ]
            }
          },
          "typeVersion": 2.3
        },
        {
          "id": "15135fe3-05d8-4adc-b879-3d3bf4076fa4",
          "name": "HTTP Request",
          "type": "n8n-nodes-base.httpRequest",
          "onError": "continueRegularOutput",
          "maxTries": 2,
          "position": [
            -432,
            448
          ],
          "parameters": {
            "url": "={{ $json.url }}",
            "options": {
              "response": {
                "response": {
                  "fullResponse": true,
                  "responseFormat": "text"
                }
              },
              "lowercaseHeaders": false
            },
            "sendHeaders": true,
            "headerParameters": {
              "parameters": [
                {
                  "name": "User-Agent",
                  "value": "Mozilla/5.0 (compatible; AIWeeklyDigest/1.0)"
                },
                {
                  "name": "Accept",
                  "value": "text/html,application/xhtml+xml"
                }
              ]
            }
          },
          "retryOnFail": true,
          "typeVersion": 4.4
        },
        {
          "id": "36624626-107e-4bfb-9611-8851afcd2777",
          "name": "Code: Extract Page Text",
          "type": "n8n-nodes-base.code",
          "position": [
            -208,
            448
          ],
          "parameters": {
            "jsCode": "const article =\n  $(\"IF: Excerpt Missing?\").item.json;\n\nconst response = $json ?? {};\n\nfunction scalar(value) {\n  if (Array.isArray(value)) {\n    return scalar(value[0]);\n  }\n\n  if (\n    value !== null &&\n    typeof value === \"object\"\n  ) {\n    return value._ ?? value[\"#text\"] ?? \"\";\n  }\n\n  return value;\n}\n\nfunction decodeBasicHtmlEntities(value = \"\") {\n  return String(value)\n    .replace(/&nbsp;/gi, \" \")\n    .replace(/&amp;/gi, \"&\")\n    .replace(/&quot;/gi, '\"')\n    .replace(/&#39;/gi, \"'\")\n    .replace(/&apos;/gi, \"'\")\n    .replace(/&lt;/gi, \"<\")\n    .replace(/&gt;/gi, \">\");\n}\n\nfunction htmlToText(value = \"\") {\n  const html = String(\n    scalar(value) ?? \"\"\n  );\n\n  return decodeBasicHtmlEntities(html)\n    .replace(/<!--[\\s\\S]*?-->/g, \" \")\n    .replace(\n      /<script[\\s\\S]*?<\\/script>/gi,\n      \" \"\n    )\n    .replace(\n      /<style[\\s\\S]*?<\\/style>/gi,\n      \" \"\n    )\n    .replace(\n      /<noscript[\\s\\S]*?<\\/noscript>/gi,\n      \" \"\n    )\n    .replace(\n      /<svg[\\s\\S]*?<\\/svg>/gi,\n      \" \"\n    )\n    .replace(\n      /<nav[\\s\\S]*?<\\/nav>/gi,\n      \" \"\n    )\n    .replace(\n      /<footer[\\s\\S]*?<\\/footer>/gi,\n      \" \"\n    )\n    .replace(\n      /<header[\\s\\S]*?<\\/header>/gi,\n      \" \"\n    )\n    .replace(\n      /<form[\\s\\S]*?<\\/form>/gi,\n      \" \"\n    )\n    .replace(\n      /<aside[\\s\\S]*?<\\/aside>/gi,\n      \" \"\n    )\n    .replace(/<br\\s*\\/?>/gi, \"\\n\")\n    .replace(/<\\/p>/gi, \"\\n\")\n    .replace(/<\\/article>/gi, \"\\n\")\n    .replace(/<\\/section>/gi, \"\\n\")\n    .replace(/<[^>]+>/g, \" \")\n    .replace(/\\s+/g, \" \")\n    .trim();\n}\n\nconst rawHtml = String(\n  response.body ??\n  response.data ??\n  response.response ??\n  response.text ??\n  \"\"\n);\n\nconst statusCode = Number(\n  response.statusCode ??\n  response.status ??\n  0\n);\n\nconst pageText = htmlToText(rawHtml);\n\nconst maximumPageCharacters = 18000;\n\nconst trimmedPageText =\n  pageText.slice(\n    0,\n    maximumPageCharacters\n  );\n\nconst minimumPageCharacters = 200;\n\nconst fetchSucceeded =\n  trimmedPageText.length >=\n  minimumPageCharacters &&\n  (\n    statusCode === 0 ||\n    (\n      statusCode >= 200 &&\n      statusCode < 400\n    )\n  );\n\nreturn {\n  json: {\n    article,\n\n    fetch: {\n      succeeded: fetchSucceeded,\n      statusCode:\n        statusCode || null,\n      retrievedAt:\n        new Date().toISOString(),\n      pageCharacterCount:\n        pageText.length,\n      submittedCharacterCount:\n        trimmedPageText.length,\n    },\n\n    pageText:\n      trimmedPageText,\n  },\n};"
          },
          "typeVersion": 2
        },
        {
          "id": "9b677600-ca02-4bd0-afde-32e2a2876a7b",
          "name": "IF: Page Text Available?",
          "type": "n8n-nodes-base.if",
          "position": [
            16,
            448
          ],
          "parameters": {
            "options": {},
            "conditions": {
              "options": {
                "version": 3,
                "leftValue": "",
                "caseSensitive": true,
                "typeValidation": "strict"
              },
              "combinator": "and",
              "conditions": [
                {
                  "id": "fe24400c-5106-4b4c-a53a-eb1f7d2229a4",
                  "operator": {
                    "type": "boolean",
                    "operation": "true",
                    "singleValue": true
                  },
                  "leftValue": "={{ $json.fetch.succeeded }}",
                  "rightValue": ""
                }
              ]
            }
          },
          "typeVersion": 2.3
        },
        {
          "id": "19f41c91-9d68-489c-ae2d-fd6a0ff34d60",
          "name": "Code: Apply Generated Excerpt",
          "type": "n8n-nodes-base.code",
          "position": [
            592,
            544
          ],
          "parameters": {
            "jsCode": "const config =\n  $(\"Workflow Config\").first().json;\n\nconst minimumExcerptCharacters =\n  Number(\n    config.minimumExcerptCharacters ??\n    30\n  );\n\nconst maxExcerptCharacters =\n  Number(\n    config.maxExcerptCharacters ??\n    4000\n  );\n\nfunction cleanText(value = \"\") {\n  return String(value ?? \"\")\n    .replace(/\\s+/g, \" \")\n    .trim();\n}\n\nfunction stripCodeFence(value = \"\") {\n  return String(value ?? \"\")\n    .replace(/^```json\\s*/i, \"\")\n    .replace(/^```\\s*/i, \"\")\n    .replace(/\\s*```$/i, \"\")\n    .trim();\n}\n\nfunction findOutputText(data) {\n  if (\n    !data ||\n    typeof data !== \"object\"\n  ) {\n    return \"\";\n  }\n\n  if (\n    typeof data.output_text ===\n    \"string\"\n  ) {\n    return data.output_text;\n  }\n\n  if (\n    typeof data.text ===\n    \"string\"\n  ) {\n    return data.text;\n  }\n\n  if (\n    typeof data.content ===\n    \"string\"\n  ) {\n    return data.content;\n  }\n\n  if (\n    typeof data.message?.content ===\n    \"string\"\n  ) {\n    return data.message.content;\n  }\n\n  if (\n    typeof data.excerpt ===\n    \"string\"\n  ) {\n    return JSON.stringify({\n      excerpt: data.excerpt,\n      confidence:\n        data.confidence ?? \"high\",\n    });\n  }\n\n  if (\n    Array.isArray(data.output)\n  ) {\n    for (\n      const outputItem of data.output\n    ) {\n      const contentItems =\n        Array.isArray(\n          outputItem?.content\n        )\n          ? outputItem.content\n          : [];\n\n      for (\n        const contentItem of\n        contentItems\n      ) {\n        if (\n          contentItem?.type ===\n            \"output_text\" &&\n          typeof contentItem.text ===\n            \"string\"\n        ) {\n          return contentItem.text;\n        }\n\n        if (\n          typeof contentItem?.text ===\n            \"string\"\n        ) {\n          return contentItem.text;\n        }\n      }\n    }\n  }\n\n  return \"\";\n}\n\nfunction findArticle() {\n  if (\n    $json.article &&\n    typeof $json.article === \"object\"\n  ) {\n    return $json.article;\n  }\n\n  try {\n    const extraction =\n      $(\"Code: Extract Page Text\")\n        .item.json;\n\n    if (\n      extraction?.article &&\n      typeof extraction.article ===\n        \"object\"\n    ) {\n      return extraction.article;\n    }\n  } catch {}\n\n  try {\n    const missingExcerptItem =\n      $(\"IF: Excerpt Missing?\")\n        .item.json;\n\n    if (\n      missingExcerptItem &&\n      typeof missingExcerptItem ===\n        \"object\"\n    ) {\n      return missingExcerptItem;\n    }\n  } catch {}\n\n  throw new Error(\n    \"Could not find the original article. Pass the article object into this node as $json.article.\"\n  );\n}\n\nfunction findFetchMetadata() {\n  if (\n    $json.fetch &&\n    typeof $json.fetch === \"object\"\n  ) {\n    return $json.fetch;\n  }\n\n  try {\n    const extraction =\n      $(\"Code: Extract Page Text\")\n        .item.json;\n\n    if (\n      extraction?.fetch &&\n      typeof extraction.fetch ===\n        \"object\"\n    ) {\n      return extraction.fetch;\n    }\n  } catch {}\n\n  return {};\n}\n\nfunction normalizeConfidence(value) {\n  const normalized =\n    cleanText(value).toLowerCase();\n\n  if (\n    [\n      \"high\",\n      \"medium\",\n      \"low\",\n    ].includes(normalized)\n  ) {\n    return normalized;\n  }\n\n  return \"low\";\n}\n\nconst article =\n  findArticle();\n\nconst fetchMetadata =\n  findFetchMetadata();\n\nconst rawOutput =\n  stripCodeFence(\n    findOutputText($json)\n  );\n\nlet generatedExcerpt = \"\";\nlet confidence = \"low\";\nlet matchedOriginalArticle = null;\nlet sourceFound = null;\nlet parseSucceeded = false;\n\nif (rawOutput) {\n  try {\n    const parsed =\n      JSON.parse(rawOutput);\n\n    generatedExcerpt =\n      cleanText(\n        parsed.excerpt\n      );\n\n    confidence =\n      normalizeConfidence(\n        parsed.confidence ?? \"low\"\n      );\n\n    matchedOriginalArticle =\n      typeof parsed.matchedOriginalArticle ===\n      \"boolean\"\n        ? parsed.matchedOriginalArticle\n        : null;\n\n    sourceFound =\n      typeof parsed.sourceFound ===\n      \"boolean\"\n        ? parsed.sourceFound\n        : null;\n\n    parseSucceeded = true;\n  } catch {\n    generatedExcerpt =\n      cleanText(rawOutput);\n\n    confidence =\n      generatedExcerpt\n        ? \"high\"\n        : \"low\";\n\n    parseSucceeded = false;\n  }\n}\n\ngeneratedExcerpt =\n  generatedExcerpt.slice(\n    0,\n    maxExcerptCharacters\n  );\n\nconst generationSource =\n  fetchMetadata.succeeded === true\n    ? \"page\"\n    : \"web_search\";\n\nconst confidenceIsUsable =\n  generationSource === \"page\"\n    ? [\"high\", \"medium\"].includes(\n        confidence\n      )\n    : confidence === \"high\";\n\nconst articleMatchIsUsable =\n  generationSource === \"page\"\n    ? true\n    : matchedOriginalArticle !== false;\n\nconst sourceWasFound =\n  generationSource === \"page\"\n    ? true\n    : sourceFound !== false;\n\nconst generatedExcerptIsUsable =\n  generatedExcerpt.length >=\n    minimumExcerptCharacters &&\n  confidenceIsUsable &&\n  articleMatchIsUsable &&\n  sourceWasFound;\n\nconst existingExcerpt =\n  cleanText(article.excerpt)\n    .slice(\n      0,\n      maxExcerptCharacters\n    );\n\nconst finalExcerpt =\n  generatedExcerptIsUsable\n    ? generatedExcerpt\n    : existingExcerpt;\n\nconst stillNeedsExcerpt =\n  finalExcerpt.length <\n  minimumExcerptCharacters;\n\nreturn {\n  json: {\n    ...article,\n\n    excerpt:\n      finalExcerpt,\n\n    needsExcerpt:\n      stillNeedsExcerpt,\n\n    excerptMetadata: {\n      generatedByOpenAI:\n        generatedExcerptIsUsable,\n\n      generationAttempted:\n        true,\n\n      generationSource,\n\n      generationConfidence:\n        confidence,\n\n      outputParseSucceeded:\n        parseSucceeded,\n\n      matchedOriginalArticle,\n\n      sourceFound,\n\n      generatedAt:\n        generatedExcerptIsUsable\n          ? new Date().toISOString()\n          : null,\n\n      fetchSucceeded:\n        fetchMetadata.succeeded ===\n        true,\n\n      fetchStatusCode:\n        fetchMetadata.statusCode ??\n        null,\n\n      blockedByChallenge:\n        fetchMetadata\n          .blockedByChallenge ===\n        true,\n\n      pageCharacterCount:\n        fetchMetadata\n          .pageCharacterCount ?? 0,\n\n      submittedCharacterCount:\n        fetchMetadata\n          .submittedCharacterCount ??\n        0,\n\n      generatedExcerptCharacterCount:\n        generatedExcerpt.length,\n\n      finalExcerptCharacterCount:\n        finalExcerpt.length,\n\n      fallbackToExistingExcerpt:\n        !generatedExcerptIsUsable &&\n        existingExcerpt.length > 0,\n\n      failureReason:\n        generatedExcerptIsUsable\n          ? null\n          : !rawOutput\n            ? \"The model returned no output.\"\n            : generatedExcerpt.length <\n                minimumExcerptCharacters\n              ? \"The generated excerpt was too short.\"\n              : !confidenceIsUsable\n                ? `The model confidence was ${confidence}.`\n                : !articleMatchIsUsable\n                  ? \"The web-search result did not confidently match the original article.\"\n                  : !sourceWasFound\n                    ? \"The model could not find a reliable source.\"\n                    : \"The generated excerpt failed validation.\",\n    },\n  },\n};"
          },
          "typeVersion": 2
        },
        {
          "id": "5b397977-47d8-4649-9fa8-1be262c1b0c7",
          "name": "Code: Aggregate Articles",
          "type": "n8n-nodes-base.code",
          "position": [
            -1008,
            256
          ],
          "parameters": {
            "jsCode": "const config = $(\"Workflow Config\").first().json;\n\n/*\nConfiguration\n*/\nconst lookbackDays = Number(\n  config.lookbackDays ?? 8\n);\n\nconst minimumExcerptCharacters = Number(\n  config.minimumExcerptCharacters ?? 30\n);\n\nif (\n  !Number.isFinite(lookbackDays) ||\n  lookbackDays < 1\n) {\n  throw new Error(\n    `\"lookbackDays\" must be a positive number. Received: ${config.lookbackDays}`\n  );\n}\n\nif (\n  !Number.isFinite(minimumExcerptCharacters) ||\n  minimumExcerptCharacters < 0\n) {\n  throw new Error(\n    `\"minimumExcerptCharacters\" must be zero or greater. Received: ${config.minimumExcerptCharacters}`\n  );\n}\n\n/*\nHelpers\n*/\nfunction cleanText(value = \"\") {\n  return String(value ?? \"\")\n    .replace(/\\s+/g, \" \")\n    .trim();\n}\n\nfunction parseDate(value) {\n  const date = new Date(value);\n\n  if (Number.isNaN(date.getTime())) {\n    return null;\n  }\n\n  return date;\n}\n\nfunction normalizeCategories(value) {\n  if (!value) {\n    return [];\n  }\n\n  const categories = Array.isArray(value)\n    ? value\n    : [value];\n\n  return [\n    ...new Set(\n      categories\n        .map((category) => cleanText(category))\n        .filter(Boolean)\n    ),\n  ];\n}\n\n/*\nCollect all articles from the merged branches.\n*/\nconst inputItems = $input.all();\n\nconst articles = inputItems\n  .map((item) => {\n    const article = item.json ?? {};\n\n    const excerpt = cleanText(\n      article.excerpt\n    );\n\n    const publishedDate = parseDate(\n      article.publishedAt\n    );\n\n    return {\n      articleId:\n        cleanText(article.articleId) ||\n        cleanText(article.url),\n\n      title:\n        cleanText(article.title),\n\n      url:\n        cleanText(article.url),\n\n      publishedAt:\n        publishedDate\n          ? publishedDate.toISOString()\n          : cleanText(article.publishedAt),\n\n      excerpt,\n\n      needsExcerpt:\n        excerpt.length <\n        minimumExcerptCharacters,\n\n      source:\n        cleanText(article.source) ||\n        \"Unknown\",\n\n      categories:\n        normalizeCategories(\n          article.categories\n        ),\n\n      excerptMetadata:\n        article.excerptMetadata ?? {\n          generatedByOpenAI: false,\n          generationAttempted: false,\n          source: \"rss\",\n        },\n\n      preparationMetadata:\n        article.preparationMetadata ?? null,\n    };\n  })\n  .filter((article) => {\n    return Boolean(\n      article.articleId &&\n      article.title &&\n      article.url\n    );\n  });\n\n/*\nDeduplicate again after merging.\n\nThis protects against accidental branch duplication\nor Merge-node configuration mistakes.\n*/\nconst seenArticleIds = new Set();\nconst uniqueArticles = [];\n\nfor (const article of articles) {\n  const articleKey =\n    article.articleId.toLowerCase();\n\n  if (seenArticleIds.has(articleKey)) {\n    continue;\n  }\n\n  seenArticleIds.add(articleKey);\n  uniqueArticles.push(article);\n}\n\n/*\nSort newest articles first.\n*/\nuniqueArticles.sort((a, b) => {\n  const dateA =\n    parseDate(a.publishedAt)?.getTime() ?? 0;\n\n  const dateB =\n    parseDate(b.publishedAt)?.getTime() ?? 0;\n\n  return dateB - dateA;\n});\n\n/*\nSource metadata\n*/\nconst sources = [\n  ...new Set(\n    uniqueArticles\n      .map((article) => article.source)\n      .filter(Boolean)\n  ),\n];\n\n/*\nExcerpt enrichment metadata\n*/\nconst generatedExcerptCount =\n  uniqueArticles.filter((article) => {\n    return (\n      article.excerptMetadata\n        ?.generatedByOpenAI === true\n    );\n  }).length;\n\nconst existingExcerptCount =\n  uniqueArticles.filter((article) => {\n    return (\n      article.needsExcerpt === false &&\n      article.excerptMetadata\n        ?.generatedByOpenAI !== true\n    );\n  }).length;\n\nconst missingExcerptCount =\n  uniqueArticles.filter((article) => {\n    return article.needsExcerpt === true;\n  }).length;\n\nconst fetchFailureCount =\n  uniqueArticles.filter((article) => {\n    return (\n      article.excerptMetadata\n        ?.fetchSucceeded === false\n    );\n  }).length;\n\n/*\nReporting period\n*/\nconst now = new Date();\n\nconst periodFrom = new Date(now);\nperiodFrom.setUTCDate(\n  periodFrom.getUTCDate() - lookbackDays\n);\n\n/*\nReturn one aggregate item.\n\nThis output is consumed by:\n- IF Has Articles\n- OpenAI digest generation\n- empty-digest formatting\n*/\nreturn [\n  {\n    json: {\n      generatedAt:\n        now.toISOString(),\n\n      period: {\n        from:\n          periodFrom.toISOString(),\n\n        to:\n          now.toISOString(),\n      },\n\n      articleCount:\n        uniqueArticles.length,\n\n      sourceCount:\n        sources.length,\n\n      sources,\n\n      articles:\n        uniqueArticles,\n\n      enrichment: {\n        existingExcerptCount,\n        generatedExcerptCount,\n        missingExcerptCount,\n        fetchFailureCount,\n      },\n\n      processing: {\n        mergedInputCount:\n          inputItems.length,\n\n        uniqueArticleCount:\n          uniqueArticles.length,\n\n        duplicateCount:\n          inputItems.length -\n          uniqueArticles.length,\n      },\n    },\n  },\n];"
          },
          "typeVersion": 2
        },
        {
          "id": "d3a2faa3-2be1-4328-bb54-b96aed30a394",
          "name": "Loop Over Items",
          "type": "n8n-nodes-base.splitInBatches",
          "position": [
            -1232,
            608
          ],
          "parameters": {
            "options": {}
          },
          "typeVersion": 3
        },
        {
          "id": "6281cd20-fef0-42f5-b972-2e30290f1cd9",
          "name": "Code: Mark Existing Excerpt",
          "type": "n8n-nodes-base.code",
          "position": [
            -720,
            640
          ],
          "parameters": {
            "jsCode": "return {\n  json: {\n    ...$json,\n\n    needsExcerpt: false,\n\n    excerptMetadata: {\n      generatedByOpenAI: false,\n      generationAttempted: false,\n      generatedAt: null,\n      fetchSucceeded: null,\n      source: \"rss\",\n    },\n  },\n};"
          },
          "typeVersion": 2
        },
        {
          "id": "5a955f7e-69d0-4171-b5b2-627d7cfdea3b",
          "name": "Wait",
          "type": "n8n-nodes-base.wait",
          "position": [
            -720,
            448
          ],
          "webhookId": "74eb1113-2881-4d78-a20a-1a90236a83e0",
          "parameters": {
            "amount": 2
          },
          "typeVersion": 1.1
        },
        {
          "id": "4a3724fd-ef86-466c-aced-c2005e877929",
          "name": "Schedule Trigger",
          "type": "n8n-nodes-base.scheduleTrigger",
          "position": [
            -3024,
            368
          ],
          "parameters": {
            "rule": {
              "interval": [
                {
                  "field": "weeks",
                  "triggerAtDay": [
                    1
                  ],
                  "triggerAtHour": 9
                }
              ]
            }
          },
          "typeVersion": 1.3
        },
        {
          "id": "e728c873-f552-4777-b940-c3d872024c97",
          "name": "IF Has Articles?",
          "type": "n8n-nodes-base.if",
          "position": [
            -1456,
            368
          ],
          "parameters": {
            "options": {},
            "conditions": {
              "options": {
                "version": 3,
                "leftValue": "",
                "caseSensitive": true,
                "typeValidation": "strict"
              },
              "combinator": "and",
              "conditions": [
                {
                  "id": "c1b00f20-8340-462c-9c7c-250720ab8e3b",
                  "operator": {
                    "type": "number",
                    "operation": "gt"
                  },
                  "leftValue": "={{$items().length}}",
                  "rightValue": 0
                }
              ]
            }
          },
          "typeVersion": 2.3
        },
        {
          "id": "5cb4c94b-6ec9-44aa-aa61-8d737c573d14",
          "name": "Summary Generator",
          "type": "@n8n/n8n-nodes-langchain.openAi",
          "position": [
            -784,
            256
          ],
          "parameters": {
            "modelId": {
              "__rl": true,
              "mode": "list",
              "value": "gpt-5-mini",
              "cachedResultName": "GPT-5-MINI"
            },
            "options": {},
            "responses": {
              "values": [
                {
                  "role": "system",
                  "content": "You are an editor producing a concise weekly AI and automation news digest.\n\nUse only the supplied article data. Identify the most important developments and synthesize them into one cohesive digest containing 3 to 5 sentences total.\n\nDo not present individual takeaways as numbered items or separate sections.\n\nFor supporting sources, return only exact articleId values copied from the supplied data. Never invent or modify an articleId.\n\nReturn valid JSON only using the exact schema requested. Do not return Markdown, HTML, commentary, or code fences."
                },
                {
                  "content": "={{\n`Create a concise weekly AI and automation news digest from the combined articles below.\n\nYour task:\n- Identify the 3 to 5 most important takeaways across all supplied articles.\n- Combine those takeaways into one cohesive digest consisting of 3 to 5 sentences total.\n- Do not create separate sections or numbered takeaways.\n- Merge duplicate or closely related stories.\n- Focus on major developments, business impact, safety, governance, product launches, and practical adoption.\n- Write for a busy business and technology audience.\n- Keep the writing direct, concise, and executive-friendly.\n- Avoid repetition and unnecessary detail.\n\nSource requirements:\n- Select 5 to 8 articles that directly support the digest.\n- For every selected source, return only its exact articleId.\n- Copy the articleId exactly as it appears in the supplied article data.\n- Never invent, shorten, rewrite, or modify an articleId.\n- Do not return duplicate articleIds.\n- Use only articleIds from the supplied input.\n- Select sources that collectively support the major themes discussed in the digest.\n\nAccuracy rules:\n- Use only information contained in the supplied articles.\n- Do not invent facts, products, announcements, statistics, dates, companies, or implications.\n- Do not include Markdown, HTML, commentary, or code fences.\n- Return valid JSON only.\n\nReturn exactly this structure:\n\n{\n  \"digestTitle\": \"AI & Automation Weekly Digest\",\n  \"digest\": \"A cohesive digest containing 3 to 5 sentences.\",\n  \"sources\": [\n    {\n      \"articleId\": \"Exact articleId from the input\",\n      \"publisher\": \"Exact publisher from the input\",\n      \"title\": \"Exact article title from the input\",\n      \"url\": \"Exact URL from the input\"\n    }\n  ]\n}\n\nReporting period:\n${$json.period.from} to ${$json.period.to}\n\nNumber of articles reviewed:\n${$json.articleCount}\n\nPublishers:\n${$json.sources.join(\", \")}\n\nCombined articles:\n${JSON.stringify($json.articles, null, 2)}`\n}}"
                }
              ]
            },
            "builtInTools": {}
          },
          "credentials": {
            "openAiApi": {
              "id": null,
              "name": "",
              "__aiGatewayManaged": true
            }
          },
          "typeVersion": 2.3
        },
        {
          "id": "2921f0c3-2eb9-4110-ad79-c1f990c58d2e",
          "name": "(Web) Generate Excerpt",
          "type": "@n8n/n8n-nodes-langchain.openAi",
          "position": [
            240,
            544
          ],
          "parameters": {
            "modelId": {
              "__rl": true,
              "mode": "list",
              "value": "gpt-5-mini",
              "cachedResultName": "GPT-5-MINI"
            },
            "options": {},
            "responses": {
              "values": [
                {
                  "role": "system",
                  "content": "You are helping generate missing excerpts for an AI news digest.\n\nYour task is to identify the article described by the supplied title, publisher and URL.\n\nIf you can confidently identify the article, produce a concise factual excerpt.\n\nIf you cannot confidently identify it, return:\n\n{\n  \"excerpt\": \"\",\n  \"confidence\":\"low\"\n}\n\nReturn valid JSON only."
                },
                {
                  "content": "={{\n`Generate an excerpt for this article.\n\nTitle:\n${$json.article.title}\n\nPublisher:\n${$json.article.source}\n\nOriginal URL:\n${$json.article.url}\n\nInstructions:\n\n- Prefer information from the original article.\n- If the page cannot be accessed, use trustworthy coverage describing the same announcement.\n- Do not invent information.\n- Write 2–3 concise sentences.\n- Keep between 250 and 700 characters.\n\nReturn exactly:\n\n{\n  \"excerpt\":\"...\",\n  \"confidence\":\"high\"\n}`\n}}"
                }
              ]
            },
            "builtInTools": {
              "webSearch": {
                "searchContextSize": "medium"
              }
            }
          },
          "credentials": {
            "openAiApi": {
              "id": null,
              "name": "",
              "__aiGatewayManaged": true
            }
          },
          "typeVersion": 2.3
        },
        {
          "id": "c3edeaf4-c238-4998-86c1-4a080a0454ff",
          "name": "Generate Excerpt",
          "type": "@n8n/n8n-nodes-langchain.openAi",
          "position": [
            240,
            352
          ],
          "parameters": {
            "modelId": {
              "__rl": true,
              "mode": "list",
              "value": "gpt-5-mini",
              "cachedResultName": "GPT-5-MINI"
            },
            "options": {},
            "responses": {
              "values": [
                {
                  "role": "system",
                  "content": "You create concise factual excerpts for articles in an AI and automation news digest.\n\nUse only the supplied title, publisher, and page text.\n\nDo not invent information.\nDo not include markdown, headings, bullets, URLs, or quotation marks.\nReturn valid JSON only."
                },
                {
                  "content": "={{\n`Create a concise excerpt for this article.\n\nTitle:\n${$json.article.title}\n\nPublisher:\n${$json.article.source}\n\nArticle text:\n${$json.pageText}\n\nRequirements:\n- Write 2 or 3 sentences.\n- Explain the main development or announcement.\n- Preserve important names, products, companies, and technical terms.\n- Use neutral, factual language.\n- Keep the excerpt between 250 and 700 characters.\n- Do not mention the page, source text, or summarization process.\n\nReturn exactly this JSON structure:\n\n{\n  \"excerpt\": \"Generated excerpt\"\n}`\n}}"
                }
              ]
            },
            "builtInTools": {}
          },
          "credentials": {
            "openAiApi": {
              "id": null,
              "name": "",
              "__aiGatewayManaged": true
            }
          },
          "typeVersion": 2.3
        }
      ],
      "active": false,
      "pinData": {},
      "settings": {
        "binaryMode": "separate",
        "availableInMCP": false,
        "executionOrder": "v1"
      },
      "versionId": "7cd7e8ae-269f-4e81-ac2a-7e71fa5944ca",
      "nodeGroups": [],
      "connections": {
        "Wait": {
          "main": [
            [
              {
                "node": "HTTP Request",
                "type": "main",
                "index": 0
              }
            ]
          ]
        },
        "HTTP Request": {
          "main": [
            [
              {
                "node": "Code: Extract Page Text",
                "type": "main",
                "index": 0
              }
            ]
          ]
        },
        "Send a message": {
          "main": [
            [
              {
                "node": "No Operation, do nothing",
                "type": "main",
                "index": 0
              }
            ]
          ]
        },
        "Loop Over Items": {
          "main": [
            [
              {
                "node": "Code: Aggregate Articles",
                "type": "main",
                "index": 0
              }
            ],
            [
              {
                "node": "IF: Excerpt Missing?",
                "type": "main",
                "index": 0
              }
            ]
          ]
        },
        "Workflow Config": {
          "main": [
            [
              {
                "node": "Fetch OpenAI RSS",
                "type": "main",
                "index": 0
              },
              {
                "node": "Fetch Google RSS",
                "type": "main",
                "index": 0
              }
            ]
          ]
        },
        "Fetch Google RSS": {
          "main": [
            [
              {
                "node": "XML: Parse Google Feed",
                "type": "main",
                "index": 0
              }
            ]
          ]
        },
        "Fetch OpenAI RSS": {
          "main": [
            [
              {
                "node": "XML: Parse OpenAI Feed",
                "type": "main",
                "index": 0
              }
            ]
          ]
        },
        "Generate Excerpt": {
          "main": [
            [
              {
                "node": "Code: Apply Generated Excerpt",
                "type": "main",
                "index": 0
              }
            ]
          ]
        },
        "IF Has Articles?": {
          "main": [
            [
              {
                "node": "Loop Over Items",
                "type": "main",
                "index": 0
              }
            ],
            [
              {
                "node": "If: Send Empty Digest?",
                "type": "main",
                "index": 0
              }
            ]
          ]
        },
        "Schedule Trigger": {
          "main": [
            [
              {
                "node": "Workflow Config",
                "type": "main",
                "index": 0
              }
            ]
          ]
        },
        "Summary Generator": {
          "main": [
            [
              {
                "node": "Code: Format LLM Output",
                "type": "main",
                "index": 0
              }
            ]
          ]
        },
        "IF: Excerpt Missing?": {
          "main": [
            [
              {
                "node": "Wait",
                "type": "main",
                "index": 0
              }
            ],
            [
              {
                "node": "Code: Mark Existing Excerpt",
                "type": "main",
                "index": 0
              }
            ]
          ]
        },
        "(Web) Generate Excerpt": {
          "main": [
            [
              {
                "node": "Code: Apply Generated Excerpt",
                "type": "main",
                "index": 0
              }
            ]
          ]
        },
        "If: Send Empty Digest?": {
          "main": [
            [
              {
                "node": "Code: Format Empty Digest",
                "type": "main",
                "index": 0
              }
            ],
            [
              {
                "node": "No Operation, do nothing",
                "type": "main",
                "index": 0
              }
            ]
          ]
        },
        "XML: Parse Google Feed": {
          "main": [
            [
              {
                "node": "Code: Extract + Filter Google Articles",
                "type": "main",
                "index": 0
              }
            ]
          ]
        },
        "XML: Parse OpenAI Feed": {
          "main": [
            [
              {
                "node": "Code: Extract OpenAI Articles",
                "type": "main",
                "index": 0
              }
            ]
          ]
        },
        "Code: Extract Page Text": {
          "main": [
            [
              {
                "node": "IF: Page Text Available?",
                "type": "main",
                "index": 0
              }
            ]
          ]
        },
        "Code: Format LLM Output": {
          "main": [
            [
              {
                "node": "Send a message",
                "type": "main",
                "index": 0
              }
            ]
          ]
        },
        "Code: Aggregate Articles": {
          "main": [
            [
              {
                "node": "Summary Generator",
                "type": "main",
                "index": 0
              }
            ]
          ]
        },
        "IF: Page Text Available?": {
          "main": [
            [
              {
                "node": "Generate Excerpt",
                "type": "main",
                "index": 0
              }
            ],
            [
              {
                "node": "(Web) Generate Excerpt",
                "type": "main",
                "index": 0
              }
            ]
          ]
        },
        "Code: Format Empty Digest": {
          "main": [
            [
              {
                "node": "Send a message",
                "type": "main",
                "index": 0
              }
            ]
          ]
        },
        "Code: Prepare DIgest Data": {
          "main": [
            [
              {
                "node": "IF Has Articles?",
                "type": "main",
                "index": 0
              }
            ]
          ]
        },
        "Code: Mark Existing Excerpt": {
          "main": [
            [
              {
                "node": "Loop Over Items",
                "type": "main",
                "index": 0
              }
            ]
          ]
        },
        "Code: Apply Generated Excerpt": {
          "main": [
            [
              {
                "node": "Loop Over Items",
                "type": "main",
                "index": 0
              }
            ]
          ]
        },
        "Code: Extract OpenAI Articles": {
          "main": [
            [
              {
                "node": "Merge Google and OpenAI articles",
                "type": "main",
                "index": 0
              }
            ]
          ]
        },
        "Merge Google and OpenAI articles": {
          "main": [
            [
              {
                "node": "Code: Prepare DIgest Data",
                "type": "main",
                "index": 0
              }
            ]
          ]
        },
        "Code: Extract + Filter Google Articles": {
          "main": [
            [
              {
                "node": "Merge Google and OpenAI articles",
                "type": "main",
                "index": 1
              }
            ]
          ]
        }
      }
    }