{
  "name": "Build a Free Local AI Website Scraper with n8n",
  "nodes": [
    {
      "id": "941a8da9-2771-4f10-992c-d879657adf3a",
      "name": "Daily Schedule",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        0
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "days",
              "daysInterval": 1,
              "triggerAtHour": 7
            }
          ]
        }
      }
    },
    {
      "id": "617ee705-a872-4024-b3d7-686e80ef5b5b",
      "name": "Manual Demo Run",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        0,
        200
      ],
      "parameters": {}
    },
    {
      "id": "e6e60cd9-aa2f-470a-9ab8-0dccac6046ca",
      "name": "Scrape Settings",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        260,
        100
      ],
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "c007aed5-2047-4899-aa87-4d87e045a877",
              "name": "targetUrl",
              "value": "https://news.ycombinator.com/",
              "type": "string"
            },
            {
              "id": "f766b45c-e3cc-425e-ad63-85cbfbbe6575",
              "name": "scrapingGoal",
              "value": "Summarize the top tech and startup stories and what trends they suggest.",
              "type": "string"
            },
            {
              "id": "389ec7cf-dccb-4758-8f7c-55f9d849a297",
              "name": "tableName",
              "value": "website_intel",
              "type": "string"
            }
          ]
        },
        "options": {}
      }
    },
    {
      "id": "7b080159-1fcb-40dd-923d-df00fa944d10",
      "name": "Create Intel Table",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "alwaysOutputData": true,
      "position": [
        520,
        100
      ],
      "parameters": {
        "operation": "executeQuery",
        "query": "=CREATE TABLE IF NOT EXISTS {{ $json.tableName }} (\n  id serial PRIMARY KEY,\n  source_url text NOT NULL,\n  scraped_at timestamptz NOT NULL DEFAULT now(),\n  title text,\n  summary text,\n  findings text\n);",
        "options": {}
      }
    },
    {
      "id": "bb446300-f272-40b1-a62f-b9a2877c143d",
      "name": "Fetch Page with Browserless",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.3,
      "position": [
        780,
        100
      ],
      "parameters": {
        "method": "POST",
        "url": "http://browserless:3000/function",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpCustomAuth",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ code: \"export default async function ({ page }) { await page.setViewport({ width: 1366, height: 900 }); await page.goto(\" + JSON.stringify($('Scrape Settings').first().json.targetUrl) + \", { waitUntil: 'networkidle2', timeout: 45000 }); await page.waitForSelector('body'); return { data: await page.content(), type: 'text/html' }; }\" }) }}",
        "options": {
          "timeout": 90000
        }
      }
    },
    {
      "id": "e2a0e0fa-b34d-49aa-b31f-9d8bacbedc6f",
      "name": "Clean Page Text",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1040,
        100
      ],
      "parameters": {
        "jsCode": "// Turn raw HTML into clean plain text for the LLM\nconst MAX_CHARS = 12000; // character cap so the local model stays within context\nconst html = $json.data || '';\n\nlet text = html\n  .replace(/<(script|style|noscript|svg|nav|footer|header|form|iframe)[\\s\\S]*?<\\/\\1>/gi, ' ')\n  .replace(/<!--[\\s\\S]*?-->/g, ' ')\n  .replace(/<\\/(p|div|li|tr|h[1-6]|br)>/gi, '\\n')\n  .replace(/<[^>]+>/g, ' ')\n  .replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&lt;/g, '<')\n  .replace(/&gt;/g, '>').replace(/&quot;/g, '\"').replace(/&#x27;|&#39;/g, \"'\")\n  .replace(/[ \\t]+/g, ' ')\n  .replace(/\\s*\\n\\s*/g, '\\n')\n  .trim();\n\nif (text.length < 200) {\n  throw new Error('Page returned almost no text (' + text.length + ' chars). The site may block headless browsers or render client-side.');\n}\n\nconst titleMatch = html.match(/<title[^>]*>([\\s\\S]*?)<\\/title>/i);\nconst settings = $('Scrape Settings').first().json;\n\nreturn [{ json: {\n  url: settings.targetUrl,\n  goal: settings.scrapingGoal,\n  pageTitle: titleMatch ? titleMatch[1].trim() : '',\n  text: text.slice(0, MAX_CHARS),\n  totalChars: text.length,\n  truncated: text.length > MAX_CHARS,\n}}];"
      }
    },
    {
      "id": "31fae3f9-9c5a-4f90-9650-c8deaa50bcc9",
      "name": "Summarize with Ollama",
      "type": "@n8n/n8n-nodes-langchain.chainLlm",
      "typeVersion": 1.9,
      "onError": "continueRegularOutput",
      "position": [
        1300,
        100
      ],
      "parameters": {
        "promptType": "define",
        "text": "=Scraping goal: {{ $json.goal }}\n\nSource URL: {{ $json.url }}\n\nPage text:\n{{ $json.text }}",
        "messages": {
          "messageValues": [
            {
              "message": "You summarize scraped web pages for a market-intelligence database.\nReply with ONLY valid JSON, no markdown, in exactly this shape:\n{\"title\": \"short title under 80 characters\", \"summary\": \"2-3 sentence summary focused on the scraping goal\", \"bullets\": [\"finding 1\", \"finding 2\", \"finding 3\"]}\nGive 3 to 5 bullets. Use only facts from the page text."
            }
          ]
        }
      }
    },
    {
      "id": "fda81ec1-9ac2-465a-9685-48a2af8c7121",
      "name": "Ollama Chat Model",
      "type": "@n8n/n8n-nodes-langchain.lmChatOllama",
      "typeVersion": 1,
      "position": [
        1320,
        320
      ],
      "parameters": {
        "model": "qwen2.5:14b-instruct-q5_K_M",
        "options": {
          "temperature": 0.2,
          "numCtx": 8192
        }
      }
    },
    {
      "id": "7004822b-5ce1-4007-817c-df377cf7730e",
      "name": "Format Row",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1580,
        100
      ],
      "parameters": {
        "jsCode": "// Reshape the model output into one insertable row\nconst page = $('Clean Page Text').first().json;\nconst raw = ($json.text || $json.response || '').trim();\n\nlet parsed = {};\ntry {\n  parsed = JSON.parse(raw.replace(/^```(?:json)?/i, '').replace(/```$/, '').trim());\n} catch (e) {\n  parsed = { title: page.pageTitle, summary: raw, bullets: [] };\n}\n\nconst bullets = Array.isArray(parsed.bullets) ? parsed.bullets : [];\n\nreturn [{ json: {\n  source_url: page.url,\n  scraped_at: new Date().toISOString(),\n  title: String(parsed.title || page.pageTitle || 'Untitled').slice(0, 200),\n  summary: String(parsed.summary || '').trim(),\n  findings: bullets.map(b => '- ' + String(b).trim()).join('\\n'),\n}}];"
      }
    },
    {
      "id": "137ed361-5ca4-4528-b88a-747d93d945bc",
      "name": "Insert Intel Row",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "onError": "continueRegularOutput",
      "position": [
        1840,
        100
      ],
      "parameters": {
        "operation": "executeQuery",
        "query": "=INSERT INTO {{ $('Scrape Settings').first().json.tableName }} (source_url, scraped_at, title, summary, findings)\nVALUES ($1, $2, $3, $4, $5)\nRETURNING id, source_url, title, scraped_at;",
        "options": {
          "queryReplacement": "={{ [$json.source_url, $json.scraped_at, $json.title, $json.summary, $json.findings] }}"
        }
      }
    },
    {
      "id": "8e9a8270-2c47-4467-89c2-ce212d7a901e",
      "name": "Insert Succeeded?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        2100,
        100
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "11cbae1e-d0bd-4250-966f-deaa5c78f837",
              "leftValue": "={{ $json.error }}",
              "rightValue": "",
              "operator": {
                "type": "string",
                "operation": "empty",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      }
    },
    {
      "id": "5a52af9b-31ef-4fa0-94e4-523b368fee85",
      "name": "Success",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        2360,
        0
      ],
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "9d8ce7bb-75cf-4a6c-afda-7730c3e4aa04",
              "name": "status",
              "value": "success",
              "type": "string"
            },
            {
              "id": "30d9879e-45c5-4960-8457-5684e3329a67",
              "name": "rowId",
              "value": "={{ $json.id }}",
              "type": "number"
            },
            {
              "id": "e71e7900-3747-4424-9ab6-451c58e0f843",
              "name": "title",
              "value": "={{ $json.title }}",
              "type": "string"
            }
          ]
        },
        "options": {}
      }
    },
    {
      "id": "f5beefa9-1cd9-4af4-98fc-73b79a190aa5",
      "name": "Debug: Log Error",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        2360,
        220
      ],
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "4d18220c-338e-438a-9088-f102f9737d02",
              "name": "status",
              "value": "failed",
              "type": "string"
            },
            {
              "id": "85c0fe27-b606-4ba7-8956-6165a631d217",
              "name": "errorMessage",
              "value": "={{ $json.error?.message || $json.error || 'Unknown error' }}",
              "type": "string"
            },
            {
              "id": "086cda12-3f14-41b0-b376-2d87452b3805",
              "name": "failedAt",
              "value": "={{ $now.toISO() }}",
              "type": "string"
            }
          ]
        },
        "options": {}
      }
    },
    {
      "id": "6dd05f5a-2eb3-4062-82d3-e54ff4ce9f0a",
      "name": "Group: 1. Trigger and settings",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -30,
        -120
      ],
      "parameters": {
        "content": "1. Trigger and settings\nSchedule or manual run, then one Set node holds URL, goal and table.",
        "width": 520,
        "height": 420,
        "color": 7
      }
    },
    {
      "id": "e99a1114-4658-406e-aade-ee332be0f0fc",
      "name": "Group: 2. Scrape",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        490,
        -120
      ],
      "parameters": {
        "content": "2. Scrape\nBrowserless renders the page and returns HTML.",
        "width": 530,
        "height": 420,
        "color": 4
      }
    },
    {
      "id": "dd0c99ec-4859-415a-b75c-93c4b4eaa6da",
      "name": "Group: 3. Clean",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1010,
        -120
      ],
      "parameters": {
        "content": "3. Clean\nCode node strips noise and caps text at 12000 characters.",
        "width": 250,
        "height": 420,
        "color": 5
      }
    },
    {
      "id": "1982a8d2-000a-4114-b9d8-4569d596c1b4",
      "name": "Group: 4. Summarize",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1270,
        -120
      ],
      "parameters": {
        "content": "4. Summarize\nBasic LLM Chain + local Ollama model.",
        "width": 290,
        "height": 600,
        "color": 6
      }
    },
    {
      "id": "d96b1501-7fbd-48a8-a785-b7faf84abda8",
      "name": "Group: 5. Store and check",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1560,
        -120
      ],
      "parameters": {
        "content": "5. Store and check\nFormat one row, insert into Postgres, branch on success.",
        "width": 1000,
        "height": 420,
        "color": 3
      }
    }
  ],
  "connections": {
    "Daily Schedule": {
      "main": [
        [
          {
            "node": "Scrape Settings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Manual Demo Run": {
      "main": [
        [
          {
            "node": "Scrape Settings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Scrape Settings": {
      "main": [
        [
          {
            "node": "Create Intel Table",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Create Intel Table": {
      "main": [
        [
          {
            "node": "Fetch Page with Browserless",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Page with Browserless": {
      "main": [
        [
          {
            "node": "Clean Page Text",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Clean Page Text": {
      "main": [
        [
          {
            "node": "Summarize with Ollama",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Summarize with Ollama": {
      "main": [
        [
          {
            "node": "Format Row",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format Row": {
      "main": [
        [
          {
            "node": "Insert Intel Row",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Insert Intel Row": {
      "main": [
        [
          {
            "node": "Insert Succeeded?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Ollama Chat Model": {
      "ai_languageModel": [
        [
          {
            "node": "Summarize with Ollama",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Insert Succeeded?": {
      "main": [
        [
          {
            "node": "Success",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Debug: Log Error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  }
}
