AutomationFlowsWeb Scraping › Generate Weekly Pinterest Content Opportunity Briefs with Apify, Openai and…

Generate Weekly Pinterest Content Opportunity Briefs with Apify, Openai and…

Original n8n title: Generate Weekly Pinterest Content Opportunity Briefs with Apify, Openai and Google Sheets

ByHanna Nosova @fetch-cat on n8n.io

Research a Pinterest niche with FetchCat's Pinterest Search Scraper, preserve every source pin in Google Sheets, and use one evidence-controlled OpenAI call to identify recurring themes, underrepresented angles, and five production-ready content briefs without claiming search…

Event trigger★★★★☆ complexityAI-powered20 nodesHTTP RequestOpenAIGoogle Sheets
Web Scraping Trigger: Event Nodes: 20 Complexity: ★★★★☆ AI nodes: yes Added:

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

This workflow follows the Google Sheets → HTTP Request 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
{
  "id": "uYl8NQHzpZwWDti1",
  "name": "Analyze Pinterest Content Opportunities with Apify, OpenAI and Google Sheets",
  "tags": [],
  "nodes": [
    {
      "id": "51000000-0000-0000-4000-8000-000000000001",
      "name": "Manual Trigger",
      "type": "n8n-nodes-base.manualTrigger",
      "position": [
        -1760,
        80
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "51000000-0000-0000-4000-8000-000000000003",
      "name": "1. Set Your Research Niche",
      "type": "n8n-nodes-base.set",
      "position": [
        -1520,
        80
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "pinterest-niche",
              "name": "niche",
              "type": "string",
              "value": "female cycling"
            },
            {
              "id": "pinterest-queries",
              "name": "searches",
              "type": "string",
              "value": "female cycling"
            },
            {
              "id": "pinterest-locale",
              "name": "locale",
              "type": "string",
              "value": "en-US"
            },
            {
              "id": "pinterest-country",
              "name": "country",
              "type": "string",
              "value": "US"
            },
            {
              "id": "pinterest-limit",
              "name": "maxResultsPerSearch",
              "type": "number",
              "value": 100
            },
            {
              "id": "pinterest-details",
              "name": "includePinDetails",
              "type": "boolean",
              "value": true
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "51000000-0000-0000-4000-8000-000000000004",
      "name": "Build FetchCat Research Input",
      "type": "n8n-nodes-base.code",
      "position": [
        -1280,
        80
      ],
      "parameters": {
        "jsCode": "const input = $input.first()?.json;\nif (!input) throw new Error('Configure 1. Set Your Research Niche.');\nconst niche = String(input.niche || '').trim();\nif (niche.length < 2 || niche.length > 120) throw new Error('Research niche must contain 2 to 120 characters.');\nconst queries = String(input.searches || niche).split(/[\\n,]+/).map((value) => value.trim()).filter(Boolean);\nif (queries.length < 1 || queries.length > 5) throw new Error('Configure between one and five search phrases, separated by commas or new lines.');\nif (new Set(queries.map((query) => query.toLowerCase())).size !== queries.length) throw new Error('Search phrases must be unique.');\nif (queries.some((query) => query.length < 2 || query.length > 150)) throw new Error('Each search phrase must contain 2 to 150 characters.');\nconst maxResultsPerQuery = Math.max(20, Math.min(Number(input.maxResultsPerSearch) || 100, 500));\nreturn [{ json: {\n  config: { niche, queries, maxResultsPerQuery },\n  actorInput: {\n    queries,\n    maxResultsPerQuery,\n    includePinDetails: Boolean(input.includePinDetails),\n    locale: String(input.locale || 'en-US').trim(),\n    country: String(input.country || 'US').trim()\n  }\n} }];"
      },
      "typeVersion": 2
    },
    {
      "id": "51000000-0000-0000-4000-8000-000000000005",
      "name": "2. Collect Pinterest Results with FetchCat",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -1040,
        80
      ],
      "parameters": {
        "url": "https://api.apify.com/v2/acts/FtsA7YTDVGAJ83XiS/run-sync-get-dataset-items",
        "method": "POST",
        "options": {
          "timeout": 310000,
          "response": {
            "response": {
              "responseFormat": "json"
            }
          }
        },
        "jsonBody": "={{ $json.actorInput }}",
        "sendBody": true,
        "sendQuery": true,
        "sendHeaders": true,
        "specifyBody": "json",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "queryParameters": {
          "parameters": [
            {
              "name": "clean",
              "value": "true"
            },
            {
              "name": "format",
              "value": "json"
            },
            {
              "name": "timeout",
              "value": "300"
            }
          ]
        },
        "headerParameters": {
          "parameters": [
            {
              "name": "Accept-Encoding",
              "value": "identity"
            }
          ]
        }
      },
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.3
    },
    {
      "id": "51000000-0000-0000-4000-8000-000000000009",
      "name": "Normalize and Deduplicate Pins",
      "type": "n8n-nodes-base.code",
      "position": [
        -800,
        80
      ],
      "parameters": {
        "jsCode": "const config = $('Build FetchCat Research Input').first().json.config;\nconst payload = $input.all().flatMap((item) => {\n  const value = item.json?.data ?? item.json;\n  return Array.isArray(value) ? value : [value];\n});\nconst researchAt = new Date().toISOString();\nconst researchDate = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/Lisbon' });\nconst unique = new Map();\nfor (const pin of payload) {\n  const pinId = String(pin.pinId || '').trim();\n  const query = String(pin.query || '').trim();\n  const pinUrl = String(pin.pinUrl || '').trim();\n  const position = Number(pin.position);\n  if (!pinId || !query || !/^https:\\/\\/(?:[a-z]+\\.)?pinterest\\.[^/]+\\/pin\\//i.test(pinUrl) || !Number.isFinite(position) || position < 1) continue;\n  const key = query.toLowerCase() + '|' + pinId;\n  if (unique.has(key)) continue;\n  const rawTitle = String(pin.title || '').trim();\n  const description = String(pin.description || '').trim();\n  unique.set(key, {\n    researchDate,\n    researchAt,\n    query,\n    pinId,\n    position,\n    title: (!rawTitle || ['pin', 'pinterest'].includes(rawTitle.toLowerCase()) ? description : rawTitle).slice(0, 220) || 'Untitled Pinterest pin',\n    pinUrl,\n    description: description.slice(0, 1000),\n    imageUrl: String(pin.imageUrl || pin.thumbnailUrl || '').trim(),\n    creatorName: String(pin.creatorName || pin.creatorUsername || '').trim(),\n    boardName: String(pin.boardName || '').trim(),\n    domain: String(pin.domain || '').trim(),\n    outboundUrl: String(pin.outboundUrl || '').trim(),\n    isVideo: Boolean(pin.isVideo),\n    saveCount: pin.saveCount !== null && pin.saveCount !== undefined && pin.saveCount !== '' && Number.isFinite(Number(pin.saveCount)) ? Number(pin.saveCount) : null,\n    repinCount: pin.repinCount !== null && pin.repinCount !== undefined && pin.repinCount !== '' && Number.isFinite(Number(pin.repinCount)) ? Number(pin.repinCount) : null\n  });\n}\nconst pins = [...unique.values()].sort((a, b) => a.query.localeCompare(b.query) || a.position - b.position);\nconst returnedQueries = new Set(pins.map((row) => row.query.toLowerCase()));\nconst emptyQueries = config.queries.filter((query) => !returnedQueries.has(query.toLowerCase()));\nif (emptyQueries.length) throw new Error('Pinterest returned no usable pins for: ' + emptyQueries.join(', ') + '. Refine those searches and retry.');\nif (pins.length < 10) throw new Error('Pinterest returned fewer than ten usable pins. Broaden the research niche and retry.');\nreturn pins.map((json) => ({ json }));"
      },
      "typeVersion": 2
    },
    {
      "id": "51000000-0000-0000-4000-8000-000000000010",
      "name": "Build Research Evidence",
      "type": "n8n-nodes-base.code",
      "position": [
        -560,
        80
      ],
      "parameters": {
        "jsCode": "const pins = $input.all().map((item) => item.json);\nconst config = $('Build FetchCat Research Input').first().json.config;\nconst sheetsEpochOffset = 25569;\nconst researchSerial = new Date(pins[0].researchAt).getTime() / 86400000 + sheetsEpochOffset;\nconst escapeFormula = (value) => String(value || '').replace(/\"/g, '\"\"');\nconst sourceRows = pins.map((pin) => {\n  return {\n    researchAt: researchSerial,\n    niche: config.niche,\n    query: pin.query,\n    position: pin.position,\n    pinLink: '=HYPERLINK(\"' + escapeFormula(pin.pinUrl) + '\",\"View pin\")',\n    title: pin.title,\n    description: pin.description,\n    creator: pin.creatorName,\n    board: pin.boardName,\n    domain: pin.domain,\n    destinationLink: pin.outboundUrl ? '=HYPERLINK(\"' + escapeFormula(pin.outboundUrl) + '\",\"Open destination\")' : '',\n    imageLink: pin.imageUrl ? '=HYPERLINK(\"' + escapeFormula(pin.imageUrl) + '\",\"View image\")' : '',\n    format: pin.isVideo ? 'Video' : 'Image',\n    saves: pin.saveCount,\n    repins: pin.repinCount,\n    pinId: pin.pinId,\n    researchKey: pins[0].researchDate + '|' + config.niche.toLowerCase() + '|' + pin.query.toLowerCase() + '|' + pin.pinId\n  };\n});\nconst stopWords = new Set('a an and are as at be by for from how in into is it of on or that the this to with you your pinterest pin ideas'.split(' '));\nconst phraseCounts = new Map();\nfor (const pin of pins) {\n  const words = (pin.title + ' ' + pin.description).toLowerCase().replace(/[^a-z0-9 ]/g, ' ').split(/\\s+/).filter((word) => word.length > 2 && !stopWords.has(word));\n  const phrases = new Set();\n  for (let i = 0; i < words.length - 1; i += 1) phrases.add(words[i] + ' ' + words[i + 1]);\n  for (const phrase of phrases) phraseCounts.set(phrase, (phraseCounts.get(phrase) || 0) + 1);\n}\nconst recurringPhrases = [...phraseCounts].filter(([, count]) => count >= 2).sort((a, b) => b[1] - a[1]).slice(0, 25).map(([phrase, count]) => ({ phrase, pins: count }));\nconst compactPins = [];\nlet packetSize = 0;\nfor (const pin of pins) {\n  const compact = { pinId: pin.pinId, query: pin.query, position: pin.position, title: pin.title.slice(0, 180), description: pin.description.slice(0, 260), creator: pin.creatorName, domain: pin.domain, format: pin.isVideo ? 'Video' : 'Image' };\n  const size = JSON.stringify(compact).length;\n  if (packetSize + size > 105000) break;\n  compactPins.push(compact);\n  packetSize += size;\n}\nconst stats = {\n  niche: config.niche,\n  searches: config.queries,\n  totalPins: pins.length,\n  analyzedPins: compactPins.length,\n  uniqueCreators: new Set(pins.map((pin) => pin.creatorName).filter(Boolean).map((value) => value.toLowerCase())).size,\n  uniqueDomains: new Set(pins.map((pin) => pin.domain).filter(Boolean).map((value) => value.toLowerCase())).size,\n  imagePins: pins.filter((pin) => !pin.isVideo).length,\n  videoPins: pins.filter((pin) => pin.isVideo).length,\n  pinsWithSaveData: pins.filter((pin) => pin.saveCount !== null).length\n};\nreturn [{ json: { stats, sourceRows, pins, researchPacket: { stats, recurringPhrases, pins: compactPins } } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "51000000-0000-0000-4000-8000-000000000011",
      "name": "3. Analyze Content Landscape and Opportunities",
      "type": "@n8n/n8n-nodes-langchain.openAi",
      "position": [
        -320,
        80
      ],
      "parameters": {
        "modelId": {
          "__rl": true,
          "mode": "id",
          "value": "gpt-5.4-mini"
        },
        "options": {
          "store": false,
          "maxTokens": 7000,
          "reasoning": {
            "reasoningOptions": {
              "effort": "low",
              "summary": "none"
            }
          },
          "textFormat": {
            "textOptions": {
              "name": "pinterest_content_opportunity_research",
              "type": "json_schema",
              "schema": "{\"type\":\"object\",\"additionalProperties\":false,\"required\":[\"executiveSummary\",\"themes\",\"underrepresentedAngles\",\"contentTests\"],\"properties\":{\"executiveSummary\":{\"type\":\"string\",\"minLength\":1,\"maxLength\":1200},\"themes\":{\"type\":\"array\",\"minItems\":4,\"maxItems\":8,\"items\":{\"type\":\"object\",\"additionalProperties\":false,\"required\":[\"name\",\"insight\",\"matchTerms\",\"evidencePinIds\"],\"properties\":{\"name\":{\"type\":\"string\",\"minLength\":1,\"maxLength\":100},\"insight\":{\"type\":\"string\",\"minLength\":1,\"maxLength\":500},\"matchTerms\":{\"type\":\"array\",\"minItems\":2,\"maxItems\":8,\"items\":{\"type\":\"string\",\"minLength\":2,\"maxLength\":80}},\"evidencePinIds\":{\"type\":\"array\",\"minItems\":2,\"maxItems\":6,\"items\":{\"type\":\"string\",\"minLength\":1}}}}},\"underrepresentedAngles\":{\"type\":\"array\",\"minItems\":3,\"maxItems\":5,\"items\":{\"type\":\"object\",\"additionalProperties\":false,\"required\":[\"angle\",\"sampleObservation\",\"contentOpportunity\",\"evidencePinIds\"],\"properties\":{\"angle\":{\"type\":\"string\",\"minLength\":1,\"maxLength\":180},\"sampleObservation\":{\"type\":\"string\",\"minLength\":1,\"maxLength\":500},\"contentOpportunity\":{\"type\":\"string\",\"minLength\":1,\"maxLength\":500},\"evidencePinIds\":{\"type\":\"array\",\"minItems\":2,\"maxItems\":6,\"items\":{\"type\":\"string\",\"minLength\":1}}}}},\"contentTests\":{\"type\":\"array\",\"minItems\":5,\"maxItems\":5,\"items\":{\"type\":\"object\",\"additionalProperties\":false,\"required\":[\"proposedPinTitle\",\"visualConcept\",\"format\",\"audienceProblem\",\"differentiatingAngle\",\"destinationContent\",\"observedPhrases\",\"suggestedSearchExpansions\",\"evidencePinIds\"],\"properties\":{\"proposedPinTitle\":{\"type\":\"string\",\"minLength\":1,\"maxLength\":180},\"visualConcept\":{\"type\":\"string\",\"minLength\":1,\"maxLength\":350},\"format\":{\"type\":\"string\",\"minLength\":1,\"maxLength\":100},\"audienceProblem\":{\"type\":\"string\",\"minLength\":1,\"maxLength\":350},\"differentiatingAngle\":{\"type\":\"string\",\"minLength\":1,\"maxLength\":350},\"destinationContent\":{\"type\":\"string\",\"minLength\":1,\"maxLength\":350},\"observedPhrases\":{\"type\":\"array\",\"minItems\":2,\"maxItems\":6,\"items\":{\"type\":\"string\",\"minLength\":2,\"maxLength\":80}},\"suggestedSearchExpansions\":{\"type\":\"array\",\"minItems\":2,\"maxItems\":6,\"items\":{\"type\":\"string\",\"minLength\":2,\"maxLength\":100}},\"evidencePinIds\":{\"type\":\"array\",\"minItems\":1,\"maxItems\":5,\"items\":{\"type\":\"string\",\"minLength\":1}}}}}}}",
              "strict": true,
              "verbosity": "low",
              "description": "Analyze only the supplied Pinterest results. Identify recurring content themes, underrepresented angles in this sample, and five production-ready content tests. Every evidencePinId must exactly match a supplied pinId. matchTerms and observedPhrases must be literal phrases found in supplied titles or descriptions. suggestedSearchExpansions are brainstorming prompts, not verified Pinterest keywords, and must never be described as popular, trending, high-volume, or demanded. Use concise natural English. Never claim search volume, trend growth, engagement, clicks, sales, or demand. Return the strict schema."
            }
          },
          "instructions": "Analyze only the supplied Pinterest results. Identify recurring content themes, underrepresented angles in this sample, and five production-ready content tests. Every evidencePinId must exactly match a supplied pinId. matchTerms and observedPhrases must be literal phrases found in supplied titles or descriptions. suggestedSearchExpansions are brainstorming prompts, not verified Pinterest keywords, and must never be described as popular, trending, high-volume, or demanded. Use concise natural English. Never claim search volume, trend growth, engagement, clicks, sales, or demand. Return the strict schema."
        },
        "responses": {
          "values": [
            {
              "content": "=Research niche: {{ $json.stats.niche }}\n\nAnalyze this Pinterest evidence:\n{{ JSON.stringify($json.researchPacket) }}"
            }
          ]
        },
        "builtInTools": {}
      },
      "credentials": {
        "openAiApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "51000000-0000-0000-4000-8000-000000000020",
      "name": "Keep Only Supplied Evidence Citations",
      "type": "n8n-nodes-base.code",
      "position": [
        -80,
        80
      ],
      "parameters": {
        "jsCode": "\nfunction parseStructured(root, requiredKeys) {\n  const seen = new Set();\n  function visit(value) {\n    if (value === null || value === undefined) return null;\n    if (typeof value === 'string') {\n      const text = value.trim().replace(/^\\x60\\x60\\x60(?:json)?\\s*/i, '').replace(/\\s*\\x60\\x60\\x60$/, '');\n      if (!text.startsWith('{')) return null;\n      try { return visit(JSON.parse(text)); } catch { return null; }\n    }\n    if (typeof value !== 'object' || seen.has(value)) return null;\n    seen.add(value);\n    if (requiredKeys.every((key) => Object.prototype.hasOwnProperty.call(value, key))) return value;\n    const preferred = ['output_text', 'outputText', 'text', 'content', 'output', 'message', 'response', 'data'];\n    for (const key of preferred) {\n      if (Object.prototype.hasOwnProperty.call(value, key)) {\n        const found = visit(value[key]);\n        if (found) return found;\n      }\n    }\n    for (const nested of Array.isArray(value) ? value : Object.values(value)) {\n      const found = visit(nested);\n      if (found) return found;\n    }\n    return null;\n  }\n  return visit(root);\n}\n\nconst evidence = $('Build Research Evidence').first().json;\nconst parsed = parseStructured($input.first().json, ['executiveSummary', 'themes', 'underrepresentedAngles', 'contentTests']);\nif (!parsed) throw new Error('OpenAI returned an invalid Pinterest research report.');\nconst suppliedIds = new Set(evidence.pins.map((pin) => String(pin.pinId)));\nlet discardedInvalidCitations = 0;\nfor (const item of [...parsed.themes, ...parsed.underrepresentedAngles, ...parsed.contentTests]) {\n  if (!Array.isArray(item.evidencePinIds) || item.evidencePinIds.length === 0) throw new Error('A Pinterest finding has no evidence pins.');\n  const validIds = [...new Set(item.evidencePinIds.map(String).filter((pinId) => suppliedIds.has(pinId)))];\n  discardedInvalidCitations += item.evidencePinIds.length - validIds.length;\n  if (validIds.length === 0) throw new Error('A Pinterest finding cites no supplied pins.');\n  item.evidencePinIds = validIds;\n}\nreturn [{ json: { ...parsed, discardedInvalidCitations } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "51000000-0000-0000-4000-8000-000000000012",
      "name": "Validate Evidence and Build Report",
      "type": "n8n-nodes-base.code",
      "position": [
        160,
        80
      ],
      "parameters": {
        "jsCode": "\nfunction parseStructured(root, requiredKeys) {\n  const seen = new Set();\n  function visit(value) {\n    if (value === null || value === undefined) return null;\n    if (typeof value === 'string') {\n      const text = value.trim().replace(/^\\x60\\x60\\x60(?:json)?\\s*/i, '').replace(/\\s*\\x60\\x60\\x60$/, '');\n      if (!text.startsWith('{')) return null;\n      try { return visit(JSON.parse(text)); } catch { return null; }\n    }\n    if (typeof value !== 'object' || seen.has(value)) return null;\n    seen.add(value);\n    if (requiredKeys.every((key) => Object.prototype.hasOwnProperty.call(value, key))) return value;\n    const preferred = ['output_text', 'outputText', 'text', 'content', 'output', 'message', 'response', 'data'];\n    for (const key of preferred) {\n      if (Object.prototype.hasOwnProperty.call(value, key)) {\n        const found = visit(value[key]);\n        if (found) return found;\n      }\n    }\n    for (const nested of Array.isArray(value) ? value : Object.values(value)) {\n      const found = visit(nested);\n      if (found) return found;\n    }\n    return null;\n  }\n  return visit(root);\n}\n\nconst evidence = $('Build Research Evidence').first().json;\nconst parsed = parseStructured($input.first().json, ['executiveSummary', 'themes', 'underrepresentedAngles', 'contentTests']);\nif (!parsed || !Array.isArray(parsed.themes) || !Array.isArray(parsed.underrepresentedAngles) || !Array.isArray(parsed.contentTests) || parsed.contentTests.length !== 5) throw new Error('OpenAI returned an invalid Pinterest research report.');\nconst byId = new Map(evidence.pins.map((pin) => [String(pin.pinId), pin]));\nconst validateEvidence = (items) => {\n  for (const item of items) {\n    if (!Array.isArray(item.evidencePinIds) || item.evidencePinIds.length === 0) throw new Error('A Pinterest finding has no evidence pins.');\n    if (item.evidencePinIds.some((pinId) => !byId.has(String(pinId)))) throw new Error('OpenAI cited a Pinterest pin that was not supplied.');\n  }\n};\nvalidateEvidence(parsed.themes); validateEvidence(parsed.underrepresentedAngles); validateEvidence(parsed.contentTests);\nconst linkEvidence = (ids) => ids.map((id) => byId.get(String(id))).map((pin) => pin.title + ' - ' + pin.pinUrl).join('\\n');\nconst allText = evidence.pins.map((pin) => (pin.title + ' ' + pin.description).toLowerCase());\nconst briefRows = [];\nconst add = (section, finding, evidenceText, matchingPins, order) => briefRows.push({ section, finding, evidence: evidenceText, matchingPins, sortOrder: order });\nadd('Summary', parsed.executiveSummary, evidence.stats.totalPins + ' source pins across ' + evidence.stats.searches.length + ' search phrase(s).', evidence.stats.totalPins, 1);\nparsed.themes.forEach((theme, index) => {\n  const terms = theme.matchTerms.map((term) => String(term).toLowerCase());\n  const count = allText.filter((text) => terms.some((term) => text.includes(term))).length;\n  add('Leading theme', theme.name + ': ' + theme.insight, 'Matched terms: ' + theme.matchTerms.join(', ') + '\\n' + linkEvidence(theme.evidencePinIds), count, 100 + index);\n});\nparsed.underrepresentedAngles.forEach((angle, index) => add('Underrepresented angle', angle.angle + '\\nSample observation: ' + angle.sampleObservation + '\\nContent opportunity: ' + angle.contentOpportunity, linkEvidence(angle.evidencePinIds), null, 200 + index));\nparsed.contentTests.forEach((test, index) => add('Content test', 'Proposed pin title: ' + test.proposedPinTitle + '\\nFormat: ' + test.format + '\\nVisual concept: ' + test.visualConcept + '\\nAudience problem: ' + test.audienceProblem + '\\nDifferentiating angle: ' + test.differentiatingAngle + '\\nDestination content: ' + test.destinationContent + '\\nObserved phrases: ' + test.observedPhrases.join(', ') + '\\nUnvalidated search-expansion ideas: ' + test.suggestedSearchExpansions.join(', '), linkEvidence(test.evidencePinIds), null, 300 + index));\nconst researchSerial = evidence.sourceRows[0].researchAt;\nconst dateKey = evidence.pins[0].researchDate;\nbriefRows.forEach((row) => { row.researchAt = researchSerial; row.niche = evidence.stats.niche; row.researchKey = dateKey + '|' + evidence.stats.niche.toLowerCase() + '|' + row.section.toLowerCase() + '|' + row.sortOrder; });\nreturn [{ json: { ...evidence, analysis: parsed, briefRows } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "51000000-0000-0000-4000-8000-000000000013",
      "name": "Prepare Pin Rows",
      "type": "n8n-nodes-base.code",
      "position": [
        400,
        80
      ],
      "parameters": {
        "jsCode": "return $json.sourceRows.map((row) => ({ json: row }));"
      },
      "typeVersion": 2
    },
    {
      "id": "51000000-0000-0000-4000-8000-000000000014",
      "name": "4. Save Source Pins",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        640,
        80
      ],
      "parameters": {
        "columns": {
          "value": {
            "Pin": "={{ $json.pinLink }}",
            "Board": "={{ $json.board }}",
            "Image": "={{ $json.imageLink }}",
            "Niche": "={{ $json.niche }}",
            "Saves": "={{ $json.saves }}",
            "Title": "={{ $json.title }}",
            "Domain": "={{ $json.domain }}",
            "Format": "={{ $json.format }}",
            "Repins": "={{ $json.repins }}",
            "Search": "={{ $json.query }}",
            "Creator": "={{ $json.creator }}",
            "Position": "={{ $json.position }}",
            "Description": "={{ $json.description }}",
            "Destination": "={{ $json.destinationLink }}",
            "Research at": "={{ $json.researchAt }}",
            "Research key": "={{ $json.researchKey }}",
            "Pinterest pin ID": "={{ $json.pinId }}"
          },
          "schema": [
            {
              "id": "Research at",
              "type": "number",
              "display": true,
              "required": false,
              "displayName": "Research at",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Niche",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Niche",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Search",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Search",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Position",
              "type": "number",
              "display": true,
              "required": false,
              "displayName": "Position",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Pin",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Pin",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Title",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Title",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Description",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Description",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Creator",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Creator",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Board",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Board",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Domain",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Domain",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Destination",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Destination",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Image",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Image",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Format",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Format",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Saves",
              "type": "number",
              "display": true,
              "required": false,
              "displayName": "Saves",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Repins",
              "type": "number",
              "display": true,
              "required": false,
              "displayName": "Repins",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Pinterest pin ID",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Pinterest pin ID",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Research key",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Research key",
              "defaultMatch": true,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [
            "Research key"
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {},
        "operation": "appendOrUpdate",
        "sheetName": {
          "__rl": true,
          "mode": "id",
          "value": "2001001",
          "cachedResultName": "Pins"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "1lylIruCx1erI2lmKSMVRr4kiwLlZT7Kjn6cBJrTOY9I"
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.7
    },
    {
      "id": "51000000-0000-0000-4000-8000-000000000017",
      "name": "Prepare Research Brief Rows",
      "type": "n8n-nodes-base.code",
      "position": [
        880,
        80
      ],
      "parameters": {
        "jsCode": "return $('Validate Evidence and Build Report').first().json.briefRows.map((row) => ({ json: row }));"
      },
      "executeOnce": true,
      "typeVersion": 2
    },
    {
      "id": "51000000-0000-0000-4000-8000-000000000018",
      "name": "5. Save Research Brief",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        1120,
        80
      ],
      "parameters": {
        "columns": {
          "value": {
            "Niche": "={{ $json.niche }}",
            "Finding": "={{ $json.finding }}",
            "Section": "={{ $json.section }}",
            "Evidence": "={{ $json.evidence }}",
            "Sort order": "={{ $json.sortOrder }}",
            "Research at": "={{ $json.researchAt }}",
            "Research key": "={{ $json.researchKey }}",
            "Matching pins": "={{ $json.matchingPins }}"
          },
          "schema": [
            {
              "id": "Research at",
              "type": "number",
              "display": true,
              "required": false,
              "displayName": "Research at",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Niche",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Niche",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Section",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Section",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Finding",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Finding",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Evidence",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Evidence",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Matching pins",
              "type": "number",
              "display": true,
              "required": false,
              "displayName": "Matching pins",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Sort order",
              "type": "number",
              "display": true,
              "required": false,
              "displayName": "Sort order",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Research key",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Research key",
              "defaultMatch": true,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [
            "Research key"
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {},
        "operation": "appendOrUpdate",
        "sheetName": {
          "__rl": true,
          "mode": "id",
          "value": "2001003",
          "cachedResultName": "Research Brief"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "1lylIruCx1erI2lmKSMVRr4kiwLlZT7Kjn6cBJrTOY9I"
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.7
    },
    {
      "id": "51000000-0000-0000-4000-8000-000000000019",
      "name": "Research Complete",
      "type": "n8n-nodes-base.code",
      "position": [
        1360,
        80
      ],
      "parameters": {
        "jsCode": "const report = $('Validate Evidence and Build Report').first().json;\nreturn [{ json: { status: 'Pinterest content opportunity research saved', ...report.stats, themeRows: report.analysis.themes.length, underrepresentedAngles: report.analysis.underrepresentedAngles.length, contentTests: report.analysis.contentTests.length, discardedInvalidCitations: report.analysis.discardedInvalidCitations || 0, note: 'Findings describe the supplied Pinterest results. Search-expansion ideas are unvalidated brainstorming prompts, not Pinterest keyword or demand data.' } }];"
      },
      "executeOnce": true,
      "typeVersion": 2
    },
    {
      "id": "51000000-0000-0000-4000-8000-000000000021",
      "name": "Workflow Overview",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -2240,
        -400
      ],
      "parameters": {
        "width": 400,
        "height": 1100,
        "content": "## Pinterest Content Opportunity Research\n\n### How it works\n\n1. Accepts a niche and one to five Pinterest search phrases.\n2. Runs `fetch_cat/pinterest-search-scraper` for up to 500 public pins per search.\n3. Saves every source pin with its position, creator, board, domain, format, and public metrics.\n4. Uses one structured AI request to identify themes, underrepresented angles, and five production-ready content briefs.\n5. Removes invalid citations, rejects unsupported findings, and saves the evidence and report to two Google Sheet tabs.\n\n### Setup\n\n- [ ] Edit the niche, searches, locale, country, and result limit in **1. Set Your Research Niche**.\n- [ ] Connect Apify HTTP Header Auth in **2. Collect Pinterest Results with FetchCat**.\n- [ ] Create `Pins` and `Research Brief` tabs with the documented headers.\n- [ ] Select the same spreadsheet and matching tab in each Google Sheets node.\n- [ ] Connect OpenAI in **3. Analyze Content Landscape and Opportunities**.\n- [ ] Run manually and inspect the source pins before acting on recommendations.\n\n### Interpretation\n\nThemes and underrepresented angles are grounded in cited pins. Matching counts use literal terms found in titles and descriptions. Suggested search expansions are clearly marked as unvalidated brainstorming prompts. Nothing measures Pinterest search volume, trend growth, clicks, sales, or demand."
      },
      "typeVersion": 1
    },
    {
      "id": "51000000-0000-0000-4000-8000-000000000022",
      "name": "Configure niche",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1808,
        -224
      ],
      "parameters": {
        "color": 7,
        "width": 480,
        "height": 400,
        "content": "## Configure niche\n\nEnter one clear research niche and one to five searches. The default collects 100 pins per search; raise it to 500 only when the extra breadth is useful."
      },
      "typeVersion": 1
    },
    {
      "id": "51000000-0000-0000-4000-8000-000000000023",
      "name": "Collect and normalize",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1328,
        -224
      ],
      "parameters": {
        "color": 7,
        "width": 720,
        "height": 400,
        "content": "## Collect and normalize\n\nFetchCat collects public Pinterest results. The workflow validates IDs, URLs, positions, titles, descriptions, creators, boards, domains, formats, and available save data."
      },
      "typeVersion": 1
    },
    {
      "id": "51000000-0000-0000-4000-8000-000000000024",
      "name": "Build evidence",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -608,
        -224
      ],
      "parameters": {
        "color": 7,
        "width": 480,
        "height": 400,
        "content": "## Build evidence\n\nCreates source rows, recurring phrases, and a bounded evidence packet. Large result sets are capped before the single AI request."
      },
      "typeVersion": 1
    },
    {
      "id": "51000000-0000-0000-4000-8000-000000000025",
      "name": "Analyze and validate",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -128,
        -224
      ],
      "parameters": {
        "color": 7,
        "width": 480,
        "height": 400,
        "content": "## Analyze and validate\n\nOpenAI proposes themes, underrepresented angles, and production briefs using supplied pin IDs. Invalid citations are removed; a finding with no supplied evidence or malformed output stops the workflow before any Sheet write."
      },
      "typeVersion": 1
    },
    {
      "id": "51000000-0000-0000-4000-8000-000000000026",
      "name": "Save research",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        352,
        -224
      ],
      "parameters": {
        "color": 7,
        "width": 1104,
        "height": 400,
        "content": "## Save research\n\nWrites source pins and the readable brief to separate tabs. Creator, board, domain, and destination fields remain on each pin when Pinterest exposes them. Date-and-niche keys make same-day reruns update existing research instead of duplicating it."
      },
      "typeVersion": 1
    }
  ],
  "active": false,
  "settings": {
    "timezone": "Europe/Lisbon",
    "binaryMode": "separate",
    "callerPolicy": "workflowsFromSameOwner",
    "errorWorkflow": "8h1CCvOikDKFI2qd",
    "executionOrder": "v1",
    "saveManualExecutions": true
  },
  "versionId": "42179042-c61c-47bd-b2f1-def344a1d95b",
  "nodeGroups": [],
  "connections": {
    "Manual Trigger": {
      "main": [
        [
          {
            "node": "1. Set Your Research Niche",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare Pin Rows": {
      "main": [
        [
          {
            "node": "4. Save Source Pins",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "4. Save Source Pins": {
      "main": [
        [
          {
            "node": "Prepare Research Brief Rows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "5. Save Research Brief": {
      "main": [
        [
          {
            "node": "Research Complete",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Research Evidence": {
      "main": [
        [
          {
            "node": "3. Analyze Content Landscape and Opportunities",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "1. Set Your Research Niche": {
      "main": [
        [
          {
            "node": "Build FetchCat Research Input",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare Research Brief Rows": {
      "main": [
        [
          {
            "node": "5. Save Research Brief",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build FetchCat Research Input": {
      "main": [
        [
          {
            "node": "2. Collect Pinterest Results with FetchCat",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalize and Deduplicate Pins": {
      "main": [
        [
          {
            "node": "Build Research Evidence",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate Evidence and Build Report": {
      "main": [
        [
          {
            "node": "Prepare Pin Rows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Keep Only Supplied Evidence Citations": {
      "main": [
        [
          {
            "node": "Validate Evidence and Build Report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "2. Collect Pinterest Results with FetchCat": {
      "main": [
        [
          {
            "node": "Normalize and Deduplicate Pins",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "3. Analyze Content Landscape and Opportunities": {
      "main": [
        [
          {
            "node": "Keep Only Supplied Evidence Citations",
            "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

Research a Pinterest niche with FetchCat's Pinterest Search Scraper, preserve every source pin in Google Sheets, and use one evidence-controlled OpenAI call to identify recurring themes, underrepresented angles, and five production-ready content briefs without claiming search…

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

More Web Scraping workflows → · Browse all categories →

Related workflows

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

Web Scraping

This workflow is Part 2 of the HR Client Acquisition system and builds on the lead discovery pipeline from the previous workflow:

Google Sheets, HTTP Request, OpenAI +2
Web Scraping

This workflow runs on a schedule to scrape job listings via Apify (Google Jobs and Career Site API), scores each role against your base resume using OpenAI, generates tailored cover letter and optiona

@Apify/N8N Nodes Apify, Google Sheets, Google Docs +4
Web Scraping

Product - SERP Analysis (Serper + Firecrawl). Uses formTrigger, httpRequest, googleSheets, openAi. Event-driven trigger; 40 nodes.

Form Trigger, HTTP Request, Google Sheets +1
Web Scraping

Product - SERP Analysis (Serper & Crawl4AI). Uses formTrigger, httpRequest, googleSheets, openAi. Event-driven trigger; 39 nodes.

Form Trigger, HTTP Request, Google Sheets +1
Web Scraping

Product - SERP Analysis (SerpAPI + Crawl4AI). Uses formTrigger, httpRequest, googleSheets, openAi. Event-driven trigger; 38 nodes.

Form Trigger, HTTP Request, Google Sheets +1