AutomationFlowsAI & RAG › Analyze Customer Reviews From 5 Platforms with Thordata Scraping & Gpt-4.1…

Analyze Customer Reviews From 5 Platforms with Thordata Scraping & Gpt-4.1…

Original n8n title: Analyze Customer Reviews From 5 Platforms with Thordata Scraping & Gpt-4.1 Reports

ByNaveen Choudhary @n8nstein on n8n.io

Automatically gather hundreds of real customer reviews from five major platforms in one run using Thordata API and Proxy — Trustpilot, Capterra, Chrome Web Store, TrustRadius, and Product Hunt — then let GPT-4.1 perform deep collective sentiment analysis, uncover common praises…

Event trigger★★★★☆ complexityAI-powered24 nodesHTTP RequestOpenAIForm TriggerGmail
AI & RAG Trigger: Event Nodes: 24 Complexity: ★★★★☆ AI nodes: yes Added:

This workflow corresponds to n8n.io template #11076 — we link there as the canonical source.

This workflow follows the Form Trigger → Gmail recipe pattern — see all workflows that pair these two integrations.

The workflow JSON

Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →

Download .json
{
  "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
          }
        ]
      ]
    }
  }
}

Credentials you'll need

Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.

Pro

For the full experience including quality scoring and batch install features for each workflow upgrade to Pro

About this workflow

Automatically gather hundreds of real customer reviews from five major platforms in one run using Thordata API and Proxy — Trustpilot, Capterra, Chrome Web Store, TrustRadius, and Product Hunt — then let GPT-4.1 perform deep collective sentiment analysis, uncover common praises…

Source: https://n8n.io/workflows/11076/ — original creator credit. Request a take-down →

More AI & RAG workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

AI & RAG

What it is An automated LinkedIn content system that takes a simple form (idea + optional file), generates LinkedIn posts with OpenAI, stores them in Notion, builds Google Slides carousels, and auto-p

Form Trigger, OpenAI, Notion +6
AI & RAG

This template is designed for content creators, podcasters, businesses, and researchers who need to transcribe long audio recordings that exceed OpenAI Whisper's 25 MB file size limit (~20 minutes of

Form Trigger, HTTP Request, OpenAI +1
AI & RAG

This workflow enables seamless speech-to-text transcription, AI-powered summarization, sentiment analysis, and automated email delivery. It supports two different input modes: Form Upload (Local File)

HTTP Request, OpenAI, Gmail +2
AI & RAG

This workflow automates weather forecast delivery by collecting city names, fetching 5-day forecasts from OpenWeatherMap, and generating professionally formatted HTML emails using GPT-4. The AI create

Gmail, OpenWeatherMap, HTTP Request +2
AI & RAG

This workflow automates the "speed-to-lead" process for insurance agencies. It instantly triggers an AI voice call when a new lead comes in, qualifies their needs via conversation, and automatically g

Form Trigger, Airtable, HTTP Request +3