AutomationFlowsData & Sheets › Market Intelligence Report Builder (stakeholder V0.3) (google Sheets)

Market Intelligence Report Builder (stakeholder V0.3) (google Sheets)

12 - Market Intelligence Report Builder (Stakeholder v0.3). Uses googleSheets, httpRequest, executeWorkflowTrigger. Event-driven trigger; 27 nodes.

Event trigger★★★★☆ complexity27 nodesGoogle SheetsHTTP RequestExecute Workflow Trigger
Data & Sheets Trigger: Event Nodes: 27 Complexity: ★★★★☆ Added:

This workflow follows the Execute Workflow Trigger → Google Sheets recipe pattern — see all workflows that pair these two integrations.

The workflow JSON

Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →

Download .json
{
  "name": "12 - Market Intelligence Report Builder (Stakeholder v0.3)",
  "nodes": [
    {
      "id": "cc120000-0000-0000-0000-0000000000n1",
      "name": "Overview Note RU",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -560,
        -360
      ],
      "parameters": {
        "height": 760,
        "width": 640,
        "content": "## 12 \u2014 Market Intelligence Report Builder v0.1 \u2014 \u0414\u0415\u0422\u0415\u0420\u041c\u0418\u041d\u0418\u0420\u041e\u0412\u0410\u041d\u041d\u042b\u0419 \u0441\u043a\u0435\u043b\u0435\u0442 (DEC-112/118)\n\n\u041e\u0442\u0447\u0451\u0442\u043d\u044b\u0439 \u0441\u043b\u043e\u0439 \u041d\u0410\u0414 \u0444\u0430\u043a\u0442\u0438\u0447\u0435\u0441\u043a\u0438\u043c \u044f\u0434\u0440\u043e\u043c WF10 (DEC-112: Claude \u2014 \u0432 report/control-\u0441\u043b\u043e\u0435, \u041d\u0415 \u0432 WF10).\n\n\u0427\u0442\u043e \u0434\u0435\u043b\u0430\u0435\u0442 (\u0414\u0415\u0422\u0415\u0420\u041c\u0418\u041d\u0418\u0420\u041e\u0412\u0410\u041d\u041d\u041e, $0):\n1. \u0420\u0443\u0447\u043d\u043e\u0439 \u0441\u0442\u0430\u0440\u0442 \u2192 Set Report Config (`enable_llm_summary=false`, `telegram_send=false`).\n2. \u0427\u0438\u0442\u0430\u0435\u0442 4 \u0432\u043a\u043b\u0430\u0434\u043a\u0438 WF10: competitor_profiles / market_angles / audience_activity_signals / content_positioning_plan.\n3. \u0412\u044b\u0431\u0438\u0440\u0430\u0435\u0442 \u041f\u041e\u0421\u041b\u0415\u0414\u041d\u0418\u0419 \u0441\u043d\u0430\u043f\u0448\u043e\u0442 WF10 \u043f\u043e `plan_id` (\u043e\u0434\u0438\u043d \u043f\u043b\u0430\u043d \u043d\u0430 \u043f\u0440\u043e\u0433\u043e\u043d); \u043f\u0440\u0435\u0434\u044b\u0434\u0443\u0449\u0438\u0439 \u043f\u043b\u0430\u043d = \u0431\u0430\u0437\u0430 \u0434\u043b\u044f \u0442\u0440\u0435\u043d\u0434\u043e\u0432 \u0443\u0433\u043b\u043e\u0432 (\u2191/\u2193/=/NEW).\n4. \u0421\u0447\u0438\u0442\u0430\u0435\u0442 \u0442\u043e\u043f \u043a\u043e\u043d\u043a\u0443\u0440\u0435\u043d\u0442\u043e\u0432 (evidence_count/confidence), \u0442\u043e\u043f \u0443\u0433\u043b\u043e\u0432 (frequency+\u0442\u0440\u0435\u043d\u0434), \u0430\u0433\u0440\u0435\u0433\u0430\u0442\u044b \u0430\u0443\u0434\u0438\u0442\u043e\u0440\u0438\u0438; \u0440\u0435\u043d\u0434\u0435\u0440\u0438\u0442 Markdown.\n5. \u041f\u0438\u0448\u0435\u0442 \u0422\u041e\u041b\u042c\u041a\u041e: market_intelligence_reports (25 \u043a\u043e\u043b\u043e\u043d\u043e\u043a v0.3, \u0441\u0445\u0435\u043c\u0430 \u0432 MARKET_INTELLIGENCE_REPORT_SCHEMA.md) + agent_requests (21, completed, $0).\n6. no_data-\u043f\u0440\u043e\u0433\u043e\u043d WF10 \u2192 report_type=no_data_notice, \u0444\u0438\u043a\u0441\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u0430\u044f \u0440\u0435\u043a\u043e\u043c\u0435\u043d\u0434\u0430\u0446\u0438\u044f, \u0431\u0435\u0437 \u0448\u0430\u0431\u043b\u043e\u043d\u043d\u043e\u0433\u043e \u043a\u043e\u043d\u0442\u0435\u043d\u0442\u0430.\n7. **source_mix \u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u0435\u043d \u0432 \u043a\u0430\u0436\u0434\u043e\u043c \u043e\u0442\u0447\u0451\u0442\u0435** (DEC-108): \u043e\u0442\u0447\u0451\u0442 \u043d\u0438\u043a\u043e\u0433\u0434\u0430 \u043d\u0435 \u043f\u043e\u0434\u0440\u0430\u0437\u0443\u043c\u0435\u0432\u0430\u0435\u0442, \u0447\u0442\u043e \u0432\u0441\u0435 \u0434\u0430\u043d\u043d\u044b\u0435 \u0441\u043e\u0431\u0440\u0430\u043d\u044b live.\n\n\u0427\u0415\u0413\u041e \u041d\u0415 \u0414\u0415\u041b\u0410\u0415\u0422: \u041d\u0415 \u0432\u044b\u0437\u044b\u0432\u0430\u0435\u0442 Claude (\u0432\u0435\u0442\u043a\u0430 = guard \u0441 \u043e\u0448\u0438\u0431\u043a\u043e\u0439; \u0432\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u2014 \u043e\u0442\u0434\u0435\u043b\u044c\u043d\u043e\u0435 \u043e\u0434\u043e\u0431\u0440\u0435\u043d\u0438\u0435: bounded facts-only prompt, \u0442\u043e\u043a\u0435\u043d-\u043a\u044d\u043f, \u0437\u0430\u043f\u0438\u0441\u044c \u0441\u0442\u043e\u0438\u043c\u043e\u0441\u0442\u0438); \u041d\u0415 \u0448\u043b\u0451\u0442 \u0432 Telegram (delivered_to=none); \u041d\u0415 \u0445\u043e\u0434\u0438\u0442 \u0432 \u0441\u0435\u0442\u044c (HTTP-\u043d\u043e\u0434 \u043d\u0435\u0442); \u041d\u0415 \u0432\u044b\u0434\u0443\u043c\u044b\u0432\u0430\u0435\u0442 \u0444\u0430\u043a\u0442\u044b/\u043a\u043e\u043d\u0442\u0430\u043a\u0442\u044b; \u041d\u0415 \u0440\u0435\u043a\u043e\u043c\u0435\u043d\u0434\u0443\u0435\u0442 outreach. active=false.\n\n**v0.2 (DEC-122):** \u043e\u0442\u0447\u0451\u0442 \u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d \u0441\u0435\u043a\u0446\u0438\u044f\u043c\u0438 executive_summary / competitor_snapshot / top_offers_and_prices / source_confidence / limitations / next_actions (\u0434\u0435\u0442\u0435\u0440\u043c\u0438\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u043e, $0). \u0414\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430 \u041e\u0425\u0420\u0410\u041d\u042f\u0415\u041c\u0410\u042f Claude-\u0432\u0435\u0442\u043a\u0430: \u0433\u0435\u0439\u0442 \u043e\u0434\u043e\u0431\u0440\u0435\u043d\u0438\u044f (token) -> evidence-bound \u043f\u0440\u043e\u043c\u043f\u0442 (\u0442\u043e\u043b\u044c\u043a\u043e \u043f\u043e\u043b\u044f \u043e\u0442\u0447\u0451\u0442\u0430) -> \u041e\u0422\u041a\u041b\u042e\u0427\u0401\u041d\u041d\u042b\u0419 HTTP-\u043f\u043b\u0435\u0439\u0441\u0445\u043e\u043b\u0434\u0435\u0440 (claude-sonnet-4-6, \u0431\u0435\u0437 \u043a\u043b\u044e\u0447\u0430) -> merge \u0441 \u0443\u0447\u0451\u0442\u043e\u043c \u0441\u0442\u043e\u0438\u043c\u043e\u0441\u0442\u0438 ($3/$15 \u0437\u0430 MTok). WF10 = \u0434\u0435\u0442\u0435\u0440\u043c\u0438\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u043e\u0435 \u0444\u0430\u043a\u0442\u043e\u0432\u043e\u0435 \u044f\u0434\u0440\u043e; Claude = \u0441\u043b\u043e\u0439 \u0431\u0438\u0437\u043d\u0435\u0441-\u0438\u043d\u0442\u0435\u0440\u043f\u0440\u0435\u0442\u0430\u0446\u0438\u0438 (DEC-112). \u041d\u0438\u043a\u0430\u043a\u0438\u0445 \u0432\u044b\u0434\u0443\u043c\u0430\u043d\u043d\u044b\u0445 \u0444\u0430\u043a\u0442\u043e\u0432/\u043a\u043e\u043d\u0442\u0430\u043a\u0442\u043e\u0432/\u0430\u0443\u0442\u0440\u0438\u0447\u0430.\n\n**v0.3 (DEC-128/129/130):** 1) \u0434\u0430\u0439\u0434\u0436\u0435\u0441\u0442 \u0440\u0443\u043a\u043e\u0432\u043e\u0434\u0438\u0442\u0435\u043b\u044f (5\u20137 \u0447\u0438\u0441\u0442\u044b\u0445 \u043f\u0443\u043d\u043a\u0442\u043e\u0432); 2) \u0447\u0438\u0441\u0442\u044b\u0435 \u0438\u043c\u0435\u043d\u0430 \u043a\u043e\u043d\u043a\u0443\u0440\u0435\u043d\u0442\u043e\u0432 (\u0432\u043c\u0435\u0441\u0442\u043e \u00ab(unnamed) \u2026\u00bb \u2014 \u00ab<Platform> offer: <\u043a\u043e\u0440\u043e\u0442\u043a\u0438\u0439 \u043e\u0444\u0444\u0435\u0440>\u00bb); 3) \u043e\u0444\u0444\u0435\u0440\u044b \u0443\u043a\u043e\u0440\u043e\u0447\u0435\u043d\u044b (max_offer_chars=90); 4) \u0431\u043b\u043e\u043a \u0441\u0430\u0439\u0442\u043e\u0432 \u043a\u043e\u043d\u043a\u0443\u0440\u0435\u043d\u0442\u043e\u0432 \u0438\u0437 competitor_site_snapshots (Stage 2 reintegration); 5) \u0431\u043b\u043e\u043a \u043f\u0443\u0431\u043b\u0438\u0447\u043d\u044b\u0445 \u043b\u0438\u0434-\u0441\u0438\u0433\u043d\u0430\u043b\u043e\u0432 \u0438\u0437 public_lead_signals (\u0442\u043e\u043b\u044c\u043a\u043e \u0430\u0433\u0440\u0435\u0433\u0430\u0442\u044b, \u0431\u0435\u0437 \u0438\u043c\u0451\u043d/\u043a\u043e\u043d\u0442\u0430\u043a\u0442\u043e\u0432); 6) \u0431\u043b\u043e\u043a\u0438 \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0439: \u043c\u0435\u043d\u0435\u0434\u0436\u0435\u0440 / \u043a\u043e\u043d\u0442\u0435\u043d\u0442 / \u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0438; 7) \u0441\u0445\u0435\u043c\u0430 \u043e\u0442\u0447\u0451\u0442\u0430 \u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0430 \u0434\u043e 25 \u043a\u043e\u043b\u043e\u043d\u043e\u043a (llm_status/llm_model/llm_input_tokens/llm_output_tokens/llm_summary_ru/llm_recommendations_ru/llm_quality_flags); 8) Claude-\u0432\u0435\u0442\u043a\u0430: \u0431\u044e\u0434\u0436\u0435\u0442\u043d\u044b\u0439 \u0433\u0435\u0439\u0442 (llm_max_input_chars, llm_max_estimated_cost_usd \u2014 \u0431\u0440\u043e\u0441\u0430\u0435\u0442 \u043e\u0448\u0438\u0431\u043a\u0443 \u0414\u041e HTTP), JSON-\u0441\u0435\u043a\u0446\u0438\u0438 (executive_summary_ru/key_findings/market_risks/recommended_next_actions/content_recommendations/source_limitations), quality-\u0444\u043b\u0430\u0433\u0438 (non_json/truncated/unverified_numbers/outreach_language); 9) \u043a\u0430\u0436\u0434\u044b\u0439 \u043f\u0440\u043e\u0433\u043e\u043d \u043f\u0438\u0448\u0435\u0442 1 \u0441\u0442\u0440\u043e\u043a\u0443 \u0432 live_source_runs."
      }
    },
    {
      "id": "cc120000-0000-0000-0000-0000000000n2",
      "name": "Setup and Test Note RU",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        120,
        -360
      ],
      "parameters": {
        "height": 720,
        "width": 620,
        "content": "## \u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0438 \u0442\u0435\u0441\u0442\n\n1. \u041d\u0415 \u0430\u043a\u0442\u0438\u0432\u0438\u0440\u043e\u0432\u0430\u0442\u044c (active=false). \u0421\u043d\u0430\u0447\u0430\u043b\u0430 \u0441\u043e\u0437\u0434\u0430\u0442\u044c \u0432\u043a\u043b\u0430\u0434\u043a\u0443 `market_intelligence_reports` (25 \u043a\u043e\u043b\u043e\u043d\u043e\u043a v0.3 \u2014 \u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043a\u0438 \u0438\u0437 docs/MARKET_INTELLIGENCE_REPORT_SCHEMA.md).\n2. \u041f\u043e\u0441\u043b\u0435 \u0438\u043c\u043f\u043e\u0440\u0442\u0430 \u043f\u0435\u0440\u0435\u043f\u0440\u0438\u0432\u044f\u0437\u0430\u0442\u044c \u043a\u0440\u0435\u0434\u0435\u043d\u0448\u043b Google Sheets \u043d\u0430 6 sheet-\u043d\u043e\u0434\u0430\u0445 (4 read + 2 append) \u0438 \u0437\u0430\u043c\u0435\u043d\u0438\u0442\u044c PASTE_SPREADSHEET_ID_HERE \u043d\u0430 \u0440\u0435\u0430\u043b\u044c\u043d\u044b\u0439 ID.\n3. \u0422\u0440\u0435\u0431\u043e\u0432\u0430\u043d\u0438\u0435: \u22651 \u043f\u0440\u043e\u0433\u043e\u043d WF10 (\u0438\u043d\u0430\u0447\u0435 \u043f\u043e\u043d\u044f\u0442\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430 \u00abcontent_positioning_plan is empty\u00bb). \u0422\u0440\u0435\u043d\u0434\u044b \u0443\u0433\u043b\u043e\u0432 \u043f\u043e\u044f\u0432\u043b\u044f\u044e\u0442\u0441\u044f \u043f\u0440\u0438 \u22652 \u043f\u0440\u043e\u0433\u043e\u043d\u0430\u0445 WF10.\n4. \u0422\u0435\u0441\u0442 1 ($0): Execute once \u2192 \u043e\u0436\u0438\u0434\u0430\u0435\u043c\u043e: market_intelligence_reports +1 (report_type=on_demand; top_competitors/top_angles \u0438\u0437 \u043f\u043e\u0441\u043b\u0435\u0434\u043d\u0435\u0433\u043e \u0441\u043d\u0430\u043f\u0448\u043e\u0442\u0430 WF10; llm_* \u043f\u0443\u0441\u0442\u044b\u0435; llm_cost_usd=0; delivered_to=none; source_mix \u0437\u0430\u043f\u043e\u043b\u043d\u0435\u043d), agent_requests +1 (request_type=report_summary, completed, $0).\n5. \u0422\u0435\u0441\u0442 2 (no-data): \u0435\u0441\u043b\u0438 \u043f\u043e\u0441\u043b\u0435\u0434\u043d\u0438\u0439 \u043f\u0440\u043e\u0433\u043e\u043d WF10 \u0431\u044b\u043b no_data \u2192 report_type=no_data_notice, \u0442\u043e\u043f-\u0441\u043f\u0438\u0441\u043a\u0438 \u043f\u0443\u0441\u0442\u044b\u0435, \u0440\u0435\u043a\u043e\u043c\u0435\u043d\u0434\u0430\u0446\u0438\u044f \u00abno_data; broaden filters or source scope\u00bb.\n6. \u0422\u0435\u0441\u0442 3 (LLM guard): enable_llm_summary=true \u2192 \u043e\u0436\u0438\u0434\u0430\u0435\u043c\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430 \u043d\u0430 \u043d\u043e\u0434\u0435 LLM Summary Guard. \u0412\u0435\u0440\u043d\u0443\u0442\u044c false.\n\nTelegram-\u0434\u043e\u0441\u0442\u0430\u0432\u043a\u0430 \u0438 Claude-summary \u2014 \u043e\u0442\u0434\u0435\u043b\u044c\u043d\u044b\u0435 \u0448\u0430\u0433\u0438 \u0437\u0430 \u043e\u0442\u0434\u0435\u043b\u044c\u043d\u044b\u043c \u043e\u0434\u043e\u0431\u0440\u0435\u043d\u0438\u0435\u043c (REPORTING_AND_TELEGRAM_SUMMARY_PLAN.md \u00a75).\n\n**v0.3:** \u0432\u043a\u043b\u0430\u0434\u043a\u0430 market_intelligence_reports \u0442\u0435\u043f\u0435\u0440\u044c 25 \u043a\u043e\u043b\u043e\u043d\u043e\u043a (\u0441\u043c. MARKET_INTELLIGENCE_REPORT_SCHEMA.md v0.3 \u2014 \u043f\u0435\u0440\u0435\u0441\u043e\u0437\u0434\u0430\u0442\u044c/\u0440\u0430\u0441\u0448\u0438\u0440\u0438\u0442\u044c \u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043a\u0438). \u0421\u043e\u0437\u0434\u0430\u0442\u044c \u0432\u043a\u043b\u0430\u0434\u043a\u0438 competitor_site_snapshots (22) / public_lead_signals (47, v0.3) / live_source_runs (23) \u0438\u0437 docs/TABLE_SCHEMA.md (\u043c\u043e\u0433\u0443\u0442 \u0431\u044b\u0442\u044c \u043f\u0443\u0441\u0442\u044b\u043c\u0438 \u2014 \u043e\u0442\u0447\u0451\u0442 \u0440\u0430\u0431\u043e\u0442\u0430\u0435\u0442 \u0431\u0435\u0437 \u043d\u0438\u0445). 8 sheet-\u043d\u043e\u0434 (6 read + 3 append... \u043f\u0435\u0440\u0435\u043f\u0440\u0438\u0432\u044f\u0437\u0430\u0442\u044c \u043a\u0440\u0435\u0434\u0435\u043d\u0448\u043b \u043d\u0430 \u0432\u0441\u0435\u0445). \u0422\u0435\u0441\u0442 Claude (\u0442\u043e\u043b\u044c\u043a\u043e \u043f\u043e \u043e\u0434\u043e\u0431\u0440\u0435\u043d\u0438\u044e): llm_approval_token=I_APPROVE_CLAUDE_REPORT_SUMMARY + enable_llm_summary=true + Anthropic \u043a\u0440\u0435\u0434\u0435\u043d\u0448\u043b \u0432 n8n + \u0412\u041a\u041b\u042e\u0427\u0418\u0422\u042c disabled HTTP-\u043d\u043e\u0434\u0443; \u0431\u044e\u0434\u0436\u0435\u0442-\u0442\u0435\u0441\u0442: llm_max_estimated_cost_usd=0.0001 -> \u043e\u0448\u0438\u0431\u043a\u0430 budget guard \u0414\u041e HTTP."
      }
    },
    {
      "id": "cc120000-0000-0000-0000-0000000000m1",
      "name": "Manual Start",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        -440,
        320
      ],
      "parameters": {}
    },
    {
      "id": "cc120000-0000-0000-0000-0000000000c1",
      "name": "Set Report Config",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -220,
        320
      ],
      "parameters": {
        "jsCode": "// WF12 Market Intelligence Report Builder config \u2014 v0.3 stakeholder report (DEC-112/118/122/128).\n// $0 by default: no Claude, no Telegram send, no external API. Reads WF10 tabs + competitor_site_snapshots\n// + public_lead_signals; writes ONE market_intelligence_reports row (25 cols, MARKET_INTELLIGENCE_REPORT_SCHEMA.md v0.3),\n// ONE agent_requests row and ONE live_source_runs row.\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 stamp=moscowStamp();\nvar __cfg = {\n  // --- hard-off defaults (DEC-112): the deterministic report is complete without any of these.\n  enable_llm_summary:false,          // optional Claude summary branch \u2014 guarded, HTTP node DISABLED\n  llm_approval_token:'',             // must equal 'I_APPROVE_CLAUDE_REPORT_SUMMARY' (explicit operator approval)\n  llm_model:'claude-sonnet-4-6',     // project stack model (CLAUDE.md); pricing for cost tracking below\n  llm_max_tokens:1200,               // hard output cap for the summary\n  llm_max_input_chars:8000,          // budget guard (DEC-128): prompt facts hard-capped at this size\n  llm_max_estimated_cost_usd:0.10,   // budget guard (DEC-128): throw BEFORE the HTTP node if estimate exceeds\n  llm_input_price_per_mtok:3.0,      // USD per 1M input tokens (claude-sonnet-4-6)\n  llm_output_price_per_mtok:15.0,    // USD per 1M output tokens (claude-sonnet-4-6)\n  telegram_send:false,               // Telegram delivery \u2014 NOT implemented (delivered_to stays 'none')\n  report_type:'on_demand',           // on_demand / weekly_digest / no_data_notice (auto-set on no_data)\n  niche_id:'credit_brokerage',\n  region:'\u041c\u043e\u0441\u043a\u0432\u0430/\u041c\u041e',\n  top_n:3,\n  max_offer_chars:90,                // v0.3: long competitor offers are shortened for readability\n  requested_by:'operator',\n  // --- Stage C Closure Patch 2: WF16 source_health enforcement + explicit fixture-report mode (\u00a711.5/\u00a711.7) ---\n  enforce_source_health:true,\n  require_source_health:true,\n  allow_degraded_report:false,   // explicit opt-in to include degraded runs (renders a visible warning)\n  allow_fixture_report:false,    // explicit opt-in to generate a fixture/manual_test report (renders watermark)\n  report_data_mode:'live',       // live | fixture | manual_test\n  allow_unverified_source:false, // PRODUCTION fail-closed (see WF10); set true ONLY for non-production/dev runs\n  report_id:'report_'+stamp,\n  run_id:'wf12_'+stamp,\n  agent_request_id:'wf12_req_'+stamp,\n  created_at:moscowIsoNow()\n};\n\nvar __agentIn=(typeof $json==='object'&&$json)?$json:{};\nfunction __ov(ck,ik){var k=ik||ck;var v=__agentIn[k];if(v!==undefined&&v!==null&&v!=='')__cfg[ck]=v;}\n__ov('agent_request_id','agent_request_id');\n__ov('report_data_mode','data_mode');\n__ov('report_type','report_type');\n__ov('niche_id','niche_id');\n__ov('region','region');\n__ov('top_n','top_n');\n__ov('enable_llm_summary','enable_llm_summary');\n__ov('llm_approval_token','llm_approval_token');\n__ov('supplied_sources','supplied_sources');\nreturn [{ json: __cfg }];"
      }
    },
    {
      "id": "cc120000-0000-0000-0000-0000000000r1",
      "name": "Read competitor_profiles",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4,
      "position": [
        0,
        320
      ],
      "parameters": {
        "authentication": "serviceAccount",
        "operation": "read",
        "documentId": {
          "__rl": true,
          "value": "={{ $env.MS_SPREADSHEET_ID || \"PASTE_SPREADSHEET_ID\" }}",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "competitor_profiles",
          "mode": "name"
        },
        "options": {}
      },
      "credentials": {
        "googleApi": {
          "name": "<your credential>"
        }
      },
      "alwaysOutputData": true,
      "onError": "continueRegularOutput",
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000
    },
    {
      "id": "cc120000-0000-0000-0000-0000000000r2",
      "name": "Read market_angles",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4,
      "position": [
        220,
        320
      ],
      "parameters": {
        "authentication": "serviceAccount",
        "operation": "read",
        "documentId": {
          "__rl": true,
          "value": "={{ $env.MS_SPREADSHEET_ID || \"PASTE_SPREADSHEET_ID\" }}",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "market_angles",
          "mode": "name"
        },
        "options": {}
      },
      "credentials": {
        "googleApi": {
          "name": "<your credential>"
        }
      },
      "alwaysOutputData": true,
      "onError": "continueRegularOutput",
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000
    },
    {
      "id": "cc120000-0000-0000-0000-0000000000r3",
      "name": "Read audience_activity_signals",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4,
      "position": [
        440,
        320
      ],
      "parameters": {
        "authentication": "serviceAccount",
        "operation": "read",
        "documentId": {
          "__rl": true,
          "value": "={{ $env.MS_SPREADSHEET_ID || \"PASTE_SPREADSHEET_ID\" }}",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "audience_activity_signals",
          "mode": "name"
        },
        "options": {}
      },
      "credentials": {
        "googleApi": {
          "name": "<your credential>"
        }
      },
      "alwaysOutputData": true,
      "onError": "continueRegularOutput",
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000
    },
    {
      "id": "cc120000-0000-0000-0000-0000000000r4",
      "name": "Read content_positioning_plan",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4,
      "position": [
        660,
        320
      ],
      "parameters": {
        "authentication": "serviceAccount",
        "operation": "read",
        "documentId": {
          "__rl": true,
          "value": "={{ $env.MS_SPREADSHEET_ID || \"PASTE_SPREADSHEET_ID\" }}",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "content_positioning_plan",
          "mode": "name"
        },
        "options": {}
      },
      "credentials": {
        "googleApi": {
          "name": "<your credential>"
        }
      },
      "alwaysOutputData": true,
      "onError": "continueRegularOutput",
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000
    },
    {
      "id": "cc120000-0000-0000-0000-0000000000b1",
      "name": "Build Deterministic Report",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        880,
        320
      ],
      "parameters": {
        "jsCode": "// --- SOCIAL-BRIDGE-001: embedded n8n/lib/social_evidence.js (drift-proof; test asserts equality) ---\nconst __SE = (function () {\n  'use strict';\n// social_evidence.js \u2014 SOCIAL-BRIDGE-001. Turn verbatim `raw_market_records` rows into bounded, strictly-scoped\n// evidence rows that the WF12 report bundle can carry, so a Telegram channel / VK community can be analysed as a\n// SOURCE even when it has no competitor profile.\n//\n// Why this exists (production defect, request req_76722076, executions 1096/1101):\n//   Telegram collection worked \u2014 30 items received, 22 written, 22 relevant, source_outcome=collected_with_data \u2014\n//   but `Build Analysis Inputs` produced ZERO analysis targets, WF28 was never called, records_analyzed was 0, and\n//   the user was told no competitor profiles with offers/prices were found. The verbatim posts were sitting in\n//   raw_market_records the whole time; WF12 simply never read them, and analysis_bridge derives targets from\n//   competitor profiles. Social posts deliberately do NOT become competitor profiles (DEC-133/DEC-135 quality\n//   policy), so valid evidence was lost between collection and analysis.\n//\n// Scoping is FAIL-CLOSED, in this order. A row must survive every gate:\n//   1. request scope   \u2014 the row's agent_request_id/source_run_id must belong to THIS request family;\n//   2. source-run scope\u2014 when the WF16 quality gate produced an eligible-run list, the row's source_run_id must be\n//                        in it (a degraded/excluded run contributes nothing);\n//   3. platform        \u2014 social only (website evidence already flows through competitor_profiles/offers);\n//   4. citable         \u2014 a real public post URL AND non-empty verbatim text, else the model could not cite it;\n//   5. post-level relevance (DEC-133/135) \u2014 decided by the POST TEXT alone: channel/system noise is dropped and\n//                        the text must derive a recognizable service. An unrelated post is never evidence.\n// Bounding: per-source and total caps, and a per-excerpt character cap, so one chatty channel cannot blow up the\n// evidence package or the paid analysis call.\n//\n// Embeddable: unique se*-prefixed names, no cross-lib require. The service classifier is intentionally a verbatim\n// copy of semantic_core.deriveServiceFromText \u2014 a Code node cannot require() a sibling lib, and\n// tests/test_social_evidence.js asserts the two stay identical.\n\nfunction seStr(v) { return v == null ? '' : String(v); }\nfunction seLow(v) { return seStr(v).toLowerCase(); }\nfunction seNum(v, d) { var n = Number(v); return isFinite(n) ? n : (d === undefined ? 0 : d); }\n\n// Same family semantics WF12 already uses for lead rows: \"req_1\" matches \"req_1::telegram::a1\".\nfunction seFamMatch(a, b) {\n  a = seStr(a); b = seStr(b);\n  if (!b) return true;\n  if (!a) return false;\n  return a === b || a.indexOf(b + '::') === 0 || b.indexOf(a + '::') === 0;\n}\n\n// --- verbatim copy of semantic_core.deriveServiceFromText (drift-asserted by the test) -----------------------\nfunction seDeriveService(text) {\n  const t = seLow(text);\n  if (!t) return 'unknown';\n  if (/\u0432\u043e\u0437\u0432\u0440\u0430\u0442\u043d[\u0430-\u044f\u0451]*\\s+\u043b\u0438\u0437\u0438\u043d\u0433|sale.?leaseback|leaseback/.test(t)) return 'auto_lease_refinance';\n  if (/\u043f\u0442\u0441|pts|\u043f\u043e\u0434 \u0437\u0430\u043b\u043e\u0433 \u0430\u0432\u0442\u043e|\u0437\u0430\u043b\u043e\u0433 \u0430\u0432\u0442\u043e\u043c\u043e\u0431\u0438\u043b|\u043f\u043e\u0434 \u0430\u0432\u0442\u043e/.test(t)) return 'pts_loan';\n  if (/\u043f\u043e\u0434 \u0437\u0430\u043b\u043e\u0433 \u043d\u0435\u0434\u0432\u0438\u0436|\u0437\u0430\u043b\u043e\u0433 \u043a\u0432\u0430\u0440\u0442\u0438\u0440|\u0437\u0430\u043b\u043e\u0433 \u0434\u043e\u043c\u0430|\u043f\u043e\u0434 \u043a\u0432\u0430\u0440\u0442\u0438\u0440/.test(t)) return 'real_estate_secured_loan';\n  if (/\u0438\u043f\u043e\u0442\u0435\u043a/.test(t) && /\u0440\u0435\u0444\u0438\u043d\u0430\u043d\u0441/.test(t)) return 'mortgage_refinance';\n  if (/\u0438\u043f\u043e\u0442\u0435\u043a/.test(t)) return 'mortgage_brokerage';\n  if (/\u043a\u0440\u0435\u0434\u0438\u0442\u043d[\u0430-\u044f\u0451]* \u0438\u0441\u0442\u043e\u0440\u0438|\u0438\u0441\u043f\u0440\u0430\u0432[\u0430-\u044f\u0451]* \u043a\u0440\u0435\u0434\u0438\u0442\u043d|\u043e\u0447\u0438\u0441\u0442[\u0430-\u044f\u0451]* \u043a\u0440\u0435\u0434\u0438\u0442\u043d|\u0431\u043a\u0438/.test(t)) return 'credit_history_consulting';\n  if (/\u043f\u043e\u0441\u043b\u0435 \u043e\u0442\u043a\u0430\u0437|\u0441 \u043f\u0440\u043e\u0441\u0440\u043e\u0447\u043a|\u043f\u043b\u043e\u0445[\u0430-\u044f\u0451]* \u043a\u0440\u0435\u0434\u0438\u0442\u043d|\u043e\u0442\u043a\u0430\u0437\u0430\u0445 \u0431\u0430\u043d\u043a/.test(t)) return 'credit_after_refusals';\n  if (/\u0434\u043b\u044f \u0431\u0438\u0437\u043d\u0435\u0441\u0430|\u0438\u043f \u0438 \u043e\u043e\u043e|\u043e\u0431\u043e\u0440\u043e\u0442\u043d|\u0442\u0435\u043d\u0434\u0435\u0440\u043d|\u0431\u0438\u0437\u043d\u0435\u0441-\u043a\u0440\u0435\u0434\u0438\u0442/.test(t)) return 'business_credit';\n  if (/\u0431\u0430\u043d\u043a\u043e\u0432\u0441\u043a[\u0430-\u044f\u0451]* \u0433\u0430\u0440\u0430\u043d\u0442/.test(t)) return 'bank_guarantee';\n  if (/\u0431\u0430\u043d\u043a\u0440\u043e\u0442/.test(t)) return 'bankruptcy_advisory';\n  if (/\u0440\u0435\u0444\u0438\u043d\u0430\u043d\u0441/.test(t)) return 'debt_refinancing';\n  if (/\u043e\u0431\u044a\u0435\u0434\u0438\u043d[\u0430-\u044f\u0451]* \u043a\u0440\u0435\u0434\u0438\u0442|\u043a\u043e\u043d\u0441\u043e\u043b\u0438\u0434\u0430\u0446/.test(t)) return 'debt_consolidation';\n  if (/\u0437\u0430\u043b\u043e\u0433|\u043f\u043e\u0434 \u043e\u0431\u0435\u0441\u043f\u0435\u0447\u0435\u043d\u0438/.test(t)) return 'secured_financing';\n  if (/\u043a\u0440\u0435\u0434\u0438\u0442\u043d[\u0430-\u044f\u0451]* \u0431\u0440\u043e\u043a\u0435\u0440|\u043f\u043e\u0434\u0431\u043e\u0440 \u0431\u0430\u043d\u043a|\u043f\u043e\u043c\u043e\u0449\u044c.{0,12}\u043a\u0440\u0435\u0434\u0438\u0442/.test(t)) return 'credit_brokerage';\n  if (/\u043a\u0440\u0435\u0434\u0438\u0442|\u0437\u0430\u0439\u043c/.test(t)) return 'consumer_credit';\n  return 'unknown';\n}\n// --- WIP3-C: niche-primary gate on top of the drift-asserted seDeriveService (semantic_core is NOT modified) ---\n// A post that matched ONLY the broad \u043a\u0440\u0435\u0434\u0438\u0442/\u0437\u0430\u0439\u043c catch-all is admitted as PRIMARY lending evidence only when it is\n// actually about lending \u2014 not macro/bond/sovereign/rating news that merely mentions \u00ab\u043a\u0440\u0435\u0434\u0438\u0442\u043d\u044b\u0439 \u0440\u0438\u0441\u043a\u00bb/\u00ab\u0434\u043e\u043b\u0433\u00bb.\n// (regression: banksta British Steel / Oracle CDS / generic bond credit-risk must not be primary evidence.)\nvar SE_OFFDOMAIN = /\u043e\u0431\u043b\u0438\u0433\u0430\u0446|\u0435\u0432\u0440\u043e\u0431\u043e\u043d\u0434|\\b\u0431\u043e\u043d\u0434|\\bcds\\b|\u0434\u0435\u0444\u043e\u043b\u0442\u043d[\u0430-\u044f\u0451]* \u0441\u0432\u043e\u043f|\u043a\u0440\u0435\u0434\u0438\u0442\u043d[\u0430-\u044f\u0451]* \u0434\u0435\u0444\u043e\u043b\u0442\u043d|\u0441\u0443\u0432\u0435\u0440\u0435\u043d|\u044d\u043c\u0438\u0442\u0435\u043d\u0442|\\b\u0441\u043f\u0440\u0435\u0434|\u0434\u043e\u0445\u043e\u0434\u043d\u043e\u0441\u0442[\u044c\u0438]|\u043d\u0430\u0446\u0438\u043e\u043d\u0430\u043b\u0438\u0437\u0430\u0446|british steel|oracle|\u0438\u043d\u0434\u0435\u043a\u0441[\u0430-\u044f\u0451]*\\s+(?:\u043c\u043e\u0441\u0431\u0438\u0440\u0436|\u0440\u0442\u0441|s&p|dow)|\u043a\u043e\u0442\u0438\u0440\u043e\u0432\u043a|\u0430\u043a\u0446\u0438[\u0439\u044f\u0435]/;\nvar SE_OFFER_SIGNAL = /\u043e\u0444\u043e\u0440\u043c|\u0437\u0430\u044f\u0432\u043a|\u0441\u0442\u0430\u0432\u043a\u0430 \u043e\u0442|\u043f\u043e\u0434 \u0437\u0430\u043b\u043e\u0433|\u0432\u044b\u0434\u0430[\u0435\u0451]\u043c|\u0432\u044b\u0434\u0430\u0447|\u043f\u043e\u043b\u0443\u0447\u0438(?:\u0442\u0435|\u0442\u044c)? \u0434\u0435\u043d\u044c\u0433|\u043e\u0434\u043e\u0431\u0440\u0435\u043d\u0438|\u0434\u0435\u043d\u044c\u0433\u0438 \u0437\u0430|\u0437\u0430\u0439\u043c \u043f\u043e\u0434|\u043a\u0440\u0435\u0434\u0438\u0442 \u043f\u043e\u0434|\u0440\u0430\u0441\u0441\u0440\u043e\u0447\u043a|\u043d\u0430\u0448\u0438 \u0443\u0441\u043b\u043e\u0432\u0438|\u043f\u043e\u0434\u0430\u0442\u044c \u0437\u0430\u044f\u0432\u043a\u0443/;\nfunction seNichePrimary(text, service) {\n  if (service !== 'consumer_credit') return true;           // specific lending services are always primary\n  var t = seLow(text);\n  if (SE_OFFER_SIGNAL.test(t)) return true;                 // a concrete loan-offer signal keeps it primary\n  if (SE_OFFDOMAIN.test(t)) return false;                   // otherwise off-domain macro/bond/rating news \u2192 not primary\n  return true;\n}\n\n// Channel/system noise (\"\u043a\u0430\u043d\u0430\u043b \u043f\u0435\u0440\u0435\u0438\u043c\u0435\u043d\u043e\u0432\u0430\u043d\", pinned-message notices \u2026) is never market evidence.\nfunction seIsSystemEvent(text) {\n  var t = seStr(text);\n  return /channel name was changed to/i.test(t)\n    || /(\u043d\u0430\u0437\u0432\u0430\u043d\u0438\u0435|\u0438\u043c\u044f)\\s+\u043a\u0430\u043d\u0430\u043b\u0430\\s+(\u0438\u0437\u043c\u0435\u043d\u0435\u043d\u043e|\u0441\u043c\u0435\u043d\u0435\u043d\u043e|\u043f\u043e\u043c\u0435\u043d\u044f)/i.test(t)\n    || /(\u0438\u0437\u043c\u0435\u043d\u0438\u043b|\u0441\u043c\u0435\u043d\u0438\u043b|\u043f\u043e\u043c\u0435\u043d\u044f\u043b)\\s+\u043d\u0430\u0437\u0432\u0430\u043d\u0438\u0435\\s+\u043a\u0430\u043d\u0430\u043b\u0430/i.test(t)\n    || /\u043a\u0430\u043d\u0430\u043b\\s+\u043f\u0435\u0440\u0435\u0438\u043c\u0435\u043d\u043e\u0432\u0430\u043d/i.test(t)\n    || /(\u0437\u0430\u043a\u0440\u0435\u043f\u043b\u0451\u043d\u043d\u043e\u0435|\u0437\u0430\u043a\u0440\u0435\u043f\u043b\u0435\u043d\u043d\u043e\u0435)\\s+\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435/i.test(t)\n    || /^\\s*(\u0432\u0441\u0442\u0443\u043f\u0438\u043b|\u043f\u0440\u0438\u0441\u043e\u0435\u0434\u0438\u043d\u0438\u043b)[\u0430-\u044f\u0451]*\\s+\u043a\\s+(\u043a\u0430\u043d\u0430\u043b\u0443|\u0433\u0440\u0443\u043f\u043f\u0435)/i.test(t);\n}\n\nvar SE_SOCIAL_PLATFORMS = ['telegram', 'vk'];\n\nfunction seDecodeEntities(s) {\n  return seStr(s)\n    .replace(/&#(\\d+);/g, function (_, d) { try { return String.fromCharCode(Number(d)); } catch (e) { return ''; } })\n    .replace(/&quot;/g, '\"').replace(/&apos;/g, \"'\").replace(/&nbsp;/g, ' ')\n    .replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');\n}\n\n// Contact scrubbing mirrors WF12's report-layer redaction: business numbers survive, direct contact identifiers\n// do not. The excerpt must stay VERBATIM in substance \u2014 we redact identifiers, we never paraphrase.\nvar SE_CONTACT_MARK = '[PUBLIC CONTACT REDACTED]';\nfunction seRedact(s) {\n  s = seStr(s); if (!s) return s;\n  s = s.replace(/[A-Za-z0-9._%+\\-]+@[A-Za-z0-9.\\-]+\\.[A-Za-z]{2,}/g, SE_CONTACT_MARK);\n  s = s.replace(/(?:\\+7|8)[\\s\\-()]*\\d{3}[\\s\\-()]*\\d{3}[\\s\\-]*\\d{2}[\\s\\-]*\\d{2}/g, SE_CONTACT_MARK);\n  s = s.replace(/\\+\\d[\\d\\s().\\-]{8,}\\d/g, SE_CONTACT_MARK);\n  s = s.replace(/@[A-Za-z0-9_]{3,32}/g, SE_CONTACT_MARK);\n  return s;\n}\n\nfunction seClean(s, max) {\n  var t = seRedact(seDecodeEntities(s)).replace(/\\s+/g, ' ').trim();\n  if (max && t.length > max) t = t.slice(0, max - 1) + '\u2026';\n  return t;\n}\n\n// The logical source a post belongs to: the channel/community, not the individual post.\nfunction seSourceKeyOf(row) {\n  var prof = seStr(row.profile_url) || seStr(row.source_url);\n  var m = prof.match(/^https?:\\/\\/([^\\/?#]+)([^?#]*)/i);\n  if (m) {\n    var host = m[1].replace(/^www\\./i, '');\n    var seg = seStr(m[2]).split('/').filter(function (x) { return x && x !== 's'; })[0] || '';\n    if (seg) return host + '/' + seg;\n    return host;\n  }\n  return seStr(row.author_handle) || seStr(row.profile_name) || seStr(row.platform);\n}\n\n/**\n * buildSocialEvidence(rows, ctx, opts)\n * @param {Array}  rows raw_market_records rows (verbatim collected posts)\n * @param {object} ctx  { agent_request_id, included_source_runs: [] }\n * @param {object} opts { max_total, max_per_source, max_excerpt_chars, platforms }\n * @returns {{evidence: Array, sources: Array, considered: number, dropped: object}}\n *   evidence rows are shaped for bundle.evidence and carry the lineage every downstream consumer needs.\n */\nfunction buildSocialEvidence(rows, ctx, opts) {\n  rows = Array.isArray(rows) ? rows : [];\n  ctx = ctx || {}; opts = opts || {};\n  var maxTotal = seNum(opts.max_total, 40);\n  var maxPerSource = seNum(opts.max_per_source, 12);\n  var maxChars = seNum(opts.max_excerpt_chars, 600);\n  var platforms = Array.isArray(opts.platforms) && opts.platforms.length ? opts.platforms : SE_SOCIAL_PLATFORMS;\n  var reqId = seStr(ctx.agent_request_id);\n  var includedRuns = Array.isArray(ctx.included_source_runs) ? ctx.included_source_runs.filter(Boolean) : [];\n\n  var dropped = { foreign_request: 0, excluded_source_run: 0, not_social: 0, no_url: 0, no_text: 0,\n    system_event: 0, off_topic: 0, low_relevance: 0, duplicate: 0, over_cap: 0 };\n  var perSource = {};\n  var seenPost = {};\n  var out = [];\n  var sources = {};\n  var considered = 0;\n\n  rows.forEach(function (r) {\n    if (!r || typeof r !== 'object') return;\n    considered++;\n\n    // 1. request scope \u2014 a row from another request may never enter this report.\n    var rowReq = seStr(r.agent_request_id), rowRun = seStr(r.source_run_id);\n    if (!(seFamMatch(rowReq, reqId) || seFamMatch(rowRun, reqId))) { dropped.foreign_request++; return; }\n    // A row that names NEITHER a request nor a run cannot be proven to belong here.\n    if (!rowReq && !rowRun) { dropped.foreign_request++; return; }\n\n    // 2. source-run scope \u2014 honour the WF16 quality gate when it produced a list.\n    if (includedRuns.length && includedRuns.indexOf(rowRun) < 0) { dropped.excluded_source_run++; return; }\n\n    // 3. social platforms only\n    var platform = seLow(r.platform);\n    if (platforms.indexOf(platform) < 0) { dropped.not_social++; return; }\n\n    // 4. citable: a public post URL and verbatim text\n    var url = seStr(r.post_url) || seStr(r.source_url) || seStr(r.profile_url);\n    if (!url) { dropped.no_url++; return; }\n    var rawText = seStr(r.text_context) || seStr(r.comment_text);\n    if (!seStr(rawText).trim()) { dropped.no_text++; return; }\n\n    // 5. post-level relevance decided by the POST TEXT alone (DEC-133/135)\n    if (seIsSystemEvent(rawText)) { dropped.system_event++; return; }\n    var service = seDeriveService(rawText);\n    if (service === 'unknown') { dropped.off_topic++; return; }\n    // WIP3-C: general finance vocabulary is insufficient \u2014 off-domain macro/bond/rating news is not primary evidence.\n    if (!seNichePrimary(rawText, service)) { dropped.low_relevance++; return; }\n\n    var excerpt = seClean(rawText, maxChars);\n    if (!excerpt) { dropped.no_text++; return; }\n\n    // dedup on the post URL \u2014 the same post must never be cited twice\n    var dkey = seStr(r.dedup_key) || url;\n    if (seenPost[dkey]) { dropped.duplicate++; return; }\n    seenPost[dkey] = true;\n\n    var key = seSourceKeyOf(r);\n    perSource[key] = seNum(perSource[key], 0);\n    if (perSource[key] >= maxPerSource) { dropped.over_cap++; return; }\n    if (out.length >= maxTotal) { dropped.over_cap++; return; }\n    perSource[key]++;\n\n    if (!sources[key]) {\n      sources[key] = {\n        source_key: key, platform: platform, source_kind: platform,\n        source_name: seClean(r.profile_name, 120) || key,\n        profile_url: seStr(r.profile_url) || seStr(r.source_url),\n        source_run_id: rowRun, agent_request_id: rowReq, records: 0\n      };\n    }\n    sources[key].records++;\n\n    out.push({\n      // bundle.evidence contract (consumed by analysis_bridge)\n      evidence_id: seStr(r.record_id) || (key + '#' + out.length),\n      competitor: sources[key].source_name,\n      url: url,\n      excerpt: excerpt,\n      finding: 'social_post',\n      collected_at: seStr(r.published_at) || seStr(r.created_at),\n      source_quality: 'accepted',\n      // social lineage the deterministic layer must keep for the report/XLSX\n      source_key: key,\n      source_kind: platform,\n      platform: platform,\n      source_run_id: rowRun,\n      agent_request_id: rowReq,\n      record_id: seStr(r.record_id),\n      derived_service: service,\n      profile_url: sources[key].profile_url\n    });\n  });\n\n  return {\n    evidence: out,\n    sources: Object.keys(sources).map(function (k) { return sources[k]; }),\n    considered: considered,\n    dropped: dropped\n  };\n}\n  return { buildSocialEvidence };\n})();\nconst { buildSocialEvidence } = __SE;\n// --- END embedded n8n/lib/social_evidence.js ---\n// --- RUN-LINEAGE-001: embedded n8n/lib/run_lineage.js (drift-proof; test asserts equality) ---\n// run_lineage.js \u2014 RUN-LINEAGE-001. ONE resolver for \"which run/request/owner does this row belong to?\".\n//\n// This defect class has now cost five separate live failures, each one a silent zero-row report:\n//   ISO-ARID-001  \u2014 WF10 strict-compared an absent agent_request_id            (28 -> 0 rows)\n//   ISO-RUNID-001 \u2014 WF10 read source_run_id||agent_request_id; WF04 writes run_id (308 -> 0 rows)\n//   data_mode     \u2014 WF10 strict-compared an empty data_mode                    (all rows dropped)\n//   no_lineage    \u2014 report_gate reads ONLY source_run_id; queue rows carry run_id (row excluded)\n//   region        \u2014 an inferred default overrode an explicit user scope        (fixed in scope_policy)\n// Every one was the same shape: a consumer strict-compares a field its producer never populates, and the pipeline\n// silently returns nothing while telling the user there is no data. The cure is not another local fallback in the\n// next workflow \u2014 it is ONE resolver every consumer shares.\n//\n// Rules:\n//   * an EMPTY optional field is not a mismatch when another canonical field carries the same identity;\n//   * owner isolation is mandatory and is NEVER relaxed by this resolver;\n//   * the current run may not be satisfied by an unrelated historical row;\n//   * malformed/absent lineage fails safely AND is observable (resolved_from tells you what matched).\n//\n// Embeddable: unique rl*-prefixed names, no cross-lib require.\n\nfunction rlStr(v) { return v == null ? '' : String(v).trim(); }\nfunction rlLow(v) { return rlStr(v).toLowerCase(); }\n\n// A source run id is `<request>::<source>::<slot>`; its family root is the request id.\nfunction rlFamilyRoot(id) {\n  var s = rlStr(id);\n  var i = s.indexOf('::');\n  return i > 0 ? s.slice(0, i) : s;\n}\n\n// resolveRunLineage(row) -> { source_run_id, agent_request_id, run_id, family_root, owner_user_id, data_mode,\n//                             source_id, has_lineage, resolved_from }\n// ONLY the explicitly supported canonical/legacy fields are consulted \u2014 in one documented order.\nfunction resolveRunLineage(row) {\n  row = row || {};\n  var srid = rlStr(row.source_run_id);\n  var arid = rlStr(row.agent_request_id);\n  var rid = rlStr(row.run_id);\n  var from = [];\n  if (srid) from.push('source_run_id');\n  if (arid) from.push('agent_request_id');\n  if (rid) from.push('run_id');\n\n  // The most specific run identity available. WF04's queue rows carry it ONLY in run_id.\n  var sourceRun = srid || rid || '';\n  // The request family: an explicit agent_request_id wins; otherwise derive it from whichever run id we have.\n  var request = arid || rlFamilyRoot(srid || rid) || '';\n  var family = rlFamilyRoot(sourceRun || request);\n\n  return {\n    source_run_id: sourceRun,\n    agent_request_id: request,\n    run_id: rid,\n    family_root: family,\n    owner_user_id: rlStr(row.owner_user_id),\n    data_mode: rlLow(row.data_mode),\n    source_id: rlStr(row.source_url || row.normalized_source_url || row.source_id || row.source_key),\n    has_lineage: !!(sourceRun || request),\n    resolved_from: from.join('|') || 'none'\n  };\n}\n\n// Does `row` belong to the run identified by `filter`? `filter` may be a request id (req_x) or a source run id\n// (req_x::website::a1); a row matches its own request family either way.\nfunction rlFamilyMatch(rowLineage, filter) {\n  var f = rlStr(filter);\n  if (!f) return true;                       // no filter = no constraint\n  if (!rowLineage || !rowLineage.has_lineage) return false;   // fail safe, but ONLY when genuinely absent\n  var ids = [rowLineage.source_run_id, rowLineage.agent_request_id, rowLineage.run_id].filter(Boolean);\n  for (var i = 0; i < ids.length; i++) {\n    var id = ids[i];\n    if (id === f) return true;\n    if (id.indexOf(f + '::') === 0) return true;   // row is a source run inside the requested family\n    if (f.indexOf(id + '::') === 0) return true;   // filter is a source run inside the row's family\n  }\n  return false;\n}\n\n// Owner isolation. An unowned (system) row is visible to its own run; a row owned by SOMEONE ELSE never is.\nfunction rlOwnerMatch(rowLineage, owner) {\n  var o = rlStr(owner);\n  if (!o) return true;\n  var ro = rlStr(rowLineage && rowLineage.owner_user_id);\n  if (!ro) return true;          // unowned/system row \u2014 isolation is enforced by the run family above\n  return ro === o;\n}\n\n// The one predicate a consumer should call: does this row belong to THIS run, for THIS owner?\n// opts: { source_run_id_filter, agent_request_id_filter, owner_user_id, data_mode_filter }\nfunction rlRowInScope(row, opts) {\n  opts = opts || {};\n  var lin = resolveRunLineage(row);\n  if (!rlOwnerMatch(lin, opts.owner_user_id)) return { in_scope: false, reason: 'owner_mismatch', lineage: lin };\n  if (rlStr(opts.source_run_id_filter) && !rlFamilyMatch(lin, opts.source_run_id_filter)) return { in_scope: false, reason: 'source_run_mismatch', lineage: lin };\n  if (rlStr(opts.agent_request_id_filter) && !rlFamilyMatch(lin, opts.agent_request_id_filter)) return { in_scope: false, reason: 'request_mismatch', lineage: lin };\n  // ABSENT is not a MISMATCH: a row that never recorded a data_mode is not evidence of the wrong mode.\n  if (rlStr(opts.data_mode_filter) && lin.data_mode && lin.data_mode !== rlLow(opts.data_mode_filter)) {\n    return { in_scope: false, reason: 'data_mode_mismatch', lineage: lin };\n  }\n  return { in_scope: true, reason: '', lineage: lin };\n}\n// --- end embedded run_lineage ---\n// --- Stage C Closure Patch 2: embedded n8n/lib/report_gate.js (drift-proof; test asserts equality) ---\n// report_gate.js \u2014 Stage C Closure Patch 2: the source_health gate consumed by WF10 + WF12.\n//\n// Pure, deterministic, NO network, $0. Turns a set of WF16 `source_health` rows into the run-level\n// eligibility decisions that WF10 (aggregation) and WF12 (stakeholder report) MUST enforce before a run\n// can appear as production market intelligence. WF10/WF12 each embed a mirror of these functions; the\n// offline harness runs the real node code and asserts it equals this library (drift-proof, like WF16).\n//\n// A source run is EXCLUDED by default when any of these hold (section 4 of the closure brief):\n//   report_eligible=false, quality_status=quarantined, data_mode=fixture, data_mode=manual_test,\n//   review_status=pending, semantic validation failed, stale beyond the freshness window,\n//   no compatible baseline (for trend comparison only \u2014 handled by selectBaseline).\n// Degraded data is excluded by default and may be included ONLY through an explicit operator opt-in\n// (allow_degraded_report=true), and then it MUST carry a visible quality warning. Fixture / manual_test\n// data may be included ONLY when the operator explicitly generates a fixture report (allow_fixture_report)\n// and then a TEST/FIXTURE watermark MUST be shown.\n\n// RUN-LINEAGE-001: the ONE resolver every consumer shares (destructured so the embed step inlines it).\n\nfunction str(v) { return v == null ? '' : String(v).trim(); }\nfunction low(v) { return str(v).toLowerCase(); }\nfunction bool(v) {\n  if (v === true) return true;\n  if (v === false) return false;\n  const s = low(v);\n  return s === 'true' || s === 'yes' || s === '1';\n}\n\n// Run-level exclusion reasons we surface to the operator (stable enum).\nconst EXCLUDE = {\n  FIXTURE: 'data_mode=fixture',\n  MANUAL_TEST: 'data_mode=manual_test',\n  QUARANTINED: 'quality_status=quarantined',\n  PENDING: 'review_status=pending',\n  SEMANTIC: 'semantic_validation_failed',\n  STALE: 'stale_source',\n  NOT_ELIGIBLE: 'report_eligible=false',\n  DEGRADED_NO_OPTIN: 'degraded_without_opt_in'\n};\n\n// Decide eligibility for ONE source_health row given the report/aggregation config.\nfunction decideRun(h, cfg) {\n  h = h || {};\n  cfg = cfg || {};\n  const allowDegraded = cfg.allow_degraded_report === true;\n  const allowFixture = cfg.allow_fixture_report === true;\n  const id = str(h.source_run_id);\n  const data_mode = low(h.data_mode) || 'live';\n  const quality_status = low(h.quality_status);\n  const review_status = low(h.review_status);\n  const flags = low(h.quality_flags);\n  const reportEligibleFlag = (h.report_eligible === undefined) ? null : bool(h.report_eligible);\n\n  const reasons = [];\n  let degraded_included = false;\n  let warning = '';\n\n  if (data_mode === 'fixture') { if (allowFixture) warning = 'fixture'; else reasons.push(EXCLUDE.FIXTURE); }\n  if (data_mode === 'manual_test') { if (allowFixture) warning = warning || 'manual_test'; else reasons.push(EXCLUDE.MANUAL_TEST); }\n  if (quality_status === 'quarantined') reasons.push(EXCLUDE.QUARANTINED);\n  // PENDING-MINORITY-001: a run-level `pending_review` FLAG only means SOME records in the run are pending \u2014\n  // WF16 sets report_eligible=false ONLY when the WHOLE run is pending (pending===total), which is enforced\n  // below via NOT_ELIGIBLE. So a minority-pending run must stay eligible here and let the per-record gate\n  // (rowEligible) drop the individual pending rows; otherwise one pending sibling poisons every clean,\n  // report_candidate record from the same source run. Only a run-level review_status=pending excludes the run.\n  if (review_status === 'pending') reasons.push(EXCLUDE.PENDING);\n  if (/semantic_validation_failed/.test(flags)) reasons.push(EXCLUDE.SEMANTIC);\n  if (/(^|; )stale_source/.test(flags)) reasons.push(EXCLUDE.STALE);\n\n  if (quality_status === 'degraded') {\n    if (allowDegraded) { degraded_included = true; warning = warning || 'degraded'; }\n    else reasons.push(EXCLUDE.DEGRADED_NO_OPTIN);\n  }\n\n  // WF16's own report_eligible=false is authoritative, UNLESS an explicit opt-in path (degraded/fixture)\n  // is covering this run. This keeps WF16 the single source of truth while honoring operator overrides.\n  const optedIn = degraded_included || (allowFixture && (data_mode === 'fixture' || data_mode === 'manual_test'));\n  if (reportEligibleFlag === false && !optedIn && reasons.indexOf(EXCLUDE.NOT_ELIGIBLE) < 0) {\n    reasons.push(EXCLUDE.NOT_ELIGIBLE);\n  }\n\n  const eligible = reasons.length === 0;\n  return {\n    source_run_id: id,\n    eligible: eligible,\n    degraded_included: eligible && degraded_included,\n    quality_status: quality_status,\n    data_mode: data_mode,\n    review_status: review_status || (/(^|; )pending_review/.test(flags) ? 'pending' : ''),\n    warning: eligible ? warning : '',\n    reason: reasons.join('; ')\n  };\n}\n\n// Build the eligibility index for a set of source_health rows.\nfunction buildEligibility(healthRows, cfg) {\n  healthRows = Array.isArray(healthRows) ? healthRows.filter(Boolean) : [];\n  const decisions = healthRows.map(function (h) { return decideRun(h, cfg); });\n  const index = {};\n  decisions.forEach(function (d) { if (d.source_run_id) index[d.source_run_id] = d; });\n  const eligible = decisions.filter(function (d) { return d.eligible; });\n  const warnings = eligible.filter(function (d) { return d.warning; })\n    .map(function (d) { return { source_run_id: d.source_run_id, warning: d.warning }; });\n  return {\n    decisions: decisions,\n    index: index,\n    eligible_run_ids: eligible.map(function (d) { return d.source_run_id; }),\n    degraded_run_ids: eligible.filter(function (d) { return d.degraded_included; }).map(function (d) { return d.source_run_id; }),\n    excluded_run_ids: decisions.filter(function (d) { return !d.eligible; }).map(function (d) { return d.source_run_id; }),\n    warnings: warnings,\n    has_warning: warnings.length > 0,\n    has_fixture_warning: warnings.some(function (w) { return w.warning === 'fixture' || w.warning === 'manual_test'; }),\n    has_degraded_included: eligible.some(function (d) { return d.degraded_included; })\n  };\n}\n\n// Per-record gate (Patch 3): merge RECORD-LOCAL lineage with the matching source_health verdict \u2014 the\n// stricter of the two wins \u2014 and FAIL CLOSED in production. A record enters intelligence only when it is\n// affirmatively verified eligible: either source_health matched-and-eligible, or the record self-attests\n// live + healthy + report-eligible. Unverified records (no health match AND no affirmative local lineage,\n// or missing source_run_id) are EXCLUDED by default; including them requires the explicit operator override\n// cfg.allow_unverified_source=true (a non-production setting). This does not rely on a source_health join\n// alone, so it still gates correctly when source_health is empty or the run is unscored.\nfunction rowEligible(row, elig, cfg) {\n  row = row || {};\n  cfg = cfg || {};\n  const allowFixture = cfg.allow_fixture_report === true;\n  const allowDegraded = cfg.allow_degraded_report === true;\n  const allowUnverified = cfg.allow_unverified_source === true; // explicit non-production/operator fail-open\n  // RUN-LINEAGE-001: the row's identity comes from the SHARED resolver, not from one field. WF04's queue rows\n  // carry the family ONLY in run_id, so reading source_run_id alone reported `no_lineage` for a row that had\n  // perfectly good lineage (live: WF10 exec 932). Same defect class as ISO-ARID-001 / ISO-RUNID-001.\n  const __lin = resolveRunLineage(row);\n  const id = __lin.source_run_id || __lin.agent_request_id;\n  const reasons = [];\n  let warning = '';\n\n  // --- source_health verdict (authoritative when the run is matched) ---\n  let healthMatched = false, healthEligible = false, healthDegradedIncluded = false;\n  if (elig && elig.index && id && Object.prototype.hasOwnProperty.call(elig.index, id)) {\n    healthMatched = true;\n    const d = elig.index[id];\n    healthEligible = d.eligible;\n    healthDegradedIncluded = d.degraded_included;\n    if (!d.eligible) reasons.push('run_excluded:' + d.reason);\n    else warning = d.warning || '';\n  }\n\n  // --- record-local lineage (always evaluated; the stricter of local/health wins) ---\n  const dm = low(row.data_mode);\n  const qs = low(row.quality_status);\n  const review = low(row.review_status);\n  const parse = low(row.parse_method);\n  const flags = low(row.quality_flags);\n  const reportFlag = row.report_eligible;\n  const reportFalse = (reportFlag === false || String(reportFlag).toLowerCase() === 'false');\n  if (dm === 'fixture' && !allowFixture) reasons.push(EXCLUDE.FIXTURE);\n  if (dm === 'manual_test' && !allowFixture) reasons.push(EXCLUDE.MANUAL_TEST);\n  if (qs === 'quarantined') reasons.push(EXCLUDE.QUARANTINED);\n  if (qs === 'degraded' && !allowDegraded) reasons.push(EXCLUDE.DEGRADED_NO_OPTIN);\n  if (review === 'pending' || /(^|; )pending_review/.test(flags)) reasons.push(EXCLUDE.PENDING);\n  if (parse === 'deterministic_uncertain_no_llm') reasons.push('parse_method=deterministic_uncertain_no_llm');\n  if (reportFalse) reasons.push(EXCLUDE.NOT_ELIGIBLE);\n  if (/(^|; )stale_source/.test(flags)) reasons.push(EXCLUDE.STALE);\n\n  // --- verification gate: production requires affirmative eligibility evidence (fail closed) ---\n  // PARSE-OUTCOME-001 (operator decision): a SINGLE successful bounded repair whose payload passed full local\n  // validation is `accepted_with_repair` \u2014 audited and confidence-capped, but reportable exactly like healthy.\n  // A failed repair / fallback-only / invalid result still lands in degraded|quarantined and stays fail-closed.\n  const selfAttestsLive = (dm === 'live' || dm === 'production') &&\n    (qs === '' || qs === 'healthy' || qs === 'accepted_with_repair' || (qs === 'degraded' && allowDegraded)) && !reportFalse;\n  // explicit operator opt-in to a fixture/manual report verifies that data too (it is watermarked downstream)\n  const fixtureOptedIn = allowFixture && (dm === 'fixture' || dm === 'manual_test');\n  const verified = (healthMatched && healthEligible) || (selfAttestsLive && cfg.require_source_health !== true) || fixtureOptedIn;\n  if (reasons.length === 0 && !verified && !allowUnverified) {\n    reasons.push(healthMatched ? 'unverified' : (id ? 'no_source_health' : 'no_lineage'));\n  }\n\n  const eligible = reasons.length === 0;\n  const degraded_included = eligible && (healthDegradedIncluded || (qs === 'degraded' && allowDegraded));\n  return { eligible: eligible, reason: reasons.join('; '), warning: eligible ? warning : '', degraded_included: degraded_included };\n}\n\n// WF12 trend baseline: pick the most recent PRIOR run that is comparable to the current snapshot.\n// Compatible = same niche/region/service filters, live/compatible data_mode, report eligible, non-empty,\n// and within a comparable time window. When none qualifies -> trend_status=no_compatible_baseline and\n// items must NOT all be marked NEW.\nfunction selectBaseline(candidates, current, cfg) {\n  candidates = Array.isArray(candidates) ? candidates.filter(Boolean) : [];\n  current = current || {};\n  cfg = cfg || {};\n  const cn = low(current.niche_id), cr = low(current.region), cs = low(current.service_filter);\n  const curWin = Number(current.time_window_days) || Number(cfg.time_window_days) || 30;\n  const compat = candidates.filter(function (b) {\n    if (str(b.run_stamp) === str(current.run_stamp)) return false;     // not the current run\n    if (cn && low(b.niche_id) && low(b.niche_id) !== cn) return false;\n    if (cr && low(b.region) && low(b.region) !== cr) return false;\n    if (cs && low(b.service_filter) && low(b.service_filter) !== cs) return false;\n    if (b.data_mode && ['live', 'production'].indexOf(low(b.data_mode)) < 0) return false; // fixture/manual baselines excluded\n    if (b.report_eligible === false) return false;\n    if (Number(b.rows_after_filters) <= 0) return false;               // empty snapshot is not a baseline\n    if (b.time_window_days && Number(b.time_window_days) !== curWin) return false; // comparable window\n    return true;\n  });\n  compat.sort(function (a, b) { return str(a.run_stamp) < str(b.run_stamp) ? -1 : 1; });\n  const baseline = compat.length ? compat[compat.length - 1] : null;\n  return {\n    baseline: baseline,\n    trend_status: baseline ? 'baseline_selected' : 'no_compatible_baseline',\n    baseline_run_stamp: baseline ? str(baseline.run_stamp) : ''\n  };\n}\n\n// Render a trend marker for a frequency vs an optional previous frequency. NEVER produce a dangling\n// \"(x34 =)\" \u2014 when there is no compatible baseline the marker is empty; an unchanged value renders no\n// bare \"=\" suffix. (Closure C1-D1 / S3-D7.)\nfunction trendMarker(freq, prevFreq, hasBaseline) {\n  freq = Number(freq) || 0;\n  if (!hasBaseline) return '';            // no baseline -> no suffix at all (not \"NEW\", not \"=\")\n  if (prevFreq === undefined || prevFreq === null) return 'new';\n  if (freq > prevFreq) return 'up';\n  if (freq < prevFreq) return 'down';\n  return 'flat';                          // unchanged -> a word, never a bare \"=\"\n}\n\n// Compose the \"(xN)\" / \"(xN, up)\" suffix safely. Empty/whitespace markers never leave a dangling token.\nfunction freqSuffix(freq, marker) {\n  freq = Number(freq) || 0;\n  const m = str(marker);\n  return m ? ('(x' + freq + ', ' + m + ')') : ('(x' + freq + ')');\n}\n// --- end embedded report_gate ---\n// Deterministic Report Builder core v0.3 (DEC-112/118/122/128): latest WF10 snapshot + website snapshots\n// + public lead signals -> 25-col report row + stakeholder Markdown. Facts only \u2014 every number/name comes\n// from sheet rows; nothing is invented; no contacts are surfaced; no outreach recommendations.\nfunction str(v){return v==null?'':String(v).trim();}\nfunction num(v){var n=Number(v);return isFinite(n)?n:0;}\nfunction low(v){return str(v).toLowerCase();}\nfunction cut(s,n){s=str(s);return s.length>n?(s.slice(0,n-1)+'\u2026'):s;}\n// RU pluralizer (1 \u0441\u0430\u0439\u0442 / 2 \u0441\u0430\u0439\u0442\u0430 / 5 \u0441\u0430\u0439\u0442\u043e\u0432) \u2014 used for honest user-facing counts (B5).\nfunction plur(n,one,few,many){n=Math.abs(Number(n)||0);var n1=n%10,n2=n%100;if(n2>10&&n2<20)return many;if(n1>1&&n1<5)return few;if(n1===1)return one;return many;}\nfunction stripMd(s){s=str(s);if(!s)return s;s=s.replace(/\\[([^\\]]*)\\]\\((https?:[^)]*)\\)/g,'$1');s=s.replace(/\\]\\(\\s*https?:[^)\\s]*\\)?/g,'');s=s.replace(/\\[[^\\]]*$/,'');s=s.replace(/[\\[\\]]+/g,'').replace(/\\\\+/g,'').replace(/\\s{2,}/g,' ').trim();return s;}\n// Defect A (C6): deterministic report-layer contact redaction. Removes direct contact identifiers that may be\n// embedded in lead evidence text (phones, @handles, t.me/VK profile links, emails) BEFORE truncation/formatting,\n// while preserving business numbers (amounts, %, rates, dates) and ordinary source-POST URLs (vk.com/wall...).\nconst CONTACT_MARK='[PUBLIC CONTACT REDACTED]';\nfunction redact(s){ s=str(s); if(!s)return s;\n  s=s.replace(/[A-Za-z0-9._%+\\-]+@[A-Za-z0-9.\\-]+\\.[A-Za-z]{2,}/g,CONTACT_MARK);            // emails (before @handle)\n  s=s.replace(/(?:\\+7|8)[\\s\\-()]*\\d{3}[\\s\\-()]*\\d{3}[\\s\\-]*\\d{2}[\\s\\-]*\\d{2}/g,CONTACT_MARK); // RU phones\n  s=s.replace(/\\+\\d[\\d\\s().\\-]{8,}\\d/g,CONTACT_MARK);                                       // intl phone-like\n  s=s.replace(/(?:https?:\\/\\/)?(?:t|telegram)\\.me\\/[A-Za-z0-9_+]+/gi,CONTACT_MARK);          // telegram links\n  s=s.replace(/@[A-Za-z0-9_]{3,32}/g,CONTACT_MARK);                

Credentials you'll need

Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.

Pro

For the full experience including quality scoring and batch install features for each workflow upgrade to Pro

About this workflow

12 - Market Intelligence Report Builder (Stakeholder v0.3). Uses googleSheets, httpRequest, executeWorkflowTrigger. Event-driven trigger; 27 nodes.

Source: https://github.com/CodeVinci8/vinci-ai-pilot/blob/main/n8n/workflows/12_market_intelligence_report_builder.json — original creator credit. Request a take-down →

More Data & Sheets workflows → · Browse all categories →

Related workflows

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

Data & Sheets

Google Maps Email Scraper Template. Uses removeDuplicates, splitInBatches, httpRequest, splitOut. Event-driven trigger; 26 nodes.

HTTP Request, Google Sheets, Execute Workflow Trigger
Data & Sheets

Email Scapper. Uses httpRequest, googleSheets, executeWorkflowTrigger. Event-driven trigger; 26 nodes.

HTTP Request, Google Sheets, Execute Workflow Trigger
Data & Sheets

This workflow takes a list of Google Maps search queries, extracts candidate business website URLs from the search results, visits each site, scrapes email addresses from the page content, and appends

HTTP Request, Google Sheets, Execute Workflow Trigger
Data & Sheets

ITHome比賽進程. Uses httpRequest, googleSheets, executeWorkflowTrigger, n8n. Event-driven trigger; 25 nodes.

HTTP Request, Google Sheets, Execute Workflow Trigger +1
Data & Sheets

08 - Touchpoint Analyzer. Uses googleSheets, httpRequest, executeWorkflowTrigger. Event-driven trigger; 25 nodes.

Google Sheets, HTTP Request, Execute Workflow Trigger