> ## Content Index
> Fetch the complete content index at: https://buildwire.ai/llms.txt
> Use this file to discover other available public pages before exploring further.

# 🎯 People Also Ask (PAA) Scraper + Content Ideas Generator
- URL: https://buildwire.ai/blog/people-also-ask-paa-scraper-content-ideas-generator/
- Published: 2025-11-25T13:46:03.000Z
- Updated: 2026-06-14T07:31:25.000Z
- Description: An intelligent automation workflow that transforms Google Search Console data into actionable content clusters using AI
- Author: Rananjay Raj
- Tags: n8n, #beehiiv, #Migrated-1781422191461, #Import 2026-06-14 13:00

![](https://buildwire.ai/content/images/2026/06/gemini_generated_image_zg4e5yzg4e5yzg4e__1_-png-t-1764072877.jpg)

## 🌟 What This Workflow Does

This n8n workflow automates the entire content research process:

1. **Pulls search data** from your Google Search Console
2. **Scrapes Google results** for "People Also Ask" questions
3. **Analyzes questions** using OpenAI GPT to identify patterns
4. **Clusters insights** into coherent content topics
5. **Saves to Notion** for your content team

**Result:** In minutes, transform raw search data into a prioritized content roadmap that your team can start creating from immediately.

---

## 📊 Technical Architecture

### Workflow Flow

Schedule Trigger (Weekly)

↓

Google Search Console Node (Fetch site queries, impressions, clicks, rankings)

↓

normalizeSearchConsole Function (Filter by volume, dedup, quality checks)

↓

SearchAPI Integration (Scrape live Google results)

↓

extractPAA Function (Parse FAQ questions from results)

↓

OpenAI GPT-4 Analysis (Cluster questions → topics)

↓

formatForNotion Function (Structure data for database)

↓

Notion Database Write (Save content ideas)

### Core Components

**Data Ingestion Layer**

- Google Search Console: Real keyword performance metrics
- SearchAPI: Live SERP scraping with People Also Ask data
- Built-in deduplication and filtering

**Processing Layer**

- JavaScript function nodes for data transformation
- Configurable filtering thresholds (impressions, position)
- Fallback mechanisms for incomplete data

**AI Analysis Layer**

- OpenAI GPT-4 for semantic clustering
- Temperature: 0 (deterministic, consistent results)
- Structured JSON output validation
- Question parsing from multiple SERP elements

**Output Layer**

- Notion database integration
- Structured properties for filtering and sorting
- Ready-to-use content cluster format

## 🔌 Integrations Required

### 1\. Google Search Console (Required)

- **Connection:** OAuth 2.0
- **Scope:** Read-only access to search performance data
- **Data:** Queries, impressions, clicks, CTR, ranking position

### 2\. SearchAPI.io (Required)

- **Connection:** API Key authentication
- **Purpose:** Scrape Google search results and "People Also Ask" sections
- **Features:** Location/language targeting, pagination
- **Pricing:** Free tier available (100 searches/month), paid plans for volume

### 3\. OpenAI API (Required)

- **Model:** GPT-4 (or gpt-3.5-turbo for cost savings)
- **Purpose:** Semantic clustering of questions into content topics
- **Settings:** Temperature 0 for deterministic output
- **Pricing:** \~$0.03-0.10 per workflow execution

### 4\. Notion (Required)

- **Connection:** OAuth 2.0 or API token
- **Purpose:** Store and organize content ideas
- **Features:** Database page creation with properties
- **Setup:** notion.so → Create integration → Share database with bot

### 5\. n8n (Required)

- **Version:** Self-hosted or cloud.n8n.io
- **Requirements:** Node.js 16+ (for self-hosted)
- **Storage:** Minimal (executions stored in database)
- **Scaling:** Runs scheduled 1x/week, no concurrent load

## 💡 Use Cases

### 1\. Content Gap Analysis 🎯

**Scenario:** Your SEO team wants to know what content to create next.

**How it works:**

- Workflow pulls all queries ranking #3-10 (good keyword difficulty)
- Extracts "People Also Ask" questions for those topics
- Groups into content clusters by topic
- Notion shows which topics have the most questions

**Result:** Content brief with 15+ article ideas, ranked by search demand

**Perfect for:** Blog strategy, pillar content planning, niche expansion

---

### 2\. FAQ Page Generation 📝

**Scenario:** You need to create a comprehensive FAQ page for your product.

**How it works:**

- Search your brand name + product terms
- Capture all "People Also Ask" questions
- AI groups similar questions together
- Export structured FAQs with answers

**Result:** Organized FAQ outline ready for content writers

**Perfect for:** Support pages, product pages, category pages

---

### 3\. Long-Form Content Outlining 📄

**Scenario:** Writing a 3,000-word guide on a topic your audience searches for.

**How it works:**

- Pull questions for your target keyword
- AI clusters them into natural sections (Introduction, Advanced, Troubleshooting, etc.)
- Each cluster becomes an H2 heading with subsections
- Questions suggest internal linking opportunities

**Result:** Battle-tested content outline based on real search demand

**Perfect for:** Pillar pages, ultimate guides, how-to articles

---

### 4\. Competitor Content Intelligence 🔍

**Scenario:** Analyze competitor domains to find content gaps.

**How it works:**

- Run the workflow for competitor keywords
- See what questions they're ranking for
- Identify questions they DON'T answer (opportunity gap)
- Create content around those gaps

**Result:** Competitive advantage with untapped content opportunities

**Perfect for:** Competitive positioning, market analysis, niche domination

---

### 5\. Product FAQ & Support Optimization 🛠️

**Scenario:** Reduce support tickets by creating answer content.

**How it works:**

- Search for your product category questions
- Cluster by support ticket category
- Create knowledge base articles for top clusters
- Link from product pages to reduce support load

**Result:** Better UX, fewer support tickets, improved SEO

**Perfect for:** SaaS products, e-commerce, service providers

---

### 6\. SEO Strategy Development 📈

**Scenario:** Build a data-driven content strategy for the quarter.

**How it works:**

- Weekly workflow runs automatically
- Accumulate content ideas in Notion over time
- Filter by search volume, intent, difficulty
- Identify content pillars and supporting articles
- Plan quarterly roadmap

**Result:** Quarter-long content calendar based on real search data

**Perfect for:** Marketing agencies, in-house teams, content platforms

## 🗄️ Output Structure

Each item saved to Notion contains:

| **Field**     | **Type**  | **Example**                                           |
| ------------- | --------- | ----------------------------------------------------- |
| Name (title)  | Text      | "Starter & Feeding"                                   |
| Keyword       | Text      | "how to make sourdough"                               |
| Description   | Rich Text | "Guide to creating and maintaining a healthy starter" |
| Intent        | Text      | "informational" / "transactional" / "navigational"    |
| Priority      | Number    | 1-5 (5 = highest)                                     |
| Questions     | Rich Text | "What is sourdough starter?..."                       |
| Article Title | Text      | "Sourdough Starter: Create & Maintain"                |

## 🚀 Setup Requirements

### Prerequisites (You Need These)

1. **Google Search Console Access**
  - Must be verified property owner
  - At least 90 days of historical data
  - Not required: API key (OAuth handles authentication)
2. **SearchAPI.io Account**
  - Free tier: 100 searches/month
  - Paid tier: $19+ for 10,000 searches/month
  - Create account: searchapi.io
  - Get API key from dashboard
3. **OpenAI API Account**
  - Requires paid account (minimum $5 credit)
  - API key from: platform.openai.com
  - Estimated cost: $0.03-0.10 per workflow run
4. **Notion Workspace**
  - Create blank database (or duplicate template)
  - Create internal integration/bot
  - Generate API token or set up OAuth
5. **n8n Instance**
  - Cloud: Sign up at cloud.n8n.io
  - Self-hosted: Docker or Node.js installation
  - Either option works; cloud is simpler for beginners

### n8n Setup Steps

**Step 1: Create Credentials**

In n8n UI:

Credentials → New Credential → Select Service

✓ Google Search Console OAuth2

✓ OpenAI API

✓ SearchAPI

✓ Notion

**Step 2: Import Workflow**

```
Import → From File → Select JSON
Choose: PAA_Scraper_Content_Ideas_Generator_SANITIZED.json
```

**Step 3: Configure Nodes**

```
Each node with a ⚙️ icon needs configuration:

Query search analytics:
  - Site URL: https://yourwebsite.com
  - Page path: /blog/ (or / for entire site)
  - Attach: Google Search Console credential

Search google:
  - Attach: SearchAPI credential
  - Location: Set to your target country
  - Language: Set to your target language

Message a model:
  - Model: gpt-4 (recommended) or gpt-3.5-turbo
  - Temperature: 0 (for consistency)
  - Attach: OpenAI credential

Create a database page:
  - Database ID: From your Notion URL
  - Attach: Notion credential
```

**Step 4: Test**

```
Click "Execute Workflow" button
Check execution logs for errors
Verify data appears in Notion
Once working, click "Activate" to schedule
```

### Configuration Parameters

**Data Filtering (in normalizeSearchConsole node)**

```
MIN_IMPRESSIONS = 25  // Only queries with 25+ monthly searches
MAX_POSITION = 8      // Only queries ranking in top 8
DEDUPE = true         // Remove duplicate keywords
```

Adjust these for your needs:

- Blog content: MIN\_IMPRESSIONS = 50
- FAQ content: MIN\_IMPRESSIONS = 10
- Competitive analysis: MIN\_IMPRESSIONS = 5

**Schedule Settings**

```
Default: Every Monday at 9 AM
Change in Schedule Trigger node:
  cron: "0 9 * * 1"  // Monday 9 AM
  
Other options:
  "0 0 * * *"        // Daily at midnight
  "0 9 * * 1-5"      // Weekdays at 9 AM
  "0 */6 * * *"      // Every 6 hours
```

## 🎨 Customization Options

### 1\. Customize the Clustering Prompt

In the "Message a model" node, edit the system prompt:

**Default:** Clusters questions into 3-5 topic groups

**Custom Examples:**

**For Blog Content (detailed outlines):**

```
"Task: Create detailed content outlines with H2 and H3 sections..."
// Adds: suggested_outline_structure, word_count_estimate
```

**For FAQ (short answers):**

```
"Task: Group questions for concise FAQ answers..."
// Adds: answer_length, difficulty_level
```

**For Product Documentation:**

```
"Task: Organize questions by product feature..."
// Adds: feature_category, use_case, priority_for_docs
```

### 3\. Filter by Search Intent

Add filtering in the AI analysis to focus on specific intent types:

```
// In formatForNotion node, add filter:
const intentsToKeep = ["informational", "navigational"];
out = out.filter(item => intentsToKeep.includes(item.intent));
```

**Intent Types:**

- **Informational:** "How to", "What is", "Why" (70% of searches)
- **Transactional:** "Buy", "Price", "Reviews" (15% of searches)
- **Navigational:** "Brand + feature", "Site search" (15% of searches)

### 4\. Add Competitor Domain Analysis

Instead of your own GSC, analyze competitor keywords:

```
// In normalizeSearchConsole, replace with:
const competitorKeywords = [
  "how to make sourdough",
  "sourdough starter maintenance",
  "sourdough troubleshooting"
];

// Then search for these keywords via SearchAPI
```

---

### 5\. Multi-Language Support

In the "Search google" node:

```
// Current: Bulgarian (bg)
"languageSettings": { "hl": "bg" },
"locationSettings": { "gl": "bg" }

// Change to your target language:
"hl": "en",      // English
"gl": "us",      // United States

// Other options:
"hl": "es", "gl": "es"  // Spanish
"hl": "de", "gl": "de"  // German
"hl": "fr", "gl": "fr"  // French
```

---

### 6\. Custom Notion Properties

Edit the "Create a database page" node to save additional fields:

**Add Trend Data:**

```
"trend_direction": "{{ ascending | descending }}",
"recent_momentum": "{{ positive | neutral | negative }}"
```

**Add Competition Data:**

```
"competitor_coverage": "high | medium | low",
"gap_opportunity": "{{ percentage }}"
```

**Add Creation Metadata:**

```
"content_type": "blog | faq | guide | tutorial",
"estimated_length": "{{ word_count }}",
"required_visuals": "{{ image_count }}"
```

---

### 7\. Extend with Slack Notifications

Add a Slack node after Notion write to notify your team:

```
New node: Slack → Send Message
Message: "📝 {{ $json.topic_title }} - Priority {{ $json.priority }}"
Channel: #content-ideas
Frequency: Only send when priority >= 4
```

---

### 8\. Add Email Report Generation

Instead of just saving to Notion, email a weekly summary:

```
New node: Gmail → Send Email
To: team@yourcompany.com
Subject: "Weekly Content Ideas - {{ items_count }} new topics"
Body: "Priority topics this week: [list]"
Include: HTML table with top 10 ideas
```

## 🏆 Success Tips

✅ **Start small:** Run on single page/topic first, expand later

✅ **Monitor costs:** Check SearchAPI and OpenAI usage monthly

✅ **Iterate prompts:** If clustering isn't right, tweak the AI prompt

✅ **Create workflow:** Team reviews Notion → writers create → track progress

✅ **Measure results:** Track which content ideas get best engagement

✅ **Optimize schedule:** Run when your team reviews (e.g., Monday morning)

✅ **Archive old ideas:** Keep Notion clean by archiving completed tasks

---

## Full JSON Workflow:

```
{
  "name": "People Also Ask (PAA) Scraper + Content Ideas Generator",
  "nodes": [
    {
      "parameters": {
        "rule": {
          "interval": [
            {}
          ]
        }
      },
      "name": "Schedule Trigger",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1,
      "position": [
        208,
        -112
      ],
      "id": "10d286d2-d473-4fb6-9be2-857e92f99890"
    },
    {
      "parameters": {
        "functionCode": "// normalizeSearchConsole - robust + dedupe + configurable filters\n// Edit the threshold values below or set them as environment variables in n8n\nconst MIN_IMPRESSIONS = (process.env.MIN_IMPRESSIONS !== undefined) ? Number(process.env.MIN_IMPRESSIONS) : 25;\nconst MAX_POSITION = (process.env.MAX_POSITION !== undefined) ? Number(process.env.MAX_POSITION) : 8; // lower is better (1 is top)\nconst DEDUPE = true; // set to false if you want duplicates preserved\n\n// Gather incoming jsons\nconst inputJsons = items.map(i => i.json);\nlet rows = [];\n\nif (inputJsons.length === 1 && Array.isArray(inputJsons[0].rows)) {\n  // shape: { rows: [ { keys: [...], clicks, impressions, ctr, position }, ... ] }\n  rows = inputJsons[0].rows.map(r => ({\n    query: Array.isArray(r.keys) ? r.keys[0] : r.keys,\n    clicks: r.clicks ?? 0,\n    impressions: r.impressions ?? 0,\n    ctr: r.ctr ?? 0,\n    position: r.position ?? null\n  }));\n} else {\n  // shape: [ { query, clicks, impressions, ctr, position }, ... ] OR similar flattened items\n  rows = inputJsons.map(r => ({\n    query: r.query ?? (Array.isArray(r.keys) ? r.keys[0] : r.keys) ?? null,\n    clicks: r.clicks ?? 0,\n    impressions: r.impressions ?? 0,\n    ctr: r.ctr ?? 0,\n    position: r.position ?? null\n  }));\n}\n\n// Normalize strings and filter out empty queries\nrows = rows.map(r => ({\n  query: typeof r.query === 'string' ? r.query.trim() : (r.query == null ? '' : String(r.query).trim()),\n  clicks: Number(r.clicks) || 0,\n  impressions: Number(r.impressions) || 0,\n  ctr: Number(r.ctr) || 0,\n  position: (r.position === null || r.position === undefined) ? null : Number(r.position)\n})).filter(r => r.query.length > 0);\n\n// Apply dedupe\nif (DEDUPE) {\n  const seen = new Set();\n  rows = rows.filter(r => {\n    const q = r.query.toLowerCase();\n    if (seen.has(q)) return false;\n    seen.add(q);\n    return true;\n  });\n}\n\n// Apply metric filters: keep only rows with impressions >= MIN_IMPRESSIONS and position <= MAX_POSITION (if position exists)\nrows = rows.filter(r => {\n  if (r.impressions < MIN_IMPRESSIONS) return false;\n  if (r.position !== null && !Number.isNaN(r.position) && r.position > MAX_POSITION) return false;\n  return true;\n});\n\n// Build output items\nconst out = rows.map(r => ({\n  json: {\n    query: r.query,\n    clicks: r.clicks,\n    impressions: r.impressions,\n    ctr: r.ctr,\n    position: r.position,\n    source: 'search-console'\n  }\n}));\n\nreturn out;\n"
      },
      "name": "normalizeSearchConsole",
      "type": "n8n-nodes-base.function",
      "typeVersion": 1,
      "position": [
        656,
        -112
      ],
      "id": "10cf3e74-3213-45d9-8cc9-d00182516837",
      "alwaysOutputData": true
    },
    {
      "parameters": {
        "functionCode": "const input = items[0].json || {};\nconst out = {...input};\n\n// Determine position (try multiple places)\nlet position = input.position ?? input.search_information?.position ?? input.current ?? null;\nif ((position === undefined || position === null) && Array.isArray(input.organic_results) && input.organic_results.length > 0) {\n  const first = input.organic_results[0];\n  position = (typeof first.position === 'number') ? first.position : (first.position ? Number(first.position) : 1);\n}\nif (position === undefined) position = null;\n\n// Extract questions (same logic you already have)\nconst candidates = [];\nfunction pushIfQuestion(text) {\n  if (!text || typeof text !== 'string') return;\n  const t = text.trim();\n  if (t.length < 5) return;\n  if (t.includes('?') || /^(what|how|why|when|where|who|can|do|does|is|are|should|which|will|как|кога|какво|защо|къде|кой|може|дали)\\b/i.test(t)) {\n    candidates.push(t);\n    return;\n  }\n  if (t.length < 80 && /^[\\w\\s\\p{L}]{3,80}$/u.test(t) && /\\b(как|кога|какво|защо|къде|кой|може|дали)\\b/i.test(t)) {\n    candidates.push(t);\n  }\n}\n\nif (Array.isArray(input.related_searches)) {\n  for (const r of input.related_searches) {\n    if (typeof r === 'string') pushIfQuestion(r);\n    else if (r && r.query) pushIfQuestion(r.query);\n    else if (r && r.title) pushIfQuestion(r.title);\n  }\n}\nif (input.answer_box) {\n  if (typeof input.answer_box === 'object') {\n    pushIfQuestion(input.answer_box.question || input.answer_box.title || input.answer_box.snippet);\n  } else if (typeof input.answer_box === 'string') pushIfQuestion(input.answer_box);\n}\nif (input.ai_overview) {\n  const ao = input.ai_overview;\n  if (Array.isArray(ao)) {\n    for (const a of ao) pushIfQuestion(typeof a === 'string' ? a : (a.title || a.snippet || a.text));\n  } else pushIfQuestion(ao.title || ao.snippet || ao.text || ao);\n}\nif (Array.isArray(input.organic_results)) {\n  for (const res of input.organic_results) {\n    if (res.title) pushIfQuestion(res.title);\n    if (res.snippet) pushIfQuestion(res.snippet);\n  }\n}\n\nlet uniq = Array.from(new Set(candidates.map(s => s.trim())));\nif (uniq.length === 0 && Array.isArray(input.organic_results)) {\n  for (const res of input.organic_results.slice(0, 10)) {\n    if (res.title && res.title.length < 160) uniq.push(res.title.trim());\n    if (uniq.length >= 10) break;\n  }\n}\nconst questions = uniq.slice(0, 50);\nif (questions.length === 0 && (input.search_parameters?.q || input.q || input.query_displayed)) {\n  const seed = input.search_parameters?.q || input.q || input.query_displayed;\n  questions.push(`What does \"${seed}\" mean?`);\n}\n\nconst resultItem = {\n  json: {\n    ...out,\n    position,\n    questions\n  }\n};\n\nreturn [ resultItem ];"
      },
      "name": "extractPAA",
      "type": "n8n-nodes-base.function",
      "typeVersion": 1,
      "position": [
        1104,
        -176
      ],
      "id": "270b3985-1724-4c85-ba45-fb392ce23573"
    },
    {
      "parameters": {
        "functionCode": "/* formatForNotion — robust JSON extraction + keyword fallback */\n\nfunction extractJSONFromText(text) {\n  const firstBracket = Math.min(\n    ...['[','{'].map(ch => {\n      const idx = text.indexOf(ch);\n      return idx >= 0 ? idx : Infinity;\n    })\n  );\n  if (firstBracket === Infinity) throw new Error(\"No JSON bracket found in text. Preview:\\n\" + (text||'').slice(0,1000));\n  const openChar = text[firstBracket];\n  const closeChar = openChar === '[' ? ']' : '}';\n  const lastBracket = text.lastIndexOf(closeChar);\n  if (lastBracket <= firstBracket) throw new Error(\"No matching close bracket in text. Preview:\\n\" + (text||'').slice(0,1000));\n  const candidate = text.slice(firstBracket, lastBracket + 1);\n  return JSON.parse(candidate);\n}\n\nfunction getRawTextFromItem(itemJson) {\n  if (!itemJson) return '';\n  // openai-like shapes\n  if (itemJson.choices && Array.isArray(itemJson.choices) && itemJson.choices[0]) {\n    const ch = itemJson.choices[0];\n    if (ch.message && ch.message.content) {\n      if (typeof ch.message.content === 'string') return ch.message.content;\n      if (Array.isArray(ch.message.content)) {\n        for (const seg of ch.message.content) if (typeof seg === 'string') return seg;\n        for (const seg of ch.message.content) if (seg && typeof seg.text === 'string') return seg.text;\n      }\n    }\n    if (typeof ch.text === 'string') return ch.text;\n    // fallback stringify\n    try { return JSON.stringify(ch); } catch(e){}\n  }\n  // other shape: output[0].content[0].text\n  if (itemJson.output && Array.isArray(itemJson.output) && itemJson.output[0]) {\n    const o0 = itemJson.output[0];\n    if (o0.content && Array.isArray(o0.content) && o0.content[0]) {\n      const c0 = o0.content[0];\n      if (typeof c0.text === 'string') return c0.text;\n      if (typeof c0 === 'string') return c0;\n    }\n    if (typeof o0.content === 'string') return o0.content;\n  }\n  // fallback: if any property looks like JSON string, return it\n  for (const k of Object.keys(itemJson)) {\n    const v = itemJson[k];\n    if (typeof v === 'string' && (v.trim().startsWith('[') || v.trim().startsWith('{'))) return v;\n  }\n  // last resort\n  try { return JSON.stringify(itemJson); } catch(e){ return ''; }\n}\n\n// -------------- begin processing -------------------\nconst itemJson = items[0] && items[0].json ? items[0].json : {};\nconst rawText = getRawTextFromItem(itemJson);\n\n// parse model output\nlet parsed;\ntry {\n  parsed = extractJSONFromText(String(rawText));\n} catch (err) {\n  const preview = {\n    availableKeys: Object.keys(itemJson || {}).slice(0,50),\n    rawPreview: String(rawText).slice(0,1200)\n  };\n  throw new Error('Failed to extract JSON from model output: ' + err.message + '\\nPreview keys: ' + JSON.stringify(preview));\n}\n\n// normalize clusters\nlet clusters;\nif (Array.isArray(parsed)) clusters = parsed;\nelse if (parsed && Array.isArray(parsed.clusters)) clusters = parsed.clusters;\nelse if (parsed && typeof parsed === 'object') clusters = [parsed];\nelse throw new Error('Parsed JSON not array/object: ' + typeof parsed);\n\n// try to discover keyword from parsed or from other places\nlet parsedKeyword = null;\n// if model returned top-level keyword or clusters contain keyword\nif (parsed && typeof parsed === 'object') {\n  if (parsed.keyword && typeof parsed.keyword === 'string') parsedKeyword = parsed.keyword;\n  else if (Array.isArray(parsed.clusters) && parsed.clusters.length > 0 && parsed.clusters[0].keyword) parsedKeyword = parsed.clusters[0].keyword;\n}\nif (!parsedKeyword && Array.isArray(clusters) && clusters.length > 0 && clusters[0].keyword) parsedKeyword = clusters[0].keyword;\n\n// fallback: try to pull from the inbound item (if upstream merging preserved them)\nlet inboundKeyword = itemJson.query || itemJson.q || (itemJson.search_parameters && itemJson.search_parameters.q) || itemJson.query_displayed || itemJson.keyword || null;\n\n// final resolvedKeyword function\nfunction resolveKeyword(c) {\n  // cluster-level keyword > parsedKeyword > inboundKeyword > null\n  return (c && c.keyword) || parsedKeyword || inboundKeyword || null;\n}\n\n// build output for Notion\nconst out = [];\nfor (const c of clusters) {\n  out.push({\n    json: {\n      keyword: resolveKeyword(c),\n      topic_title: c.topic_title || c.topic || null,\n      suggested_article_title: c.suggested_article_title || c.title || null,\n      short_description: c.short_description || c.summary || null,\n      intent: c.intent || 'informational',\n      priority: Number.isFinite(Number(c.priority)) ? Number(c.priority) : 3,\n      questions: Array.isArray(c.questions) ? c.questions.join('\\n') : String(c.questions || '')\n    }\n  });\n}\n\nreturn out;\n"
      },
      "name": "formatForNotion",
      "type": "n8n-nodes-base.function",
      "typeVersion": 1,
      "position": [
        1904,
        -112
      ],
      "id": "13c286bd-30a9-4ac1-8c52-abdc1e3ee53f"
    },
    {
      "parameters": {},
      "name": "NoOp End",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        2352,
        -112
      ],
      "id": "2654231a-cc0f-4ef7-b877-f2c7608b0668"
    },
    {
      "parameters": {
        "operation": "getPageInsights",
        "siteUrl": "{{ $env.GSC_SITE_URL }}",
        "dateRangeMode": "last3mo",
        "rowLimit": 50,
        "dimensions": [
          "query"
        ],
        "filters": {
          "filter": [
            {
              "dimension": "page",
              "expression": "{{ $env.GSC_PAGE_PATH }}"
            }
          ]
        }
      },
      "type": "n8n-nodes-google-search-console.googleSearchConsole",
      "typeVersion": 1,
      "position": [
        432,
        -112
      ],
      "id": "14ce48f7-0a85-4a20-9264-fdc9c1fb229a",
      "name": "Query search analytics",
      "credentials": {
        "googleSearchConsoleOAuth2Api": {
          "id": "{{ $env.GSC_CREDENTIAL_ID }}",
          "name": "Google Search Console account"
        }
      }
    },
    {
      "parameters": {
        "modelId": {
          "__rl": true,
          "value": "gpt-4",
          "mode": "list",
          "cachedResultName": "GPT-4"
        },
        "responses": {
          "values": [
            {
              "content": "=Input: {\n  \"keyword\": \"{{ $json.search_parameters.q }}\",\n  \"questions\": {{ JSON.stringify($json.questions || []) }},\n  \"metrics\": {\n    \"clicks\": {{ $json.clicks || 0 }},\n    \"impressions\": {{ $json.impressions || 0 }},\n    \"ctr\": {{ $json.ctr || 0 }},\n    \"position\": {{ $json.position === null ? \"null\" : $json.position }}\n  }\n}\n\nTask: Return strictly valid JSON — a root-level ARRAY named \"clusters\" (i.e. return only: [ {...}, {...} ]). For every cluster object, INCLUDE the same keyword from the Input exactly as provided, using the key \"keyword\". Each cluster object must have:\n\n- \"keyword\": string (must match Input.keyword)\n- \"topic_title\": string\n- \"questions\": [string,...]\n- \"suggested_article_title\": string\n- \"short_description\": string (20–40 words)\n- \"intent\": one of [\"informational\",\"transactional\",\"navigational\"]\n- \"priority\": integer 1-5\n\nConstraints:\n1. Return **only** the JSON array — no prose, no markdown.\n2. The model must echo Input.keyword into every cluster as the \"keyword\" field.\n3. Keep output compact and valid JSON.\n\nExample cluster element:\n{\n  \"keyword\":\"how to make sourdough\",\n  \"topic_title\":\"Starter & feeding\",\n  \"questions\":[\"What is sourdough starter?\",\"How often to feed starter?\"],\n  \"suggested_article_title\":\"Sourdough Starter: Create & Maintain a Healthy Starter\",\n  \"short_description\":\"A concise how-to guide to start and maintain a healthy sourdough starter for reliable loaves.\",\n  \"intent\":\"informational\",\n  \"priority\":4\n}\n"
            },
            {
              "role": "system",
              "content": "You are an assistant that clusters FAQ-style questions into coherent topic groups and returns valid JSON only. Do not emit any explanation, prose, or extra characters — only the JSON array requested. Be concise, factual, and consistent.\n"
            }
          ]
        },
        "builtInTools": {},
        "options": {
          "temperature": 0
        }
      },
      "type": "@n8n/n8n-nodes-langchain.openAi",
      "typeVersion": 2,
      "position": [
        1328,
        -176
      ],
      "id": "ed351e67-e7b0-4ebe-a533-fc614ee11337",
      "name": "Message a model",
      "alwaysOutputData": false,
      "credentials": {
        "openAiApi": {
          "id": "{{ $env.OPENAI_CREDENTIAL_ID }}",
          "name": "OpenAi account"
        }
      }
    },
    {
      "parameters": {
        "resource": "databasePage",
        "databaseId": {
          "__rl": true,
          "value": "{{ $env.NOTION_DATABASE_ID }}",
          "mode": "list",
          "cachedResultName": "New Topics",
          "cachedResultUrl": "https://www.notion.so/NOTION_DATABASE_ID"
        },
        "title": "=New Topics",
        "propertiesUi": {
          "propertyValues": [
            {
              "key": "Keyword|rich_text",
              "textContent": "={{ $json.keyword }}"
            },
            {
              "key": "Name|title",
              "title": "={{ $json.topic_title }}"
            },
            {
              "key": "Description|rich_text",
              "textContent": "={{ $json.short_description }}"
            },
            {
              "key": "Intent|rich_text",
              "textContent": "={{ $json.intent }}"
            },
            {
              "key": "Priority|rich_text",
              "textContent": "={{ $json.priority.toString() }}"
            },
            {
              "key": "Questions|rich_text",
              "textContent": "={{ $json.questions }}"
            }
          ]
        },
        "options": {}
      },
      "type": "n8n-nodes-base.notion",
      "typeVersion": 2.2,
      "position": [
        2128,
        -112
      ],
      "id": "10d06184-c37b-4aba-a0b4-fdfcab071c89",
      "name": "Create a database page",
      "credentials": {
        "notionApi": {
          "id": "{{ $env.NOTION_CREDENTIAL_ID }}",
          "name": "Notion account"
        }
      }
    },
    {
      "parameters": {
        "q": "={{ $json.query }}",
        "locationSettings": {
          "gl": "bg"
        },
        "languageSettings": {
          "hl": "bg"
        },
        "searchOptions": {},
        "timeFilters": {},
        "pagination": {
          "num": "10"
        },
        "advancedOptions": {},
        "requestOptions": {}
      },
      "type": "@searchapi/n8n-nodes-searchapi.searchApi",
      "typeVersion": 1,
      "position": [
        880,
        -112
      ],
      "id": "fbf6db7d-d924-4812-8a7e-d504450f3d87",
      "name": "Search google",
      "credentials": {
        "searchApi": {
          "id": "{{ $env.SEARCHAPI_CREDENTIAL_ID }}",
          "name": "SearchApi account"
        }
      }
    },
    {
      "parameters": {},
      "type": "n8n-nodes-base.merge",
      "typeVersion": 3.2,
      "position": [
        1680,
        -112
      ],
      "id": "d30f9e5e-a649-4f43-85b5-b05113c72f20",
      "name": "Merge"
    }
  ],
  "pinData": {},
  "connections": {
    "Schedule Trigger": {
      "main": [
        [
          {
            "node": "Query search analytics",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "normalizeSearchConsole": {
      "main": [
        [
          {
            "node": "Search google",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "formatForNotion": {
      "main": [
        [
          {
            "node": "Create a database page",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Query search analytics": {
      "main": [
        [
          {
            "node": "normalizeSearchConsole",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "extractPAA": {
      "main": [
        [
          {
            "node": "Message a model",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Message a model": {
      "main": [
        [
          {
            "node": "Merge",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Create a database page": {
      "main": [
        [
          {
            "node": "NoOp End",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Search google": {
      "main": [
        [
          {
            "node": "extractPAA",
            "type": "main",
            "index": 0
          },
          {
            "node": "Merge",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Merge": {
      "main": [
        [
          {
            "node": "formatForNotion",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "versionId": "1.0.0-sanitized",
  "meta": {
    "templateCredsSetupCompleted": false
  },
  "tags": [
    {
      "id": "seo",
      "name": "SEO"
    },
    {
      "id": "content-research",
      "name": "Content Research"
    },
    {
      "id": "ai-analysis",
      "name": "AI Analysis"
    }
  ]
}
```

---

**Your content research is about to get a lot faster. Let's go!** 🚀