{
  "version": "1.0.0",
  "exported_at": "2026-05-31T00:00:00.000Z",
  "project": {
    "name": "Localch Lead Scraper for Switzerland",
    "description": "Scrapes local.ch business leads for editable keyword/location searches. Extracts keyword, title, location, category, opening status, rating, review count, phone/mobile when visible, email when visible, website when visible, description/review snippet, languages when visible, and local.ch profile URL. Pagination strategy: finite known URL list for pages 1-10 per keyword/location using navigate.urls plus loop-continue. Cookie handling is done with a non-failing JavaScript cleanup step. Uses local_lead_scraper.csv to avoid stale append data from earlier test runs.",
    "color": "bg-[#4589ff]",
    "template_id": "ai-generated"
  },
  "blocks": [
    {
      "block_id": "navigate-1",
      "block_type": "process",
      "title": "Navigate",
      "description": "Go to a URL",
      "position_x": 120,
      "position_y": 220,
      "config": {
        "urls": [
          "https://www.local.ch/en/q?what=Restaurant&where=Bern&page=1",
          "https://www.local.ch/en/q?what=Restaurant&where=Bern&page=2",
          "https://www.local.ch/en/q?what=Restaurant&where=Bern&page=3",
          "https://www.local.ch/en/q?what=Restaurant&where=Bern&page=4",
          "https://www.local.ch/en/q?what=Restaurant&where=Bern&page=5",
          "https://www.local.ch/en/q?what=Restaurant&where=Bern&page=6",
          "https://www.local.ch/en/q?what=Restaurant&where=Bern&page=7",
          "https://www.local.ch/en/q?what=Restaurant&where=Bern&page=8",
          "https://www.local.ch/en/q?what=Restaurant&where=Bern&page=9",
          "https://www.local.ch/en/q?what=Restaurant&where=Bern&page=10",
          "https://www.local.ch/de/q?what=anwalt&where=Basel&page=1",
          "https://www.local.ch/de/q?what=anwalt&where=Basel&page=2",
          "https://www.local.ch/de/q?what=anwalt&where=Basel&page=3",
          "https://www.local.ch/de/q?what=anwalt&where=Basel&page=4",
          "https://www.local.ch/de/q?what=anwalt&where=Basel&page=5",
          "https://www.local.ch/de/q?what=anwalt&where=Basel&page=6",
          "https://www.local.ch/de/q?what=anwalt&where=Basel&page=7",
          "https://www.local.ch/de/q?what=anwalt&where=Basel&page=8",
          "https://www.local.ch/de/q?what=anwalt&where=Basel&page=9",
          "https://www.local.ch/de/q?what=anwalt&where=Basel&page=10",
          "https://www.local.ch/de/q?what=anwalt&where=Z%C3%BCrich&page=1",
          "https://www.local.ch/de/q?what=anwalt&where=Z%C3%BCrich&page=2",
          "https://www.local.ch/de/q?what=anwalt&where=Z%C3%BCrich&page=3",
          "https://www.local.ch/de/q?what=anwalt&where=Z%C3%BCrich&page=4",
          "https://www.local.ch/de/q?what=anwalt&where=Z%C3%BCrich&page=5",
          "https://www.local.ch/de/q?what=anwalt&where=Z%C3%BCrich&page=6",
          "https://www.local.ch/de/q?what=anwalt&where=Z%C3%BCrich&page=7",
          "https://www.local.ch/de/q?what=anwalt&where=Z%C3%BCrich&page=8",
          "https://www.local.ch/de/q?what=anwalt&where=Z%C3%BCrich&page=9",
          "https://www.local.ch/de/q?what=anwalt&where=Z%C3%BCrich&page=10",
          "https://www.local.ch/de/q?what=Podologie&where=&page=1",
          "https://www.local.ch/de/q?what=Podologie&where=&page=2",
          "https://www.local.ch/de/q?what=Podologie&where=&page=3",
          "https://www.local.ch/de/q?what=Podologie&where=&page=4",
          "https://www.local.ch/de/q?what=Podologie&where=&page=5",
          "https://www.local.ch/de/q?what=Podologie&where=&page=6",
          "https://www.local.ch/de/q?what=Podologie&where=&page=7",
          "https://www.local.ch/de/q?what=Podologie&where=&page=8",
          "https://www.local.ch/de/q?what=Podologie&where=&page=9",
          "https://www.local.ch/de/q?what=Podologie&where=&page=10"
        ],
        "color": "bg-[#4589ff]"
      }
    },
    {
      "block_id": "wait-for-page-load-1",
      "block_type": "process",
      "title": "Wait for Page Load",
      "description": "Wait for page to finish loading",
      "position_x": 480,
      "position_y": 220,
      "config": {
        "timeout": 30
      }
    },
    {
      "block_id": "sleep-1",
      "block_type": "process",
      "title": "Sleep",
      "description": "Wait for specified time",
      "position_x": 840,
      "position_y": 220,
      "config": {
        "duration": 2
      }
    },
    {
      "block_id": "inject-javascript-1",
      "block_type": "process",
      "title": "Inject JavaScript",
      "description": "Execute JavaScript on page",
      "position_x": 1200,
      "position_y": 220,
      "config": {
        "jsCode": "(() => { const visible = el => !!(el && (el.offsetWidth || el.offsetHeight || el.getClientRects().length)); const buttons = Array.from(document.querySelectorAll('button, [role=\"button\"], input[type=\"button\"], input[type=\"submit\"]')); const accept = buttons.find(el => visible(el) && /^(allow all|accept|accept all|akzeptieren|alle akzeptieren|tout accepter|accepter|accetta|accetta tutto)$/i.test((el.textContent || el.value || '').trim())); if (accept) accept.click(); const overlays = Array.from(document.querySelectorAll('#onetrust-banner-sdk, #onetrust-consent-sdk, .ot-sdk-container, .onetrust-pc-dark-filter, [id*=\"onetrust\"], [class*=\"onetrust\"], [class*=\"cookie\" i]')); overlays.forEach(el => { if (visible(el)) { el.style.setProperty('display', 'none', 'important'); el.style.setProperty('visibility', 'hidden', 'important'); el.style.setProperty('pointer-events', 'none', 'important'); } }); document.body && document.body.style.setProperty('overflow', 'auto', 'important'); return true; })();",
        "waitForCompletion": true,
        "timeout": 10
      }
    },
    {
      "block_id": "sleep-2",
      "block_type": "process",
      "title": "Sleep",
      "description": "Wait for specified time",
      "position_x": 1560,
      "position_y": 220,
      "config": {
        "duration": 2
      }
    },
    {
      "block_id": "structured-export-1",
      "block_type": "process",
      "title": "Structured Export",
      "description": "Export data with custom columns",
      "position_x": 1920,
      "position_y": 220,
      "config": {
        "rowSelector": "article:has(address):has(a[href*=\"/d/\"])",
        "fileName": "local_lead_scraper.csv",
        "saveLocation": "C:\\Users\\theskd\\Documents\\UScraper\\templates",
        "includeHeaders": true,
        "fileMode": "append",
        "columns": [
          {
            "name": "keyword",
            "selector": "(() => { const u = new URL(location.href); const what = u.searchParams.get('what') || ''; const where = u.searchParams.get('where') || ''; return where ? `${what} in ${where}` : what; })()",
            "attribute": "text",
            "isJs": true
          },
          {
            "name": "title",
            "selector": "(() => { const clean = s => (s || '').replace(/\\s+/g, ' ').trim(); const txt = clean(ROW.innerText); const heading = Array.from(ROW.querySelectorAll('h2,h3,[itemprop=\"name\"]')).map(e => clean(e.innerText || e.textContent)).find(s => s && s.length < 90 && !/recommended by local\\.ch|^Restaurant in|^Anwalt in|^Podologie/i.test(s)); if (heading) return heading; const addr = txt.match(/[A-ZÄÖÜÀ-ÿ][^,]{1,80}(?:strasse|straße|gasse|weg|platz|allee|ring|quai|route|rue|via|graben)[^,]{0,60},\\s*\\d{4}\\s+[A-ZÄÖÜÀ-ÿ][A-Za-zÄÖÜäöüéèàçÉÈÀÇ .'-]+/i); if (addr && addr.index > 0) return clean(txt.slice(0, addr.index)); const a = ROW.querySelector('a[href*=\"/d/\"]'); return clean(a ? (a.innerText || a.textContent) : '').slice(0, 120); })()",
            "attribute": "text",
            "isJs": true
          },
          {
            "name": "location",
            "selector": "(() => { const clean = s => (s || '').replace(/\\s+/g, ' ').trim(); const address = ROW.querySelector('address'); if (address) return clean(address.innerText.replace(/\\n+/g, ', ')); const txt = clean(ROW.innerText); const addr = txt.match(/[A-ZÄÖÜÀ-ÿ][^,]{1,80}(?:strasse|straße|gasse|weg|platz|allee|ring|quai|route|rue|via|graben)[^,]{0,60},\\s*\\d{4}\\s+[A-ZÄÖÜÀ-ÿ][A-Za-zÄÖÜäöüéèàçÉÈÀÇ .'-]+/i); return addr ? clean(addr[0]) : ''; })()",
            "attribute": "text",
            "isJs": true
          },
          {
            "name": "category",
            "selector": "(() => { const clean = s => (s || '').replace(/\\s+/g, ' ').trim(); const txt = clean(ROW.innerText); const addr = txt.match(/[A-ZÄÖÜÀ-ÿ][^,]{1,80}(?:strasse|straße|gasse|weg|platz|allee|ring|quai|route|rue|via|graben)[^,]{0,60},\\s*\\d{4}\\s+[A-ZÄÖÜÀ-ÿ][A-Za-zÄÖÜäöüéèàçÉÈÀÇ .'-]+/i); let tail = addr ? txt.slice(addr.index + addr[0].length) : txt; tail = tail.replace(/([a-zäöüéèàç])([A-ZÄÖÜ][a-zäöüéèàç])/g, '$1|$2'); tail = tail.split(/Rating|No reviews|Keine Bewertungen|Closed|Open|Geschlossen|Offen|\\d{1,2}\\.\\s[A-Z]|\\d{1,2}\\s(?:January|February|March|April|May|June|July|August|September|October|November|December)/i)[0]; const parts = tail.split(/[•|]/).map(clean).filter(s => s && s.length < 60 && !/@|www\\.|https?:|\\+41|^0\\d{2}|^P\\.O\\.|^Postfach/i.test(s)); return Array.from(new Set(parts)).slice(0, 8).join('•'); })()",
            "attribute": "text",
            "isJs": true
          },
          {
            "name": "opening_hours",
            "selector": "(() => { const txt = (ROW.innerText || '').replace(/\\s+/g, ' ').trim(); const m = txt.match(/\\b(Open now|Open until[^.]*|Closed until[^.]*|Geschlossen bis[^.]*|Offen bis[^.]*|Ouvert[^.]*|Fermé[^.]*|Aperto[^.]*|Chiuso[^.]*)/i); return m ? m[1].trim() : ''; })()",
            "attribute": "text",
            "isJs": true
          },
          {
            "name": "customer_rating",
            "selector": "(() => { const txt = ROW.innerText || ''; const m = txt.match(/(?:Rating\\s*)?([1-5][.,]\\d|5(?:\\.0)?)(?:\\s*\\/\\s*5|\\s*of\\s*5|\\s*stars)?/i); return m ? m[1].replace(',', '.') : ''; })()",
            "attribute": "text",
            "isJs": true
          },
          {
            "name": "reviews_count",
            "selector": "(() => { const txt = ROW.innerText || ''; if (/from one rating/i.test(txt)) return '1'; const m = txt.match(/from\\s+(\\d[\\d'’ ]*)\\s+(?:ratings?|reviews?)/i) || txt.match(/\\((\\d[\\d'’ ]*)\\)/) || txt.match(/(\\d[\\d'’ ]*)\\s*(Bewertungen|reviews|avis|recensioni)/i); return m ? m[1].replace(/[^\\d]/g, '') : ''; })()",
            "attribute": "text",
            "isJs": true
          },
          {
            "name": "telephone",
            "selector": "(() => { const tel = ROW.querySelector('a[href^=\"tel:\"]'); if (tel) return (tel.textContent || tel.href.replace(/^tel:/, '')).trim(); const phones = Array.from(new Set(((ROW.innerText || '').match(/(?:\\+41|0)\\s?\\d{2}\\s?\\d{3}\\s?\\d{2}\\s?\\d{2}/g) || []).map(p => p.replace(/\\s+/g, ' ').trim()))); return phones.find(p => !/^(?:\\+41\\s?7|07)/.test(p)) || ''; })()",
            "attribute": "text",
            "isJs": true
          },
          {
            "name": "mobile_phone",
            "selector": "(() => { const phones = Array.from(new Set(((ROW.innerText || '').match(/(?:\\+41|0)\\s?\\d{2}\\s?\\d{3}\\s?\\d{2}\\s?\\d{2}/g) || []).map(p => p.replace(/\\s+/g, ' ').trim()))); return phones.find(p => /^(?:\\+41\\s?7|07)/.test(p)) || ''; })()",
            "attribute": "text",
            "isJs": true
          },
          {
            "name": "email",
            "selector": "(() => { const mail = ROW.querySelector('a[href^=\"mailto:\"]'); if (mail) return mail.href.replace(/^mailto:/, '').split('?')[0]; const m = (ROW.innerText || '').match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}/ig); return m ? Array.from(new Set(m))[0] : ''; })()",
            "attribute": "text",
            "isJs": true
          },
          {
            "name": "website",
            "selector": "(() => { const links = Array.from(ROW.querySelectorAll('a[href^=\"http\"]')); const a = links.find(a => { try { const h = new URL(a.href).hostname; return !/local\\.ch$/i.test(h) && !/google|facebook|instagram|maps|maplibre/i.test(h); } catch(e) { return false; } }); return a ? (a.textContent.trim() || a.href).replace(/^https?:\\/\\//, '').replace(/\\/$/, '') : ''; })()",
            "attribute": "text",
            "isJs": true
          },
          {
            "name": "description",
            "selector": "(() => { const clean = s => (s || '').replace(/\\s+/g, ' ').trim(); const txt = clean(ROW.innerText); let d = ''; const ratingIdx = txt.search(/Rating\\s*[1-5]|[1-5][.,]\\d\\s*\\/\\s*5|No reviews|Keine Bewertungen/i); const date = txt.match(/\\d{1,2}\\.\\s(?:January|February|March|April|May|June|July|August|September|October|November|December|Januar|Februar|März|April|Mai|Juni|Juli|August|September|Oktober|November|Dezember)\\s+\\d{4}/i); if (date) { const beforeDate = txt.slice(0, date.index); const addr = beforeDate.match(/[A-ZÄÖÜÀ-ÿ][^,]{1,80}(?:strasse|straße|gasse|weg|platz|allee|ring|quai|route|rue|via|graben)[^,]{0,60},\\s*\\d{4}\\s+[A-ZÄÖÜÀ-ÿ][A-Za-zÄÖÜäöüéèàçÉÈÀÇ .'-]+/i); if (addr) d = beforeDate.slice(addr.index + addr[0].length); } if (!d && ratingIdx > 0) d = txt.slice(0, ratingIdx); d = d.replace(/.*?(?:Restaurant|Takeout|Home delivery service|Pizzeria|Anwalt|Podologie|Fusspflege|Hotel|Banquet|Events)\\s*/i, '').replace(/[•|]/g, ' '); d = clean(d); return d.length > 25 ? d.slice(0, 500) : ''; })()",
            "attribute": "text",
            "isJs": true
          },
          {
            "name": "languages",
            "selector": "(() => { const txt = ROW.innerText || ''; const marker = txt.match(/(?:Languages|Sprachen|Langues|Lingue)[:\\s]+([\\s\\S]{0,160})/i); if (!marker) return ''; const langs = ['Deutsch', 'Englisch', 'Französisch', 'Italienisch', 'Portugiesisch', 'Spanisch', 'German', 'English', 'French', 'Italian', 'Portuguese', 'Spanish']; return langs.filter(l => new RegExp('\\\\b' + l + '\\\\b', 'i').test(marker[1])).join(','); })()",
            "attribute": "text",
            "isJs": true
          },
          {
            "name": "profile_url",
            "selector": "(() => { const a = ROW.querySelector('a[href*=\"/d/\"]'); return a ? a.href : ''; })()",
            "attribute": "text",
            "isJs": true
          }
        ]
      }
    },
    {
      "block_id": "loop-continue-1",
      "block_type": "process",
      "title": "Loop Continue",
      "description": "Continue multi-input loop",
      "position_x": 2280,
      "position_y": 220,
      "config": {}
    }
  ],
  "connections": [
    {
      "from_block_id": "navigate-1",
      "from_connector_id": "right",
      "to_block_id": "wait-for-page-load-1",
      "to_connector_id": "left"
    },
    {
      "from_block_id": "wait-for-page-load-1",
      "from_connector_id": "right",
      "to_block_id": "sleep-1",
      "to_connector_id": "left"
    },
    {
      "from_block_id": "sleep-1",
      "from_connector_id": "right",
      "to_block_id": "inject-javascript-1",
      "to_connector_id": "left"
    },
    {
      "from_block_id": "inject-javascript-1",
      "from_connector_id": "right",
      "to_block_id": "sleep-2",
      "to_connector_id": "left"
    },
    {
      "from_block_id": "sleep-2",
      "from_connector_id": "right",
      "to_block_id": "structured-export-1",
      "to_connector_id": "left"
    },
    {
      "from_block_id": "structured-export-1",
      "from_connector_id": "right",
      "to_block_id": "loop-continue-1",
      "to_connector_id": "left"
    }
  ],
  "canvas_elements": [
    {
      "id": "group-load",
      "element_type": "group",
      "title": "Page Load",
      "color": "#08bdba",
      "position_x": 48,
      "position_y": 116,
      "width": 1760,
      "height": 296,
      "z_index": 20,
      "data": {
        "memberBlockIds": [
          "navigate-1",
          "wait-for-page-load-1",
          "sleep-1",
          "sleep-2"
        ]
      }
    },
    {
      "id": "group-interaction",
      "element_type": "group",
      "title": "Interaction",
      "color": "#a56eff",
      "position_x": 1128,
      "position_y": 116,
      "width": 380,
      "height": 296,
      "z_index": 20,
      "data": {
        "memberBlockIds": [
          "inject-javascript-1"
        ]
      }
    },
    {
      "id": "group-extract",
      "element_type": "group",
      "title": "Data Extraction",
      "color": "#42be65",
      "position_x": 1848,
      "position_y": 116,
      "width": 380,
      "height": 296,
      "z_index": 20,
      "data": {
        "memberBlockIds": [
          "structured-export-1"
        ]
      }
    },
    {
      "id": "group-pagination",
      "element_type": "group",
      "title": "Pagination Loop",
      "color": "#ff832b",
      "position_x": 2208,
      "position_y": 116,
      "width": 380,
      "height": 296,
      "z_index": 20,
      "data": {
        "memberBlockIds": [
          "loop-continue-1"
        ]
      }
    },
    {
      "id": "note-overview",
      "element_type": "note",
      "title": "Overview",
      "content": "Scrapes local.ch business leads for editable keyword/location searches. Extracts keyword, title, location, category, opening status, rating, review count, phone/mobile when visible, email when visible, website when visible, description/review snippet, languages when visible, and local.ch profile URL. Pagination strategy: finite known URL list for pages 1-10 per keyword/location using navigate.urls plus loop-continue. Cookie handling is done with a non-failing JavaScript cleanup step. Uses local_lead_scraper.csv to avoid stale append data from earlier test runs.",
      "color": "#f1c21b",
      "position_x": 80,
      "position_y": 20,
      "width": 480,
      "height": 160,
      "z_index": 22,
      "data": {}
    },
    {
      "id": "note-block-inject-javascript-1",
      "element_type": "note",
      "title": "Note: Inject JavaScript",
      "content": "Runs custom JavaScript in the page: `(() => { const visible = el => !!(el && (el.offsetWidth || el.offsetHeight || el.getClientRects().le...` Verify in browser if results are empty.",
      "color": "#ee5396",
      "position_x": 1400,
      "position_y": 200,
      "width": 340,
      "height": 140,
      "z_index": 22,
      "data": {
        "block_id": "inject-javascript-1"
      }
    },
    {
      "id": "note-block-structured-export-1",
      "element_type": "note",
      "title": "Note: Structured Export",
      "content": "Structured export with JS columns (keyword, title, location, category, opening_hours). These selectors are fragile — update if the site layout changes.",
      "color": "#ee5396",
      "position_x": 2120,
      "position_y": 200,
      "width": 340,
      "height": 130,
      "z_index": 22,
      "data": {
        "block_id": "structured-export-1"
      }
    },
    {
      "id": "note-block-loop-continue-1",
      "element_type": "note",
      "title": "Note: Loop Continue",
      "content": "Loop Continue advances a multi-URL or multi-text loop. Place at the end of the loop body with a clear back-edge to the loop start.",
      "color": "#ee5396",
      "position_x": 2480,
      "position_y": 200,
      "width": 340,
      "height": 123,
      "z_index": 22,
      "data": {
        "block_id": "loop-continue-1"
      }
    }
  ]
}