{
  "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"
}