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 →
{
"name": "06 - Approved Candidates Runner",
"nodes": [
{
"id": "a6000000-0000-0000-0000-0000000000n1",
"name": "Overview Note RU",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-380,
-180
],
"parameters": {
"content": "## 06 \u2014 \u0417\u0430\u043f\u0443\u0441\u043a \u043e\u0434\u043e\u0431\u0440\u0435\u043d\u043d\u044b\u0445 \u043a\u0430\u043d\u0434\u0438\u0434\u0430\u0442\u043e\u0432 (Approved Candidates Runner)\n\n\u041c\u043e\u0441\u0442 \u043c\u0435\u0436\u0434\u0443 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u0438\u0435\u043c (Workflow 05) \u0438 \u043f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0435\u043c URL (Workflow 04).\n\n\u0427\u0442\u043e \u0434\u0435\u043b\u0430\u0435\u0442:\n1. \u0420\u0443\u0447\u043d\u043e\u0439 \u0441\u0442\u0430\u0440\u0442.\n2. \u0427\u0438\u0442\u0430\u0435\u0442 \u0432\u043a\u043b\u0430\u0434\u043a\u0443 url_candidates.\n3. \u0427\u0438\u0442\u0430\u0435\u0442 \u0432\u043a\u043b\u0430\u0434\u043a\u0443 url_registry (\u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a \u043f\u0440\u0430\u0432\u0434\u044b \u0434\u0435\u0434\u0443\u043f\u0430) \u041f\u041e\u0412\u0422\u041e\u0420\u041d\u041e, \u0432 \u043c\u043e\u043c\u0435\u043d\u0442 \u043f\u0440\u043e\u0433\u043e\u043d\u0430.\n4. \u0417\u0430\u043d\u043e\u0432\u043e \u043d\u043e\u0440\u043c\u0430\u043b\u0438\u0437\u0443\u0435\u0442 candidate_url \u0442\u0435\u043c\u0438 \u0436\u0435 \u043f\u0440\u0430\u0432\u0438\u043b\u0430\u043c\u0438, \u0447\u0442\u043e Workflow 04/05, \u0438 \u0441\u0432\u0435\u0440\u044f\u0435\u0442 normalized_source_url \u0441 url_registry.\n5. \u0424\u0438\u043b\u044c\u0442\u0440\u0443\u0435\u0442 \u0441\u0442\u0440\u043e\u043a\u0438: approval_status=approved \u0418 candidate_url \u043d\u0435 \u043f\u0443\u0441\u0442 \u0418 normalized_source_url \u041e\u0422\u0421\u0423\u0422\u0421\u0422\u0412\u0423\u0415\u0422 \u0432 url_registry. approval_status processed/duplicate/rejected/error \u2014 \u043f\u0440\u043e\u043f\u0443\u0441\u043a.\n6. \u0412\u0410\u0416\u041d\u041e: dedup_status / registry_status \u0432 url_candidates \u2014 \u044d\u0442\u043e \u043f\u043e\u0434\u0441\u043a\u0430\u0437\u043a\u0438 \u0432\u0440\u0435\u043c\u0435\u043d\u0438 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u0438\u044f (advisory), \u0430 \u041d\u0415 \u0444\u0438\u043d\u0430\u043b\u044c\u043d\u0430\u044f \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0430. Workflow 06 \u041d\u0415 \u0434\u043e\u0432\u0435\u0440\u044f\u0435\u0442 \u0438\u043c \u0438 \u043f\u0435\u0440\u0435\u043f\u0440\u043e\u0432\u0435\u0440\u044f\u0435\u0442 url_registry. \u0415\u0441\u043b\u0438 \u043e\u043f\u0435\u0440\u0430\u0442\u043e\u0440 \u0432\u0440\u0443\u0447\u043d\u0443\u044e \u043f\u043e\u0441\u0442\u0430\u0432\u0438\u043b unique/not_in_registry, \u043d\u043e URL \u0443\u0436\u0435 \u0432 \u0440\u0435\u0435\u0441\u0442\u0440\u0435 \u2014 \u043a\u0430\u043d\u0434\u0438\u0434\u0430\u0442 \u043f\u0440\u043e\u043f\u0443\u0441\u043a\u0430\u0435\u0442\u0441\u044f \u0441 \u043f\u0440\u0438\u0447\u0438\u043d\u043e\u0439 registry_recheck_duplicate.\n7. \u041f\u0440\u0438\u043e\u0440\u0438\u0442\u0435\u0437\u0430\u0446\u0438\u044f: \u0441\u043d\u0430\u0447\u0430\u043b\u0430 direct_competitor, \u0437\u0430\u0442\u0435\u043c \u0432\u044b\u0448\u0435 confidence_score, \u0437\u0430\u0442\u0435\u043c \u043d\u0438\u0436\u0435 rank.\n8. \u0416\u0451\u0441\u0442\u043a\u0430\u044f \u043e\u0442\u0441\u0435\u0447\u043a\u0430 \u2014 \u043c\u0430\u043a\u0441\u0438\u043c\u0443\u043c 5 \u043a\u0430\u043d\u0434\u0438\u0434\u0430\u0442\u043e\u0432 \u0437\u0430 \u043f\u0440\u043e\u0433\u043e\u043d.\n9. \u0413\u043e\u0442\u043e\u0432\u0438\u0442 \u0431\u0430\u0442\u0447 URL \u0432 \u0444\u043e\u0440\u043c\u0430\u0442\u0435 Workflow 04 + \u0433\u043e\u0442\u043e\u0432\u044b\u0439 \u0431\u043b\u043e\u043a \u0434\u043b\u044f \u0432\u0441\u0442\u0430\u0432\u043a\u0438 \u0432 Set URL List.\n10. \u0423\u0437\u0435\u043b Mark Candidates Processed \u0412\u041a\u041b\u042e\u0427\u0401\u041d \u0438 \u0431\u0435\u0437\u043e\u043f\u0430\u0441\u0435\u043d: \u043e\u043d \u0441\u0442\u0430\u0432\u0438\u0442 approval_status=processed \u0422\u041e\u041b\u042c\u041a\u041e \u0434\u043b\u044f \u043a\u0430\u043d\u0434\u0438\u0434\u0430\u0442\u043e\u0432, \u0447\u0435\u0439 normalized_source_url \u0443\u0436\u0435 \u043f\u043e\u044f\u0432\u0438\u043b\u0441\u044f \u0432 url_registry (\u0442.\u0435. Workflow 04 \u0438\u0445 \u0440\u0435\u0430\u043b\u044c\u043d\u043e \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u0430\u043b). \u0418\u0434\u0435\u043c\u043f\u043e\u0442\u0435\u043d\u0442\u043d\u043e: \u043f\u043e\u0432\u0442\u043e\u0440\u043d\u044b\u0439 \u043f\u0440\u043e\u0433\u043e\u043d \u043d\u0435 \u0442\u0440\u043e\u0433\u0430\u0435\u0442 \u0443\u0436\u0435 processed \u0438 \u041d\u0415 \u043f\u043e\u043c\u0435\u0447\u0430\u0435\u0442 \u043f\u0440\u043e\u043f\u0443\u0449\u0435\u043d\u043d\u044b\u0435/\u043d\u0435\u0443\u0434\u0430\u0447\u043d\u044b\u0435. \u0421\u0432\u0435\u0436\u0435\u0432\u044b\u0431\u0440\u0430\u043d\u043d\u044b\u0435 \u043a\u0430\u043d\u0434\u0438\u0434\u0430\u0442\u044b \u043e\u0441\u0442\u0430\u044e\u0442\u0441\u044f approved \u0434\u043e \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u0438\u044f \u0447\u0435\u0440\u0435\u0437 url_registry.\n\n\u0410\u0433\u0440\u0435\u0433\u0430\u0442\u043e\u0440/\u043a\u0430\u0442\u0430\u043b\u043e\u0433/\u043c\u0430\u0440\u043a\u0435\u0442\u043f\u043b\u0435\u0439\u0441/\u0441\u043e\u0446\u0441\u0435\u0442\u044c/\u043c\u0435\u0434\u0438\u0430 \u043c\u043e\u0436\u043d\u043e \u0432\u044b\u0431\u0440\u0430\u0442\u044c, \u0415\u0421\u041b\u0418 approval_status=approved, \u043d\u043e \u0432 \u0432\u044b\u0431\u0440\u0430\u043d\u043d\u043e\u043c \u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0435 \u0441\u0442\u0430\u0432\u0438\u0442\u0441\u044f \u043f\u0440\u0435\u0434\u0443\u043f\u0440\u0435\u0436\u0434\u0435\u043d\u0438\u0435: candidate_type \u043d\u0435 direct_competitor; \u043f\u0440\u043e\u0432\u0435\u0440\u0438\u0442\u044c \u043f\u0435\u0440\u0435\u0434 Workflow 04.\n\n\u0427\u0415\u0413\u041e \u041d\u0415 \u0414\u0415\u041b\u0410\u0415\u0422 (\u0430\u0440\u0445\u0438\u0442\u0435\u043a\u0442\u0443\u0440\u043d\u043e):\n- \u041d\u0415 \u0434\u0443\u0431\u043b\u0438\u0440\u0443\u0435\u0442 \u043b\u043e\u0433\u0438\u043a\u0443 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u0438\u044f, \u041d\u0415 \u0432\u044b\u0437\u044b\u0432\u0430\u0435\u0442 Apify, \u041d\u0415 \u0438\u0449\u0435\u0442 \u043d\u043e\u0432\u044b\u0435 URL.\n- \u041d\u0415 \u0432\u044b\u0437\u044b\u0432\u0430\u0435\u0442 Firecrawl/Claude \u043d\u0430\u043f\u0440\u044f\u043c\u0443\u044e \u2014 \u0430\u043d\u0430\u043b\u0438\u0437 \u0442\u043e\u043b\u044c\u043a\u043e \u0447\u0435\u0440\u0435\u0437 Workflow 04.\n- \u041d\u0415 Telegram, \u041d\u0415 \u0440\u0430\u0441\u043f\u0438\u0441\u0430\u043d\u0438\u0435, \u041d\u0415 \u0430\u0432\u0442\u043e-\u043e\u0434\u043e\u0431\u0440\u0435\u043d\u0438\u0435. \u041f\u0435\u0440\u0435\u0432\u043e\u0434 \u0432 processed \u2014 \u0442\u043e\u043b\u044c\u043a\u043e \u043f\u043e \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u0438\u044e \u043f\u0440\u0438\u0441\u0443\u0442\u0441\u0442\u0432\u0438\u044f \u0432 url_registry (\u043d\u0435 \u043f\u043e \u0444\u0430\u043a\u0442\u0443 \u0432\u044b\u0431\u043e\u0440\u0430).\n\nv0.1: \u0440\u0443\u0447\u043d\u0430\u044f \u043f\u0435\u0440\u0435\u0434\u0430\u0447\u0430 (manual handoff). \u041f\u043e\u0442\u043e\u043a: Workflow 05 \u2192 url_candidates \u2192 \u043e\u0434\u043e\u0431\u0440\u0435\u043d\u0438\u0435 \u2192 Workflow 06 (recheck url_registry) \u2192 (\u0440\u0443\u0447\u043d\u0430\u044f \u0432\u0441\u0442\u0430\u0432\u043a\u0430) \u2192 Workflow 04 \u2192 monitor_queue.",
"height": 560,
"width": 560,
"color": 4
}
},
{
"id": "a6000000-0000-0000-0000-0000000000n2",
"name": "Setup and Test Note RU",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
220,
-180
],
"parameters": {
"content": "## \u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0438 \u0442\u0435\u0441\u0442 (\u0432\u0440\u0443\u0447\u043d\u0443\u044e)\n\n1. \u041d\u0415 \u0430\u043a\u0442\u0438\u0432\u0438\u0440\u043e\u0432\u0430\u0442\u044c workflow (active=false).\n2. \u0412\u043a\u043b\u0430\u0434\u043a\u0438 \u0443\u0436\u0435 \u0435\u0441\u0442\u044c: url_candidates (26 \u043a\u043e\u043b\u043e\u043d\u043e\u043a), url_registry (10 \u043a\u043e\u043b\u043e\u043d\u043e\u043a). \u041d\u043e\u0432\u044b\u0445 \u0432\u043a\u043b\u0430\u0434\u043e\u043a \u041d\u0415 \u0442\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f.\n3. \u041f\u041e\u0421\u041b\u0415 \u0418\u041c\u041f\u041e\u0420\u0422\u0410 \u043f\u0435\u0440\u0435\u043f\u0440\u0438\u0432\u044f\u0437\u0430\u0442\u044c \u043a\u0440\u0435\u0434\u0435\u043d\u0448\u043b (ID \u043b\u043e\u043a\u0430\u043b\u044c\u043d\u044b):\n - Read url_candidates \u2192 Google Sheets - Marketing Scout Service Account\n - Read url_registry \u2192 \u0442\u043e\u0442 \u0436\u0435 \u043a\u0440\u0435\u0434\u0435\u043d\u0448\u043b\n - Mark Candidates Processed (confirmed in url_registry) \u2192 \u0442\u043e\u0442 \u0436\u0435 \u043a\u0440\u0435\u0434\u0435\u043d\u0448\u043b\n4. \u041d\u0430 \u0432\u0441\u0435\u0445 Google Sheets \u043d\u043e\u0434\u0430\u0445 \u0432\u0441\u0442\u0430\u0432\u0438\u0442\u044c \u0440\u0435\u0430\u043b\u044c\u043d\u044b\u0439 Spreadsheet ID (\u0437\u0430\u043c\u0435\u043d\u0438\u0442\u044c PASTE_SPREADSHEET_ID_HERE).\n5. \u041d\u0438\u043a\u0430\u043a\u0438\u0445 \u0432\u043d\u0435\u0448\u043d\u0438\u0445 API-\u043a\u043b\u044e\u0447\u0435\u0439 \u0437\u0434\u0435\u0441\u044c \u043d\u0435\u0442 (\u043d\u0435\u0442 Apify/Firecrawl/Claude).\n\n\u0420\u0435\u0442\u0435\u0441\u0442 (\u043f\u043e\u0441\u043b\u0435 \u043f\u0430\u0442\u0447\u0430 registry recheck):\n\u0430) \u0421\u0442\u0430\u0440\u044b\u0439 \u0434\u0443\u0431\u043b\u0438\u043a\u0430\u0442, \u043f\u043e\u043c\u0435\u0447\u0435\u043d\u043d\u044b\u0439 \u043e\u043f\u0435\u0440\u0430\u0442\u043e\u0440\u043e\u043c approved/unique/not_in_registry, \u043d\u043e \u0443\u0436\u0435 \u043f\u0440\u0438\u0441\u0443\u0442\u0441\u0442\u0432\u0443\u044e\u0449\u0438\u0439 \u0432 url_registry \u2192 \u0414\u041e\u041b\u0416\u0415\u041d \u0431\u044b\u0442\u044c \u043f\u0440\u043e\u043f\u0443\u0449\u0435\u043d \u0441 \u043f\u0440\u0438\u0447\u0438\u043d\u043e\u0439 registry_recheck_duplicate (\u041d\u0415 \u0432\u044b\u0431\u0440\u0430\u043d).\n\u0431) \u041d\u043e\u0432\u044b\u0439 direct_competitor \u0441 approval_status=approved, \u043a\u043e\u0442\u043e\u0440\u043e\u0433\u043e \u041d\u0415\u0422 \u0432 url_registry \u2192 \u0414\u041e\u041b\u0416\u0415\u041d \u0431\u044b\u0442\u044c \u0432\u044b\u0431\u0440\u0430\u043d.\n\n\u041f\u0435\u0440\u0432\u044b\u0439/\u043e\u0431\u044b\u0447\u043d\u044b\u0439 \u0442\u0435\u0441\u0442:\n1. \u0412 url_candidates \u043e\u0434\u043e\u0431\u0440\u0438\u0442\u044c \u041e\u0414\u041d\u0423 \u0441\u0442\u0440\u043e\u043a\u0443 direct_competitor: approval_status=approved, \u0437\u0430\u043f\u043e\u043b\u043d\u0438\u0442\u044c approved_by/approved_at.\n2. Execute Workflow \u043e\u0434\u0438\u043d \u0440\u0430\u0437.\n3. \u041e\u0442\u043a\u0440\u044b\u0442\u044c \u0432\u044b\u0445\u043e\u0434 \u0443\u0437\u043b\u0430 Build Execution Summary & Handoff: selected_count, selected_urls, skipped (\u0441 reason_category), \u0433\u043e\u0442\u043e\u0432\u044b\u0439 \u0431\u043b\u043e\u043a workflow_04_set_url_list_block.\n4. \u0421\u043a\u043e\u043f\u0438\u0440\u043e\u0432\u0430\u0442\u044c URL (\u22645) \u0432 Workflow 04 \u2192 Set URL List, \u0437\u0430\u043f\u0443\u0441\u0442\u0438\u0442\u044c Workflow 04 \u2192 \u043e\u0436\u0438\u0434\u0430\u0442\u044c monitor_queue + \u0441\u0442\u0440\u043e\u043a\u0443 \u0432 url_registry.\n5. \u041f\u041e\u0421\u041b\u0415 \u043f\u0440\u043e\u0433\u043e\u043d\u0430 Workflow 04 (URL \u043f\u043e\u043f\u0430\u043b\u0438 \u0432 url_registry) \u043f\u0435\u0440\u0435\u0437\u0430\u043f\u0443\u0441\u0442\u0438\u0442\u044c Workflow 06: \u0443\u0437\u0435\u043b Mark Candidates Processed \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u0438 \u0438 \u0438\u0434\u0435\u043c\u043f\u043e\u0442\u0435\u043d\u0442\u043d\u043e \u043f\u0440\u043e\u0441\u0442\u0430\u0432\u0438\u0442 approval_status=processed \u0442\u043e\u043b\u044c\u043a\u043e \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0451\u043d\u043d\u044b\u043c \u043a\u0430\u043d\u0434\u0438\u0434\u0430\u0442\u0430\u043c. \u0420\u0443\u0447\u043d\u0430\u044f \u043f\u0440\u0430\u0432\u043a\u0430 \u043d\u0435 \u043d\u0443\u0436\u043d\u0430.\n\n\u041e\u0436\u0438\u0434\u0430\u0435\u043c\u043e: 0 \u0442\u0440\u0430\u0442 \u0432 \u0441\u0430\u043c\u043e\u043c 06; \u0441\u0442\u043e\u0438\u043c\u043e\u0441\u0442\u044c \u2014 \u0442\u043e\u043b\u044c\u043a\u043e Workflow 04 (\u22645 URL).",
"height": 560,
"width": 560,
"color": 4
}
},
{
"id": "a6000000-0000-0000-0000-000000000002",
"name": "Manual Start",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [
120,
420
],
"parameters": {}
},
{
"id": "a6000000-0000-0000-0000-0000000000cf",
"name": "Set Runner Config",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
240,
600
],
"parameters": {
"jsCode": "// Runner configuration. Edit runner_mode to switch selection behavior.\n// first_pass_domain_diversity (DEFAULT) \u2014 max 1 selected URL per normalized domain per run.\n// deep_domain_analysis \u2014 allow multiple URLs per domain (cap 3/domain/run), explicit.\n// Both modes always keep: max_per_run=5, url_registry runtime recheck, exact normalized-URL dedup,\n// manual handoff to Workflow 04, no auto-call, no auto-mark processed.\nconst runner_mode = 'first_pass_domain_diversity';\nreturn [{ json: { runner_mode: runner_mode } }];"
}
},
{
"id": "a6000000-0000-0000-0000-0000000000rd",
"name": "Read url_candidates",
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4,
"position": [
340,
420
],
"alwaysOutputData": true,
"onError": "continueRegularOutput",
"parameters": {
"authentication": "serviceAccount",
"operation": "read",
"documentId": {
"__rl": true,
"value": "PASTE_SPREADSHEET_ID_HERE",
"mode": "id"
},
"sheetName": {
"__rl": true,
"value": "url_candidates",
"mode": "name"
},
"options": {}
},
"credentials": {
"googleApi": {
"name": "<your credential>"
}
},
"retryOnFail": true,
"maxTries": 3,
"waitBetweenTries": 5000
},
{
"id": "a6000000-0000-0000-0000-0000000000rg",
"name": "Read url_registry",
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4,
"position": [
540,
420
],
"alwaysOutputData": true,
"onError": "continueRegularOutput",
"parameters": {
"authentication": "serviceAccount",
"operation": "read",
"documentId": {
"__rl": true,
"value": "PASTE_SPREADSHEET_ID_HERE",
"mode": "id"
},
"sheetName": {
"__rl": true,
"value": "url_registry",
"mode": "name"
},
"options": {}
},
"credentials": {
"googleApi": {
"name": "<your credential>"
}
},
"retryOnFail": true,
"maxTries": 3,
"waitBetweenTries": 5000
},
{
"id": "a6000000-0000-0000-0000-0000000000se",
"name": "Select, Prioritize & Annotate",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
760,
420
],
"parameters": {
"jsCode": "// Single source of truth for selection. Reads url_candidates rows AND RE-READS url_registry\n// at runtime. candidate_url is RE-NORMALIZED with the SAME rules as Workflow 04/05 and verified\n// to NOT already exist in url_registry before selection. Editable dedup_status / registry_status\n// in url_candidates are advisory discovery-time hints only and are NOT trusted.\n//\n// runner_mode (from \"Set Runner Config\"):\n// first_pass_domain_diversity (DEFAULT) \u2014 max 1 selected URL per normalized domain per run;\n// extra approved same-domain URLs are skipped with reason_category=duplicate_domain_in_run.\n// deep_domain_analysis \u2014 multiple approved URLs per domain allowed intentionally, capped at 3\n// per domain per run; extras beyond 3 skipped with reason_category=domain_deep_limit;\n// selected items carry a deep-mode warning.\n// BOTH modes ALWAYS enforce: url_registry runtime recheck, exact normalized-URL dedup, and the\n// hard cap max_per_run=5. url_registry semantics are UNCHANGED (full normalized URL, not domain).\nconst rows = $('Read url_candidates').all().map(i => i.json).filter(Boolean);\n\n// Re-read url_registry (source of truth for dedup) and build a set of normalized URLs.\nlet registry = [];\ntry { registry = $('Read url_registry').all().map(i => i.json).filter(Boolean); } catch (e) { registry = []; }\nconst regSet = new Set(registry.map(r => String(r.normalized_source_url || '').trim()).filter(Boolean));\n\n// Runner config (default first_pass_domain_diversity).\nlet runner_mode = 'first_pass_domain_diversity';\ntry {\n const cfg = $('Set Runner Config').first().json || {};\n const m = String(cfg.runner_mode || '').trim().toLowerCase();\n if (m === 'deep_domain_analysis' || m === 'first_pass_domain_diversity') runner_mode = m;\n} catch (e) { /* keep default */ }\nconst DEEP = (runner_mode === 'deep_domain_analysis');\nconst PER_DOMAIN_CAP = DEEP ? 3 : 1; // deep: max 3 URLs/domain/run; first-pass: max 1/domain/run\n\nfunction pad(n){ return String(n).padStart(2, '0'); }\nfunction moscowIsoNow(){var m=new Date(Date.now()+10800000);return m.toISOString().replace('Z','+03:00');}\nfunction moscowStamp(){var z=function(n){return String(n).padStart(2,'0');};var m=new Date(Date.now()+10800000);return m.getUTCFullYear()+z(m.getUTCMonth()+1)+z(m.getUTCDate())+'_'+z(m.getUTCHours())+z(m.getUTCMinutes())+z(m.getUTCSeconds());}\nconst now = moscowIsoNow();\nconst stamp = moscowStamp();\nconst run_id = 'approved_run_' + stamp;\nconst MAX = 5; // HARD CAP: max 5 approved URLs per run (both modes)\n\nfunction str(v){ return (v==null?'':String(v)).trim(); }\nfunction lc(v){ return str(v).toLowerCase(); }\n\n// URL normalizer \u2014 SAME rules as Workflow 04/05 (keys must match url_registry).\nfunction normalizeUrl(u){\n u = String(u || '').trim();\n if (!u) return '';\n try {\n const noFrag = u.split('#')[0];\n const url = new URL(noFrag);\n url.protocol = (url.protocol || '').toLowerCase();\n url.hostname = (url.hostname || '').toLowerCase();\n const drop = ['utm_source','utm_medium','utm_campaign','utm_term','utm_content','gclid','yclid','fbclid'];\n for (const p of drop) url.searchParams.delete(p);\n let path = url.pathname || '/';\n if (path.length > 1 && path.endsWith('/')) path = path.slice(0, -1);\n url.pathname = path;\n return url.toString();\n } catch (e) { return u; }\n}\n\n// Domain extractor from candidate_url \u2014 SAME rules as Workflow 05 (hostname, lowercase, strip leading www).\nfunction extractDomain(u){\n let h = '';\n try { h = new URL(String(u||'').trim()).hostname; } catch (e) { h = ''; }\n if (!h) { const m = String(u||'').match(/^https?:\\/\\/([^\\/?#]+)/i); h = m ? m[1] : ''; }\n h = h.toLowerCase();\n if (h.indexOf('www.') === 0) h = h.slice(4);\n return h;\n}\n\n// Root homepage detection (path empty or '/') \u2014 used as a priority tiebreaker.\nfunction isRootUrl(u){\n // \u00a75 sandbox-safe (DEC-103): the n8n Code sandbox may not expose the URL global; new URL would throw and\n // wrongly mark a real homepage (https://cashmotor.ru/, https://carcapital.ru/) as non-root. Use regex.\n u=String(u||'').trim(); if(!u)return false;\n var m=u.split('#')[0].split('?')[0].match(/^https?:\\/\\/[^\\/]+(\\/[^?#]*)?$/i);\n if(!m)return false;\n var p=m[1]||'/';\n return (p===''||p==='/');\n}\n\n// Non-direct-competitor types are allowed when approved, but flagged with a warning.\nconst NON_DIRECT = ['aggregator','directory','marketplace','social','media_article'];\n\nconst evaluated = [];\nfor (const r of rows){\n // Skip fully blank rows (alwaysOutputData can emit an empty item on an empty sheet).\n if (!str(r.candidate_id) && !str(r.candidate_url)) continue;\n\n const approval = lc(r.approval_status);\n const url = str(r.candidate_url);\n const ctype = lc(r.candidate_type);\n const normalized = normalizeUrl(url); // re-normalized at runtime; stored field is NOT trusted\n const domain = extractDomain(normalized || url); // re-derived from candidate_url, not from stored domain\n const isRoot = isRootUrl(normalized || url);\n\n let eligible = true; let skip_category = ''; let skip_reason = '';\n if (approval === 'processed'){ eligible=false; skip_category='already_processed'; skip_reason='approval_status=processed (already handed off)'; }\n else if (approval === 'duplicate' || approval === 'rejected' || approval === 'error'){ eligible=false; skip_category='duplicate_status'; skip_reason='approval_status=' + approval + ' (not eligible)'; }\n else if (approval !== 'approved'){ eligible=false; skip_category='approval_status_not_approved'; skip_reason='approval_status=' + (approval||'empty') + ' (need approved)'; }\n else if (url === ''){ eligible=false; skip_category='missing_candidate_url'; skip_reason='candidate_url empty'; }\n else if (normalized !== '' && regSet.has(normalized)){ eligible=false; skip_category='registry_recheck_duplicate'; skip_reason='registry_recheck_duplicate: normalized_source_url already exists in url_registry (editable dedup_status/registry_status ignored)'; }\n\n const warning = (eligible && NON_DIRECT.includes(ctype))\n ? 'not_direct_competitor_optional_warning: candidate_type is not direct_competitor; review before Workflow 04.'\n : '';\n\n evaluated.push({ row:r, eligible, skip_category, skip_reason, warning, ctype, url, normalized, domain, isRoot,\n confidence: parseInt(r.confidence_score)||0,\n rank: parseInt(r.rank)||999 });\n}\n\n// Prioritize eligible: direct_competitor first, higher confidence, lower rank, then root homepage first.\nconst eligibleList = evaluated.filter(e => e.eligible);\neligibleList.sort((a,b)=>{\n const ad = a.ctype==='direct_competitor'?0:1;\n const bd = b.ctype==='direct_competitor'?0:1;\n if (ad!==bd) return ad-bd;\n if (b.confidence!==a.confidence) return b.confidence-a.confidence;\n if (a.rank!==b.rank) return a.rank-b.rank;\n const ar = a.isRoot?0:1; const br = b.isRoot?0:1;\n return ar-br;\n});\n\n// Select in priority order, enforcing the per-domain cap (mode-dependent) and the overall hard cap.\nlet batch_index = 0;\nlet selectedCount = 0;\nconst domainCounts = {}; // domain -> selected count this run\nfor (let i=0;i<eligibleList.length;i++){\n const e = eligibleList[i];\n const dom = e.domain;\n const dcount = dom ? (domainCounts[dom]||0) : 0;\n if (dom && dcount >= PER_DOMAIN_CAP){\n e._selected = false;\n if (DEEP){\n e._skip_category = 'domain_deep_limit';\n e._skip_reason = 'domain_deep_limit: more than ' + PER_DOMAIN_CAP + ' approved URLs from domain ' + dom + ' in deep_domain_analysis mode; extra skipped.';\n } else {\n e._skip_category = 'duplicate_domain_in_run';\n e._skip_reason = 'Same domain already selected in this run; use deep_domain_analysis mode for multi-page domain analysis.';\n }\n continue;\n }\n if (selectedCount >= MAX){\n e._selected = false;\n e._skip_category = 'over_limit';\n e._skip_reason = 'over_limit (eligible but exceeds max_per_run=5; approve in a later run)';\n continue;\n }\n e._selected = true; selectedCount++; batch_index++; e._batch_index = batch_index;\n if (dom) domainCounts[dom] = (domainCounts[dom]||0) + 1;\n if (DEEP) e._deep_warning = 'deep_domain_analysis mode: multiple URLs from same domain allowed intentionally.';\n}\n\nconst out = [];\nfor (const e of evaluated){\n const r = e.row;\n const isSel = (e._selected === true);\n const skip_category = isSel ? '' : (e._skip_category || e.skip_category);\n const skip = isSel ? '' : (e._skip_reason || e.skip_reason);\n const warning = [e.warning || '', (isSel && e._deep_warning) ? e._deep_warning : ''].filter(Boolean).join(' | ');\n out.push({ json: {\n candidate_id: str(r.candidate_id),\n discovery_request_id: str(r.discovery_request_id),\n candidate_url: e.url,\n normalized_source_url: e.normalized,\n stored_normalized_source_url: str(r.normalized_source_url),\n domain: e.domain,\n is_root: e.isRoot,\n candidate_type: str(r.candidate_type),\n confidence_score: e.confidence,\n rank: e.rank,\n approval_status: (skip_category==='registry_recheck_duplicate') ? 'processed' : str(r.approval_status),\n dedup_status: str(r.dedup_status),\n registry_status: str(r.registry_status),\n approved_by: str(r.approved_by),\n approved_at: str(r.approved_at),\n service_hint: str(r.service_hint),\n region_hint: str(r.region_hint),\n notes: str(r.notes),\n runner_mode: runner_mode,\n warning: warning,\n _selected: isSel,\n _confirm_processed: (skip_category==='registry_recheck_duplicate'), // approved + now in url_registry => WF04 processed it (safe, idempotent)\n processed_update_payload: (skip_category==='registry_recheck_duplicate') ? { candidate_id: str(r.candidate_id), approval_status: 'processed' } : null,\n _eligible: e.eligible,\n _skip_category: skip_category,\n _skip_reason: skip,\n run_id: run_id,\n batch_index: isSel ? e._batch_index : 0,\n target_url: isSel ? e.url : '',\n source_type: 'scraped_web',\n platform: 'website',\n source_candidate_id: str(r.candidate_id),\n parsed_at: now,\n source_note: 'approved_candidates_runner',\n force_reprocess: false\n }});\n}\nreturn out;"
}
},
{
"id": "a6000000-0000-0000-0000-0000000000if",
"name": "IF Confirmed Processed?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
980,
340
],
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose"
},
"conditions": [
{
"id": "a6-if-confirm-01",
"leftValue": "={{ $json._confirm_processed }}",
"rightValue": true,
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
}
}
},
{
"id": "a6000000-0000-0000-0000-0000000000up",
"name": "Mark Candidates Processed (confirmed in url_registry)",
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4,
"position": [
1200,
260
],
"parameters": {
"authentication": "serviceAccount",
"operation": "update",
"documentId": {
"__rl": true,
"value": "PASTE_SPREADSHEET_ID_HERE",
"mode": "id"
},
"sheetName": {
"__rl": true,
"value": "url_candidates",
"mode": "name"
},
"columns": {
"mappingMode": "defineBelow",
"value": {
"candidate_id": "={{ $json.candidate_id }}",
"approval_status": "={{ $json.approval_status }}",
"notes": "={{ ($json.notes ? $json.notes + ' | ' : '') + 'Processed=confirmed in url_registry by WF06 run_id=' + $json.run_id + ' at ' + $now.toISO() }}"
},
"matchingColumns": [
"candidate_id"
],
"schema": []
},
"options": {}
},
"credentials": {
"googleApi": {
"name": "<your credential>"
}
},
"retryOnFail": true,
"maxTries": 3,
"waitBetweenTries": 5000
},
{
"id": "a6000000-0000-0000-0000-0000000000su",
"name": "Build Execution Summary & Handoff",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
980,
540
],
"parameters": {
"jsCode": "// One summary item per run. Aggregates the annotated rows from the selection node.\nconst all = $('Select, Prioritize & Annotate').all().map(i => i.json);\nconst selected = all.filter(r => r._selected === true);\nconst skipped = all.filter(r => r._selected !== true);\nconst registry_recheck_duplicate = skipped.filter(r => r._skip_category === 'registry_recheck_duplicate').length;\nconst confirm_processed_count = all.filter(r => r._confirm_processed === true).length;\nconst run_id = (all[0] && all[0].run_id) || '';\nconst runner_mode = (all[0] && all[0].runner_mode) || 'first_pass_domain_diversity';\nfunction moscowIsoNow(){var m=new Date(Date.now()+10800000);return m.toISOString().replace('Z','+03:00');}\nconst now = moscowIsoNow();\n\nconst selected_urls = selected.map(r => r.candidate_url);\nconst selected_domains = Array.from(new Set(selected.map(r => r.domain).filter(Boolean)));\nconst domain_selected_counts = {};\nfor (const r of selected){ if (r.domain){ domain_selected_counts[r.domain] = (domain_selected_counts[r.domain]||0) + 1; } }\n\nconst handoff_lines = selected_urls.map(function(u){ return \" '\" + u + \"',\"; }).join('\\n');\nconst set_url_list_block = 'const rawUrls = [\\n' + handoff_lines + '\\n];';\n\nconst domain_diversity = (runner_mode === 'deep_domain_analysis')\n ? 'deep_domain_analysis: multiple URLs per domain allowed intentionally (cap 3/domain/run); extras skipped as domain_deep_limit. url_registry semantics unchanged.'\n : 'first_pass_domain_diversity (default): max 1 URL per domain per run; second+ URL from a selected domain skipped as duplicate_domain_in_run. url_registry semantics unchanged.';\n\nreturn [{ json: {\n workflow: '06 - Approved Candidates Runner',\n run_id: run_id,\n generated_at: now,\n total_candidates_read: all.length,\n selected_count: selected.length,\n skipped_count: skipped.length,\n registry_recheck_duplicate: registry_recheck_duplicate,\n confirm_processed_count: confirm_processed_count,\n max_per_run: 5,\n runner_mode: runner_mode,\n processing_mode: 'manual_handoff_to_workflow_04',\n registry_recheck: 'enabled: url_registry re-read at runtime, candidate_url re-normalized; editable dedup_status/registry_status are advisory only',\n domain_diversity: domain_diversity,\n domain_selected_counts: domain_selected_counts,\n selected_domains: selected_domains,\n selected_urls: selected_urls,\n selected: selected.map(function(r){ return { batch_index:r.batch_index, candidate_id:r.candidate_id, candidate_url:r.candidate_url, normalized_source_url:r.normalized_source_url, domain:r.domain, is_root:r.is_root, candidate_type:r.candidate_type, confidence_score:r.confidence_score, rank:r.rank, service_hint:r.service_hint, discovery_request_id:r.discovery_request_id, warning:r.warning }; }),\n skipped: skipped.map(function(r){ return { candidate_id:r.candidate_id, candidate_url:r.candidate_url, domain:r.domain, candidate_type:r.candidate_type, reason_category:r._skip_category, reason:r._skip_reason }; }),\n workflow_04_set_url_list_block: set_url_list_block,\n operator_note: (selected.length === 0)\n ? 'No eligible candidates after runtime registry recheck. Approve at least one direct_competitor candidate whose normalized URL is NOT already in url_registry, then re-run.'\n : 'Copy the URLs above into Workflow 04 -> Set URL List (max 5), run Workflow 04. Marking processed is AUTOMATIC and SAFE: the enabled \"Mark Candidates Processed (confirmed in url_registry)\" node sets approval_status=processed ONLY for approved candidates whose normalized_source_url now appears in url_registry (i.e. WF04 actually wrote them). It never marks skipped/failed candidates and never marks freshly-selected handoffs (they stay approved until confirmed). Idempotent: re-running WF06 after WF04 confirms + marks; already-processed rows are skipped. Flow: approve -> WF06 (handoff) -> run WF04 -> re-run WF06 (auto-confirm + mark). runner_mode=' + runner_mode + '. Items carrying a warning need review before Workflow 04.'\n}}];"
}
}
],
"connections": {
"Manual Start": {
"main": [
[
{
"node": "Set Runner Config",
"type": "main",
"index": 0
}
]
]
},
"Read url_candidates": {
"main": [
[
{
"node": "Read url_registry",
"type": "main",
"index": 0
}
]
]
},
"Read url_registry": {
"main": [
[
{
"node": "Select, Prioritize & Annotate",
"type": "main",
"index": 0
}
]
]
},
"Select, Prioritize & Annotate": {
"main": [
[
{
"node": "IF Confirmed Processed?",
"type": "main",
"index": 0
},
{
"node": "Build Execution Summary & Handoff",
"type": "main",
"index": 0
}
]
]
},
"Set Runner Config": {
"main": [
[
{
"node": "Read url_candidates",
"type": "main",
"index": 0
}
]
]
},
"IF Confirmed Processed?": {
"main": [
[
{
"node": "Mark Candidates Processed (confirmed in url_registry)",
"type": "main",
"index": 0
}
],
[]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
},
"versionId": "a6000000-approved-candidates-runner-v004-runner-modes-20260607"
}
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.
googleApi
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
06 - Approved Candidates Runner. Uses googleSheets. Event-driven trigger; 10 nodes.
Source: https://github.com/CodeVinci8/vinci-ai-pilot/blob/main/n8n/workflows/06_approved_candidates_runner.json — 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.
TrackCollect_deeper. Uses googleSheets, httpRequest, @n-octo-n/n8n-nodes-json-database, itemLists. Event-driven trigger; 80 nodes.
This template is ideal for solo store owners, eCommerce marketers, automation beginners, or anyone using Shopify and Gmail who wants to recover lost revenue without coding.
PCN. Uses googleSheets, httpRequest, @n-octo-n/n8n-nodes-json-database, itemLists. Event-driven trigger; 60 nodes.
Product Ad Machine. Uses googleDrive, googleSheets, httpRequest, @ffmpeg-micro/n8n-nodes-ffmpeg-micro. Event-driven trigger; 60 nodes.
Basic AI SEO KW Research n8n DataForSEO Gumroad 030125. Uses googleSheets, googleDrive, httpRequest. Event-driven trigger; 56 nodes.