{
  "name": "Monitor and score RSS opportunities in Google Sheets with Gmail",
  "nodes": [
    {
      "id": "note",
      "name": "Template Guide",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        0,
        -560
      ],
      "parameters": {
        "color": 4,
        "width": 1180,
        "height": 500,
        "content": "# Monitor and score RSS opportunities in Google Sheets with Gmail\n\n## Who's it for\nFreelancers, consultants, recruiters, sales teams and partnership managers who repeatedly scan RSS feeds and need a reliable review queue without automating outreach.\n\n## How it works\nThe workflow checks one or more RSS feeds manually or every 30 minutes. It processes feeds sequentially, normalizes each entry, remembers URLs for 90 days and calculates an explainable score from editable positive, high-value and exclusion keywords. Every new opportunity is appended to Google Sheets. Gmail sends one digest only when an item reaches the configured priority threshold. Applications, proposals and outreach always remain under human control.\n\n## How to set up\nOpen **Configure Template** and add RSS URLs, keywords, a threshold, the Google Sheet tab and your notification email. Create a sheet from the included CSV headers, select it in **Save Opportunities**, connect Google Sheets and Gmail credentials, then run the workflow manually before activating the schedule.\n\n## Requirements\n- n8n Cloud or self-hosted n8n\n- One or more public RSS feeds\n- Google Sheets OAuth2\n- Gmail OAuth2 for notifications\n\n## How to customize\nAdjust keywords and weights in **Normalize, Deduplicate & Score**, change the schedule, or replace Gmail with Slack or another review channel. No paid AI API is required."
      },
      "typeVersion": 1
    },
    {
      "id": "note-config",
      "name": "Step 1 - Configuration",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        260,
        40
      ],
      "parameters": {
        "width": 520,
        "height": 440,
        "content": "## 1. Configure\n\nSet the RSS feed URLs, scoring keywords, threshold, Google Sheet tab and notification recipient here."
      },
      "typeVersion": 1
    },
    {
      "id": "note-process",
      "name": "Step 2 - Processing",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        800,
        40
      ],
      "parameters": {
        "width": 1040,
        "height": 560,
        "content": "## 2. Process\n\nFeeds run sequentially. Entries are normalized, deduplicated for 90 days and scored with visible reasons."
      },
      "typeVersion": 1
    },
    {
      "id": "note-output",
      "name": "Step 3 - Review Queue",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1860,
        40
      ],
      "parameters": {
        "width": 1080,
        "height": 560,
        "content": "## 3. Store and notify\n\nEvery new item is stored in Google Sheets. Gmail only sends a digest when an item reaches the configured threshold."
      },
      "typeVersion": 1
    },
    {
      "id": "manual",
      "name": "Run Manually",
      "type": "n8n-nodes-base.manualTrigger",
      "position": [
        80,
        180
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "schedule",
      "name": "Every 30 Minutes",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        80,
        340
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "minutes",
              "minutesInterval": 30
            }
          ]
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "fields",
      "name": "Configure Template",
      "type": "n8n-nodes-base.set",
      "position": [
        340,
        260
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "config-rssFeedUrls",
              "name": "rssFeedUrls",
              "type": "string",
              "value": "PASTE_ONE_OR_MORE_RSS_URLS_HERE"
            },
            {
              "id": "config-searchName",
              "name": "searchName",
              "type": "string",
              "value": "AI automation opportunities"
            },
            {
              "id": "config-priorityThreshold",
              "name": "priorityThreshold",
              "type": "number",
              "value": 75
            },
            {
              "id": "config-includeKeywords",
              "name": "includeKeywords",
              "type": "string",
              "value": "n8n, automation, api, ai agent, claude, openai, next.js"
            },
            {
              "id": "config-highValueKeywords",
              "name": "highValueKeywords",
              "type": "string",
              "value": "long term, ongoing, migration, self-hosted, production, architecture"
            },
            {
              "id": "config-excludeKeywords",
              "name": "excludeKeywords",
              "type": "string",
              "value": "unpaid, commission only, exposure"
            },
            {
              "id": "config-googleSheetTab",
              "name": "googleSheetTab",
              "type": "string",
              "value": "Opportunities"
            },
            {
              "id": "config-notificationEmail",
              "name": "notificationEmail",
              "type": "string",
              "value": "user@example.com"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "config",
      "name": "Build Search Configuration",
      "type": "n8n-nodes-base.code",
      "position": [
        600,
        260
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const config = $input.first().json;\nconst list = (value) => String(value || \"\").split(\",\").map((item) => item.trim().toLowerCase()).filter(Boolean);\nconst urls = String(config.rssFeedUrls || \"\").split(/\\r?\\n|,/).map((url) => url.trim()).filter((url) => /^https?:\\/\\//i.test(url));\nconst scoring = {\n  priorityThreshold: Number(config.priorityThreshold || 75),\n  includeKeywords: list(config.includeKeywords),\n  highValueKeywords: list(config.highValueKeywords),\n  excludeKeywords: list(config.excludeKeywords),\n};\n\nreturn urls.slice(0, 10).map((rssUrl, index) => ({\n  json: {\n    name: urls.length === 1 ? config.searchName : config.searchName + \" \" + (index + 1),\n    rssUrl,\n    scoring,\n  },\n}));",
        "language": "javaScript"
      },
      "typeVersion": 2
    },
    {
      "id": "loop",
      "name": "Loop Over Searches",
      "type": "n8n-nodes-base.splitInBatches",
      "position": [
        860,
        260
      ],
      "parameters": {
        "options": {},
        "batchSize": 1
      },
      "typeVersion": 3
    },
    {
      "id": "rss",
      "name": "Read RSS Feed",
      "type": "n8n-nodes-base.rssFeedRead",
      "position": [
        1120,
        380
      ],
      "parameters": {
        "url": "={{ $json.rssUrl }}"
      },
      "typeVersion": 1.2,
      "alwaysOutputData": true
    },
    {
      "id": "score",
      "name": "Normalize, Deduplicate & Score",
      "type": "n8n-nodes-base.code",
      "position": [
        1380,
        380
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const search = $(\"Loop Over Searches\").item.json;\nconst entries = $input.all().slice(0, 50).map((item) => item.json || {});\nconst state = $getWorkflowStaticData(\"global\");\nstate.seen = state.seen || {};\nconst now = Date.now();\nconst retentionMs = 90 * 24 * 60 * 60 * 1000;\n\nfor (const [key, seenAt] of Object.entries(state.seen)) {\n  if (now - Number(seenAt) > retentionMs) delete state.seen[key];\n}\n\nconst plain = (value) => String(value || \"\").replace(/<[^>]+>/g, \" \").replace(/&nbsp;/gi, \" \").replace(/&amp;/g, \"&\").replace(/\\s+/g, \" \").trim();\nconst candidates = [];\n\nfor (const entry of entries) {\n  const title = plain(entry.title);\n  const url = plain(entry.link || entry.guid);\n  if (!title || !/^https?:\\/\\//i.test(url) || state.seen[url]) continue;\n\n  const description = plain(entry.contentSnippet || entry.description || entry.content);\n  const haystack = (title + \" \" + description).toLowerCase();\n  let score = 20;\n  const reasons = [];\n\n  for (const keyword of search.scoring.includeKeywords) {\n    if (haystack.includes(keyword.toLowerCase())) { score += 8; reasons.push(\"match: \" + keyword); }\n  }\n  for (const keyword of search.scoring.highValueKeywords) {\n    if (haystack.includes(keyword.toLowerCase())) { score += 10; reasons.push(\"high value: \" + keyword); }\n  }\n  for (const keyword of search.scoring.excludeKeywords) {\n    if (haystack.includes(keyword.toLowerCase())) { score -= 30; reasons.push(\"risk: \" + keyword); }\n  }\n\n  score = Math.max(0, Math.min(100, score));\n  state.seen[url] = now;\n  candidates.push({\n    detected_at: new Date(now).toISOString(),\n    source_search: search.name,\n    title,\n    url,\n    published_at: entry.isoDate || entry.pubDate || \"\",\n    score,\n    score_reasons: reasons.join(\" | \") || \"No configured keyword matched\",\n    categories: (Array.isArray(entry.categories) ? entry.categories : [entry.category]).filter(Boolean).join(\", \"),\n    budget_label: (haystack.match(/budget[^.\\n]{0,80}/i) || [\"\"])[0],\n    status: score >= search.scoring.priorityThreshold ? \"priority_review\" : \"to_review\",\n  });\n}\n\nreturn [{ json: { candidates, priorityThreshold: search.scoring.priorityThreshold } }];",
        "language": "javaScript"
      },
      "typeVersion": 2
    },
    {
      "id": "has-new",
      "name": "Has New Opportunities?",
      "type": "n8n-nodes-base.if",
      "position": [
        1640,
        380
      ],
      "parameters": {
        "conditions": {
          "options": {
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "operator": {
                "type": "number",
                "operation": "gt"
              },
              "leftValue": "={{ $json.candidates.length }}",
              "rightValue": 0
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "expand",
      "name": "Expand Opportunities",
      "type": "n8n-nodes-base.code",
      "position": [
        1900,
        300
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "return ($input.first().json.candidates || []).map((candidate) => ({ json: candidate }));",
        "language": "javaScript"
      },
      "typeVersion": 2
    },
    {
      "id": "sheets",
      "name": "Save Opportunities",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        2160,
        300
      ],
      "parameters": {
        "columns": {
          "value": {
            "url": "={{ $json.url }}",
            "score": "={{ $json.score }}",
            "title": "={{ $json.title }}",
            "status": "={{ $json.status }}",
            "categories": "={{ $json.categories }}",
            "detected_at": "={{ $json.detected_at }}",
            "budget_label": "={{ $json.budget_label }}",
            "published_at": "={{ $json.published_at }}",
            "score_reasons": "={{ $json.score_reasons }}",
            "source_search": "={{ $json.source_search }}"
          },
          "schema": [
            {
              "id": "detected_at",
              "type": "string",
              "displayName": "detected_at"
            },
            {
              "id": "source_search",
              "type": "string",
              "displayName": "source_search"
            },
            {
              "id": "title",
              "type": "string",
              "displayName": "title"
            },
            {
              "id": "url",
              "type": "string",
              "displayName": "url"
            },
            {
              "id": "published_at",
              "type": "string",
              "displayName": "published_at"
            },
            {
              "id": "score",
              "type": "number",
              "displayName": "score"
            },
            {
              "id": "score_reasons",
              "type": "string",
              "displayName": "score_reasons"
            },
            {
              "id": "categories",
              "type": "string",
              "displayName": "categories"
            },
            {
              "id": "budget_label",
              "type": "string",
              "displayName": "budget_label"
            },
            {
              "id": "status",
              "type": "string",
              "displayName": "status"
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": []
        },
        "options": {},
        "operation": "append",
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "={{ $('Configure Template').first().json.googleSheetTab }}"
        },
        "documentId": {
          "__rl": true,
          "mode": "url",
          "value": ""
        }
      },
      "typeVersion": 4.5
    },
    {
      "id": "digest",
      "name": "Build Priority Digest",
      "type": "n8n-nodes-base.code",
      "position": [
        2420,
        300
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const items = $input.all().map((item) => item.json || {});\nconst threshold = Number($(\"Configure Template\").first().json.priorityThreshold || 75);\nconst priority = items.filter((item) => Number(item.score) >= threshold);\nconst lines = priority.map((item) => [\n  \"Score \" + item.score + \"/100 - \" + item.title,\n  item.url,\n  item.score_reasons,\n].join(\"\\n\"));\n\nreturn [{ json: {\n  priorityCount: priority.length,\n  subject: priority.length + \" priority automation opportunity\" + (priority.length === 1 ? \"\" : \"ies\") + \" to review\",\n  message: lines.join(\"\\n\\n---\\n\\n\") || \"No priority opportunity in this run.\",\n} }];",
        "language": "javaScript"
      },
      "typeVersion": 2
    },
    {
      "id": "has-priority",
      "name": "Has Priority Opportunities?",
      "type": "n8n-nodes-base.if",
      "position": [
        2680,
        300
      ],
      "parameters": {
        "conditions": {
          "options": {
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "operator": {
                "type": "number",
                "operation": "gt"
              },
              "leftValue": "={{ $json.priorityCount }}",
              "rightValue": 0
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "gmail",
      "name": "Send Priority Digest",
      "type": "n8n-nodes-base.gmail",
      "position": [
        2940,
        220
      ],
      "parameters": {
        "sendTo": "={{ $('Configure Template').first().json.notificationEmail }}",
        "message": "={{ $json.message }}",
        "options": {},
        "subject": "={{ $json.subject }}"
      },
      "typeVersion": 2.1
    },
    {
      "id": "wait",
      "name": "Wait Before Next Search",
      "type": "n8n-nodes-base.wait",
      "position": [
        3200,
        380
      ],
      "parameters": {
        "unit": "seconds",
        "amount": 2,
        "resume": "timeInterval",
        "options": {}
      },
      "typeVersion": 1.1
    },
    {
      "id": "done",
      "name": "Finished",
      "type": "n8n-nodes-base.noOp",
      "position": [
        1120,
        120
      ],
      "parameters": {},
      "typeVersion": 1
    }
  ],
  "active": false,
  "settings": {
    "timezone": "UTC",
    "executionOrder": "v1",
    "executionTimeout": 180,
    "saveManualExecutions": true,
    "saveDataErrorExecution": "all",
    "saveDataSuccessExecution": "none"
  },
  "connections": {
    "Run Manually": {
      "main": [
        [
          {
            "node": "Configure Template",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read RSS Feed": {
      "main": [
        [
          {
            "node": "Normalize, Deduplicate & Score",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Every 30 Minutes": {
      "main": [
        [
          {
            "node": "Configure Template",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Configure Template": {
      "main": [
        [
          {
            "node": "Build Search Configuration",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Loop Over Searches": {
      "main": [
        [
          {
            "node": "Finished",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Read RSS Feed",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Save Opportunities": {
      "main": [
        [
          {
            "node": "Build Priority Digest",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Expand Opportunities": {
      "main": [
        [
          {
            "node": "Save Opportunities",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send Priority Digest": {
      "main": [
        [
          {
            "node": "Wait Before Next Search",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Priority Digest": {
      "main": [
        [
          {
            "node": "Has Priority Opportunities?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Has New Opportunities?": {
      "main": [
        [
          {
            "node": "Expand Opportunities",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Wait Before Next Search",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Wait Before Next Search": {
      "main": [
        [
          {
            "node": "Loop Over Searches",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Search Configuration": {
      "main": [
        [
          {
            "node": "Loop Over Searches",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Has Priority Opportunities?": {
      "main": [
        [
          {
            "node": "Send Priority Digest",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Wait Before Next Search",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalize, Deduplicate & Score": {
      "main": [
        [
          {
            "node": "Has New Opportunities?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}