{
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "nodes": [
    {
      "id": "7a22b325-5cc4-4410-a3e0-4b6157bf2fd1",
      "name": "Universal Safe Parser",
      "type": "n8n-nodes-base.code",
      "position": [
        784,
        3632
      ],
      "parameters": {
        "jsCode": "// === UNIVERSAL SAFE PARSER (STANDARDIZATION + UNIQUE ID) ===\nconst results = [];\n\nfor (const item of $input.all()) {\n  const json = item.json || {};\n  const html = json.html || json.data || json.content || \"\";\n  const sourceUrl =\n    $(\"Loop Through Sources (Rate Limit Safe)\").first().json.source_url || \"\";\n  let sourceType = $(\"Loop Through Sources (Rate Limit Safe)\").first().json\n    .source_type;\n  let productName = $(\"Loop Through Sources (Rate Limit Safe)\").first().json\n    .product_name;\n\n  if (!sourceType || sourceType === \"unknown\") {\n    sourceType = detectSource(sourceUrl, html);\n  }\n\n  /** @type {any[]} */\n  let reviews = [];\n  let error = null;\n\n  if (!html || html.length < 200) {\n    error = \"No HTML content\";\n  } else {\n    try {\n      if (sourceType === \"trustpilot\") reviews = parseTrustpilot(html);\n      else if (sourceType === \"capterra\") reviews = parseCapterra(html);\n      else if (sourceType === \"chromewebstore\")\n        reviews = parseChromeWebStore(html);\n      else if (sourceType === \"producthunt\") reviews = parseProductHunt(html);\n      else if (sourceType === \"trustradius\") reviews = parseTrustRadius(html);\n      else error = `Unknown source: ${sourceType}`;\n    } catch (e) {\n      error = e instanceof Error ? e.message : String(e);\n      //error = e.message;\n    }\n  }\n\n  // === STANDARDIZE EVERY REVIEW + ADD UNIQUE ID ===\n  const standardizedReviews = (reviews || []).map((review) => {\n    const cleanText = (review.text || \"\").trim();\n    const cleanTitle = (review.title || \"\").trim();\n    const author = (review.author || \"Anonymous\").trim();\n    let date = null;\n    if (review.date) {\n      const d = new Date(review.date);\n      // Check if 'd' is a valid date object\n      if (!isNaN(d.getTime())) {\n        try {\n          date = d.toISOString().split(\"T\")[0];\n        } catch (e) {\n          date = null; // Fallback if ISO conversion fails\n        }\n      } else {\n        // Optional: If date is \"2 days ago\", it ends up here.\n        // You can leave it null or map it to today's date if strictly necessary.\n        date = null;\n      }\n    }\n\n    // === UNIQUE ID: deterministic hash based on source + author + date + first 100 chars of text ===\n    // This survives reformatting, extra spaces, etc. \u2014 99.99% duplicate-proof\n    const hashString = `${sourceType}|${author}|${date}|${cleanText.substring(0, 150)}`;\n    function simpleHash(str) {\n      let hash = 0;\n      for (let i = 0; i < str.length; i++) {\n        const char = str.charCodeAt(i);\n        hash = (hash << 5) - hash + char;\n        hash = hash & hash; // Convert to 32-bit integer\n      }\n      return Math.abs(hash).toString(36); // short, URL-safe string\n    }\n    const unique_id = `${sourceType}_${simpleHash(hashString)}`;\n\n    return {\n      unique_id, // \u2190 Your primary key / dedupe field\n      author: author,\n      rating: review.rating ? Number(review.rating) : null,\n      title: cleanTitle || \"[No title provided]\",\n      text: cleanText,\n      pros: review.pros || \"[Not specified]\",\n      cons: review.cons || \"[Not specified]\",\n      date: date,\n      job_title: review.job_title || \"[Not provided]\",\n      company_size: review.company_size || \"[Unknown]\",\n      industry: review.industry || \"[Unknown]\",\n      location: review.location || \"[Not specified]\",\n      verified: review.verified || false,\n\n      scraped_at: new Date().toISOString(),\n    };\n  });\n\n  const previousPage = $(\"URL Builder\").first().json.page_number || 1;\n  results.push({\n    json: {\n      //...json,\n      // === CRITICAL: PASS METADATA FOR PAGINATION LOOP ===\n      product_name: productName,\n      page_number: previousPage,\n      review_count: standardizedReviews.length,\n      reviews: standardizedReviews,\n      source_url: sourceUrl,\n      source_type: sourceType,      \n      success: !error,\n      error_message: error,\n    },\n  });\n}\n\n  // === STEP 2: SAVE TO GLOBAL STORAGE ===\n  const staticData = $getWorkflowStaticData('global');\n  // Initialize if it doesn't exist (safety check)\n  if (!staticData.allScrapedReviews) staticData.allScrapedReviews = [];\n  \n  // Append the results from this specific loop iteration to the global list\n  // We map 'results' because it contains { json: ... } structure\n  const cleanItems = results.map(r => r.json);\n  staticData.allScrapedReviews = staticData.allScrapedReviews.concat(cleanItems);\n\nreturn results;\n\n// ==================== HELPER FUNCTIONS ====================\n\nfunction detectSource(url, html) {\n  const u = (url || \"\").toLowerCase();\n  const h = html || \"\";\n\n  // A. Check URL first\n  if (u.includes(\"trustpilot\")) return \"trustpilot\";\n  if (u.includes(\"capterra\")) return \"capterra\";\n  if (u.includes(\"chrome.google.com\") || u.includes(\"chromewebstore\"))\n    return \"chromewebstore\";\n  if (u.includes(\"producthunt\")) return \"producthunt\";\n  if (u.includes(\"trustradius\")) return \"trustradius\";\n\n  // B. Check HTML Signatures (Fallback)\n  if (\n    h.includes(\"data-service-review-card-paper\") ||\n    h.includes(\"trustpilot-widget\")\n  )\n    return \"trustpilot\";\n  if (h.includes(\"__next_f\") && h.includes(\"CapterraProduct\"))\n    return \"capterra\";\n  if (h.includes(\"T7rvce\") && h.includes(\"LfYwpe\")) return \"chromewebstore\";\n  if (\n    h.includes(\"DetailedReviewConnection\") ||\n    h.includes(\"DetailedReviewEdge\")\n  )\n    return \"producthunt\";\n  if (h.includes(\"Review_review__\") || h.includes(\"trScore\"))\n    return \"trustradius\";\n\n  return \"unknown\";\n}\n\nfunction decodeHtml(html) {\n  if (!html) return \"\";\n  return html\n    .replace(/&amp;/g, \"&\")\n    .replace(/&lt;/g, \"<\")\n    .replace(/&gt;/g, \">\")\n    .replace(/&quot;/g, '\"')\n    .replace(/&#039;/g, \"'\")\n    .replace(/<br\\s*\\/?>/gi, \"\\n\");\n}\n\n// --- 1. Trustpilot Parser ---\nfunction parseTrustpilot(html) {\n  if (\n    html.includes(\"cf-browser-verification\") ||\n    html.includes(\"Just a moment\")\n  ) {\n    throw new Error(\"Blocked by Cloudflare.\");\n  }\n  const cards = html.split('data-service-review-card-paper=\"true\"').slice(1);\n  const extracted = [];\n\n  for (const card of cards) {\n    const authorMatch = card.match(\n      /data-consumer-name-typography=\"true\">([^<]+)</,\n    );\n    const ratingMatch = card.match(/Rated (\\d) out of 5/);\n    const titleMatch = card.match(\n      /data-service-review-title-typography=\"true\">([^<]+)</,\n    );\n    const textMatch = card.match(\n      /data-service-review-text-typography=\"true\">([\\s\\S]*?)(?=<\\/p>|<div|$)/,\n    );\n    const dateMatch = card.match(/datetime=\"([^\"]+)\"/);\n    const locationMatch = card.match(\n      /data-consumer-country-typography=\"true\">([^<]+)</,\n    );\n\n    if (authorMatch && textMatch) {\n      extracted.push({\n        author: authorMatch[1].trim(),\n        rating: ratingMatch ? parseInt(ratingMatch[1]) : null,\n        title: titleMatch ? titleMatch[1].trim() : null,\n        text: decodeHtml(textMatch[1]).trim(),\n        date: dateMatch ? dateMatch[1] : null,\n        location: locationMatch ? locationMatch[1].trim() : null,\n        verified: card.includes(\"Verified\"),\n        source: \"trustpilot\",\n      });\n    }\n  }\n  return extracted;\n}\n\n// --- 2. Capterra Parser ---\nfunction parseCapterra(html) {\n  let fullDataString = \"\";\n  const regex = /<script>self\\.__next_f\\.push\\(\\[1,\"([\\s\\S]*?)\"\\]\\)<\\/script>/g;\n  let match;\n  while ((match = regex.exec(html)) !== null) {\n    fullDataString += match[1];\n  }\n\n  if (!fullDataString) {\n    throw new Error(\"No Next.js data script tags found.\");\n  }\n\n  // C. Build Data Map (Your Split Logic)\n  const dataMap = new Map();\n  // In the regex capture, newlines are stored as the literal string \"\\\\n\"\n  const chunks = fullDataString.split(\"\\\\n\");\n\n  chunks.forEach((chunk) => {\n    if (!chunk) return;\n    const firstColonIndex = chunk.indexOf(\":\");\n    if (firstColonIndex === -1) return;\n\n    const key = chunk.substring(0, firstColonIndex);\n    let value = chunk.substring(firstColonIndex + 1);\n    // Unescape quotes *after* splitting\n    value = value.replace(/\\\\\"/g, '\"');\n    dataMap.set(key, value);\n  });\n\n  if (dataMap.size === 0)\n    throw new Error(\"Data map empty. Regex succeeded but split failed.\");\n\n  // D. Recursive Resolver (Your Cache Logic)\n  const resolvedCache = new Map();\n  function resolve(key) {\n    if (resolvedCache.has(key)) return resolvedCache.get(key);\n\n    let valueStr = dataMap.get(key);\n    if (!valueStr) return null;\n\n    let result;\n    if (valueStr.startsWith(\"{\") || valueStr.startsWith(\"[\")) {\n      try {\n        result = JSON.parse(valueStr, (reviverKey, reviverValue) => {\n          if (\n            typeof reviverValue === \"string\" &&\n            reviverValue.startsWith(\"$\")\n          ) {\n            const refKey = reviverValue.substring(1);\n            return resolve(refKey);\n          }\n          return reviverValue;\n        });\n      } catch (e) {\n        result = null;\n      }\n    } else if (valueStr.startsWith(\"$\")) {\n      result = resolve(valueStr.substring(1));\n    } else if (valueStr.startsWith('\"') && valueStr.endsWith('\"')) {\n      result = valueStr.substring(1, valueStr.length - 1);\n    } else {\n      result = valueStr;\n    }\n    resolvedCache.set(key, result);\n    return result;\n  }\n\n  // E. Find Main Reviews Key (Your Key Finder)\n  let mainReviewsKey = null;\n  for (const [key, value] of dataMap.entries()) {\n    if (\n      value.includes('\"__typename\":\"CapterraProduct\"') &&\n      value.includes('\"textReviews\":')\n    ) {\n      const reviewKeyMatch = value.match(\n        /\"textReviews\":\"\\$([a-zA-Z0-9]{1,5})\"/,\n      );\n      if (reviewKeyMatch && reviewKeyMatch[1]) {\n        mainReviewsKey = reviewKeyMatch[1];\n        break;\n      }\n    }\n  }\n\n  // Fallback strategy if Product Object search fails\n  if (!mainReviewsKey) {\n    for (const [key, value] of dataMap.entries()) {\n      if (value.startsWith('[\"$') && value.endsWith('\"]')) {\n        // Basic heuristic: assume the first array of references might be it\n        mainReviewsKey = key;\n        break;\n      }\n    }\n  }\n\n  if (!mainReviewsKey) throw new Error(\"Could not find main review list key.\");\n\n  // F. Resolve List\n  const mainReviewsList = resolve(mainReviewsKey);\n  if (!Array.isArray(mainReviewsList))\n    throw new Error(\"Resolved review list is not an array.\");\n\n  // G. Map Data (Your Mapper)\n  const getRating = (rating) =>\n    rating !== null && rating !== undefined\n      ? parseFloat(rating).toString()\n      : null;\n\n  return mainReviewsList\n    .map((review) => {\n      if (!review || !review.reviewer) return null;\n\n      let formattedDate = review.writtenOn; // Keep original if parsing fails\n      try {\n        // Simple date formatting\n        formattedDate = new Date(review.writtenOn).toISOString().split(\"T\")[0];\n      } catch (e) {}\n\n      return {\n        author: review.reviewer.anonymityOn\n          ? \"Verified Reviewer\"\n          : review.reviewer.fullName,\n        job_title: review.reviewer.jobTitle,\n        company_size: review.reviewer.companySize,\n        industry: review.reviewer.industry,\n        rating: getRating(review.overallRating),\n        title: review.title,\n        text: review.generalComments,\n        pros: review.prosText,\n        cons: review.consText,\n        date: formattedDate,\n        verified: review.reviewer.isValidated ? \"Yes\" : \"No\",\n        source: \"capterra\",\n      };\n    })\n    .filter((r) => r !== null);\n}\n\n// --- 3. Chrome Web Store Parser ---\nfunction parseChromeWebStore(html) {\n  const chunks = html.split('class=\"T7rvce\"').slice(1);\n  const extracted = [];\n  for (const chunk of chunks) {\n    const authorMatch = chunk.match(/class=\"LfYwpe\">([^<]+)</);\n    const ratingMatch = chunk.match(/aria-label=\"(\\d) out of 5 stars\"/);\n    const dateMatch = chunk.match(/class=\"ydlbEf\">([^<]+)</);\n    const textMatch = chunk.match(/class=\"fzDEpf\"[^>]*>([\\s\\S]*?)<\\/p>/);\n\n    if (authorMatch) {\n      extracted.push({\n        author: authorMatch[1].trim(),\n        rating: ratingMatch ? parseInt(ratingMatch[1]) : null,\n        date: dateMatch ? dateMatch[1].trim() : null,\n        text: textMatch ? decodeHtml(textMatch[1]).trim() : null,\n        source: \"chromewebstore\",\n      });\n    }\n  }\n  return extracted;\n}\n\n// --- 4. Product Hunt Parser ---\nfunction parseProductHunt(html) {\n  const extracted = [];\n  const seenIds = new Set();\n  \n  const startMatch = html.match(/DetailedReviewConnection.*?edges/);\n  if (!startMatch) return []; // Return empty if not found\n  \n  const section = html.substring(startMatch.index);\n  const blocks = section.split(/DetailedReviewEdge.*?node.*?DetailedReview/);\n  blocks.shift();\n\n  const getVal = (block, key) => {\n    const r = new RegExp(`\"${key}\":\"([^\"]+)\"`);\n    const m = block.match(r);\n    return m ? m[1] : null;\n  };\n\n  for (const block of blocks) {\n    const reviewId = getVal(block, \"id\");\n    if (reviewId && seenIds.has(reviewId)) {\n      continue;\n    }\n    if (reviewId) seenIds.add(reviewId);\n    \n    const name = getVal(block, \"name\") || \"Anonymous\";\n    const body = getVal(block, \"body\") || getVal(block, \"overallExperience\");\n    const date = getVal(block, \"createdAt\");\n\n    if (body) {\n      extracted.push({\n        unique_id: `producthunt_${reviewId}`,\n        author: name,\n        text: decodeHtml(body.replace(/\\\\n/g, \"\\n\")),\n        date: date,\n        source: \"producthunt\",\n      });\n    }\n  }\n  return extracted;\n}\n\n// --- 5. TrustRadius Parser ---\nfunction parseTrustRadius(html) {\n  const blocks = html.split('<article class=\"Review_review');\n  blocks.shift();\n  const extracted = [];\n  for (const block of blocks) {\n    const titleMatch = block.match(\n      /Header_heading__[^>]+><a[^>]+>([^<]+)<\\/a>/,\n    );\n    const ratingMatch = block.match(/data-rating=\"([\\d\\.]+)\"/);\n    const nameMatch = block.match(/Byline_name__[^>]+>([^<]+)<\\/div>/);\n    const dateMatch = block.match(/datetime=\"([^\"]+)\"/);\n    const summaryMatch = block.match(\n      /Use Cases and Deployment Scope<\\/h3><p[^>]*>(.*?)<\\/p>/,\n    );\n\n    if (ratingMatch) {\n      extracted.push({\n        author: nameMatch ? decodeHtml(nameMatch[1]) : \"Anonymous\",\n        rating: parseFloat(ratingMatch[1]),\n        title: titleMatch ? decodeHtml(titleMatch[1]) : null,\n        text: summaryMatch ? decodeHtml(summaryMatch[1]) : null,\n        date: dateMatch ? dateMatch[1] : null,\n        source: \"trustradius\",\n      });\n    }\n  }\n  return extracted;\n}\n"
      },
      "typeVersion": 2,
      "alwaysOutputData": true
    },
    {
      "id": "a3ebf41d-973d-4426-800a-82454b59cd56",
      "name": "URL Builder",
      "type": "n8n-nodes-base.code",
      "position": [
        320,
        3632
      ],
      "parameters": {
        "jsCode": "// === DYNAMIC URL BUILDER ===\nconst item = $input.first().json;\n\n// Get the current page number from the loop context, default to 1\nconst page = item.page_number || 1;\nlet url = item.source_url;\nconst type = item.source_type;\n\nif (!url) {\n  throw new Error(\"Pagination Error: 'source_url' was lost in the loop.\");\n}\n\n// 1. Chrome Web Store: Skip pagination (Infinite Scroll logic is too complex for this method)\nif (type === 'chromewebstore') {\n  return { ...item, target_url: url };\n}\n\n// 2. Pagination Logic\n// If we are on Page 1, we usually don't need the parameter (safer to avoid redirects)\n\nif (page > 1) {\n  // If ProductHunt already has params (like ?feed=single), this correctly uses '&'\n  const separator = url.includes('?') ? '&' : '?';\n  url = `${url}${separator}page=${page}`;\n}\n\nreturn { \n  ...item, \n  target_url: url \n};"
      },
      "typeVersion": 2
    },
    {
      "id": "6a3780d0-3d41-4572-8fbd-09aa88e15669",
      "name": "Manual Trigger",
      "type": "n8n-nodes-base.manualTrigger",
      "position": [
        -688,
        3616
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "33b6d69b-4e91-4d18-af3f-71f1ddfaa4ae",
      "name": "Prepare Review Sources",
      "type": "n8n-nodes-base.code",
      "position": [
        -352,
        3616
      ],
      "parameters": {
        "jsCode": "// === 1. Reset global storage for scraped reviews ===\nconst staticData = $getWorkflowStaticData('global');\nstaticData.allScrapedReviews = [];\n\n// === 2. Hard-coded defaults (your fallback) ===\nconst defaultProduct = {\n    name: \"Thordata\",\n    trustpilot_domain: \"thordata.com\",\n    capterra_url: \"\",\n    chrome_webstore_url: \"\",\n    trustradius_url: \"\",\n    producthunt_slug: \"\"\n};\n\n// === 3. Start with defaults ===\nlet product = { ...defaultProduct };\n\n// === 4. Override with Form input (highest priority) ===\nconst formData = $input.first()?.json;\n\nif (formData && (\n    formData.productName || \n    formData.domain || \n    formData.capterraUrl || \n    formData.chromeStoreUrl || \n    formData.trustRadius || \n    formData.producthunt\n)) {\n    product.name              = (formData.productName || defaultProduct.name)?.trim() || defaultProduct.name;\n    product.trustpilot_domain = (formData.domain || defaultProduct.trustpilot_domain)?.trim() || defaultProduct.trustpilot_domain;\n    product.capterra_url      = (formData.capterraUrl || defaultProduct.capterra_url)?.trim() || \"\";\n    product.chrome_webstore_url = (formData.chromeStoreUrl || defaultProduct.chrome_webstore_url)?.trim() || \"\";\n    product.trustradius_url   = (formData.trustRadius || defaultProduct.trustradius_url)?.trim() || \"\";\n    product.producthunt_slug  = (formData.producthunt || defaultProduct.producthunt_slug)?.trim() || \"\";\n}\n\n// === 5. Override with Webhook POST body (if present) ===\nelse if ($node[\"Webhook Trigger\"] && $input.first()?.json && Object.keys($input.first().json).length > 0) {\n    const body = $input.first().json;\n    \n    product.name              = (body.productName || defaultProduct.name)?.trim() || defaultProduct.name;\n    product.trustpilot_domain = (body.domain || defaultProduct.trustpilot_domain)?.trim() || defaultProduct.trustpilot_domain;\n    product.capterra_url      = (body.capterraUrl || \"\").toString().trim() || \"\";\n    product.chrome_webstore_url = (body.chromeStoreUrl || \"\").toString().trim() || \"\";\n    product.trustradius_url   = (body.trustRadius || \"\").toString().trim() || \"\";\n    product.producthunt_slug  = (body.producthunt || defaultProduct.producthunt_slug)?.trim() || \"\";\n}\n\n// (Manual \u201cExecute Workflow\u201d button \u2192 uses defaults only)\n\n// === 6. Build the standardized sources array ===\nconst sources = [];\n\nif (product.trustpilot_domain) {\n    sources.push({\n        product_name: product.name,\n        source_type: \"trustpilot\",\n        source_url: \"https://www.trustpilot.com/review/\" + product.trustpilot_domain\n    });\n}\n\nif (product.capterra_url) {\n    sources.push({\n        product_name: product.name,\n        source_type: \"capterra\",\n        source_url: product.capterra_url\n    });\n}\n\nif (product.chrome_webstore_url) {\n    sources.push({\n        product_name: product.name,\n        source_type: \"chromewebstore\",\n        source_url: product.chrome_webstore_url\n    });\n}\n\nif (product.trustradius_url) {\n    sources.push({\n        product_name: product.name,\n        source_type: \"trustradius\",\n        source_url: product.trustradius_url\n    });\n}\n\nif (product.producthunt_slug) {\n    sources.push({\n        product_name: product.name,\n        source_type: \"producthunt\",\n        source_url: \"https://www.producthunt.com/products/\" + product.producthunt_slug + \"/reviews?feed=single&filter=all&order=newest\"\n    });\n}\n\n// === 7. Return one item per source ===\nreturn sources.map(source => ({ json: source }));"
      },
      "typeVersion": 2
    },
    {
      "id": "202cbefc-0ac8-4f8f-b497-802f3598c0b1",
      "name": "Initialize Pagination",
      "type": "n8n-nodes-base.set",
      "position": [
        96,
        3632
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "d12b0a88-1a31-4cff-9abf-dc25101b6de4",
              "name": "page_number",
              "type": "number",
              "value": 1
            }
          ]
        },
        "includeOtherFields": true
      },
      "typeVersion": 3.4
    },
    {
      "id": "347afbbd-8f94-41aa-b0db-b159f70431a9",
      "name": "Loop Through Sources (Rate Limit Safe)",
      "type": "n8n-nodes-base.splitInBatches",
      "position": [
        -128,
        3616
      ],
      "parameters": {
        "options": {}
      },
      "typeVersion": 3
    },
    {
      "id": "5708ec7e-2a50-49b1-bbd5-dc2b514e29ae",
      "name": "Scrape Page (Robust) via Thordata API",
      "type": "n8n-nodes-base.httpRequest",
      "onError": "continueRegularOutput",
      "position": [
        560,
        3632
      ],
      "parameters": {
        "url": "https://universalapi.thordata.com/request",
        "method": "POST",
        "options": {
          "proxy": "http://td-customer-username:user@example.com:9999",
          "timeout": 120000
        },
        "sendBody": true,
        "contentType": "form-urlencoded",
        "authentication": "genericCredentialType",
        "bodyParameters": {
          "parameters": [
            {
              "name": "url",
              "value": "={{ $json.target_url }}"
            },
            {
              "name": "type",
              "value": "html"
            },
            {
              "name": "js_render",
              "value": "True"
            },
            {
              "name": "clean_content",
              "value": "css"
            }
          ]
        },
        "genericAuthType": "httpHeaderAuth"
      },
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      },
      "retryOnFail": true,
      "typeVersion": 4.3,
      "alwaysOutputData": true
    },
    {
      "id": "fb1e5835-d23c-47fd-a4cc-54648f5b4817",
      "name": "Has More Pages?",
      "type": "n8n-nodes-base.if",
      "position": [
        1024,
        3632
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "loose"
          },
          "combinator": "or",
          "conditions": [
            {
              "id": "d0cee53f-bdb6-4e2a-a9ac-e18950366991",
              "operator": {
                "type": "boolean",
                "operation": "equals"
              },
              "leftValue": "={{ ($json.reviews.length > 0 || $json.review_count > 0) && !['chromewebstore', 'producthunt'].includes($json.source_type) }}",
              "rightValue": true
            }
          ]
        },
        "looseTypeValidation": true
      },
      "typeVersion": 2.2
    },
    {
      "id": "e2131ae6-aff2-47c8-8fbf-768f38696bc2",
      "name": "Increment Page Counter",
      "type": "n8n-nodes-base.set",
      "position": [
        1312,
        3616
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "ac85baa8-149f-4c43-bb8e-58b44b39ecc0",
              "name": "page_number",
              "type": "number",
              "value": "={{ $json.page_number + 1 }}"
            }
          ]
        },
        "includeOtherFields": true
      },
      "typeVersion": 3.4
    },
    {
      "id": "e457c5c7-51df-491b-addb-03e718d3ddfb",
      "name": "Rate Limit Delay (2s)",
      "type": "n8n-nodes-base.wait",
      "position": [
        1520,
        3616
      ],
      "parameters": {
        "amount": 2
      },
      "typeVersion": 1.1
    },
    {
      "id": "c7af6943-9a5c-4d47-a1c6-4a18f76d8d40",
      "name": "Deduplicate & Summarize All Reviews",
      "type": "n8n-nodes-base.code",
      "position": [
        96,
        3424
      ],
      "parameters": {
        "jsCode": "// === STEP 3: READ GLOBAL STORAGE ===\nconst staticData = $getWorkflowStaticData('global');\nconst allItems = staticData.allScrapedReviews || [];\n\nconst allReviews = [];\nconst errors = [];\nconst seenIds = new Set();       // \u2190 Extra safety net\nconst dedupedReviews = [];\n\nallItems.forEach(item => {\n  if (!item) return [];\n  const json = item.json || item;\n  if (!json) return [];\n  \n  const { reviews = [], success, error_message, source_detected, source_url } = json;\n\n  if (!success) {\n    errors.push({ source: source_detected || source_url, error: error_message });\n  } else {\n  // Ensure reviews is actually an array before looping\n    if (Array.isArray(reviews)) {  \n    reviews.forEach(r => {\n      if (!seenIds.has(r.unique_id)) {\n        seenIds.add(r.unique_id);\n        dedupedReviews.push(r);\n      }\n    });\n  }\n  }\n});\n\nreturn [{\n  json: {\n    summary: {\n      product: \"Thordata\",\n      total_reviews_scraped: dedupedReviews.length,\n      unique_reviews: dedupedReviews.length,\n      sources_attempted: $input.all().length,\n      sources_failed: errors.length,\n      scraped_at: new Date().toISOString()\n    },\n    reviews: dedupedReviews,\n    errors: errors.length > 0 ? errors : null\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "021e2bfb-a706-4932-888a-c54f43db63a9",
      "name": "Prepare Data for AI Analysis",
      "type": "n8n-nodes-base.code",
      "position": [
        320,
        3424
      ],
      "parameters": {
        "jsCode": "const { summary, reviews } = $input.first().json;\n\n// Format reviews into readable text\nconst reviewText = reviews.map((r, idx) => {\n  return `Review #${idx + 1} (${r.source} - Rating: ${r.rating}/5):\nAuthor: ${r.author || 'Anonymous'}\nDate: ${r.date}\n${r.title ? 'Title: ' + r.title : ''}\nText: ${r.text || '[No text provided]'}`;\n}).join('\\n\\n---\\n\\n');\n\n// Calculate rating distribution\nconst ratings = reviews.map(r => r.rating).filter(r => r);\nconst avgRating = ratings.length > 0 \n  ? (ratings.reduce((a, b) => a + b, 0) / ratings.length).toFixed(2) \n  : 0;\n\nconst ratingDist = {\n  '5': ratings.filter(r => r === 5).length,\n  '4': ratings.filter(r => r === 4).length,\n  '3': ratings.filter(r => r === 3).length,\n  '2': ratings.filter(r => r === 2).length,\n  '1': ratings.filter(r => r === 1).length\n};\n\n// Sources breakdown\nconst sources = [...new Set(reviews.map(r => r.source))];\n\nreturn [{\n  json: {\n    // Flattened for template\n    product: summary.product,\n    total_reviews: summary.unique_reviews,\n    avg_rating: avgRating,\n    \n    // For the prompt\n    all_reviews_text: reviewText,\n    \n    // Additional context\n    rating_distribution: ratingDist,\n    sources: sources,\n    date_range: {\n      earliest: reviews.map(r => r.date).sort()[0],\n      latest: reviews.map(r => r.date).sort().reverse()[0]\n    },\n    \n    // Keep original for reference\n    original_reviews: reviews,\n    original_summary: summary\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "861af668-6cae-4e1e-9c72-1c29b959506e",
      "name": "AI Review Sentiment Analysis",
      "type": "@n8n/n8n-nodes-langchain.openAi",
      "position": [
        544,
        3424
      ],
      "parameters": {
        "modelId": {
          "__rl": true,
          "mode": "list",
          "value": "gpt-4.1-2025-04-14",
          "cachedResultName": "GPT-4.1-2025-04-14"
        },
        "options": {},
        "responses": {
          "values": [
            {
              "content": "=You are a B2B product analyst. Analyze these {{$json.total_reviews}} product reviews collectively.\n\nPRODUCT: {{$json.product}}\nAVERAGE RATING: {{$json.avg_rating}}/5\nDATE RANGE: {{$json.date_range.earliest}} to {{$json.date_range.latest}}\nSOURCES: {{$json.sources}}\n\nRating Distribution:\n- 5 stars: {{$json.rating_distribution[\"5\"]}} reviews\n- 1 star: {{$json.rating_distribution[\"1\"]}} reviews\n\nREVIEWS:\n{{$json.all_reviews_text}}\n\nAnalyze all reviews collectively. Provide ONLY JSON, NO markdown:\n\n{\n  \"collective_sentiment_score\": <-1 to 1>,\n  \"collective_sentiment_label\": \"<positive or neutral or negative>\",\n  \"confidence\": <0-1>,\n  \"overall_emotion\": \"<satisfied or frustrated or delighted or disappointed>\",\n  \"urgency\": \"<critical or high or medium or low>\",\n  \n  \"batch_analysis\": {\n    \"common_praise\": [\"theme 1\", \"theme 2\", \"theme 3\"],\n    \"common_complaints\": [\"issue 1\", \"issue 2\", \"issue 3\"],\n    \"critical_issues\": [{\"issue\": \"description\", \"mentions\": 3, \"severity\": \"high\"}],\n    \"key_features_mentioned\": [\"feature 1\", \"feature 2\"],\n    \"pricing_sentiment\": \"<fair or overpriced>\",\n    \"support_quality\": \"<excellent or good or poor>\"\n  },\n  \n  \"trend\": {\n    \"direction\": \"<improving or declining or stable>\",\n    \"key_finding\": \"main insight\"\n  },\n  \n  \"recommended_actions\": {\n    \"product_team\": \"action\",\n    \"sales_team\": \"action\",\n    \"support_team\": \"action\"\n  },\n  \n  \"overall_health\": \"<healthy or at-risk>\",\n  \"churn_risk\": \"<low or medium or high>\"\n}"
            }
          ]
        },
        "builtInTools": {}
      },
      "credentials": {
        "openAiApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2
    },
    {
      "id": "e0b090cb-ef8b-4b07-aa89-46df26731575",
      "name": "Return Final Results",
      "type": "n8n-nodes-base.respondToWebhook",
      "position": [
        656,
        3152
      ],
      "parameters": {
        "options": {},
        "respondWith": "json",
        "responseBody": "={{\n  {\n    \"product_name\": $json.product,\n    \"stats\": $json.original_summary,\n    \"data\": $json.original_reviews\n  }\n}}"
      },
      "typeVersion": 1.4
    },
    {
      "id": "1bda3f48-85b5-4a18-b5e6-7bb4e1d866f0",
      "name": "Webhook Trigger",
      "type": "n8n-nodes-base.webhook",
      "position": [
        -688,
        3840
      ],
      "parameters": {
        "path": "c35353c9-a0e5-4f0a-86a4-cc5466bd83fd",
        "options": {},
        "httpMethod": "POST",
        "responseMode": "responseNode"
      },
      "typeVersion": 2.1
    },
    {
      "id": "e0faf882-df2d-424f-956c-42bcf461b92c",
      "name": "Form Trigger \u2013 Submit Sources",
      "type": "n8n-nodes-base.formTrigger",
      "position": [
        -688,
        3408
      ],
      "parameters": {
        "path": "sources",
        "options": {},
        "formTitle": "Submit Product Review Sources",
        "formFields": {
          "values": [
            {
              "fieldLabel": "productName",
              "placeholder": "Thordata",
              "requiredField": true
            },
            {
              "fieldLabel": "domain",
              "placeholder": "thordata.com"
            },
            {
              "fieldLabel": "capterraUrl",
              "placeholder": "https://www.capterra.com/p/168264/ZoomInfo-sales/reviews"
            },
            {
              "fieldLabel": "chromeStoreUrl",
              "placeholder": "https://chromewebstore.google.com/detail/vpn-for-chrome-nordvpn-pr/YOUR_AWS_SECRET_KEY_HERE"
            },
            {
              "fieldLabel": "trustRadius",
              "placeholder": "https://www.trustradius.com/products/leadgrabber-pro/reviews/all"
            },
            {
              "fieldLabel": "producthunt",
              "placeholder": "gemini-6"
            }
          ]
        },
        "formDescription": "Provide review source URLs for a product. Placeholders shown for convenience, replace with your product's details as needed."
      },
      "typeVersion": 1
    },
    {
      "id": "1d1fd03e-0fdf-47a1-8773-b542a7ed192a",
      "name": "Render Elegant Email Template",
      "type": "n8n-nodes-base.code",
      "position": [
        912,
        3424
      ],
      "parameters": {
        "jsCode": "// === 1. Get the AI raw JSON string ===\nconst openAiOutput = $input.first().json.output?.[0]?.content?.[0]?.text;\n\nif (!openAiOutput) {\n  throw new Error(\"OpenAI response missing\");\n}\n\nlet aiAnalysis;\ntry {\n  aiAnalysis = JSON.parse(openAiOutput);\n} catch (e) {\n  throw new Error(`Failed to parse AI response as JSON:\\n${openAiOutput}`);\n}\n\n// Now safe to use\nconst product = $('Prepare Data for AI Analysis').first().json.product || \"Unknown Product\";\nconst totalReviews = $('Prepare Data for AI Analysis').first().json.total_reviews || 0;\nconst avgRating = $('Prepare Data for AI Analysis').first().json.avg_rating ? Number($('Prepare Data for AI Analysis').first().json.avg_rating).toFixed(2) : \"N/A\";\n\n// === 3. Colors ===\nconst sentimentColor = {\n  positive: \"#10b981\",\n  neutral: \"#f59e0b\",\n  negative: \"#ef4444\"\n}[aiAnalysis.collective_sentiment_label] || \"#6b7280\";\n\nconst healthColor = aiAnalysis.overall_health === \"healthy\" ? \"#10b981\" : \"#ef4444\";\nconst churnColor = aiAnalysis.churn_risk === \"low\" ? \"#10b981\" : \n                   aiAnalysis.churn_risk === \"medium\" ? \"#f59e0b\" : \"#ef4444\";\n\n// === 4. Generate stunning responsive HTML email ===\nconst html = `<!DOCTYPE html>\n<html>\n<head>\n  <meta charset=\"utf-8\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n  <title>${product} Review Intelligence Report</title>\n  <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap\" rel=\"stylesheet\">\n  <style>\n    body { font-family: 'Inter', system-ui, sans-serif; background: #f9fafb; margin: 0; padding: 0; }\n    .container { max-width: 680px; margin: 40px auto; background: white; border-radius: 16px; overflow: hidden; box-shadow: 0 20px 40px rgba(0,0,0,0.08); }\n    .header { background: linear-gradient(135deg, #6366f1, #8b5cf6); padding: 48px 32px; text-align: center; color: white; }\n    .header h1 { margin: 0; font-size: 32px; font-weight: 700; }\n    .header p { margin: 12px 0 0; font-size: 17px; opacity: 0.95; }\n    .body { padding: 40px 36px; color: #1f2937; line-height: 1.7; }\n    .sentiment-badge { display: inline-block; padding: 12px 28px; border-radius: 12px; font-weight: 600; font-size: 20px; background: ${sentimentColor}15; color: ${sentimentColor}; }\n    .stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 20px; margin: 36px 0; }\n    .stat { background: #f8fafc; padding: 24px; border-radius: 14px; text-align: center; border: 1px solid #e2e8f0; }\n    .stat-value { font-size: 36px; font-weight: 700; margin: 0 0 8px; color: #0f172a; }\n    .stat-label { color: #64748b; font-size: 14px; text-transform: uppercase; letter-spacing: 0.8px; }\n    .section { margin-top: 44px; }\n    .section h2 { font-size: 22px; color: #111827; margin: 0 0 18px; padding-bottom: 10px; border-bottom: 3px solid #e5e7eb; position: relative; }\n    .section h2::after { content: ''; position: absolute; bottom: -3px; left: 0; width: 60px; height: 3px; background: #6366f1; }\n    ul { padding-left: 20px; margin: 16px 0; }\n    li { margin: 14px 0; position: relative; }\n    li::marker { color: #6366f1; }\n    .critical { background: #fef2f2; border-left: 5px solid #ef4444; padding: 16px; border-radius: 0 8px 8px 0; margin: 16px 0; }\n    .badge { padding: 6px 14px; border-radius: 999px; font-size: 13px; font-weight: 600; margin-left: 8px; }\n    .high { background: #fee2e2; color: #991b1b; }\n    .footer { background: #f8fafc; padding: 32px; text-align: center; color: #64748b; font-size: 14px; border-top: 1px solid #e2e8f0; }\n  </style>\n</head>\n<body>\n  <div class=\"container\">\n    <div class=\"header\">\n      <h1>${product}</h1>\n      <p>AI-Powered Review Intelligence Report \u2022 ${new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}</p>\n    </div>\n\n    <div class=\"body\">\n      <div style=\"text-align:center; margin-bottom:40px;\">\n        <div class=\"sentiment-badge\">\n          ${aiAnalysis.collective_sentiment_label.toUpperCase()} SENTIMENT\n        </div>\n      </div>\n\n      <div class=\"stats\">\n        <div class=\"stat\">\n          <div class=\"stat-value\">${totalReviews || 0}</div>\n          <div class=\"stat-label\">Reviews Analyzed</div>\n        </div>\n        <div class=\"stat\">\n          <div class=\"stat-value\">${avgRating || 'N/A'}</div>\n          <div class=\"stat-label\">Average Rating</div>\n        </div>\n        <div class=\"stat\">\n          <div class=\"stat-value\" style=\"color:${healthColor}\">${aiAnalysis.overall_health?.toUpperCase()}</div>\n          <div class=\"stat-label\">Product Health</div>\n        </div>\n        <div class=\"stat\">\n          <div class=\"stat-value\" style=\"color:${churnColor}\">${aiAnalysis.churn_risk?.toUpperCase()}</div>\n          <div class=\"stat-label\">Churn Risk</div>\n        </div>\n      </div>\n\n      <div class=\"section\">\n        <h2>Top Praises</h2>\n        <ul>${(aiAnalysis.batch_analysis?.common_praise || []).map(p => `<li>${p}</li>`).join('') || '<li>No significant praise detected</li>'}</ul>\n      </div>\n\n      <div class=\"section\">\n        <h2>Common Complaints</h2>\n        <ul>${(aiAnalysis.batch_analysis?.common_complaints || []).map(c => `<li>${c}</li>`).join('') || '<li>No major complaints found</li>'}</ul>\n      </div>\n\n      ${aiAnalysis.batch_analysis?.critical_issues?.length ? `\n      <div class=\"section\">\n        <h2>Critical Issues (Immediate Attention)</h2>\n        ${aiAnalysis.batch_analysis.critical_issues.map(issue => `\n          <div class=\"critical\">\n            <strong>${issue.issue}</strong>\n            <span class=\"badge high\">${issue.severity.toUpperCase()}</span>\n            <em>\u2014 ${issue.mentions} users mentioned</em>\n          </div>\n        `).join('')}\n      </div>` : ''}\n\n      <div class=\"section\">\n        <h2>Recommended Actions</h2>\n        <ul>\n          <li><strong>Product Team:</strong> ${aiAnalysis.recommended_actions?.product_team || 'N/A'}</li>\n          <li><strong>Sales Team:</strong> ${aiAnalysis.recommended_actions?.sales_team || 'N/A'}</li>\n          <li><strong>Support Team:</strong> ${aiAnalysis.recommended_actions?.support_team || 'N/A'}</li>\n        </ul>\n      </div>\n    </div>\n\n    <div class=\"footer\">\n      Generated automatically by <strong>Thordata Review Intelligence Engine</strong><br>\n      ${new Date().toLocaleString('en-US', { dateStyle: 'long', timeStyle: 'medium' })}\n    </div>\n  </div>\n</body>\n</html>`;\n\nreturn [{\n  json: {\n    email_html: html,\n    product_name: product,\n    total_reviews: totalReviews,\n    avg_rating: avgRating,\n    ai_analysis: aiAnalysis\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "202ad2fa-de6d-491b-85a3-6fe7d044af9e",
      "name": "Send Executive Summary Email",
      "type": "n8n-nodes-base.gmail",
      "position": [
        1152,
        3424
      ],
      "parameters": {
        "sendTo": "user@example.com",
        "message": "=={{ $json.email_html }}",
        "options": {
          "appendAttribution": false
        },
        "subject": "={{ $json.product_name }} Review Analysis \u2013 {{ new Date().toLocaleDateString() }}"
      },
      "credentials": {
        "gmailOAuth2": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.1
    },
    {
      "id": "30be5cf7-662f-4c0f-94ec-0c48a460ebef",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -208,
        2160
      ],
      "parameters": {
        "width": 1088,
        "height": 864,
        "content": "## \ud83c\udf1f Scrape & AI-Analyze Reviews from 5 Platforms in One Click\n\nAutomatically collect hundreds of customer reviews from Trustpilot, Capterra, Chrome Web Store, TrustRadius, and Product Hunt \u2192 deduplicate \u2192 let GPT-4.1 analyze sentiment, praises, complaints, churn risk, and give actionable recommendations \u2192 receive a beautiful executive email report.\n\n### Who\u2019s it for  \n- Product managers & founders  \n- Growth/marketing teams  \n- Customer success & support leads  \n- Agencies delivering review reports  \n\n### How it works  \n1. Submit product URLs (form, webhook or defaults)  \n2. Smart pagination + Cloudflare-safe scraping  \n3. Universal parser standardizes all reviews  \n4. AI collective analysis (not review-by-review)  \n5. Gorgeous HTML summary emailed automatically  \n\n### Requirements  \n- Thordata API key (free tier OK) \u2192 HTTP Header Auth credential  \n- OpenAI API key  \n- Gmail (or swap for any email node)  \n\n### How to set up  \n1. Add your Thordata key \u2192 create HTTP Header Auth credential  \n2. Add OpenAI credential  \n3. Connect Gmail  \n4. Test instantly with defaults (Thordata reviews)  \n\n### Customize easily  \n- Change default product in \u201cPrepare Review Sources\u201d  \n- Edit AI prompt for different analysis style  \n- Tweak the email design in \u201cRender Elegant Email Template\u201d  \n\nPlug-and-play \u00b7 No browser automation \u00b7 Fully deduplicated \u00b7 Rate-limit safe"
      },
      "typeVersion": 1
    },
    {
      "id": "24334689-b603-4ddd-8a40-4083b3852cc5",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1136,
        3616
      ],
      "parameters": {
        "color": 7,
        "width": 368,
        "height": 128,
        "content": "### \ud83d\udd04 STEP 1: Choose your input  \n\u2022 Manual Trigger \u2192 quick test with defaults  \n\u2022 Form Trigger \u2192 user-friendly UI  \n\u2022 Webhook \u2192 integrate with Zapier/Make/etc."
      },
      "typeVersion": 1
    },
    {
      "id": "42c3e5a6-e35c-446a-b52a-a94eaa95a3ef",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -416,
        3904
      ],
      "parameters": {
        "color": 7,
        "width": 896,
        "height": 96,
        "content": "### \ud83d\udee0\ufe0f STEP 2: Build source list  \nAccepts Trustpilot domain, full URLs for Capterra/Chrome/TrustRadius, and Product Hunt slug \u2192 creates clean source objects."
      },
      "typeVersion": 1
    },
    {
      "id": "2de7f216-99a1-4519-864b-dbd102181e89",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -416,
        3360
      ],
      "parameters": {
        "color": 7,
        "width": 416,
        "content": "### \ud83d\udd04 STEP 3: Smart pagination loop (rate-limit safe)  \n\u2022 Handles all 5 sites differently  \n\u2022 Builds correct ?page= URLs  \n\u2022 Scrapes via Thordata (JS-rendered + proxy rotation)  \n\u2022 Stops when no more reviews on page  \n\u2022 2-second polite delay between requests"
      },
      "typeVersion": 1
    },
    {
      "id": "7b3943e3-dbf4-4fb4-8073-68ed2f079ff2",
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        80,
        3216
      ],
      "parameters": {
        "color": 7,
        "width": 352,
        "height": 128,
        "content": "### \ud83e\uddf9 STEP 4: Global storage + deduplication  \nCollects reviews from every source & page \u2192 unique IDs prevent duplicates even if formatting changes."
      },
      "typeVersion": 1
    },
    {
      "id": "7653a681-20bc-4680-ac30-d7524d95f330",
      "name": "Sticky Note5",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        912,
        3232
      ],
      "parameters": {
        "color": 5,
        "width": 416,
        "height": 128,
        "content": "### \ud83e\udd16 STEP 5: AI analysis & beautiful report  \n\u2022 All reviews sent together to GPT-4.1  \n\u2022 Structured insights + sentiment scoring  \n\u2022 Renders responsive HTML email  \n\u2022 Sends executive summary automatically"
      },
      "typeVersion": 1
    }
  ],
  "connections": {
    "URL Builder": {
      "main": [
        [
          {
            "node": "Scrape Page (Robust) via Thordata API",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Manual Trigger": {
      "main": [
        [
          {
            "node": "Prepare Review Sources",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Has More Pages?": {
      "main": [
        [
          {
            "node": "Increment Page Counter",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Loop Through Sources (Rate Limit Safe)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Webhook Trigger": {
      "main": [
        [
          {
            "node": "Prepare Review Sources",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Initialize Pagination": {
      "main": [
        [
          {
            "node": "URL Builder",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Rate Limit Delay (2s)": {
      "main": [
        [
          {
            "node": "URL Builder",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Universal Safe Parser": {
      "main": [
        [
          {
            "node": "Has More Pages?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Increment Page Counter": {
      "main": [
        [
          {
            "node": "Rate Limit Delay (2s)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare Review Sources": {
      "main": [
        [
          {
            "node": "Loop Through Sources (Rate Limit Safe)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "AI Review Sentiment Analysis": {
      "main": [
        [
          {
            "node": "Render Elegant Email Template",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare Data for AI Analysis": {
      "main": [
        [
          {
            "node": "Return Final Results",
            "type": "main",
            "index": 0
          },
          {
            "node": "AI Review Sentiment Analysis",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send Executive Summary Email": {
      "main": [
        []
      ]
    },
    "Render Elegant Email Template": {
      "main": [
        [
          {
            "node": "Send Executive Summary Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Form Trigger \u2013 Submit Sources": {
      "main": [
        [
          {
            "node": "Prepare Review Sources",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Deduplicate & Summarize All Reviews": {
      "main": [
        [
          {
            "node": "Prepare Data for AI Analysis",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Scrape Page (Robust) via Thordata API": {
      "main": [
        [
          {
            "node": "Universal Safe Parser",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Loop Through Sources (Rate Limit Safe)": {
      "main": [
        [
          {
            "node": "Deduplicate & Summarize All Reviews",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Initialize Pagination",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}