{
  "meta": {
    "templateCredsSetupCompleted": false
  },
  "name": "N8N-AIS-Florida Insurance Digest",
  "nodes": [
    {
      "id": "3e311ca0-370b-44b9-b736-70911f389e19",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -2176,
        -528
      ],
      "parameters": {
        "width": 480,
        "height": 896,
        "content": "## N8N-AIS-Florida Insurance Digest\n\n### How it works\n\n1. The workflow is triggered on a schedule. \n2. It fetches RSS feeds and normalizes the data. \n3. Filters out articles that have already been seen. \n4. Uses AI to analyze batches of new articles.\n5. Builds an HTML newsletter and sends it to valid email subscribers. \n6. Updates the seen hashes with information on sent articles.\n\n### Setup steps\n\n- [ ] Configure RSS feed URLs in the 'Fetch' nodes.\n- [ ] Set up access credentials for SharePoint and Microsoft Excel nodes for seen hashes handling.\n- [ ] Ensure Microsoft Outlook node is configured for sending emails.\n- [ ] Configure AI settings in the 'Basic LLM Chain' node.\n\n### Customization\n\nAdjust RSS feed URLs and the AI model settings to tune the types of articles processed and the analysis details."
      },
      "typeVersion": 1
    },
    {
      "id": "2ee8686b-432a-4267-90a5-b295dd2f341b",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1616,
        -352
      ],
      "parameters": {
        "color": 7,
        "width": 480,
        "height": 304,
        "content": "## Initial setup and trigger\n\nStarts workflow on schedule and loads seen article hashes."
      },
      "typeVersion": 1
    },
    {
      "id": "a8448c73-012d-49fa-b283-2952a9072ec5",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1104,
        -352
      ],
      "parameters": {
        "color": 7,
        "width": 240,
        "height": 304,
        "content": "## Prepare seen set\n\nBuilds a set of seen hashes for fast lookup."
      },
      "typeVersion": 1
    },
    {
      "id": "32d28fbe-0148-432e-aa13-7bc52c58ed7b",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -832,
        -496
      ],
      "parameters": {
        "color": 7,
        "width": 240,
        "height": 880,
        "content": "## Fetch RSS feeds\n\nFetches articles from different RSS sources."
      },
      "typeVersion": 1
    },
    {
      "id": "a899056a-d24c-4817-ab6b-6fc7ec1c8998",
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -560,
        -528
      ],
      "parameters": {
        "color": 7,
        "width": 240,
        "height": 896,
        "content": "## Normalize article data\n\nApplies custom code to normalize fetched article data."
      },
      "typeVersion": 1
    },
    {
      "id": "3c3b8f69-73c1-4e28-abe4-3920f0fc4407",
      "name": "Sticky Note5",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -272,
        -368
      ],
      "parameters": {
        "color": 7,
        "width": 240,
        "height": 400,
        "content": "## Merge articles\n\nMerges all normalized articles into a single batch."
      },
      "typeVersion": 1
    },
    {
      "id": "5fd456d1-6d26-481b-9c50-4c88880a9963",
      "name": "Sticky Note6",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        16,
        -320
      ],
      "parameters": {
        "color": 7,
        "width": 400,
        "height": 304,
        "content": "## Filter and check relevance\n\nDrops already seen articles and filters relevant ones."
      },
      "typeVersion": 1
    },
    {
      "id": "29c999c3-0d90-45de-83b5-3c9d546b1a45",
      "name": "Sticky Note7",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        448,
        -320
      ],
      "parameters": {
        "color": 7,
        "width": 928,
        "height": 480,
        "content": "## Batch processing\n\nSplits articles into batches and processes them using AI."
      },
      "typeVersion": 1
    },
    {
      "id": "67a474eb-1857-4b08-b07f-9480e47af025",
      "name": "Sticky Note8",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1408,
        -416
      ],
      "parameters": {
        "color": 7,
        "width": 848,
        "height": 384,
        "content": "## Build and send newsletter\n\nBuilds HTML newsletter and sends it out."
      },
      "typeVersion": 1
    },
    {
      "id": "a268ae4c-cf55-4cf0-af15-162ea40f897e",
      "name": "Sticky Note9",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1952,
        0
      ],
      "parameters": {
        "color": 7,
        "width": 384,
        "height": 304,
        "content": "## Update seen hashes\n\nAppends hashes of sent articles to the seen set."
      },
      "typeVersion": 1
    },
    {
      "id": "schedule-trigger",
      "name": "When Daily at 4am",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        -1568,
        -224
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "triggerAtHour": 4
            }
          ]
        }
      },
      "typeVersion": 1.3
    },
    {
      "id": "fetch-ij",
      "name": "Fetch Insurance Journal Feed",
      "type": "n8n-nodes-base.rssFeedRead",
      "position": [
        -784,
        -368
      ],
      "parameters": {
        "url": "https://www.insurancejournal.com/rss/news",
        "options": {
          "ignoreSSL": false
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "fetch-cj",
      "name": "Fetch Claims Journal Feed",
      "type": "n8n-nodes-base.rssFeedRead",
      "position": [
        -784,
        -224
      ],
      "parameters": {
        "url": "https://www.claimsjournal.com/rss/news",
        "options": {
          "ignoreSSL": false
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "fetch-picl",
      "name": "Fetch Property Insurance Blog Feed",
      "type": "n8n-nodes-base.rssFeedRead",
      "position": [
        -784,
        64
      ],
      "parameters": {
        "url": "https://www.propertyinsurancecoveragelaw.com/feed",
        "options": {
          "ignoreSSL": false
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "normalize-ij",
      "name": "Normalize Insurance Journal Data",
      "type": "n8n-nodes-base.code",
      "position": [
        -512,
        -368
      ],
      "parameters": {
        "jsCode": "function simpleHash(s) {\n  let h = 0;\n  for (let i = 0; i < s.length; i++) { h = ((h << 5) - h) + s.charCodeAt(i); h |= 0; }\n  return Math.abs(h).toString(36) + '_' + s.length.toString(36);\n}\nconst items = $input.all();\nconst results = [];\nfor (const item of items) {\n  const j = item.json;\n  const link = j.link || j.url || j.guid || '';\n  if (!j.title || !link) continue;\n  let date = '';\n  if (j.pubDate) date = new Date(j.pubDate).toISOString();\n  else if (j.isoDate) date = new Date(j.isoDate).toISOString();\n  const description = (j.contentSnippet || j.content || j.description || '').replace(/<[^>]*>/g, '').substring(0, 400);\n  const cleanLink = link.toLowerCase().replace(/\\/$/, '').split('?')[0];\n  results.push({ json: { title: j.title.trim(), date, link: link.trim(), url_hash: simpleHash(cleanLink), source: 'Insurance Journal', stream: 'Insurance News', description } });\n}\nreturn results.length > 0 ? results : [{ json: { _empty: true, source: 'Insurance Journal' } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "normalize-cj",
      "name": "Normalize Claims Journal Data",
      "type": "n8n-nodes-base.code",
      "position": [
        -512,
        -224
      ],
      "parameters": {
        "jsCode": "function simpleHash(s) {\n  let h = 0;\n  for (let i = 0; i < s.length; i++) { h = ((h << 5) - h) + s.charCodeAt(i); h |= 0; }\n  return Math.abs(h).toString(36) + '_' + s.length.toString(36);\n}\nconst items = $input.all();\nconst results = [];\nfor (const item of items) {\n  const j = item.json;\n  const link = j.link || j.url || j.guid || '';\n  if (!j.title || !link) continue;\n  let date = '';\n  if (j.pubDate) date = new Date(j.pubDate).toISOString();\n  else if (j.isoDate) date = new Date(j.isoDate).toISOString();\n  const description = (j.contentSnippet || j.content || j.description || '').replace(/<[^>]*>/g, '').substring(0, 400);\n  const cleanLink = link.toLowerCase().replace(/\\/$/, '').split('?')[0];\n  results.push({ json: { title: j.title.trim(), date, link: link.trim(), url_hash: simpleHash(cleanLink), source: 'Claims Journal', stream: 'Claims News', description } });\n}\nreturn results.length > 0 ? results : [{ json: { _empty: true, source: 'Claims Journal' } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "normalize-picl",
      "name": "Normalize Property Blog Data",
      "type": "n8n-nodes-base.code",
      "position": [
        -512,
        64
      ],
      "parameters": {
        "jsCode": "function simpleHash(s) {\n  let h = 0;\n  for (let i = 0; i < s.length; i++) { h = ((h << 5) - h) + s.charCodeAt(i); h |= 0; }\n  return Math.abs(h).toString(36) + '_' + s.length.toString(36);\n}\nconst items = $input.all();\nconst results = [];\nfor (const item of items) {\n  const j = item.json;\n  const link = j.link || j.url || j.guid || '';\n  if (!j.title || !link) continue;\n  let date = '';\n  if (j.pubDate) date = new Date(j.pubDate).toISOString();\n  else if (j.isoDate) date = new Date(j.isoDate).toISOString();\n  const description = (j.contentSnippet || j.content || j.description || '').replace(/<[^>]*>/g, '').substring(0, 400);\n  const cleanLink = link.toLowerCase().replace(/\\/$/, '').split('?')[0];\n  results.push({ json: { title: j.title.trim(), date, link: link.trim(), url_hash: simpleHash(cleanLink), source: 'Property Insurance Coverage Law Blog', stream: 'Legal Commentary', description } });\n}\nreturn results.length > 0 ? results : [{ json: { _empty: true, source: 'Property Insurance Coverage Law Blog' } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "merge-all",
      "name": "Merge All Feeds",
      "type": "n8n-nodes-base.merge",
      "position": [
        -224,
        -240
      ],
      "parameters": {
        "numberInputs": 5
      },
      "typeVersion": 3.2
    },
    {
      "id": "split-batches",
      "name": "Split in Batches of 30",
      "type": "n8n-nodes-base.splitInBatches",
      "position": [
        736,
        -208
      ],
      "parameters": {
        "options": {},
        "batchSize": 30
      },
      "typeVersion": 3
    },
    {
      "id": "parse-batch",
      "name": "Parse AI Response",
      "type": "n8n-nodes-base.code",
      "position": [
        1232,
        -160
      ],
      "parameters": {
        "jsCode": "const raw = $input.item.json.output || $input.item.json.data || $input.item.json.text || '';\nlet data;\ntry {\n  const match = raw.match(/\\{[\\s\\S]*\\}/);\n  data = match ? JSON.parse(match[0]) : null;\n  if (!data || !Array.isArray(data.summarized_articles)) throw new Error('Bad structure');\n} catch (e) {\n  console.error('Parse error:', e.message);\n  return [{ json: { _batchError: true } }];\n}\nreturn data.summarized_articles.map(a => ({ json: a }));"
      },
      "typeVersion": 2
    },
    {
      "id": "build-newsletter",
      "name": "Build Newsletter HTML",
      "type": "n8n-nodes-base.code",
      "position": [
        1456,
        -304
      ],
      "parameters": {
        "jsCode": "const items = $input.all();\n\nconst newsletterDate = new Date().toLocaleDateString('en-US', {\n  weekday: 'long',\n  year: 'numeric',\n  month: 'long',\n  day: 'numeric'\n});\n\nconst articles = items.filter(i => !i.json._batchError && i.json.headline);\n\nif (articles.length === 0) {\n  return [{\n    json: {\n      newsletter_html: '<p>No new articles today.</p>',\n      newsletter_date: newsletterDate,\n      article_count: 0\n    }\n  }];\n}\n\nconst streams = {};\n\nfor (const item of articles) {\n  const a = item.json;\n  const s = a.stream || 'General';\n\n  if (!streams[s]) streams[s] = [];\n  streams[s].push(a);\n}\n\nconst streamOrder = [\n  'Insurance News',\n  'Claims News',\n  'Opinions',\n  'Legal Commentary',\n  'Catastrophe / Disaster'\n];\n\nfunction escapeHtml(value) {\n  return String(value || '')\n    .replace(/&/g, '&amp;')\n    .replace(/</g, '&lt;')\n    .replace(/>/g, '&gt;')\n    .replace(/\"/g, '&quot;');\n}\n\nfunction buildRow(a) {\n  const d = a.date\n    ? new Date(a.date).toLocaleDateString('en-US', {\n        month: 'short',\n        day: 'numeric',\n        year: 'numeric'\n      })\n    : '';\n\n  const headline = escapeHtml(a.headline || '');\n  const summary = escapeHtml(a.summary || '');\n  const whyItMatters = escapeHtml(a.why_it_matters || '');\n  const source = escapeHtml(a.source || '');\n  const sourceUrl = a.source_url || '#';\n\n  return `\n    <tr>\n      <td style=\"padding:0 24px 18px 24px;\">\n        <table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" role=\"presentation\" style=\"border-collapse:collapse;background:#ffffff;border:1px solid #e5e7eb;border-radius:12px;overflow:hidden;\">\n          <tr>\n            <td style=\"padding:22px 22px 20px 22px;border-left:4px solid #004080;\">\n              \n              <table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" role=\"presentation\" style=\"border-collapse:collapse;\">\n                <tr>\n                  <td style=\"padding:0 0 8px 0;\">\n                    <h3 style=\"margin:0;font-size:17px;line-height:1.35;font-weight:700;color:#1a1a2e;\">\n                      <a href=\"${sourceUrl}\" style=\"color:#1a1a2e;text-decoration:none;\">\n                        ${headline}\n                      </a>\n                    </h3>\n                  </td>\n                </tr>\n              </table>\n\n              <p style=\"margin:0 0 14px 0;font-size:14px;color:#374151;line-height:1.65;\">\n                ${summary}\n              </p>\n\n              <table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" role=\"presentation\" style=\"border-collapse:collapse;margin:0 0 14px 0;\">\n                <tr>\n                  <td style=\"background:#ecfdf5;border:1px solid #d1fae5;border-radius:8px;padding:12px 14px;\">\n                    <p style=\"margin:0;font-size:13px;color:#065f46;line-height:1.55;\">\n                      <strong style=\"color:#059669;\">Why it matters:</strong> ${whyItMatters}\n                    </p>\n                  </td>\n                </tr>\n              </table>\n\n              <table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" role=\"presentation\" style=\"border-collapse:collapse;\">\n                <tr>\n                  <td align=\"left\" style=\"vertical-align:middle;\">\n                    <a href=\"${sourceUrl}\" style=\"display:inline-block;background:#004080;color:#ffffff;text-decoration:none;font-size:12px;font-weight:700;letter-spacing:.2px;padding:9px 13px;border-radius:999px;\">\n                      Read full article\n                    </a>\n                  </td>\n                  <td align=\"right\" style=\"vertical-align:middle;font-size:12px;color:#6b7280;line-height:1.4;\">\n                    ${source}${d ? ' &bull; ' + d : ''}\n                  </td>\n                </tr>\n              </table>\n\n            </td>\n          </tr>\n        </table>\n      </td>\n    </tr>\n  `;\n}\n\nfunction buildSection(key, arts) {\n  return `\n    <tr>\n      <td style=\"padding:28px 24px 14px 24px;\">\n        <table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" role=\"presentation\" style=\"border-collapse:collapse;\">\n          <tr>\n            <td style=\"background:#004080;border-radius:10px;padding:13px 16px;\">\n              <h2 style=\"margin:0;font-size:16px;line-height:1.3;font-weight:700;color:#ffffff;\">\n                ${escapeHtml(key)}\n              </h2>\n            </td>\n            <td width=\"72\" align=\"right\" style=\"background:#004080;border-radius:10px;padding:13px 16px;font-size:12px;color:#bfdbfe;font-weight:700;\">\n              ${arts.length} ${arts.length === 1 ? 'item' : 'items'}\n            </td>\n          </tr>\n        </table>\n      </td>\n    </tr>\n    ${arts.map(buildRow).join('')}\n  `;\n}\n\nlet body = '';\nconst used = {};\n\nfor (const k of streamOrder) {\n  if (streams[k]) {\n    body += buildSection(k, streams[k]);\n    used[k] = true;\n  }\n}\n\nfor (const k of Object.keys(streams)) {\n  if (!used[k]) {\n    body += buildSection(k, streams[k]);\n  }\n}\n\nconst html = `\n<!DOCTYPE html>\n<html>\n<head>\n  <meta charset=\"UTF-8\">\n  <title>Florida Insurance &amp; Legal Digest</title>\n</head>\n\n<body style=\"margin:0;padding:0;background:#f3f4f6;font-family:Arial,Helvetica,sans-serif;\">\n  <table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" role=\"presentation\" style=\"border-collapse:collapse;background:#f3f4f6;\">\n    <tr>\n      <td align=\"center\" style=\"padding:28px 14px;\">\n\n        <table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" role=\"presentation\" style=\"border-collapse:collapse;max-width:700px;margin:0 auto;\">\n          \n          <tr>\n            <td style=\"background:#004080;border-radius:18px 18px 0 0;padding:0;\">\n              \n              <table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" role=\"presentation\" style=\"border-collapse:collapse;\">\n                <tr>\n                  <td style=\"padding:32px 28px 28px 28px;text-align:center;\">\n                    \n                    <img src=\"https://automatedintelligentsolutions.com/wp-content/uploads/2025/08/logo_icon.jpg\" alt=\"AIS\" style=\"background:#ffffff;padding:7px 12px;border-radius:8px;max-height:52px;display:block;margin:0 auto 18px auto;border:0;\">\n                    \n                    <h1 style=\"margin:0;font-size:25px;line-height:1.25;font-weight:700;color:#ffffff;letter-spacing:.2px;\">\n                      Florida Insurance &amp; Legal Digest\n                    </h1>\n                    \n                    <p style=\"margin:10px 0 0 0;font-size:14px;line-height:1.5;color:#bfdbfe;\">\n                      ${newsletterDate} &bull; ${articles.length} ${articles.length === 1 ? 'article' : 'articles'}\n                    </p>\n\n                  </td>\n                </tr>\n              </table>\n\n            </td>\n          </tr>\n\n          <tr>\n            <td style=\"background:#ffffff;border-left:1px solid #e5e7eb;border-right:1px solid #e5e7eb;\">\n              \n              <table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" role=\"presentation\" style=\"border-collapse:collapse;\">\n                <tr>\n                  <td style=\"padding:24px 28px 6px 28px;\">\n                    <p style=\"margin:0;font-size:14px;line-height:1.7;color:#4b5563;\">\n                      A curated digest of recent Florida insurance, claims, appellate, legal commentary, and catastrophe-related developments.\n                    </p>\n                  </td>\n                </tr>\n              </table>\n\n              <table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" role=\"presentation\" style=\"border-collapse:collapse;\">\n                ${body}\n              </table>\n\n            </td>\n          </tr>\n\n          <tr>\n            <td style=\"background:#ffffff;border-left:1px solid #e5e7eb;border-right:1px solid #e5e7eb;border-bottom:1px solid #e5e7eb;border-radius:0 0 18px 18px;padding:24px 28px 30px 28px;\">\n              \n              <table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" role=\"presentation\" style=\"border-collapse:collapse;background:#f9fafb;border:1px solid #e5e7eb;border-radius:12px;\">\n                <tr>\n                  <td style=\"padding:18px 20px;text-align:center;\">\n                    <p style=\"margin:0 0 6px 0;font-size:13px;line-height:1.5;color:#374151;\">\n                      Curated by <strong>AIS &mdash; Automated Intelligent Solutions</strong>\n                    </p>\n                    <p style=\"margin:0;font-size:12px;line-height:1.5;color:#6b7280;\">\n                      automatedintelligentsolutions.com\n                    </p>\n                  </td>\n                </tr>\n              </table>\n\n            </td>\n          </tr>\n\n        </table>\n\n      </td>\n    </tr>\n  </table>\n</body>\n</html>\n`;\n\nreturn [{\n  json: {\n    newsletter_html: html,\n    newsletter_date: newsletterDate,\n    article_count: articles.length\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "get-subscribers",
      "name": "Extract Subscribers List",
      "type": "n8n-nodes-base.microsoftExcel",
      "position": [
        1744,
        -192
      ],
      "parameters": {
        "table": {
          "__rl": true,
          "mode": "list",
          "value": "{547E7A23-B88B-4D67-9961-18D8EEE7BA53}",
          "cachedResultUrl": "https://legalgpts-my.sharepoint.com/personal/jeff_legalgpts_com/_layouts/15/Doc.aspx?sourcedoc=%7B5B3A4B43-0BB7-4159-995C-3488C20A7846%7D&file=gators_digest.xlsx&action=default&mobileredirect=true&DefaultItemOpen=1&activeCell=Contacts!A1:B3",
          "cachedResultName": "emails"
        },
        "filters": {},
        "resource": "table",
        "workbook": {
          "__rl": true,
          "mode": "list",
          "value": "YOUR_DRIVE_ITEM_ID",
          "cachedResultUrl": "https://legalgpts-my.sharepoint.com/personal/jeff_legalgpts_com/_layouts/15/Doc.aspx?sourcedoc=%7BC90B35B3-1CC7-42A6-9967-3D7CA9A288C7%7D&file=fl_insurance_digest.xlsx&action=default&mobileredirect=true&DefaultItemOpen=1",
          "cachedResultName": "fl_insurance_digest"
        },
        "operation": "getRows",
        "returnAll": true,
        "worksheet": {
          "__rl": true,
          "mode": "list",
          "value": "{+1234567890+1234567890}",
          "cachedResultUrl": "https://legalgpts-my.sharepoint.com/personal/jeff_legalgpts_com/_layouts/15/Doc.aspx?sourcedoc=%7B5B3A4B43-0BB7-4159-995C-3488C20A7846%7D&file=gators_digest.xlsx&action=default&mobileredirect=true&DefaultItemOpen=1&activeCell=Contacts!A1",
          "cachedResultName": "Contacts"
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "filter-subs",
      "name": "Filter by Valid Emails",
      "type": "n8n-nodes-base.filter",
      "position": [
        1936,
        -192
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": false,
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "ve",
              "operator": {
                "type": "string",
                "operation": "contains"
              },
              "leftValue": "={{ $json.Email }}",
              "rightValue": "@"
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "fetch-fema",
      "name": "Fetch Google News Feed",
      "type": "n8n-nodes-base.rssFeedRead",
      "position": [
        -784,
        -80
      ],
      "parameters": {
        "url": "=https://news.google.com/rss/search?q=%22Florida%22+%22insurance%22+%22law%22(%22+OR+%22claims%22+OR+%22disputed%22)+when:7d&hl=en-US&gl=US&ceid=US:en",
        "options": {
          "ignoreSSL": false
        }
      },
      "retryOnFail": false,
      "typeVersion": 1.2
    },
    {
      "id": "normalize-fema",
      "name": "Normalize Google News Data",
      "type": "n8n-nodes-base.code",
      "position": [
        -512,
        -80
      ],
      "parameters": {
        "jsCode": "function simpleHash(s) {\n  let h = 0;\n  for (let i = 0; i < s.length; i++) { h = ((h << 5) - h) + s.charCodeAt(i); h |= 0; }\n  return Math.abs(h).toString(36) + '_' + s.length.toString(36);\n}\nconst items = $input.all();\nconst results = [];\nfor (const item of items) {\n  const j = item.json;\n  const link = j.link || j.url || j.guid || '';\n  if (!j.title || !link) continue;\n  let date = '';\n  if (j.pubDate) date = new Date(j.pubDate).toISOString();\n  else if (j.isoDate) date = new Date(j.isoDate).toISOString();\n  const description = (j.contentSnippet || j.content || j.description || '').replace(/<[^>]*>/g, '').substring(0, 400);\n  const cleanLink = link.toLowerCase().replace(/\\/$/, '').split('?')[0];\n  results.push({ json: { title: j.title.trim(), date, link: link.trim(), url_hash: simpleHash(cleanLink), source: 'Google News', stream: 'Catastrophe / Disaster', description } });\n}\nreturn results.length > 0 ? results : [{ json: { _empty: true, source: 'Google News' } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "sp-load-seen",
      "name": "Load Seen Hashes",
      "type": "n8n-nodes-base.microsoftExcel",
      "position": [
        -1280,
        -224
      ],
      "parameters": {
        "table": {
          "__rl": true,
          "mode": "list",
          "value": "{1B89FD7F-333F-4DC2-86D1-5A172CFC8A61}",
          "cachedResultUrl": "https://legalgpts-my.sharepoint.com/personal/jeff_legalgpts_com/_layouts/15/Doc.aspx?sourcedoc=%7B5B3A4B43-0BB7-4159-995C-3488C20A7846%7D&file=gators_digest.xlsx&action=default&mobileredirect=true&DefaultItemOpen=1&activeCell=SeenArticles!A1:C2",
          "cachedResultName": "tblSeen"
        },
        "filters": {},
        "resource": "table",
        "workbook": {
          "__rl": true,
          "mode": "list",
          "value": "YOUR_DRIVE_ITEM_ID",
          "cachedResultUrl": "https://legalgpts-my.sharepoint.com/personal/jeff_legalgpts_com/_layouts/15/Doc.aspx?sourcedoc=%7BC90B35B3-1CC7-42A6-9967-3D7CA9A288C7%7D&file=fl_insurance_digest.xlsx&action=default&mobileredirect=true&DefaultItemOpen=1",
          "cachedResultName": "fl_insurance_digest"
        },
        "operation": "getRows",
        "returnAll": true,
        "worksheet": {
          "__rl": true,
          "mode": "list",
          "value": "{CD6755B6-5B9A-46DA-9822-F37FCA477FF3}",
          "cachedResultUrl": "https://legalgpts-my.sharepoint.com/personal/jeff_legalgpts_com/_layouts/15/Doc.aspx?sourcedoc=%7BC90B35B3-1CC7-42A6-9967-3D7CA9A288C7%7D&file=fl_insurance_digest.xlsx&action=default&mobileredirect=true&DefaultItemOpen=1&activeCell=SeenArticles!A1",
          "cachedResultName": "SeenArticles"
        }
      },
      "retryOnFail": true,
      "typeVersion": 2.2,
      "alwaysOutputData": true
    },
    {
      "id": "build-seen-set",
      "name": "Build Seen Hash Set",
      "type": "n8n-nodes-base.code",
      "position": [
        -1056,
        -224
      ],
      "parameters": {
        "jsCode": "// Build a Set of url_hashes from the SharePoint cache for fast lookup\nconst items = $input.all();\nconst seenHashes = new Set();\nfor (const item of items) {\n  const j = item.json || {};\n  const h = j.url_hash || j.URL_HASH || j['url_hash'];\n  if (h && typeof h === 'string') seenHashes.add(h.trim());\n}\nconsole.log('Loaded ' + seenHashes.size + ' previously seen article hashes');\nreturn [{ json: { seenHashes: Array.from(seenHashes), seenCount: seenHashes.size } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "fetch-courtlistener",
      "name": "Fetch CourtListener Data",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -784,
        208
      ],
      "parameters": {
        "url": "https://www.courtlistener.com/api/rest/v4/search",
        "options": {
          "response": {
            "response": {
              "responseFormat": "json"
            }
          }
        },
        "sendQuery": true,
        "sendHeaders": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "type",
              "value": "o"
            },
            {
              "name": "order_by",
              "value": "dateFiled desc"
            },
            {
              "name": "filed_after",
              "value": "={{ $now.minus({days: 10}).toFormat('yyyy-MM-dd') }}"
            },
            {
              "name": "court",
              "value": "fla,fladistctapp,flmd,flsd,flnd"
            },
            {
              "name": "q",
              "value": "(   \"first-party property\" OR   \"first party property\" OR   \"property insurance\" OR   \"insurance coverage\" OR   \"coverage dispute\" OR   \"coverage defense\" OR   \"residential property\" OR   \"commercial property\" OR   \"hurricane damage\" OR   \"windstorm\" OR   \"water damage\" OR   \"sinkhole\" OR   \"catastrophe claim\" OR   \"bad faith\" OR   \"civil remedy notice\" OR   \"appraisal\" OR   \"insurance appraisal\" OR   \"fraud investigation\" OR   \"SIU\" OR   \"special investigation unit\" OR   \"premises liability\" OR   \"automobile insurance\" OR   \"auto insurance\" OR   \"title insurance\" OR   \"real estate\" OR   \"subrogation\" )"
            }
          ]
        },
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "0a88fd259d976676e6458a92987412d9f7aa665e"
            },
            {
              "name": "Accept",
              "value": "application/json"
            }
          ]
        }
      },
      "typeVersion": 4.4
    },
    {
      "id": "normalize-cl",
      "name": "Normalize CourtListener Data",
      "type": "n8n-nodes-base.code",
      "position": [
        -512,
        208
      ],
      "parameters": {
        "jsCode": "function simpleHash(s) {\n  let h = 0;\n  for (let i = 0; i < s.length; i++) { h = ((h << 5) - h) + s.charCodeAt(i); h |= 0; }\n  return Math.abs(h).toString(36) + '_' + s.length.toString(36);\n}\n\nconst raw = $input.first().json;\nconst arr = raw.results || [];\nconst results = [];\n\nfor (const op of arr) {\n  // v4 Search API field names\n  const link = op.absolute_url ? 'https://www.courtlistener.com' + op.absolute_url : '';\n  const title = op.caseName || op.caseNameFull || 'Untitled Opinion';\n  if (!link || !title) continue;\n\n  let date = '';\n  if (op.dateFiled) date = new Date(op.dateFiled).toISOString();\n  else if (op.dateArgued) date = new Date(op.dateArgued).toISOString();\n\n  // Use the snippet field (v4 search returns snippets, not full text)\n  let description = '';\n  if (Array.isArray(op.opinions) && op.opinions[0] && op.opinions[0].snippet) {\n    description = op.opinions[0].snippet.replace(/<[^>]*>/g, '').replace(/\\s+/g, ' ').trim().substring(0, 400);\n  }\n\n  // Court display name and ID\n  const court = op.court || '';\n  const courtId = op.court_id || '';\n  const docket = op.docketNumber || '';\n  const citations = Array.isArray(op.citation) ? op.citation.join(', ') : '';\n\n  const cleanLink = link.toLowerCase().replace(/\\/$/, '').split('?')[0];\n\n  results.push({\n    json: {\n      title: title.trim(),\n      date,\n      link: link.trim(),\n      url_hash: simpleHash(cleanLink),\n      source: 'CourtListener',\n      stream: 'Opinions',\n      court,\n      court_id: courtId,\n      docket,\n      citations,\n      description\n    }\n  });\n}\n\nreturn results.length > 0 ? results : [{ json: { _empty: true, source: 'CourtListener' } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "filter-unseen",
      "name": "Filter Unseen Articles",
      "type": "n8n-nodes-base.code",
      "position": [
        64,
        -192
      ],
      "parameters": {
        "jsCode": "// Filter merged articles to only those NOT in the seen-hash set\nconst items = $input.all();\nconst seenArr = $('Build Seen Hash Set').first().json.seenHashes || [];\nconst seenSet = new Set(seenArr);\nconst seenLinkSet = new Set();\nconst kept = [];\nlet alreadySeen = 0, duplicateInRun = 0, empty = 0;\nfor (const item of items) {\n  const j = item.json;\n  if (j._empty || !j.title || !j.link || !j.url_hash) { empty++; continue; }\n  if (seenSet.has(j.url_hash)) { alreadySeen++; continue; }\n  if (seenLinkSet.has(j.url_hash)) { duplicateInRun++; continue; }\n  seenLinkSet.add(j.url_hash);\n  kept.push(item);\n}\nconsole.log('New=' + kept.length + ', alreadySeen=' + alreadySeen + ', duplicateInRun=' + duplicateInRun + ', empty=' + empty);\nif (kept.length === 0) return [{ json: { _noItems: true, message: 'No new articles to summarize.' } }];\nreturn kept;"
      },
      "typeVersion": 2
    },
    {
      "id": "if-any-new",
      "name": "If New Articles Exist",
      "type": "n8n-nodes-base.if",
      "position": [
        496,
        -192
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": false,
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "has-new",
              "operator": {
                "type": "boolean",
                "operation": "notEquals"
              },
              "leftValue": "={{ $json._noItems }}",
              "rightValue": true
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "build-hash-rows",
      "name": "Create New Hash Records",
      "type": "n8n-nodes-base.code",
      "position": [
        2000,
        128
      ],
      "parameters": {
        "jsCode": "// Collect all url_hashes from articles that were summarized and sent\nconst filtered = $('Filter Unseen Articles').all();\nconst today = new Date().toISOString().split('T')[0];\nconst rows = [];\nfor (const item of filtered) {\n  const j = item.json;\n  if (!j.url_hash || j._noItems) continue;\n  rows.push({ json: { url_hash: j.url_hash, seen_date: today, source: j.source || 'Unknown' } });\n}\nconsole.log('Appending ' + rows.length + ' new hashes to seen cache');\nreturn rows.length > 0 ? rows : [{ json: { _empty: true } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "sp-append-hashes",
      "name": "Append New Hashes",
      "type": "n8n-nodes-base.microsoftExcel",
      "position": [
        2192,
        128
      ],
      "parameters": {
        "table": {
          "__rl": true,
          "mode": "list",
          "value": "{1B89FD7F-333F-4DC2-86D1-5A172CFC8A61}",
          "cachedResultName": "tblSeen"
        },
        "options": {},
        "dataMode": "autoMap",
        "resource": "table",
        "workbook": {
          "__rl": true,
          "mode": "list",
          "value": "YOUR_DRIVE_ITEM_ID",
          "cachedResultUrl": "https://legalgpts-my.sharepoint.com/personal/jeff_legalgpts_com/_layouts/15/Doc.aspx?sourcedoc=%7BC90B35B3-1CC7-42A6-9967-3D7CA9A288C7%7D&file=fl_insurance_digest.xlsx&action=default&mobileredirect=true&DefaultItemOpen=1",
          "cachedResultName": "fl_insurance_digest"
        },
        "worksheet": {
          "__rl": true,
          "mode": "list",
          "value": "{CD6755B6-5B9A-46DA-9822-F37FCA477FF3}",
          "cachedResultName": "SeenArticles"
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "946963d1-fea2-47b9-9650-0d54817803f6",
      "name": "Check Article Relevance",
      "type": "n8n-nodes-base.code",
      "position": [
        272,
        -192
      ],
      "parameters": {
        "jsCode": "const items = $input.all();\nconst floridaKeywords = /\\b(florida|fla\\.|miami|tampa|orlando|jacksonville|tallahassee|fort lauderdale|hurricane|dca|sinkhole)\\b/i;\nconst insuranceKeywords = /\\b(insur|claim|policy|premium|adjuster|coverage|deductible|liability|carrier|reinsur|catastrophe|cat loss|underwrit|appraisal|bad faith|liti|appeal|opinion)\\b/i;\n\nconst kept = [];\nlet dropped = 0;\nfor (const item of items) {\n  const j = item.json;\n  if (j._noItems) { kept.push(item); continue; }\n  const text = ((j.title || '') + ' ' + (j.description || '')).toLowerCase();\n\n  // CourtListener and Property Insurance Blog get a free pass \u2014 they're already relevant by nature\n  if (j.source === 'CourtListener' || j.source === 'Property Insurance Coverage Law Blog') {\n    kept.push(item);\n    continue;\n  }\n\n  // For broader sources, require BOTH a Florida signal AND an insurance/legal signal\n  const isFL = floridaKeywords.test(text);\n  const isInsLegal = insuranceKeywords.test(text);\n\n  if (isFL && isInsLegal) {\n    kept.push(item);\n  } else {\n    dropped++;\n  }\n}\nconsole.log('Relevance filter: kept=' + kept.length + ', dropped=' + dropped);\nreturn kept.length > 0 ? kept : [{ json: { _noItems: true, message: 'No relevant articles after filter.' } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "36bf7f3d-877f-492f-bff8-8945ff14b164",
      "name": "LLM Chain Processing",
      "type": "@n8n/n8n-nodes-langchain.chainLlm",
      "position": [
        944,
        -160
      ],
      "parameters": {
        "text": "=Summarize the following {{ $input.all().length }} articles for the Florida Insurance & Legal Digest newsletter.\n\nArticles:\n{{ JSON.stringify($input.all().map(i => i.json)) }}\n\nReturn only this JSON structure with no markdown fences:\n{\"summarized_articles\":[{\"headline\":\"concise title\",\"summary\":\"2 sentences max\",\"why_it_matters\":\"1 sentence\",\"stream\":\"stream from input\",\"source\":\"source from input\",\"source_url\":\"exact link from input\",\"date\":\"date from input\"}]}",
        "batching": {},
        "messages": {
          "messageValues": [
            {
              "message": "You are an expert editor for a Florida insurance and legal newsletter. Your readers are insurance professionals, claims handlers, and attorneys. Write concise, factual summaries. Return only valid JSON with no markdown fences or explanation."
            }
          ]
        },
        "promptType": "define"
      },
      "typeVersion": 1.9
    },
    {
      "id": "80e50238-af07-4483-8d34-78643daa8d3b",
      "name": "OpenAI GPT-4 Model",
      "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
      "position": [
        880,
        16
      ],
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "list",
          "value": "gpt-4o-mini",
          "cachedResultName": "gpt-4o-mini"
        },
        "options": {},
        "builtInTools": {}
      },
      "typeVersion": 1.3
    },
    {
      "id": "19b61d38-9868-43fc-857d-46b90adb8738",
      "name": "Send Newsletter via Outlook",
      "type": "n8n-nodes-base.microsoftOutlook",
      "position": [
        2112,
        -192
      ],
      "parameters": {
        "subject": "=Florida Insurance & Legal Digest \u2014 {{ $('Build Newsletter HTML').first().json.newsletter_date }}",
        "bodyContent": "={{ $('Build Newsletter HTML').first().json.newsletter_html }}",
        "toRecipients": "={{ $json.Email }}",
        "additionalFields": {
          "bodyContentType": "html"
        }
      },
      "typeVersion": 2
    },
    {
      "id": "8b875153-2882-49c7-b78c-655348ff1f93",
      "name": "AIS Warning",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -2176,
        -848
      ],
      "parameters": {
        "color": 3,
        "width": 420,
        "height": 260,
        "content": "## \u26a0\ufe0f AI connections & credentials\n\nAfter import, verify the dotted AI sub-connections (model \u2192 agent, output parser \u2192 agent, tools \u2192 agent) are attached on the canvas, and set your own AI credentials on every AI node before running.\n\nSupport: support@ais.email"
      },
      "typeVersion": 1
    }
  ],
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "connections": {
    "Merge All Feeds": {
      "main": [
        [
          {
            "node": "Filter Unseen Articles",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Load Seen Hashes": {
      "main": [
        [
          {
            "node": "Build Seen Hash Set",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse AI Response": {
      "main": [
        [
          {
            "node": "Split in Batches of 30",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "When Daily at 4am": {
      "main": [
        [
          {
            "node": "Load Seen Hashes",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "OpenAI GPT-4 Model": {
      "ai_languageModel": [
        [
          {
            "node": "LLM Chain Processing",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Build Seen Hash Set": {
      "main": [
        [
          {
            "node": "Fetch Insurance Journal Feed",
            "type": "main",
            "index": 0
          },
          {
            "node": "Fetch Claims Journal Feed",
            "type": "main",
            "index": 0
          },
          {
            "node": "Fetch Google News Feed",
            "type": "main",
            "index": 0
          },
          {
            "node": "Fetch Property Insurance Blog Feed",
            "type": "main",
            "index": 0
          },
          {
            "node": "Fetch CourtListener Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "LLM Chain Processing": {
      "main": [
        [
          {
            "node": "Parse AI Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Newsletter HTML": {
      "main": [
        [
          {
            "node": "Extract Subscribers List",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "If New Articles Exist": {
      "main": [
        [
          {
            "node": "Split in Batches of 30",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Google News Feed": {
      "main": [
        [
          {
            "node": "Normalize Google News Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Filter Unseen Articles": {
      "main": [
        [
          {
            "node": "Check Article Relevance",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Filter by Valid Emails": {
      "main": [
        [
          {
            "node": "Send Newsletter via Outlook",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Split in Batches of 30": {
      "main": [
        [
          {
            "node": "Build Newsletter HTML",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "LLM Chain Processing",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Article Relevance": {
      "main": [
        [
          {
            "node": "If New Articles Exist",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Create New Hash Records": {
      "main": [
        [
          {
            "node": "Append New Hashes",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract Subscribers List": {
      "main": [
        [
          {
            "node": "Filter by Valid Emails",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch CourtListener Data": {
      "main": [
        [
          {
            "node": "Normalize CourtListener Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Claims Journal Feed": {
      "main": [
        [
          {
            "node": "Normalize Claims Journal Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalize Google News Data": {
      "main": [
        [
          {
            "node": "Merge All Feeds",
            "type": "main",
            "index": 2
          }
        ]
      ]
    },
    "Send Newsletter via Outlook": {
      "main": [
        [
          {
            "node": "Create New Hash Records",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Insurance Journal Feed": {
      "main": [
        [
          {
            "node": "Normalize Insurance Journal Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalize CourtListener Data": {
      "main": [
        [
          {
            "node": "Merge All Feeds",
            "type": "main",
            "index": 4
          }
        ]
      ]
    },
    "Normalize Property Blog Data": {
      "main": [
        [
          {
            "node": "Merge All Feeds",
            "type": "main",
            "index": 3
          }
        ]
      ]
    },
    "Normalize Claims Journal Data": {
      "main": [
        [
          {
            "node": "Merge All Feeds",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Normalize Insurance Journal Data": {
      "main": [
        [
          {
            "node": "Merge All Feeds",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Property Insurance Blog Feed": {
      "main": [
        [
          {
            "node": "Normalize Property Blog Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}