{
  "version": "1.0.0",
  "exported_at": "2026-06-01T23:30:00.000Z",
  "project": {
    "name": "Searchch Lead Scraper via URL",
    "description": "Scrapes Search.ch directory leads for https://search.ch/tel/?was=Restaurant&wo=Berne. Extracts title, address, phone numbers, fax, email, website, detail link, categories, information, rating, review count, and source URL. Search.ch reports 762 entries but the public listing URL appears to cap lazy-loaded result cards at about 200 via the pages parameter; this template loads the maximum public listing batch with pages=20, scrolls until stable, then enriches each visible listing by fetching its Search.ch detail page when available. Email/fax/information may still be blank if Search.ch does not expose them publicly.",
    "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": {
        "url": "https://search.ch/tel/?was=Restaurant&wo=Berne&pages=20",
        "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": 45
      }
    },
    {
      "block_id": "wait-for-element-1",
      "block_type": "process",
      "title": "Wait for Element",
      "description": "Wait until element appears",
      "position_x": 840,
      "position_y": 220,
      "config": {
        "selector": "article.tel-resultentry",
        "timeout": 45,
        "visible": true
      }
    },
    {
      "block_id": "inject-javascript-1",
      "block_type": "process",
      "title": "Inject JavaScript",
      "description": "Execute custom JavaScript",
      "position_x": 1200,
      "position_y": 220,
      "config": {
        "jsCode": "return (async () => {\n  const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));\n  const uniq = arr => Array.from(new Set(arr.map(v => String(v || '').trim()).filter(Boolean)));\n  const blockedHosts = ['search.ch', 'booking-widget.services.local.ch', 'bookingwidget.local.ch', 'mylocalina.ch', 'calendato.com'];\n\n  document.querySelectorAll('#onetrust-consent-sdk, .onetrust-pc-dark-filter, .ot-sdk-container, [class*=\"cookie\" i][role=\"dialog\"], [id*=\"cookie\" i][role=\"dialog\"]').forEach(el => {\n    try { el.remove(); } catch (e) {}\n  });\n\n  let lastCount = document.querySelectorAll('article.tel-resultentry').length;\n  let lastHeight = document.body.scrollHeight;\n  let stableRounds = 0;\n  for (let i = 0; i < 35 && stableRounds < 5; i++) {\n    window.scrollTo(0, document.body.scrollHeight);\n    await sleep(1000);\n    const currentCount = document.querySelectorAll('article.tel-resultentry').length;\n    const currentHeight = document.body.scrollHeight;\n    if (currentCount === lastCount && currentHeight === lastHeight) stableRounds += 1;\n    else stableRounds = 0;\n    lastCount = currentCount;\n    lastHeight = currentHeight;\n  }\n\n  const parser = new DOMParser();\n  const rows = Array.from(document.querySelectorAll('article.tel-resultentry'));\n\n  function absUrl(href) {\n    try { return href ? new URL(href, location.origin).href : ''; } catch (e) { return href || ''; }\n  }\n\n  function extractWebsite(root) {\n    const links = Array.from(root.querySelectorAll('a[href^=\"http\"]'));\n    const candidates = links.filter(a => {\n      const txt = (a.textContent || '').trim().toLowerCase();\n      const cls = a.className || '';\n      const href = a.href || '';\n      const isWebAction = /(^|\\s)sl-icon-website(\\s|$)/.test(cls) || txt === 'web' || txt.startsWith('www.');\n      const blocked = blockedHosts.some(host => href.includes(host));\n      return isWebAction && !blocked;\n    });\n    return candidates[0] ? candidates[0].href : '';\n  }\n\n  function extractInfo(root) {\n    const selectors = ['.tel-info', '.tel-information', '.tel-description', '.tel-entry-info', '.tel-detail-text', '[itemprop=\"description\"]', '.sl-card-body p', 'article p'];\n    for (const sel of selectors) {\n      const text = Array.from(root.querySelectorAll(sel)).map(el => (el.textContent || '').replace(/\\s+/g, ' ').trim()).filter(Boolean).join(' ');\n      if (text && text.length > 20) return text.slice(0, 3000);\n    }\n    const meta = root.querySelector('meta[name=\"description\"]')?.getAttribute('content') || '';\n    return meta.replace(/\\s+/g, ' ').trim().slice(0, 3000);\n  }\n\n  async function enrich(row) {\n    const detailA = row.querySelector('h1 a, a.tel-result-detail-link');\n    const detailUrl = absUrl(detailA ? detailA.getAttribute('href') : '');\n    row.dataset.usDetailLink = detailUrl;\n\n    const rowPhones = uniq(Array.from(row.querySelectorAll('a[href^=\"tel:\"]')).map(a => a.textContent || a.getAttribute('href').replace(/^tel:/, '')));\n    row.dataset.usPhoneNumbers = rowPhones.join(' | ');\n\n    const rowWebsite = extractWebsite(row);\n    if (rowWebsite) row.dataset.usWebsite = rowWebsite;\n\n    if (!detailUrl || !detailUrl.includes(location.host)) return;\n\n    try {\n      const response = await fetch(detailUrl, { credentials: 'include' });\n      if (!response.ok) return;\n      const html = await response.text();\n      const doc = parser.parseFromString(html, 'text/html');\n\n      const emails = uniq(Array.from(doc.querySelectorAll('a[href^=\"mailto:\"]')).map(a => (a.textContent || a.getAttribute('href').replace(/^mailto:/, '')).split('?')[0]));\n      const fax = uniq(Array.from(doc.querySelectorAll('a[href^=\"fax:\"], .tel-fax, [class*=\"fax\" i]')).map(el => (el.textContent || el.getAttribute('href') || '').replace(/^fax:/, '')));\n      const detailPhones = uniq(Array.from(doc.querySelectorAll('a[href^=\"tel:\"]')).map(a => a.textContent || a.getAttribute('href').replace(/^tel:/, '')));\n      const detailWebsite = extractWebsite(doc);\n      const info = extractInfo(doc);\n\n      if (emails.length) row.dataset.usEmail = emails.join(' | ');\n      if (fax.length) row.dataset.usFax = fax.join(' | ');\n      if (detailPhones.length) row.dataset.usPhoneNumbers = uniq(rowPhones.concat(detailPhones)).join(' | ');\n      if (detailWebsite) row.dataset.usWebsite = detailWebsite;\n      if (info) row.dataset.usInformation = info;\n    } catch (e) {}\n  }\n\n  const concurrency = 6;\n  let index = 0;\n  async function worker() {\n    while (index < rows.length) {\n      const row = rows[index++];\n      await enrich(row);\n      await sleep(120);\n    }\n  }\n  await Promise.all(Array.from({ length: concurrency }, worker));\n\n  window.scrollTo(0, 0);\n  await sleep(500);\n  return rows.length;\n})();",
        "waitForCompletion": true,
        "timeout": 300
      }
    },
    {
      "block_id": "sleep-1",
      "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.tel-resultentry",
        "fileName": "search-lead-scraper-via-url.csv",
        "saveLocation": "C:\\Users\\theskd\\Documents\\UScraper\\templates",
        "includeHeaders": true,
        "fileMode": "create",
        "columns": [
          {
            "name": "title",
            "selector": "h1 a",
            "attribute": "text"
          },
          {
            "name": "address",
            "selector": ".tel-address",
            "attribute": "text"
          },
          {
            "name": "phone_numbers",
            "selector": "ROW.dataset.usPhoneNumbers || Array.from(new Set(Array.from(ROW.querySelectorAll('a[href^=\"tel:\"]')).map(a => (a.textContent || a.getAttribute('href').replace(/^tel:/, '')).trim()).filter(Boolean))).join(' | ')",
            "attribute": "text",
            "isJs": true
          },
          {
            "name": "fax",
            "selector": "ROW.dataset.usFax || ''",
            "attribute": "text",
            "isJs": true
          },
          {
            "name": "email",
            "selector": "ROW.dataset.usEmail || Array.from(new Set(Array.from(ROW.querySelectorAll('a[href^=\"mailto:\"]')).map(a => (a.textContent || a.getAttribute('href').replace(/^mailto:/, '')).split('?')[0].trim()).filter(Boolean))).join(' | ')",
            "attribute": "text",
            "isJs": true
          },
          {
            "name": "website",
            "selector": "ROW.dataset.usWebsite || ''",
            "attribute": "text",
            "isJs": true
          },
          {
            "name": "detail_link",
            "selector": "ROW.dataset.usDetailLink || (() => { const a = ROW.querySelector('h1 a, a.tel-result-detail-link'); return a ? new URL(a.getAttribute('href'), location.origin).href : ''; })()",
            "attribute": "text",
            "isJs": true
          },
          {
            "name": "categories",
            "selector": ".tel-categories",
            "attribute": "text"
          },
          {
            "name": "information",
            "selector": "ROW.dataset.usInformation || ''",
            "attribute": "text",
            "isJs": true
          },
          {
            "name": "rating",
            "selector": "(() => { const el = ROW.querySelector('.tel-rating'); const title = el ? (el.getAttribute('title') || '') : ''; const m = title.match(/([0-9]+(?:\\.[0-9]+)?)/); return m ? m[1] : title; })()",
            "attribute": "text",
            "isJs": true
          },
          {
            "name": "review_count",
            "selector": ".sl-smaller",
            "attribute": "text"
          },
          {
            "name": "source_url",
            "selector": "location.href",
            "attribute": "text",
            "isJs": true
          }
        ]
      }
    },
    {
      "block_id": "end-1",
      "block_type": "output",
      "title": "End",
      "description": "Terminate execution flow",
      "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": "wait-for-element-1",
      "to_connector_id": "left"
    },
    {
      "from_block_id": "wait-for-element-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-1",
      "to_connector_id": "left"
    },
    {
      "from_block_id": "sleep-1",
      "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": "end-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",
          "wait-for-element-1",
          "sleep-1"
        ]
      }
    },
    {
      "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-control",
      "element_type": "group",
      "title": "Control Flow",
      "color": "#8d8d8d",
      "position_x": 2208,
      "position_y": 116,
      "width": 380,
      "height": 296,
      "z_index": 20,
      "data": {
        "memberBlockIds": [
          "end-1"
        ]
      }
    },
    {
      "id": "note-overview",
      "element_type": "note",
      "title": "Overview",
      "content": "Scrapes Search.ch directory leads for https://search.ch/tel/?was=Restaurant&wo=Berne. Extracts title, address, phone numbers, fax, email, website, detail link, categories, information, rating, review count, and source URL. Search.ch reports 762 entries but the public listing URL appears to cap lazy-loaded result cards at about 200 via the pages parameter; this template loads the maximum public listing batch with pages=20, scrolls until stable, then enriches each visible listing by fetching its Search.ch detail page when available. Email/fax/information may still be blank if Search.ch does not expose them publicly.",
      "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: `return (async () => {\n  const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));\n  const...` 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 (phone_numbers, fax, email, website, detail_link). These selectors are fragile — update if the site layout changes.",
      "color": "#ee5396",
      "position_x": 2120,
      "position_y": 200,
      "width": 340,
      "height": 129,
      "z_index": 22,
      "data": {
        "block_id": "structured-export-1"
      }
    }
  ]
}