{
  "version": "1.0.0",
  "exported_at": "2026-05-31T00:00:00.000Z",
  "project": {
    "name": "Google Maps Reviews Scraper",
    "description": "Best-effort Google Maps reviews scraper inferred from the Octoparse catalog entry. Target site: Google Maps place pages. Extracts reviewer name, rating, review date, review text, likes, owner/store replies, and place context. Navigation strategy: multi-URL Google Maps place URL loop using navigate.urls + loop-continue; each place page opens the Reviews tab, expands visible review text, auto-scrolls the reviews feed until no more reviews appear, then appends all loaded reviews to google-maps-reviews-scraper.csv. Replace the example Google Maps place URLs with your own place URLs if needed. Limitations: Google Maps frequently changes DOM classes, may show cookie/CAPTCHA/login/consent interstitials, and may throttle or virtualize reviews; selectors are best-effort without attached Page Analysis JSON.",
    "color": "bg-[#4589ff]",
    "template_id": "ai-generated-google-maps-reviews"
  },
  "blocks": [
    {
      "block_id": "navigate-1",
      "block_type": "process",
      "title": "Navigate",
      "description": "Go to one or more Google Maps place URLs",
      "position_x": 120,
      "position_y": 220,
      "config": {
        "urls": [
          "https://www.google.com/maps/search/?api=1&query=Googleplex",
          "https://www.google.com/maps/search/?api=1&query=Eiffel%20Tower"
        ],
        "color": "bg-[#4589ff]",
        "tags": [
          "google-maps",
          "place-urls",
          "reviews"
        ]
      }
    },
    {
      "block_id": "wait-for-page-load-1",
      "block_type": "process",
      "title": "Wait for Page Load",
      "description": "Wait for Google Maps shell to finish loading",
      "position_x": 456,
      "position_y": 220,
      "config": {
        "timeout": 45
      }
    },
    {
      "block_id": "sleep-1",
      "block_type": "process",
      "title": "Sleep",
      "description": "Allow Google Maps dynamic content to render",
      "position_x": 792,
      "position_y": 220,
      "config": {
        "duration": 5
      }
    },
    {
      "block_id": "inject-javascript-1",
      "block_type": "process",
      "title": "Inject JavaScript",
      "description": "Dismiss Google consent/cookie dialogs when present",
      "position_x": 1128,
      "position_y": 220,
      "config": {
        "jsCode": "(async () => {\n  const labels = ['Accept all', 'Accept', 'I agree', 'Reject all', 'Close'];\n  const candidates = Array.from(document.querySelectorAll('button, div[role=\"button\"], input[type=\"submit\"]'));\n  for (const el of candidates) {\n    const text = (el.innerText || el.value || el.getAttribute('aria-label') || '').trim();\n    if (labels.some(label => text.toLowerCase() === label.toLowerCase() || text.toLowerCase().includes(label.toLowerCase()))) {\n      try { el.click(); await new Promise(r => setTimeout(r, 1500)); return 'clicked consent: ' + text; } catch (e) {}\n    }\n  }\n  return 'no consent dialog found';\n})();",
        "waitForCompletion": true,
        "timeout": 10
      }
    },
    {
      "block_id": "inject-javascript-2",
      "block_type": "process",
      "title": "Inject JavaScript",
      "description": "Open the Reviews tab/panel on the Google Maps place page",
      "position_x": 1464,
      "position_y": 220,
      "config": {
        "jsCode": "(async () => {\n  const sleep = ms => new Promise(r => setTimeout(r, ms));\n  const clickCandidate = () => {\n    const els = Array.from(document.querySelectorAll('button, div[role=\"button\"], a'));\n    const reviewEls = els.filter(el => {\n      const txt = (el.innerText || el.getAttribute('aria-label') || '').trim();\n      return /reviews?/i.test(txt) && !/write a review/i.test(txt);\n    });\n    const tab = reviewEls.find(el => el.getAttribute('role') === 'tab') || reviewEls[0];\n    if (tab) { tab.click(); return (tab.innerText || tab.getAttribute('aria-label') || 'reviews button').trim(); }\n    return null;\n  };\n  let clicked = clickCandidate();\n  if (!clicked) {\n    await sleep(2500);\n    clicked = clickCandidate();\n  }\n  await sleep(3000);\n  return clicked ? 'opened reviews via: ' + clicked : 'reviews control not found; page may already show reviews or selectors changed';\n})();",
        "waitForCompletion": true,
        "timeout": 20
      }
    },
    {
      "block_id": "sleep-2",
      "block_type": "process",
      "title": "Sleep",
      "description": "Wait for reviews panel to load",
      "position_x": 1800,
      "position_y": 220,
      "config": {
        "duration": 4
      }
    },
    {
      "block_id": "inject-javascript-3",
      "block_type": "process",
      "title": "Inject JavaScript",
      "description": "Expand review text and scroll the reviews feed until loading stops",
      "position_x": 2136,
      "position_y": 220,
      "config": {
        "jsCode": "(async () => {\n  const sleep = ms => new Promise(r => setTimeout(r, ms));\n  const findFeed = () => document.querySelector('div[role=\"feed\"]') || document.querySelector('[aria-label*=\"Reviews\"]') || document.querySelector('[aria-label*=\"reviews\"]') || document.scrollingElement;\n  const expandMoreButtons = () => {\n    const buttons = Array.from(document.querySelectorAll('button, div[role=\"button\"]'));\n    let clicked = 0;\n    for (const btn of buttons) {\n      const label = (btn.innerText || btn.getAttribute('aria-label') || '').trim();\n      if (/^(more|see more)$/i.test(label) || /more/i.test(label)) {\n        try { btn.click(); clicked++; } catch (e) {}\n      }\n    }\n    return clicked;\n  };\n  let feed = findFeed();\n  let stableCycles = 0;\n  let previousHeight = -1;\n  let previousReviewCount = -1;\n  let totalExpanded = 0;\n  for (let i = 0; i < 80; i++) {\n    feed = findFeed();\n    totalExpanded += expandMoreButtons();\n    const currentReviewCount = document.querySelectorAll('div.jftiEf, div[data-review-id]').length;\n    const currentHeight = feed ? feed.scrollHeight : document.body.scrollHeight;\n    if (feed) {\n      feed.scrollTop = currentHeight;\n      feed.dispatchEvent(new Event('scroll', { bubbles: true }));\n    } else {\n      window.scrollTo(0, document.body.scrollHeight);\n    }\n    await sleep(1600);\n    const newFeed = findFeed();\n    const newHeight = newFeed ? newFeed.scrollHeight : document.body.scrollHeight;\n    const newReviewCount = document.querySelectorAll('div.jftiEf, div[data-review-id]').length;\n    if (newHeight === previousHeight && newReviewCount === previousReviewCount) {\n      stableCycles++;\n    } else {\n      stableCycles = 0;\n    }\n    previousHeight = newHeight;\n    previousReviewCount = newReviewCount;\n    if (stableCycles >= 5) break;\n  }\n  totalExpanded += expandMoreButtons();\n  return `loaded_reviews=${document.querySelectorAll('div.jftiEf, div[data-review-id]').length}; expanded_buttons=${totalExpanded}`;\n})();",
        "waitForCompletion": true,
        "timeout": 180
      }
    },
    {
      "block_id": "wait-for-element-1",
      "block_type": "process",
      "title": "Wait for Element",
      "description": "Wait for at least one Google Maps review row",
      "position_x": 2472,
      "position_y": 220,
      "config": {
        "selector": "div.jftiEf, div[data-review-id]",
        "timeout": 45,
        "visible": true
      }
    },
    {
      "block_id": "structured-export-1",
      "block_type": "process",
      "title": "Structured Export",
      "description": "Export Google Maps review fields to CSV",
      "position_x": 2808,
      "position_y": 220,
      "config": {
        "rowSelector": "div.jftiEf, div[data-review-id]",
        "fileName": "google-maps-reviews-scraper.csv",
        "saveLocation": "C:\\Users\\theskd\\Documents\\UScraper\\templates",
        "includeHeaders": true,
        "fileMode": "append",
        "columns": [
          {
            "name": "place_name",
            "selector": "(() => { const h1 = document.querySelector('h1'); return h1 ? h1.innerText.trim() : ''; })()",
            "attribute": "text",
            "isJs": true
          },
          {
            "name": "place_url",
            "selector": "window.location.href",
            "attribute": "text",
            "isJs": true
          },
          {
            "name": "reviewer_name",
            "selector": ".d4r55, .WNxzHc, [aria-label][role='button']",
            "attribute": "text"
          },
          {
            "name": "reviewer_profile_url",
            "selector": "button.WEBjve, button[data-href], a[href*='/maps/contrib/']",
            "attribute": "href"
          },
          {
            "name": "rating",
            "selector": "(() => { const el = ROW.querySelector('span.kvMYJc, span[role=\"img\"][aria-label*=\"star\"], span[aria-label*=\"stars\"], span[aria-label*=\"Star\"]'); const label = el ? (el.getAttribute('aria-label') || el.innerText || '') : ''; const m = label.match(/[0-9]+([.,][0-9]+)?/); return m ? m[0].replace(',', '.') : label.trim(); })()",
            "attribute": "text",
            "isJs": true
          },
          {
            "name": "review_date",
            "selector": ".rsqaWe, .xRkPPb",
            "attribute": "text"
          },
          {
            "name": "review_text",
            "selector": ".wiI7pd, .MyEned span, span[lang]",
            "attribute": "text"
          },
          {
            "name": "likes",
            "selector": "(() => { const els = Array.from(ROW.querySelectorAll('button, span, div')); const el = els.find(e => /like/i.test(e.getAttribute('aria-label') || '') || /thumb/i.test(e.getAttribute('aria-label') || '')); const label = el ? (el.getAttribute('aria-label') || el.innerText || '') : ''; const m = label.match(/\\d+/); return m ? m[0] : ''; })()",
            "attribute": "text",
            "isJs": true
          },
          {
            "name": "owner_reply_text",
            "selector": "(() => { const reply = ROW.querySelector('.CDe7pd, .wiI7pd ~ .CDe7pd, div[class*=\"owner\"], div[class*=\"response\"]'); if (reply) return reply.innerText.trim(); const blocks = Array.from(ROW.querySelectorAll('div')).map(d => d.innerText.trim()).filter(Boolean); const owner = blocks.find(t => /response from the owner|owner|manager/i.test(t)); return owner || ''; })()",
            "attribute": "text",
            "isJs": true
          },
          {
            "name": "review_id",
            "selector": "ROW.getAttribute('data-review-id') || ''",
            "attribute": "text",
            "isJs": true
          }
        ]
      }
    },
    {
      "block_id": "sleep-3",
      "block_type": "process",
      "title": "Sleep",
      "description": "Short delay between Google Maps place URLs",
      "position_x": 3144,
      "position_y": 220,
      "config": {
        "duration": 2
      }
    },
    {
      "block_id": "loop-continue-1",
      "block_type": "process",
      "title": "Loop Continue",
      "description": "Advance to the next Google Maps place URL",
      "position_x": 3480,
      "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": "inject-javascript-2",
      "to_connector_id": "left"
    },
    {
      "from_block_id": "inject-javascript-2",
      "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": "inject-javascript-3",
      "to_connector_id": "left"
    },
    {
      "from_block_id": "inject-javascript-3",
      "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": "structured-export-1",
      "to_connector_id": "left"
    },
    {
      "from_block_id": "structured-export-1",
      "from_connector_id": "right",
      "to_block_id": "sleep-3",
      "to_connector_id": "left"
    },
    {
      "from_block_id": "sleep-3",
      "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": 3344,
      "height": 296,
      "z_index": 20,
      "data": {
        "memberBlockIds": [
          "navigate-1",
          "wait-for-page-load-1",
          "sleep-1",
          "sleep-2",
          "wait-for-element-1",
          "sleep-3"
        ]
      }
    },
    {
      "id": "group-interaction",
      "element_type": "group",
      "title": "Interaction",
      "color": "#a56eff",
      "position_x": 1056,
      "position_y": 116,
      "width": 1328,
      "height": 296,
      "z_index": 20,
      "data": {
        "memberBlockIds": [
          "inject-javascript-1",
          "inject-javascript-2",
          "inject-javascript-3"
        ]
      }
    },
    {
      "id": "group-extract",
      "element_type": "group",
      "title": "Data Extraction",
      "color": "#42be65",
      "position_x": 2736,
      "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": 3408,
      "position_y": 116,
      "width": 380,
      "height": 296,
      "z_index": 20,
      "data": {
        "memberBlockIds": [
          "loop-continue-1"
        ]
      }
    },
    {
      "id": "note-overview",
      "element_type": "note",
      "title": "Overview",
      "content": "Best-effort Google Maps reviews scraper inferred from the Octoparse catalog entry. Target site: Google Maps place pages. Extracts reviewer name, rating, review date, review text, likes, owner/store replies, and place context. Navigation strategy: multi-URL Google Maps place URL loop using navigate.urls + loop-continue; each place page opens the Reviews tab, expands visible review text, auto-scrolls the reviews feed until no more reviews appear, then appends all loaded reviews to google-maps-reviews-scraper.csv. Replace the example Google Maps place URLs with your own place URLs if needed. Limitations: Google Maps frequently changes DOM classes, may show cookie/CAPTCHA/login/consent interstitials, and may throttle or virtualize reviews; selectors are best-effort without attached Page Analysis JSON.",
      "color": "#f1c21b",
      "position_x": 80,
      "position_y": 20,
      "width": 480,
      "height": 160,
      "z_index": 22,
      "data": {}
    },
    {
      "id": "note-block-navigate-1",
      "element_type": "note",
      "title": "Note: Navigate",
      "content": "Multi-URL loop over 2 pages. Pair with loop-continue at the end of each iteration.",
      "color": "#ee5396",
      "position_x": 320,
      "position_y": 200,
      "width": 328,
      "height": 107,
      "z_index": 22,
      "data": {
        "block_id": "navigate-1"
      }
    },
    {
      "id": "note-block-inject-javascript-1",
      "element_type": "note",
      "title": "Note: Inject JavaScript",
      "content": "Runs custom JavaScript in the page: `(async () => {\n  const labels = ['Accept all', 'Accept', 'I agree', 'Reject all', 'Close'];\n  const ...` Verify in browser if results are empty.",
      "color": "#ee5396",
      "position_x": 1328,
      "position_y": 200,
      "width": 340,
      "height": 140,
      "z_index": 22,
      "data": {
        "block_id": "inject-javascript-1"
      }
    },
    {
      "id": "note-block-inject-javascript-2",
      "element_type": "note",
      "title": "Note: Inject JavaScript",
      "content": "Runs custom JavaScript in the page: `(async () => {\n  const sleep = ms => new Promise(r => setTimeout(r, ms));\n  const clickCandidate = (...` Verify in browser if results are empty.",
      "color": "#ee5396",
      "position_x": 1664,
      "position_y": 200,
      "width": 340,
      "height": 140,
      "z_index": 22,
      "data": {
        "block_id": "inject-javascript-2"
      }
    },
    {
      "id": "note-block-inject-javascript-3",
      "element_type": "note",
      "title": "Note: Inject JavaScript",
      "content": "Runs custom JavaScript in the page: `(async () => {\n  const sleep = ms => new Promise(r => setTimeout(r, ms));\n  const findFeed = () => d...` Verify in browser if results are empty.",
      "color": "#ee5396",
      "position_x": 2336,
      "position_y": 200,
      "width": 340,
      "height": 140,
      "z_index": 22,
      "data": {
        "block_id": "inject-javascript-3"
      }
    },
    {
      "id": "note-block-structured-export-1",
      "element_type": "note",
      "title": "Note: Structured Export",
      "content": "Structured export with JS columns (place_name, place_url, rating, likes, owner_reply_text). These selectors are fragile — update if the site layout changes.",
      "color": "#ee5396",
      "position_x": 3008,
      "position_y": 200,
      "width": 340,
      "height": 132,
      "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": 3680,
      "position_y": 200,
      "width": 340,
      "height": 123,
      "z_index": 22,
      "data": {
        "block_id": "loop-continue-1"
      }
    }
  ]
}