{
  "nodes": [
    {
      "id": "78fecc54-92cd-453f-9841-b346f465353b",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1232,
        288
      ],
      "parameters": {
        "width": 480,
        "height": 784,
        "content": "## Send weekly Reddit sentiment digests to Notion via ScraperAPI\n\n### How it works\n\n1. The workflow is triggered every Monday at 9 am.\n2. It configures source and notion database parameters.\n3. Sources are split to scrape content via ScraperAPI.\n4. The content is prepared and classified with an LLM chain.\n5. Results are parsed, aggregated into a weekly digest, and saved to Notion.\n\n### Setup steps\n\n- [ ] Configure the 'Every Monday at 9am' schedule trigger to the desired time.\n- [ ] Set up the sources and notion_database_id in the 'Configure' node.\n- [ ] Ensure ScraperAPI credentials are correctly configured.\n- [ ] Verify LLM Chain configuration for sentiment classification.\n- [ ] Connect to Notion using the appropriate API token and database settings.\n\n### Customization\n\nConsider modifying the 'Monday at 9am' schedule to fit different reporting requirements, or adjust the sources being analyzed."
      },
      "typeVersion": 1
    },
    {
      "id": "abba02d5-aca5-4187-959b-1f03d7137069",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -672,
        288
      ],
      "parameters": {
        "color": 7,
        "width": 480,
        "height": 304,
        "content": "## Schedule and configure sources\n\nStarts the workflow on a schedule and sets up the sources and Notion database configuration."
      },
      "typeVersion": 1
    },
    {
      "id": "43e181f3-1eb5-4b93-8057-0ce912cf4c29",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -96,
        288
      ],
      "parameters": {
        "color": 7,
        "width": 464,
        "height": 304,
        "content": "## Extract sources and scrape data\n\nSplits sources and scrapes content using ScraperAPI."
      },
      "typeVersion": 1
    },
    {
      "id": "8ff81e9d-1d2e-499d-9a84-3f96dfdd1c74",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        432,
        288
      ],
      "parameters": {
        "color": 7,
        "width": 624,
        "height": 448,
        "content": "## Process and classify content\n\nPrepares content for sentiment analysis and classifies sentiment using an LLM."
      },
      "typeVersion": 1
    },
    {
      "id": "6512de25-07b5-464d-8720-ea47d2618d6a",
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1088,
        288
      ],
      "parameters": {
        "color": 7,
        "width": 720,
        "height": 304,
        "content": "## Parse results and build digest\n\nParses LLM classifications, aggregates results into a weekly digest, and creates a Notion page."
      },
      "typeVersion": 1
    },
    {
      "id": "schedule-trigger",
      "name": "When Monday at 9am",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        -624,
        416
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 9 * * 1"
            }
          ]
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "configure",
      "name": "Set Source and Notion ID",
      "type": "n8n-nodes-base.set",
      "position": [
        -336,
        416
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "sources",
              "name": "sources",
              "type": "array",
              "value": "={{ [ { url: 'https://old.reddit.com/r/n8n/top/?t=week', label: 'r/n8n top this week' }, { url: 'https://old.reddit.com/r/webscraping/top/?t=week', label: 'r/webscraping top this week' }, { url: 'https://old.reddit.com/r/automation/top/?t=week', label: 'r/automation top this week' } ] }}"
            },
            {
              "id": "notion-db",
              "name": "notion_database_id",
              "type": "string",
              "value": "REPLACE_WITH_NOTION_DATABASE_ID"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "split-sources",
      "name": "Split Source Items",
      "type": "n8n-nodes-base.splitOut",
      "position": [
        -48,
        416
      ],
      "parameters": {
        "options": {},
        "fieldToSplitOut": "sources"
      },
      "typeVersion": 1
    },
    {
      "id": "scrape-via-scraperapi",
      "name": "Scrape Data via ScraperAPI",
      "type": "n8n-nodes-scraperapi-official.scraperApi",
      "maxTries": 2,
      "position": [
        224,
        416
      ],
      "parameters": {
        "apiUrl": "={{ $json.url }}",
        "resource": "api",
        "operation": "apiRequest",
        "apiOptionalParameters": {
          "apiCountryCode": "={{ $json.country || 'us' }}"
        }
      },
      "retryOnFail": true,
      "typeVersion": 1,
      "continueOnFail": true,
      "waitBetweenTries": 2000
    },
    {
      "id": "prepare-content",
      "name": "Convert HTML to Plain Text",
      "type": "n8n-nodes-base.code",
      "position": [
        480,
        416
      ],
      "parameters": {
        "jsCode": "// Strip HTML to plain text, truncate, and carry source metadata forward\n// so the LLM gets a clean prompt.\n\nconst stripHtml = (s) => String(s == null ? '' : s)\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(/&lt;/gi, '<')\n  .replace(/&gt;/gi, '>')\n  .replace(/&quot;/gi, '\"')\n  .replace(/&#39;/gi, \"'\")\n  .replace(/\\s+/g, ' ')\n  .trim();\n\nconst MAX_CHARS = 30000;\n\nreturn $input.all().map((item, i) => {\n  const meta = ($('Split Source Items').itemMatching(i) || {}).json || {};\n  const response = (item.json && item.json.response) || {};\n  const rawBody = response.body || '';\n  const text = stripHtml(rawBody).slice(0, MAX_CHARS);\n\n  return {\n    json: {\n      source_label: meta.label || '(untitled)',\n      source_url: meta.url || '',\n      status_code: response.statusCode || 0,\n      content: text,\n    },\n    pairedItem: { item: i },\n  };\n});\n",
        "language": "javaScript"
      },
      "typeVersion": 2
    },
    {
      "id": "classify-sentiment",
      "name": "Sentiment Analysis Agent",
      "type": "@n8n/n8n-nodes-langchain.chainLlm",
      "position": [
        752,
        416
      ],
      "parameters": {
        "text": "=Source: {{ $json.source_label }}\nURL: {{ $json.source_url }}\n\nContent:\n\n{{ $json.content }}",
        "messages": {
          "messageValues": [
            {
              "type": "SystemMessagePromptTemplate",
              "message": "You are a community sentiment analyst. The user will give you scraped content from a Reddit page \u2014 a subreddit listing (top/new/hot), a search results page, or a single thread. Pages are plain text stripped from HTML and may include Reddit navigation chrome; ignore the chrome and focus on the actual posts, comments and titles. Extract every distinct post or comment you can identify \u2014 a post title alone counts when no body text is available. Classify the sentiment of each (positive | neutral | negative), surface the most common themes (3-5 short labels), and pick up to 5 representative quotes (post titles are valid quotes for listing pages). Return ONLY a JSON object with this exact shape \u2014 no preamble, no markdown fence:\n\n{\n  \"overall_sentiment\": \"positive\" | \"neutral\" | \"negative\" | \"mixed\",\n  \"positive_count\": <integer>,\n  \"neutral_count\": <integer>,\n  \"negative_count\": <integer>,\n  \"top_themes\": [\"short label\", \"...\"],\n  \"sample_quotes\": [{\"sentiment\": \"positive\" | \"neutral\" | \"negative\", \"quote\": \"...\"}],\n  \"notes\": \"one short sentence summarizing tone\"\n}\n\nOnly return zero counts when the content genuinely contains no posts/comments (e.g. blocked page, empty listing). Never make up content."
            }
          ]
        },
        "promptType": "define"
      },
      "typeVersion": 1.9
    },
    {
      "id": "openai-chat-model",
      "name": "OpenAI GPT-5 Mini",
      "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
      "position": [
        752,
        576
      ],
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "list",
          "value": "gpt-5-mini"
        },
        "options": {
          "responseFormat": "json_object"
        },
        "responsesApiEnabled": false
      },
      "typeVersion": 1.3
    },
    {
      "id": "parse-classification",
      "name": "Normalize Sentiment Data",
      "type": "n8n-nodes-base.code",
      "position": [
        1136,
        416
      ],
      "parameters": {
        "jsCode": "// Normalize the LLM's classification and re-attach source metadata.\n// With response_format: json_object the Basic LLM Chain surfaces the parsed\n// fields directly on item.json. Older n8n versions wrap them under text /\n// output / response \u2014 handle both shapes.\n\nreturn $input.all().map((item, i) => {\n  const j = item.json || {};\n\n  let parsed = j;\n  const looksParsed = parsed.overall_sentiment !== undefined\n    || parsed.positive_count !== undefined\n    || Array.isArray(parsed.sample_quotes);\n\n  if (!looksParsed) {\n    const raw = j.text || j.output || j.response || '';\n    try {\n      parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;\n    } catch (e) {\n      parsed = { error: 'Failed to parse model output', raw: String(raw).slice(0, 500) };\n    }\n  }\n\n  const meta = ($('Convert HTML to Plain Text').itemMatching(i) || {}).json || {};\n\n  return {\n    json: {\n      source_label: meta.source_label || '',\n      source_url: meta.source_url || '',\n      overall_sentiment: parsed.overall_sentiment || 'unknown',\n      positive_count: Number(parsed.positive_count) || 0,\n      neutral_count: Number(parsed.neutral_count) || 0,\n      negative_count: Number(parsed.negative_count) || 0,\n      top_themes: Array.isArray(parsed.top_themes) ? parsed.top_themes : [],\n      sample_quotes: Array.isArray(parsed.sample_quotes) ? parsed.sample_quotes : [],\n      notes: parsed.notes || '',\n    },\n    pairedItem: { item: i },\n  };\n});\n",
        "language": "javaScript"
      },
      "typeVersion": 2
    },
    {
      "id": "build-weekly-digest",
      "name": "Compile Weekly Digest",
      "type": "n8n-nodes-base.code",
      "position": [
        1408,
        416
      ],
      "parameters": {
        "jsCode": "// Aggregate all per-source classifications into a single weekly digest.\n// Emits one item with block-sized strings ready for the Notion node.\n\nconst items = $input.all().map(i => i.json).filter(Boolean);\nconst weekOf = new Date().toISOString().slice(0, 10);\n\nconst trunc = (s, n) => {\n  const str = String(s == null ? '' : s);\n  return str.length > n ? str.slice(0, n - 1) + '\u2026' : str;\n};\n\nconst totals = { positive: 0, neutral: 0, negative: 0 };\nconst themeCounts = new Map();\nconst sourceLines = [];\nconst quoteLines = [];\n\nfor (const it of items) {\n  const label = it.source_label || '(unknown source)';\n  const sentiment = it.overall_sentiment || 'unknown';\n  const pos = Number(it.positive_count) || 0;\n  const neu = Number(it.neutral_count) || 0;\n  const neg = Number(it.negative_count) || 0;\n  const themes = Array.isArray(it.top_themes) ? it.top_themes : [];\n  const quotes = Array.isArray(it.sample_quotes) ? it.sample_quotes : [];\n\n  totals.positive += pos;\n  totals.neutral  += neu;\n  totals.negative += neg;\n\n  for (const t of themes) {\n    const key = String(t).toLowerCase().trim();\n    if (!key) continue;\n    themeCounts.set(key, (themeCounts.get(key) || 0) + 1);\n  }\n\n  sourceLines.push(`\u2022 ${label} \u2192 ${sentiment} (pos ${pos} \u00b7 neu ${neu} \u00b7 neg ${neg})`);\n\n  for (const q of quotes.slice(0, 2)) {\n    const text = trunc(q.quote || '', 240);\n    quoteLines.push(`[${q.sentiment || '?'}] \"${text}\" \u2014 ${label}`);\n  }\n}\n\nconst total = totals.positive + totals.neutral + totals.negative;\nconst pct = (n) => total > 0 ? Math.round((n / total) * 100) : 0;\n\nconst topThemes = [...themeCounts.entries()]\n  .sort((a, b) => b[1] - a[1])\n  .slice(0, 5)\n  .map(([t]) => t);\n\nconst overall =\n  total === 0 ? 'no signal' :\n  totals.positive > totals.negative * 1.5 ? 'mostly positive' :\n  totals.negative > totals.positive * 1.5 ? 'mostly negative' :\n  'mixed';\n\nconst intro = `${items.length} source${items.length === 1 ? '' : 's'} analyzed for the week of ${weekOf}. Overall tone: ${overall}. ${total} reviews/comments extracted.`;\nconst stats = `Positive: ${totals.positive} (${pct(totals.positive)}%) \u00b7 Neutral: ${totals.neutral} (${pct(totals.neutral)}%) \u00b7 Negative: ${totals.negative} (${pct(totals.negative)}%)`;\nconst themes = topThemes.length ? topThemes.map((t, i) => `${i + 1}. ${t}`).join('\\n') : '(no themes surfaced)';\nconst sources = sourceLines.join('\\n') || '(no sources scraped)';\nconst quotes  = quoteLines.length ? quoteLines.join('\\n\\n') : '(no representative quotes)';\n\nreturn [{\n  json: {\n    page_title: `Weekly sentiment digest \u2014 ${weekOf}`,\n    week_of: weekOf,\n    total_sources: items.length,\n    total_reviews: total,\n    overall,\n    intro_block:   trunc(intro,   1800),\n    stats_block:   trunc(stats,   1800),\n    themes_block:  trunc(themes,  1800),\n    sources_block: trunc(sources, 1800),\n    quotes_block:  trunc(quotes,  1800),\n  },\n}];\n",
        "language": "javaScript"
      },
      "typeVersion": 2
    },
    {
      "id": "create-notion-page",
      "name": "Add Digest to Notion Page",
      "type": "n8n-nodes-base.notion",
      "position": [
        1664,
        416
      ],
      "parameters": {
        "title": "={{ $json.page_title }}",
        "blockUi": {
          "blockValues": [
            {
              "type": "heading_2",
              "richText": false,
              "textContent": "Overview"
            },
            {
              "type": "paragraph",
              "richText": false,
              "textContent": "={{ $json.intro_block }}"
            },
            {
              "type": "heading_2",
              "richText": false,
              "textContent": "Sentiment counts"
            },
            {
              "type": "paragraph",
              "richText": false,
              "textContent": "={{ $json.stats_block }}"
            },
            {
              "type": "heading_2",
              "richText": false,
              "textContent": "Top themes"
            },
            {
              "type": "paragraph",
              "richText": false,
              "textContent": "={{ $json.themes_block }}"
            },
            {
              "type": "heading_2",
              "richText": false,
              "textContent": "Per-source breakdown"
            },
            {
              "type": "paragraph",
              "richText": false,
              "textContent": "={{ $json.sources_block }}"
            },
            {
              "type": "heading_2",
              "richText": false,
              "textContent": "Sample quotes"
            },
            {
              "type": "paragraph",
              "richText": false,
              "textContent": "={{ $json.quotes_block }}"
            }
          ]
        },
        "options": {},
        "resource": "databasePage",
        "operation": "create",
        "databaseId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $('Set Source and Notion ID').first().json.notion_database_id }}"
        }
      },
      "typeVersion": 2.2
    }
  ],
  "connections": {
    "OpenAI GPT-5 Mini": {
      "ai_languageModel": [
        [
          {
            "node": "Sentiment Analysis Agent",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Split Source Items": {
      "main": [
        [
          {
            "node": "Scrape Data via ScraperAPI",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "When Monday at 9am": {
      "main": [
        [
          {
            "node": "Set Source and Notion ID",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Compile Weekly Digest": {
      "main": [
        [
          {
            "node": "Add Digest to Notion Page",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalize Sentiment Data": {
      "main": [
        [
          {
            "node": "Compile Weekly Digest",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Sentiment Analysis Agent": {
      "main": [
        [
          {
            "node": "Normalize Sentiment Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Set Source and Notion ID": {
      "main": [
        [
          {
            "node": "Split Source Items",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Convert HTML to Plain Text": {
      "main": [
        [
          {
            "node": "Sentiment Analysis Agent",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Scrape Data via ScraperAPI": {
      "main": [
        [
          {
            "node": "Convert HTML to Plain Text",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}