{
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "nodes": [
    {
      "id": "90e2d3fd-7d16-4906-a03c-7191d619eba9",
      "name": "Step 1 \u2014 Configure & Scrape",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        8656,
        7136
      ],
      "parameters": {
        "color": 7,
        "width": 936,
        "height": 528,
        "content": "**Step 1 \u2014 Configure & Scrape**\nEdit `searchUrl` in `Configure Search` to target your niche (change the `q=` value). Set `maxResults` for sample size."
      },
      "typeVersion": 1
    },
    {
      "id": "a4058d66-e53c-4f7f-94db-49d56c13c542",
      "name": "Step 2 \u2014 Poll Until Complete",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        9616,
        7136
      ],
      "parameters": {
        "color": 7,
        "width": 868,
        "height": 528,
        "content": "**Step 2 \u2014 Poll Until Complete**\nWaits 20 seconds, then checks whether the Apify run has finished. Loops back if still running. "
      },
      "typeVersion": 1
    },
    {
      "id": "26440afd-c0be-4410-84cc-cffbb51414fa",
      "name": "Step 3 \u2014 Normalize, Score & Rank",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        10496,
        7120
      ],
      "parameters": {
        "color": 7,
        "width": 952,
        "height": 532,
        "content": "**Step 3 \u2014 Normalize, Score & Rank**\nWeights profile fields (title 3\u00d7, skills 2\u00d7, bio 1\u00d7). Extracts unigrams, bigrams, and trigrams. Assigns tiers: **Essential** \u226550% \u00b7 **High Demand** \u226525% \u00b7 **Targeted** \u226510% \u00b7 **Niche** <10%"
      },
      "typeVersion": 1
    },
    {
      "id": "3440d1b6-4811-4136-bd62-8ec44ac70db2",
      "name": "Configure Search",
      "type": "n8n-nodes-base.set",
      "position": [
        8768,
        7280
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "af6c489c-806f-47b4-a50b-5c046d470c3d",
              "name": "searchUrl",
              "type": "string",
              "value": "https://www.upwork.com/nx/search/talent/?nbs=1&q=YOUR+SEARCH+TERM&top_rated_plus=yes&page=1"
            },
            {
              "id": "4d76217a-9a60-4c20-9639-0f5f01c7c2fb",
              "name": "maxResults",
              "type": "number",
              "value": 20
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "b5db3ebb-9710-4513-a4b2-50d49b6b6478",
      "name": "Run Workflow Manually",
      "type": "n8n-nodes-base.manualTrigger",
      "position": [
        8992,
        7280
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "9dc3a235-6e33-4e2b-a390-f66e12a47df2",
      "name": "Run Apify Actor",
      "type": "n8n-nodes-base.httpRequest",
      "notes": "Replace YOUR_NICHE_HERE in the URL with your niche, e.g. video+editor or shopify+developer",
      "position": [
        9216,
        7280
      ],
      "parameters": {
        "url": "https://api.apify.com/v2/acts/powerai~upwork-talent-scraper/runs?token=YOUR_TOKEN_HERE",
        "method": "POST",
        "options": {},
        "jsonBody": "{\n  \"searchUrl\": \"={{ $json.searchUrl }}\",\n  \"maxResults\": {{ $json.maxResults }}\n}\n",
        "sendBody": true,
        "sendHeaders": true,
        "specifyBody": "json",
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "86659a89-d126-41f6-948e-10d434b81af4",
      "name": "Save Run ID and Dataset ID",
      "type": "n8n-nodes-base.set",
      "position": [
        9440,
        7280
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "assign-run-id",
              "name": "runId",
              "type": "string",
              "value": "={{ $json.data.id }}"
            },
            {
              "id": "assign-dataset-id",
              "name": "datasetId",
              "type": "string",
              "value": "={{ $json.data.defaultDatasetId }}"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "fbcbc3e2-7ecb-48a3-bc94-4782ea9150b6",
      "name": "Wait 20 Seconds",
      "type": "n8n-nodes-base.wait",
      "position": [
        9664,
        7280
      ],
      "parameters": {
        "amount": 20
      },
      "typeVersion": 1.1
    },
    {
      "id": "b2d8258c-d2aa-4852-bf81-10f27acea7f1",
      "name": "Check Run Status",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        9872,
        7280
      ],
      "parameters": {
        "url": "=",
        "options": {},
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth"
      },
      "typeVersion": 4.2
    },
    {
      "id": "2bd8516d-4eba-42e0-a875-84a47bc31db5",
      "name": "Is Run Complete?",
      "type": "n8n-nodes-base.if",
      "position": [
        10096,
        7280
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "condition-status",
              "operator": {
                "type": "string",
                "operation": "equals"
              },
              "leftValue": "={{ $json.data.status }}",
              "rightValue": "SUCCEEDED"
            }
          ]
        }
      },
      "typeVersion": 2
    },
    {
      "id": "26f65a5c-6dd1-4fc1-8c82-44275307246a",
      "name": "Carry IDs Through Loop",
      "type": "n8n-nodes-base.set",
      "position": [
        10320,
        7440
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "assign-loop-run-id",
              "name": "runId",
              "type": "string",
              "value": "={{ $json.data.id }}"
            },
            {
              "id": "assign-loop-dataset-id",
              "name": "datasetId",
              "type": "string",
              "value": "={{ $json.data.defaultDatasetId }}"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "bb6b31a5-fba6-4f02-8a4b-e77e52e31730",
      "name": "Fetch Dataset Results",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        10320,
        7280
      ],
      "parameters": {
        "url": "=https://api.apify.com/v2/datasets/{{ $json.datasetId }}/items?format=json&clean=true",
        "options": {},
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth"
      },
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "759d8083-0a8a-4448-b949-3edf019aea72",
      "name": "Normalize Profile Data",
      "type": "n8n-nodes-base.code",
      "position": [
        10544,
        7280
      ],
      "parameters": {
        "jsCode": "\n// Build a weighted profile object from raw Apify data.\n// Title gets 3x weight, skills 2x, bio/description 1x, category 1.5x.\n// Each section is kept separate so downstream scoring can weight them.\n\nconst items = $input.all();\n\nreturn items.map((item, idx) => {\n  const p = item.json;\n\n  const title    = (p.title || p.professionalTitle || '').trim();\n  const bio      = (p.description || p.overview || p.bio || '').trim();\n  const category = [p.category || '', p.subcategory || ''].filter(Boolean).join(' ').trim();\n\n  const skills = Array.isArray(p.skills)\n    ? p.skills.map(s => (typeof s === 'string' ? s : s.name || '')).join(' ').trim()\n    : '';\n\n  const certs = Array.isArray(p.certifications)\n    ? p.certifications.map(c => (typeof c === 'string' ? c : c.name || '')).join(' ')\n    : '';\n\n  // Weighted concatenation: repeat high-signal fields\n  const weightedText = [\n    title, title, title,           // 3x\n    skills, skills,                // 2x\n    category, category,            // 1.5x (rounded to 2)\n    bio,                           // 1x\n    certs                          // 1x\n  ].join(' ').toLowerCase().replace(/[^a-z0-9\\s]/g, ' ').replace(/\\s+/g, ' ').trim();\n\n  return {\n    json: {\n      profileIndex: idx + 1,\n      name: p.name || p.fullName || 'Profile ' + (idx + 1),\n      titleText: title.toLowerCase(),\n      skillsText: skills.toLowerCase(),\n      bioText: bio.toLowerCase(),\n      weightedText,\n      wordCount: weightedText.split(' ').filter(Boolean).length\n    }\n  };\n});\n"
      },
      "typeVersion": 2
    },
    {
      "id": "01a4f658-ca54-4563-b3a6-bba270316b7c",
      "name": "Score Keywords by Coverage",
      "type": "n8n-nodes-base.code",
      "position": [
        10752,
        7280
      ],
      "parameters": {
        "jsCode": "\n// Coverage-based keyword scoring using document frequency.\n// Tracks how many individual profiles contain each keyword (coverage),\n// not just how many times it appears in total.\n// Produces unigrams, bigrams, and trigrams.\n\nconst STOPWORDS = new Set([\n  // pronouns & articles\n  'i','me','my','we','our','you','your','he','him','his','she','her','it','its',\n  'they','them','their','this','that','these','those','a','an','the',\n  // verbs\n  'am','is','are','was','were','be','been','being','have','has','had','do','does',\n  'did','get','got','getting','make','made','making','use','used','using',\n  'need','needs','help','helped','helping','work','worked','working',\n  'can','will','just','should','would','could','may','might','shall',\n  // conjunctions / prepositions / adverbs\n  'and','but','if','or','as','of','at','by','for','with','about','into',\n  'through','before','after','above','below','to','from','up','down','in',\n  'out','on','off','over','under','then','here','there','when','where',\n  'how','all','both','each','more','most','some','no','not','only','same',\n  'so','than','too','very','also','well','good','great','new','time','now',\n  // contractions / fragments\n  've','re','ll','don','didn','won','m','s','t','d',\n  // filler words common in profiles\n  'over','years','year','plus','amp','etc','including','various','many',\n  'across','within','along','around','based','per','since','while','such'\n]);\n\nconst MIN_WORD_LEN = 3;\nconst MIN_PROFILE_COVERAGE = 2; // keyword must appear in at least 2 profiles\n\nconst profiles = $input.all();\nconst totalProfiles = profiles.length;\n\n// tokenise a single profile text into clean words\nfunction tokenise(text) {\n  return (text || '')\n    .replace(/[^a-z0-9\\s]/g, ' ')\n    .replace(/\\s+/g, ' ')\n    .trim()\n    .split(' ')\n    .filter(w => w.length >= MIN_WORD_LEN && !STOPWORDS.has(w));\n}\n\n// per-keyword: { totalOccurrences, profilesContaining (Set of profile indices) }\nconst stats = {};\n\nfunction record(ngram, profileIdx, occurrences) {\n  if (!stats[ngram]) stats[ngram] = { total: 0, profiles: new Set() };\n  stats[ngram].total += occurrences;\n  stats[ngram].profiles.add(profileIdx);\n}\n\nfor (let i = 0; i < profiles.length; i++) {\n  const words = tokenise(profiles[i].json.weightedText || '');\n\n  // unigrams\n  const uniCounts = {};\n  for (const w of words) {\n    uniCounts[w] = (uniCounts[w] || 0) + 1;\n  }\n  for (const [w, cnt] of Object.entries(uniCounts)) {\n    record(w, i, cnt);\n  }\n\n  // bigrams\n  const biCounts = {};\n  for (let j = 0; j < words.length - 1; j++) {\n    const bg = words[j] + ' ' + words[j + 1];\n    biCounts[bg] = (biCounts[bg] || 0) + 1;\n  }\n  for (const [bg, cnt] of Object.entries(biCounts)) {\n    record(bg, i, cnt);\n  }\n\n  // trigrams\n  const triCounts = {};\n  for (let j = 0; j < words.length - 2; j++) {\n    const tg = words[j] + ' ' + words[j + 1] + ' ' + words[j + 2];\n    triCounts[tg] = (triCounts[tg] || 0) + 1;\n  }\n  for (const [tg, cnt] of Object.entries(triCounts)) {\n    record(tg, i, cnt);\n  }\n}\n\n// build scored list\nconst results = [];\nfor (const [kw, data] of Object.entries(stats)) {\n  const profileCount = data.profiles.size;\n  if (profileCount < MIN_PROFILE_COVERAGE) continue;\n\n  const wordCount = kw.split(' ').length;\n  const ngramType = wordCount === 1 ? 'Single Word' : wordCount === 2 ? '2-Word Phrase' : '3-Word Phrase';\n  const coveragePct = +((profileCount / totalProfiles) * 100).toFixed(1);\n\n  // composite score: coverage drives rank, total occurrences break ties\n  const score = +(coveragePct * 0.7 + (data.total / totalProfiles) * 0.3).toFixed(3);\n\n  results.push({ keyword: kw, ngramType, profileCount, totalProfiles, coveragePct, occurrences: data.total, score });\n}\n\nresults.sort((a, b) => b.score - a.score);\n\nreturn results.map(r => ({ json: r }));\n"
      },
      "typeVersion": 2
    },
    {
      "id": "17a611c7-630d-4b79-9d09-9d1e36d6ee93",
      "name": "Format Results",
      "type": "n8n-nodes-base.code",
      "position": [
        10976,
        7280
      ],
      "parameters": {
        "jsCode": "\n// Assign coverage-based tiers and placement advice.\n// Tiers reflect how commonly a keyword appears across profiles\n// rather than arbitrary rank cutoffs.\n\nconst items = $input.all();\n\nreturn items.map((item, index) => {\n  const { keyword, ngramType, profileCount, totalProfiles, coveragePct, occurrences, score } = item.json;\n\n  let tier;\n  if (coveragePct >= 50)      tier = 'Essential';\n  else if (coveragePct >= 25) tier = 'High Demand';\n  else if (coveragePct >= 10) tier = 'Targeted';\n  else                        tier = 'Niche';\n\n  const isPhrase = ngramType !== 'Single Word';\n  let placement;\n  if (coveragePct >= 50)      placement = isPhrase ? 'Profile title + overview intro' : 'Profile title + skills list';\n  else if (coveragePct >= 25) placement = isPhrase ? 'Overview paragraph + portfolio tags' : 'Skills list + overview';\n  else if (coveragePct >= 10) placement = 'Overview body + specialisation section';\n  else                        placement = 'Niche section or portfolio descriptions';\n\n  return {\n    json: {\n      rank:          index + 1,\n      keyword,\n      ngramType,\n      profilesCoverage: profileCount + ' / ' + totalProfiles,\n      coveragePct:   coveragePct + '%',\n      occurrences,\n      compositeScore: score,\n      tier,\n      placement\n    }\n  };\n});\n"
      },
      "typeVersion": 2
    },
    {
      "id": "6bc167c7-869a-4707-ac60-626bea200114",
      "name": "Save to Google Sheets",
      "type": "n8n-nodes-base.googleSheets",
      "notes": "Set your Google Sheet ID above. Column headers in Sheet1 must be: Rank, Keyword, Count, Type, Tier, Suggestion",
      "position": [
        11200,
        7280
      ],
      "parameters": {
        "columns": {
          "value": {
            "Rank": "={{ $json.rank }}",
            "Tier": "={{ $json.tier }}",
            "Type": "={{ $json.ngramType }}",
            "Score": "={{ $json.compositeScore }}",
            "Keyword": "={{ $json.keyword }}",
            "Coverage": "={{ $json.coveragePct }}",
            "Profiles": "={{ $json.profilesCoverage }}",
            "Placement": "={{ $json.placement }}",
            "Occurrences": "={{ $json.occurrences }}"
          },
          "schema": [
            {
              "id": "Rank",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "Rank",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Keyword",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "Keyword",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Count",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "Count",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Type",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "Type",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Tier",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "Tier",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Suggestion",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "Suggestion",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": []
        },
        "options": {},
        "operation": "append",
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Sheet1"
        },
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": "YOUR_GOOGLE_SHEET_ID_HERE"
        }
      },
      "typeVersion": 4.5
    },
    {
      "id": "832c6dc5-c035-4b53-8625-45267b3e103c",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        7760,
        7056
      ],
      "parameters": {
        "width": 832,
        "height": 640,
        "content": "## Upwork Freelancer Profile Keyword Analyzer\n\nFind out exactly which keywords top-rated Upwork freelancers use in their profiles \nso you can optimize yours for search visibility and client trust.\n\n## How it works\nTHere\u2019s a concise version:\n\nThe workflow scrapes up to 20 top Upwork profiles for any search term using Apify.\n It scores profile fields with weighted importance, giving titles 3\u00d7 weight and skills 2\u00d7 weight over bio text. \nIt then measures keyword coverage across profiles, tracking how many profiles contain each keyword. \nSingle words, two-word phrases, and three-word phrases are analyzed. \nKeywords are scored with a composite formula and grouped into tiers: Essential, High Demand, Targeted, and Niche. \nFinal results are exported to Google Sheets with coverage data and placement suggestions.\n\n## How to set up\n1. Add your Apify API token to the `Run Apify Actor` node URL\n2. Add your Google Sheets OAuth2 credential in n8n\n3. Paste your Google Sheet ID into the `Save to Google Sheets` node\n4. In `Configure Search`, update the `searchUrl` to your target niche\n\n## How to customize\nChange `maxResults` in `Configure Search` from 20 to 50+ for a larger keyword sample.\n Filter results in Google Sheets by the `Tier` column to focus on Essential and High Demand keywords first."
      },
      "typeVersion": 1
    }
  ],
  "connections": {
    "Format Results": {
      "main": [
        [
          {
            "node": "Save to Google Sheets",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Run Apify Actor": {
      "main": [
        [
          {
            "node": "Save Run ID and Dataset ID",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Wait 20 Seconds": {
      "main": [
        [
          {
            "node": "Check Run Status",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Run Status": {
      "main": [
        [
          {
            "node": "Is Run Complete?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Configure Search": {
      "main": [
        [
          {
            "node": "Run Apify Actor",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Is Run Complete?": {
      "main": [
        [
          {
            "node": "Fetch Dataset Results",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Carry IDs Through Loop",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Dataset Results": {
      "main": [
        [
          {
            "node": "Normalize Profile Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Run Workflow Manually": {
      "main": [
        [
          {
            "node": "Configure Search",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Carry IDs Through Loop": {
      "main": [
        [
          {
            "node": "Wait 20 Seconds",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalize Profile Data": {
      "main": [
        [
          {
            "node": "Score Keywords by Coverage",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Save Run ID and Dataset ID": {
      "main": [
        [
          {
            "node": "Wait 20 Seconds",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Score Keywords by Coverage": {
      "main": [
        [
          {
            "node": "Format Results",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}