This workflow corresponds to n8n.io template #15193 — 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 →
{
"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
}
]
]
}
}
}
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.
httpHeaderAuth
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
Find out exactly which keywords top-rated Upwork freelancers use in their profiles — so you can optimize yours for search visibility and client trust.
Source: https://n8n.io/workflows/15193/ — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
Automate LinkedIn lead generation by scraping comments from targeted posts and enriching profiles with detailed data
This automated n8n workflow scrapes job listings from Upwork using Apify, processes and cleans the data, and generates daily email reports with job summaries. The system uses Google Sheets for data st
Transform LinkedIn profile URLs into comprehensive enriched lead profiles, quickly and automatically.
Transform any website into a structured knowledge repository with this intelligent crawler that extracts hyperlinks from the homepage, intelligently filters images and content pages, and aggregates fu
Content creators, researchers, educators, and digital marketers who need to discover high-quality YouTube training videos on specific topics. Perfect for building curated learning resource lists, comp