{
  "id": "kuGonIAboDQKn5h3",
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "name": "Analyze competitor strategy from YouTube and RSS to Notion & Telegram",
  "tags": [],
  "nodes": [
    {
      "id": "871f2e3f-b311-4fb0-8c0f-430365aa3259",
      "name": "Schedule Trigger",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        -144,
        64
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "triggerAtHour": 8
            }
          ]
        }
      },
      "typeVersion": 1.3
    },
    {
      "id": "94f99a23-6074-4a45-ac2f-7c345f4b6ea0",
      "name": "YouTube (Competitor A): Search Video",
      "type": "n8n-nodes-base.youTube",
      "position": [
        144,
        -224
      ],
      "parameters": {
        "limit": 3,
        "filters": {
          "channelId": "input your competitor channel ID here",
          "publishedAfter": "={{ new Date(Date.now() - 24*60*60*1000).toISOString() }}"
        },
        "options": {
          "order": "date"
        },
        "resource": "video"
      },
      "credentials": {
        "youTubeOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "8f8876ba-5810-4a51-af01-d961546fc9bb",
      "name": "Apify - Get Dataset Items",
      "type": "@apify/n8n-nodes-apify.apify",
      "position": [
        896,
        -128
      ],
      "parameters": {
        "resource": "Datasets",
        "datasetId": "={{ $json.defaultDatasetId }}",
        "authentication": "apifyOAuth2Api"
      },
      "credentials": {
        "apifyOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "ef9f0fb5-710b-4de5-9a52-4d623bda4cd4",
      "name": "Code - Normalize Apify Items",
      "type": "n8n-nodes-base.code",
      "position": [
        1120,
        -128
      ],
      "parameters": {
        "jsCode": "const ytNodeName = \"Merge (YouTube) (Mode Append)\";\n\nconst ytItems = $items(ytNodeName).map(i => i.json);\nconst apifyItems = $input.all();\n\nfunction getVideoIdFromUrl(url = \"\") {\n  try {\n    const u = new URL(url);\n    if (u.hostname.includes(\"youtube.com\")) return u.searchParams.get(\"v\");\n    if (u.hostname === \"youtu.be\") return u.pathname.replace(\"/\", \"\") || null;\n  } catch (e) {}\n  return null;\n}\n\nfunction pickYtVideoId(yt) {\n  return (\n    yt?.id?.videoId ||\n    yt?.videoId ||\n    yt?.snippet?.resourceId?.videoId ||\n    getVideoIdFromUrl(yt?.url) ||\n    null\n  );\n}\n\n// Build lookup from YT by videoId\nconst ytById = {};\nfor (const yt of ytItems) {\n  const vid = pickYtVideoId(yt);\n  if (vid) ytById[vid] = yt;\n}\n\n// Normalize Apify items, match YT by videoId (not by idx)\nreturn apifyItems.map(({ json: ap }) => {\n  const apVideoId =\n    ap?.id ||\n    getVideoIdFromUrl(ap?.url) ||\n    null;\n\n  const yt = apVideoId ? (ytById[apVideoId] || {}) : {};\n\n  const transcriptOnly = (ap?.transcript_only_text || \"\").toString().trim();\n  const fallbackDesc = (yt?.snippet?.description || yt?.description || \"\").toString().trim();\n  const content = (transcriptOnly || fallbackDesc).slice(0, 12000);\n\n  const title = ap?.title || yt?.snippet?.title || yt?.title || \"\";\n  const url = ap?.url || (apVideoId ? `https://www.youtube.com/watch?v=${apVideoId}` : \"\");\n  const publishedAt = yt?.snippet?.publishedAt || yt?.publishedAt || \"\";\n\n  return {\n    json: {\n      type: \"YT_TRANSCRIPT\",\n      videoId: apVideoId,\n      title,\n      url,\n      publishedAt,\n      content,\n      source: \"apify_best_youtube_transcripts_scraper\",\n      hasTranscript: Boolean(transcriptOnly),\n      // debug fields (optional, can be deleted)\n      _match: {\n        matchedBy: \"videoId\",\n        ytFound: Boolean(apVideoId && ytById[apVideoId]),\n      }\n    }\n  };\n});"
      },
      "typeVersion": 2
    },
    {
      "id": "a34e7448-f0ed-4a78-b649-7d9bea1e810b",
      "name": "Merge (YouTube) (Mode Append)",
      "type": "n8n-nodes-base.merge",
      "position": [
        448,
        -128
      ],
      "parameters": {},
      "typeVersion": 3.2
    },
    {
      "id": "c9ff9afd-a5de-4154-bf47-dcf2256d69d3",
      "name": "YouTube (Competitor B): Search Video",
      "type": "n8n-nodes-base.youTube",
      "position": [
        144,
        -32
      ],
      "parameters": {
        "limit": 3,
        "filters": {
          "channelId": "input your competitor channel ID here",
          "publishedAfter": "={{ new Date(Date.now() - 24*60*60*1000).toISOString() }}"
        },
        "options": {
          "order": "date"
        },
        "resource": "video"
      },
      "credentials": {
        "youTubeOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "74a46e76-53b4-416a-9023-6a1e0bb7849a",
      "name": "Apify - Run an Actor",
      "type": "@apify/n8n-nodes-apify.apify",
      "position": [
        672,
        -128
      ],
      "parameters": {
        "actorId": {
          "__rl": true,
          "mode": "list",
          "value": "L57jETyu9qT6J7bs5",
          "cachedResultUrl": "https://console.apify.com/actors/L57jETyu9qT6J7bs5/input",
          "cachedResultName": "Best Youtube Transcripts Scraper (scrape-creators/best-youtube-transcripts-scraper)"
        },
        "customBody": "={\n  \"videoUrls\": [\"{{'https://www.youtube.com/watch?v=' + $json.id.videoId}}\"]\n}",
        "authentication": "apifyOAuth2Api"
      },
      "credentials": {
        "apifyOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "9dc7cbf4-32a5-495e-9136-ed2cc967931f",
      "name": "RSS Feed (Competitor A): TechCrunch",
      "type": "n8n-nodes-base.rssFeedRead",
      "position": [
        144,
        160
      ],
      "parameters": {
        "url": "input your competitor RSS here",
        "options": {
          "ignoreSSL": true
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "c7cb3520-3d62-4676-b0cd-31115a13b4f7",
      "name": "RSS Feed (Competitor B): n8n Blog",
      "type": "n8n-nodes-base.rssFeedRead",
      "position": [
        144,
        352
      ],
      "parameters": {
        "url": "input your competitor RSS here",
        "options": {
          "ignoreSSL": true
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "8227ba9d-9f2f-48df-a3a7-72d64a13db8a",
      "name": "Merge (RSS): Mode Append",
      "type": "n8n-nodes-base.merge",
      "position": [
        448,
        256
      ],
      "parameters": {},
      "typeVersion": 3.2
    },
    {
      "id": "a4ea4a59-d8aa-4fc9-b80b-6dae596ddc93",
      "name": "Merge (All Data): Mode Append",
      "type": "n8n-nodes-base.merge",
      "position": [
        1344,
        64
      ],
      "parameters": {},
      "typeVersion": 3.2
    },
    {
      "id": "b1936f26-0828-4bb5-b88a-6c1d328a7d99",
      "name": "Code (Data Prep)",
      "type": "n8n-nodes-base.code",
      "position": [
        1568,
        64
      ],
      "parameters": {
        "jsCode": "function stripHtml(html = \"\") {\n  return html\n    .replace(/<script[\\s\\S]*?<\\/script>/gi, \"\")\n    .replace(/<style[\\s\\S]*?<\\/style>/gi, \"\")\n    .replace(/<\\/?[^>]+(>|$)/g, \" \")\n    .replace(/\\s+/g, \" \")\n    .trim();\n}\n\nfunction clip(text = \"\", max = 6000) {\n  const t = (text || \"\").toString().trim();\n  return t.length > max ? t.slice(0, max) + \"\u2026\" : t;\n}\n\nfunction toMs(x) {\n  const ms = Date.parse(x || \"\");\n  return Number.isNaN(ms) ? 0 : ms;\n}\n\n// FIX: The Node runtime code is missing this.getWorkflowStaticData()\nconst staticData =\n  (typeof getWorkflowStaticData === \"function\" && getWorkflowStaticData(\"global\")) ||\n  (typeof $getWorkflowStaticData === \"function\" && $getWorkflowStaticData(\"global\"));\n\nif (!staticData) throw new Error(\"Static Data API not available in this Code node runtime\");\n\nstaticData.seen = staticData.seen || {};\n\nconst now = new Date();\nconst nowIso = now.toISOString();\nconst SEEN_TTL_DAYS = 7;\nconst TTL_MS = SEEN_TTL_DAYS * 24 * 60 * 60 * 1000;\n\n// ===== Token control =====\nconst MAX_ITEMS_TOTAL = 8;           // Gemini limits purchases to a maximum of 8 items per day.\nconst MAX_YT_ITEMS = 2;              // Prioritize the two most recent videos\nconst RSS_HOT_POOL = 10;             // Top 10 hottest RSS posts\nconst MAX_CONTENT_RSS = 1400;        // A sharp cut to reduce the token price\nconst MAX_CONTENT_YT = 1800;         // transcript also cuts strongly\n\nfunction isRss(json) {\n  return Boolean(\n    json.link &&\n      (json.pubDate ||\n        json.isoDate ||\n        json.content ||\n        json.contentSnippet ||\n        json.description ||\n        json[\"content:encoded\"])\n  );\n}\n\nfunction competitorTagFromUrl(url = \"\") {\n  if (url.includes(\"techcrunch.com\")) return \"techcrunch\";\n  if (url.includes(\"n8n.io\")) return \"n8n\";\n  return \"unknown\";\n}\n\n// \u201cHot score\u201d heuristic for RSS: recency + keyword + source boost\nfunction rssHotScore(item) {\n  const ageMs = now.getTime() - toMs(item.publishedAt);\n  const ageHours = ageMs > 0 ? ageMs / (1000 * 60 * 60) : 9999;\n\n  // Recency score: 0..1 (highest new)\n  const recency = Math.max(0, 1 - ageHours / 48); // trong 48h \u0111\u1ea7u l\u00e0 quan tr\u1ecdng\n\n  const title = (item.title || \"\").toLowerCase();\n  const content = (item.content || \"\").toLowerCase();\n\n  // Keyword weight (depending on your domain, you can adjust it further)\n  const kw = [\n    { re: /\\b(ai|agent|llm|genai|rag)\\b/i, w: 0.30 },\n    { re: /\\b(robot|humanoid|automation)\\b/i, w: 0.25 },\n    { re: /\\b(hack|breach|cyber|security|ransom)\\b/i, w: 0.25 },\n    { re: /\\b(acquire|acquisition|merge|funding|raise|valuation|ipo)\\b/i, w: 0.20 },\n    { re: /\\b(nvidia|google|microsoft|meta|apple|openai|anthropic)\\b/i, w: 0.15 },\n    { re: /\\b(regulation|ban|lawsuit|court|policy)\\b/i, w: 0.15 },\n  ];\n\n  let kwScore = 0;\n  for (const k of kw) {\n    if (k.re.test(title) || k.re.test(content)) kwScore += k.w;\n  }\n\n  // Source boost: TechCrunch tends to be more \"market-moving\".\n  const sourceBoost = item.competitorTag === \"techcrunch\" ? 0.10 : 0.03;\n\n  // Penalty for the song \"stale / evergreen\" in the style of \"how to track santa ... 2022\"\n  const stalePenalty = /\\b(2020|2021|2022|2023)\\b/i.test(title) ? 0.25 : 0;\n\n  return recency + kwScore + sourceBoost - stalePenalty;\n}\n\nconst inputItems = $input.all();\n\n// --- Normalize each item into a common schema ---\nconst normalized = inputItems.map(({ json }) => {\n  if (!isRss(json)) {\n    const competitorTag = json.competitorTag || \"unknown\";\n    const videoId = json.videoId || json.id;\n    const url = json.url || (videoId ? `https://www.youtube.com/watch?v=${videoId}` : \"\");\n\n    return {\n      id: `yt:${videoId || url}`,\n      competitorTag,\n      sourceType: \"youtube\",\n      title: json.title || \"\",\n      url,\n      publishedAt: json.publishedAt || \"\",\n      content: clip(json.content || \"\", MAX_CONTENT_YT),\n    };\n  }\n\n  const title = json.title || \"\";\n  const url = json.link || \"\";\n  const publishedAt = json.isoDate || json.pubDate || \"\";\n  const rawContent =\n    json[\"content:encoded\"] ||\n    json.content ||\n    json.contentSnippet ||\n    json.description ||\n    \"\";\n\n  const competitorTag = competitorTagFromUrl(url);\n\n  return {\n    id: `rss:${url || title}`,\n    competitorTag,\n    sourceType: \"rss\",\n    title,\n    url,\n    publishedAt,\n    content: clip(stripHtml(rawContent), MAX_CONTENT_RSS),\n  };\n});\n\n// --- Dedup using staticData.seen with TTL ---\nconst fresh = [];\nfor (const item of normalized) {\n  const key = item.id;\n  const seenAt = staticData.seen[key];\n\n  if (seenAt) {\n    const seenMs = Date.parse(seenAt);\n    if (!Number.isNaN(seenMs) && Date.now() - seenMs < TTL_MS) continue;\n  }\n\n  staticData.seen[key] = nowIso;\n  fresh.push(item);\n}\n\n// --- Prune old keys ---\nfor (const [key, ts] of Object.entries(staticData.seen)) {\n  const ms = Date.parse(ts);\n  if (Number.isNaN(ms) || Date.now() - ms > TTL_MS) delete staticData.seen[key];\n}\n\nif (fresh.length === 0) {\n  return [{\n    json: {\n      competitorContext: `DATE=${nowIso.slice(0, 10)} | ITEMS=0\\nTASK=No new items in last 24h.`,\n      sources: [],\n      stats: { input: inputItems.length, normalized: normalized.length, fresh: 0 },\n      competitorTag: \"mixed\",\n      date: nowIso.slice(0, 10),\n    }\n  }];\n}\n\n// ===== Reduce items to control token size =====\n// Sort YT newest first\nconst yt = fresh\n  .filter(x => x.sourceType === \"youtube\")\n  .sort((a, b) => toMs(b.publishedAt) - toMs(a.publishedAt))\n  .slice(0, MAX_YT_ITEMS);\n\n// RSS: calculates the hot score -> gets the top 10 hottest topics.\nconst rssAll = fresh.filter(x => x.sourceType === \"rss\");\nconst rssHotTop10 = rssAll\n  .map(x => ({ ...x, _hotScore: rssHotScore(x) }))\n  .sort((a, b) => (b._hotScore || 0) - (a._hotScore || 0))\n  .slice(0, RSS_HOT_POOL)\n  .map(({ _hotScore, ...rest }) => rest);\n\n// Final list: prioritize YouTube (max 2), then fill in the top 10 hottest RSS feeds until you have 8.\nconst final = [...yt];\nfor (const r of rssHotTop10) {\n  if (final.length >= MAX_ITEMS_TOTAL) break;\n  final.push(r);\n}\n\n// If there is still a shortage (low RSS), fill from the remaining (YT/RSS) according to recency.\nif (final.length < MAX_ITEMS_TOTAL) {\n  const finalIds = new Set(final.map(x => x.id));\n  const remainder = fresh\n    .filter(x => !finalIds.has(x.id))\n    .sort((a, b) => toMs(b.publishedAt) - toMs(a.publishedAt));\n\n  for (const it of remainder) {\n    if (final.length >= MAX_ITEMS_TOTAL) break;\n    final.push(it);\n  }\n}\n\n// --- Build compact context (decrease token) ---\nconst lines = [];\nlines.push(`DATE=${nowIso.slice(0, 10)} | ITEMS=${final.length}`);\nlines.push(`TASK=insight>summary; detect positioning; propose 1 counter-tactic.`);\nlines.push(\"\");\n\nfinal.forEach((it, idx) => {\n  lines.push(`--#${idx + 1} ${it.sourceType.toUpperCase()} ${it.competitorTag}`);\n  if (it.publishedAt) lines.push(`P=${it.publishedAt}`);\n  lines.push(`T=${it.title}`);\n  lines.push(`U=${it.url}`);\n  lines.push(`C=${(it.content || \"\").replace(/\\s+/g, \" \").trim()}`);\n  lines.push(\"\");\n});\n\nconst competitorTags = Array.from(new Set(final.map(x => x.competitorTag))).filter(Boolean);\nconst pageCompetitorTag = competitorTags.length === 1 ? competitorTags[0] : \"mixed\";\n\nreturn [{\n  json: {\n    competitorContext: lines.join(\"\\n\"),\n    sources: final,\n    stats: { input: inputItems.length, normalized: normalized.length, fresh: final.length },\n    competitorTag: pageCompetitorTag,\n    date: nowIso.slice(0, 10),\n    // debug optional:\n    hotPool: {\n      rssCandidates: rssAll.length,\n      rssHotTop10: rssHotTop10.length,\n      ytPicked: yt.length,\n      final: final.length,\n    }\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "dd74498e-fecc-4d59-9002-457c04bf37b1",
      "name": "Google Gemini - Generate",
      "type": "@n8n/n8n-nodes-langchain.googleGemini",
      "position": [
        1792,
        64
      ],
      "parameters": {
        "modelId": {
          "__rl": true,
          "mode": "list",
          "value": "models/gemini-2.5-flash",
          "cachedResultName": "models/gemini-2.5-flash"
        },
        "options": {
          "temperature": 0.3,
          "systemMessage": "You are a Competitor Intelligence Analyst."
        },
        "messages": {
          "values": [
            {
              "content": "=Read this content (Blogs & Video Transcripts):\n\n{{ $json.competitorContext }}\n\nDeep Analysis:\n1) Core Message: What is the ONE main thing they want the audience to believe today?\n2) Hidden Strategy: Detect keywords or angles they are pushing (e.g., 'Cheap', 'Enterprise', 'AI-first').\n3) Counter-Tactic: Suggest 1 specific content angle for us to beat them.\n\nReturn STRICT JSON only (no markdown fences), schema:\n{\n  \"report_title\": \"Daily Intel: {{$node[\"Code (Data Prep)\"].json.date}}\",\n  \"summary\": \"Short summary\",\n  \"strategy_markdown\": \"## Strategy\\n* Point 1\\n* Point 2\",\n  \"telegram_html\": \"<b>Title</b>...\"\n}\n\nConstraints:\n- telegram_html < 4000 chars. NO <br>, NO <ul>, NO <li>; use \\n and \u2022 bullets only; only tags allowed: <b>, <i>, <u>, <s>, <code>, <pre>.\n- Include source links at the bottom of telegram_html (use URLs from the context). No <br>, <ul>, <li>; use newline + bullet.\n- Focus on insights, not just summary"
            }
          ]
        },
        "simplify": false,
        "jsonOutput": true
      },
      "credentials": {
        "googlePalmApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "93d83705-5488-4d28-af76-9920c4eee683",
      "name": "Telegram: Send Message",
      "type": "n8n-nodes-base.telegram",
      "position": [
        2368,
        -32
      ],
      "parameters": {
        "text": "={{ $json.telegram_html }}",
        "chatId": "input your chat ID here",
        "additionalFields": {
          "parse_mode": "HTML"
        }
      },
      "credentials": {
        "telegramApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "2e89d05d-3791-4464-81a9-288d4d806ce0",
      "name": "Code (Build Notion Blocks)",
      "type": "n8n-nodes-base.code",
      "position": [
        2592,
        160
      ],
      "parameters": {
        "jsCode": "function normalizeInline(s) {\n  let t = String(s ?? \"\");\n\n  // Convert basic HTML bold/italic to markdown\n  t = t.replace(/<\\/?b>/gi, \"**\");\n  t = t.replace(/<\\/?strong>/gi, \"**\");\n  t = t.replace(/<\\/?i>/gi, \"*\");\n  t = t.replace(/<\\/?em>/gi, \"*\");\n  t = t.replace(/<br\\s*\\/?>/gi, \"\\n\");\n\n  // Strip any remaining html tags\n  t = t.replace(/<[^>]+>/g, \"\");\n\n  return t;\n}\n\nfunction pushText(arr, content, annotations = {}, link = null) {\n  if (!content) return;\n  arr.push({\n    type: \"text\",\n    text: {\n      content,\n      ...(link ? { link: { url: link } } : {}),\n    },\n    annotations: {\n      bold: !!annotations.bold,\n      italic: !!annotations.italic,\n      strikethrough: !!annotations.strikethrough,\n      underline: !!annotations.underline,\n      code: !!annotations.code,\n      color: \"default\",\n    },\n  });\n}\n\n// Inline markdown parser: **bold**, *italic*, `code`, URLs\nfunction inlineToRichText(input) {\n  const s0 = normalizeInline(input);\n  const out = [];\n\n  const tokenRegex =\n    /(`[^`]+`)|(\\*\\*[^*]+\\*\\*)|(\\*[^*]+\\*)|(https?:\\/\\/[^\\s)>\"']+)/g;\n\n  let last = 0;\n  let m;\n  while ((m = tokenRegex.exec(s0)) !== null) {\n    if (m.index > last) pushText(out, s0.slice(last, m.index));\n\n    const tok = m[0];\n\n    if (tok.startsWith(\"`\") && tok.endsWith(\"`\")) {\n      pushText(out, tok.slice(1, -1), { code: true });\n    } else if (tok.startsWith(\"**\") && tok.endsWith(\"**\")) {\n      pushText(out, tok.slice(2, -2), { bold: true });\n    } else if (tok.startsWith(\"*\") && tok.endsWith(\"*\")) {\n      pushText(out, tok.slice(1, -1), { italic: true });\n    } else if (tok.startsWith(\"http\")) {\n      pushText(out, tok, {}, tok);\n    } else {\n      pushText(out, tok);\n    }\n\n    last = tokenRegex.lastIndex;\n  }\n\n  if (last < s0.length) pushText(out, s0.slice(last));\n\n  return out.filter(x => x?.text?.content?.length);\n}\n\nfunction heading2(text) {\n  return {\n    object: \"block\",\n    type: \"heading_2\",\n    heading_2: { rich_text: inlineToRichText(text) },\n  };\n}\n\nfunction divider() {\n  return { object: \"block\", type: \"divider\", divider: {} };\n}\n\nfunction paragraph(text) {\n  const t = String(text ?? \"\").trim();\n  if (!t) return null;\n  return {\n    object: \"block\",\n    type: \"paragraph\",\n    paragraph: { rich_text: inlineToRichText(t) },\n  };\n}\n\nfunction bullet(text) {\n  const t = String(text ?? \"\").trim();\n  if (!t) return null;\n  return {\n    object: \"block\",\n    type: \"bulleted_list_item\",\n    bulleted_list_item: { rich_text: inlineToRichText(t) },\n  };\n}\n\nfunction toggle(title, children = []) {\n  return {\n    object: \"block\",\n    type: \"toggle\",\n    toggle: {\n      rich_text: inlineToRichText(title),\n      children,\n    },\n  };\n}\n\nfunction extractUrls(text) {\n  const s = String(text ?? \"\");\n  const urls = s.match(/https?:\\/\\/[^\\s)>\"']+/g) || [];\n  const seen = new Set();\n  const out = [];\n  for (const u of urls) {\n    const clean = u.replace(/[.,;]+$/g, \"\");\n    if (!seen.has(clean)) {\n      seen.add(clean);\n      out.push(clean);\n    }\n  }\n  return out;\n}\n\nfunction mdToBlocks(md) {\n  const blocks = [];\n  const lines = String(md ?? \"\").split(/\\r?\\n/);\n\n  for (const raw of lines) {\n    const line = raw.trim();\n    if (!line) continue;\n\n    if (line.startsWith(\"## \")) {\n      blocks.push(heading2(line.replace(/^##\\s+/, \"\")));\n      continue;\n    }\n\n    if (line.startsWith(\"* \") || line.startsWith(\"- \")) {\n      blocks.push(bullet(line.replace(/^(\\*|\\-)\\s+/, \"\")));\n      continue;\n    }\n\n    blocks.push(paragraph(line));\n  }\n\n  return blocks.filter(Boolean);\n}\n\n// ===== Input =====\nconst rp = $node[\"Code - Robust Parser (Gemini JSON)\"].json;\nconst summary = rp.summary;\nconst strategyMd = rp.strategy_markdown;\nconst telegramHtml = rp.telegram_html;\n\n// ===== Build page content (WITH ALL 3 EMOJI OPTIONS) =====\nconst children = [];\n\n// Option #1: Emoji headings\nchildren.push(heading2(\"\ud83e\udde0 Summary\"));\nchildren.push(paragraph(summary || \"(no summary)\"));\nchildren.push(divider());\n\n// Strategy section\nconst strategyBlocks = mdToBlocks(strategyMd);\nchildren.push(heading2(\"\ud83e\udde9 Strategy\"));\n\n// Option #3: Emoji in toggle title\nchildren.push(toggle(\"\ud83d\udc47 Open strategy details\", strategyBlocks));\nchildren.push(divider());\n\n// Sources\nconst urls = extractUrls(telegramHtml);\nif (urls.length) {\n  children.push(heading2(\"\ud83d\udd17 Sources\"));\n\n  // Option #2: Emoji prefix for source bullets\n  for (const u of urls.slice(0, 30)) children.push(bullet(`\ud83d\udd17 ${u}`));\n}\n\nreturn [{ json: { children } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "37f4bff9-7a2c-4a39-a9cb-0b84e33dc403",
      "name": "Notion - Create Database Page",
      "type": "n8n-nodes-base.notion",
      "position": [
        2368,
        160
      ],
      "parameters": {
        "simple": false,
        "options": {
          "iconType": "emoji"
        },
        "resource": "databasePage",
        "databaseId": {
          "__rl": true,
          "mode": "id",
          "value": "INPUT_DATABASE_ID_FROM_URL"
        },
        "propertiesUi": {
          "propertyValues": [
            {
              "key": "date|date",
              "date": "={{ $json.meta.date }}",
              "includeTime": false
            },
            {
              "key": "Name|title",
              "title": "={{ $json.report_title }}"
            }
          ]
        }
      },
      "credentials": {
        "notionApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "ede0979a-12e6-431f-a7ea-52f7f8553c92",
      "name": "HTTP Request (Notion Append Children)",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        2816,
        160
      ],
      "parameters": {
        "url": "={{`https://api.notion.com/v1/blocks/${$node[\"Notion - Create Database Page\"].json.id}/children`}}",
        "method": "PATCH",
        "options": {},
        "jsonBody": "={{ JSON.stringify({ children: $node[\"Code (Build Notion Blocks)\"].json.children }) }}",
        "sendBody": true,
        "sendHeaders": true,
        "specifyBody": "json",
        "authentication": "predefinedCredentialType",
        "headerParameters": {
          "parameters": [
            {
              "name": "Notion-Version",
              "value": "2022-06-28"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "nodeCredentialType": "notionApi"
      },
      "credentials": {
        "notionApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.3
    },
    {
      "id": "b7cdd909-272a-4c99-8db2-f4906eaba205",
      "name": "Code - Robust Parser (Gemini JSON)",
      "type": "n8n-nodes-base.code",
      "position": [
        2144,
        64
      ],
      "parameters": {
        "jsCode": "function extractJsonObject(text) {\n  const t = (text ?? \"\").toString().trim();\n  const s = t.indexOf(\"{\");\n  const e = t.lastIndexOf(\"}\");\n  if (s === -1 || e === -1 || e <= s) {\n    throw new Error(\"No JSON object found in Gemini output\");\n  }\n  return t.slice(s, e + 1);\n}\n\nfunction sanitizeTelegramHtml(html) {\n  let s = (html ?? \"\").toString();\n\n  // Telegram HTML DOES NOT support <br>, <ul>, <li>\n  s = s.replace(/<br\\s*\\/?>/gi, \"\\n\");\n  s = s.replace(/<\\/?ul[^>]*>/gi, \"\\n\");\n  s = s.replace(/<li[^>]*>\\s*/gi, \"\u2022 \");\n  s = s.replace(/<\\/li>\\s*/gi, \"\\n\");\n\n  // Convert links <a href=\"url\">text</a> => \"text: url\"\n  // (Telegram supports <a>, but Gemini often truncates mid-tag => easiest is to remove <a> entirely)\n  s = s.replace(\n    /<a\\s+[^>]*href\\s*=\\s*\"(.*?)\"[^>]*>(.*?)<\\/a>/gi,\n    (_, url, text) => {\n      const t = (text ?? \"\").replace(/<[^>]+>/g, \"\").trim();\n      return t ? `${t}: ${url}` : url;\n    }\n  );\n\n  // Remove ALL tags except Telegram-safe subset\n  // Allowed: b/strong, i/em, u/ins, s/strike/del, code, pre\n  s = s.replace(\n    /<(?!\\/?(b|strong|i|em|u|ins|s|strike|del|code|pre)\\b)[^>]*>/gi,\n    \"\"\n  );\n\n  // Normalize newlines\n  s = s.replace(/\\r\\n/g, \"\\n\");\n  s = s.replace(/\\n{3,}/g, \"\\n\\n\").trim();\n\n  // If Gemini left unbalanced tags, safest is to strip ALL remaining tags\n  // (Telegram will fail on unbalanced tags)\n  const openB = (s.match(/<b>/gi) || []).length;\n  const closeB = (s.match(/<\\/b>/gi) || []).length;\n  const openI = (s.match(/<i>/gi) || []).length;\n  const closeI = (s.match(/<\\/i>/gi) || []).length;\n  const openU = (s.match(/<u>/gi) || []).length;\n  const closeU = (s.match(/<\\/u>/gi) || []).length;\n\n  if (openB !== closeB || openI !== closeI || openU !== closeU) {\n    s = s.replace(/<[^>]+>/g, \"\");\n  }\n\n  return s;\n}\n\nfunction safeTruncate(text, limit = 3800) {\n  if (!text) return text;\n  if (text.length <= limit) return text;\n\n  // Cut near a newline to avoid ugly cut\n  const cut = text.slice(0, limit - 30);\n  const lastNl = cut.lastIndexOf(\"\\n\");\n  const out = (lastNl > 200 ? cut.slice(0, lastNl) : cut).trim();\n\n  return out + \"\\n\\n(\u2026truncated)\";\n}\n\n// ---- Read Gemini node output (robust paths) ----\nconst j = $json;\n\nconst candidatesText =\n  j?.candidates?.[0]?.content?.parts?.map(p => p.text).filter(Boolean).join(\"\\n\");\n\nconst possible =\n  candidatesText ||\n  j?.text ||\n  j?.output ||\n  j?.response ||\n  j?.data ||\n  j?.result ||\n  j?.content ||\n  j?.message ||\n  (typeof j === \"string\" ? j : \"\");\n\nconst raw = extractJsonObject(possible);\n\nlet parsed;\ntry {\n  parsed = JSON.parse(raw);\n} catch (err) {\n  throw new Error(`JSON.parse failed: ${err.message}\\nRAW_JSON:\\n${raw}`);\n}\n\n// ---- Sanitize + truncate for Telegram HTML parse mode ----\nparsed.telegram_html = safeTruncate(sanitizeTelegramHtml(parsed.telegram_html), 4000);\n\n// ---- Add meta (keep it the same) ----\nreturn [{\n  json: {\n    ...parsed,\n    meta: {\n      generatedAt: new Date().toISOString(),\n      competitorTag: $node[\"Code (Data Prep)\"].json.competitorTag,\n      date: $node[\"Code (Data Prep)\"].json.date,\n    }\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "91b1f5c5-ebeb-4d2b-8406-045abb4057e7",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -720,
        -400
      ],
      "parameters": {
        "width": 480,
        "height": 944,
        "content": "## AI-Powered Intelligence Watchdog: YouTube & RSS to Telegram & Notion\n\nTransform how you track market rivals. Instead of drowning in noise, this workflow acts as your dedicated **AI Strategy Analyst**. It autonomously monitors competitor communication channels, decodes their underlying tactics, and delivers a high-level executive briefing every morning.\n\n### Key Capabilities\n* **Deep Video Analysis:** Unlike simple trackers, this workflow uses **Apify** to fetch full **video transcripts**, allowing AI to \"watch\" and analyze competitor videos in depth, not just read titles.\n* **Strategic Intelligence:** Google Gemini doesn't just summarize; it identifies **Core Messages**, detects shifts in **Tone/Positioning**, and suggests actionable **Counter-Tactics**.\n* **Dual Delivery:** Sends an instant, actionable HTML summary to **Telegram** for quick reading, and simultaneously archives a beautifully formatted report (using Notion Blocks) into **Notion** for long-term research.\n\n### Setup Instructions\n1.  **Credentials:** You need API keys for `YouTube Data API`, `Apify`, `Google Gemini`, `Telegram`, and `Notion`.\n2.  **Apify Setup:** Create a free account at Apify.com. This workflow uses the `youtube-transcript-scraper` actor (efficient & low cost).\n3.  **Notion Setup:** Create a Database with two properties: `Name` (Title) and `date` (Date). Copy the Database ID from the URL into the **Notion** node.\n4.  **Configuration:**\n    * **YouTube:** Update `Channel ID` in the YouTube nodes to target your specific competitors.\n    * **RSS:** Update feed URLs in the RSS nodes.\n\n### Customization Tips\n* **Adjust the \"Lens\":** Open the **Gemini** node and modify the System Instruction to change the analysis focus (e.g., focus on \"Pricing Changes\" or \"New Product Features\").\n* **Control Token Usage:** The `Code - Data Prep` node has a config section to limit the number of items processed daily (default is optimized for free tier limits)."
      },
      "typeVersion": 1
    },
    {
      "id": "ece84946-03c8-4155-9688-555ea666d1e9",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -176,
        -368
      ],
      "parameters": {
        "color": 7,
        "width": 480,
        "height": 880,
        "content": "## 1. Data Sources (User Config)\n* **Setup:** Enter `Channel ID` for YouTube & `URL` for RSS nodes.\n* **Scale Up:** To track more competitors, duplicate these nodes and connect them to the **Merge** nodes.\n* **Note:** Remember to increase the **\"Number of Inputs\"** in the Merge nodes if you add more sources."
      },
      "typeVersion": 1
    },
    {
      "id": "ef79b0ad-978d-4e77-9e59-8a6775fb8369",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        384,
        -368
      ],
      "parameters": {
        "color": 7,
        "width": 864,
        "height": 800,
        "content": "## 2. Ingestion & Transcripts\n* **Aggregation:** Combines data from all sources.\n* **Deep Dive:** Triggers **Apify** to fetch full transcripts for all new YouTube videos found.\n* **Normalization:** Standardizes Blog & Video data into a unified format."
      },
      "typeVersion": 1
    },
    {
      "id": "5c695a4c-fab5-4c52-97ef-4221f8d11a9c",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1312,
        -160
      ],
      "parameters": {
        "color": 7,
        "width": 736,
        "height": 416,
        "content": "## 3. AI Strategy Core\n* **Data Prep:** Deduplicates items (Static Data) & prepares context.\n* **Gemini Analyst:** Reads content to extract \"Strategy\", \"Tone\", and \"Counter-Tactics\"."
      },
      "typeVersion": 1
    },
    {
      "id": "c78df5b9-4568-481d-92ac-1af11cd67747",
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2112,
        -160
      ],
      "parameters": {
        "color": 7,
        "width": 880,
        "height": 512,
        "content": "## 4. Professional Reporting\n* **Telegram:** Sends an executive HTML summary.\n* **Notion:** Archives the full report with rich formatting (Headings, Toggles)."
      },
      "typeVersion": 1
    }
  ],
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "versionId": "94d00ca3-e315-4292-a186-69e75d59e37e",
  "connections": {
    "Code (Data Prep)": {
      "main": [
        [
          {
            "node": "Google Gemini - Generate",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Schedule Trigger": {
      "main": [
        [
          {
            "node": "YouTube (Competitor A): Search Video",
            "type": "main",
            "index": 0
          },
          {
            "node": "YouTube (Competitor B): Search Video",
            "type": "main",
            "index": 0
          },
          {
            "node": "RSS Feed (Competitor A): TechCrunch",
            "type": "main",
            "index": 0
          },
          {
            "node": "RSS Feed (Competitor B): n8n Blog",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Apify - Run an Actor": {
      "main": [
        [
          {
            "node": "Apify - Get Dataset Items",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Google Gemini - Generate": {
      "main": [
        [
          {
            "node": "Code - Robust Parser (Gemini JSON)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge (RSS): Mode Append": {
      "main": [
        [
          {
            "node": "Merge (All Data): Mode Append",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Apify - Get Dataset Items": {
      "main": [
        [
          {
            "node": "Code - Normalize Apify Items",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Code (Build Notion Blocks)": {
      "main": [
        [
          {
            "node": "HTTP Request (Notion Append Children)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Code - Normalize Apify Items": {
      "main": [
        [
          {
            "node": "Merge (All Data): Mode Append",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge (All Data): Mode Append": {
      "main": [
        [
          {
            "node": "Code (Data Prep)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge (YouTube) (Mode Append)": {
      "main": [
        [
          {
            "node": "Apify - Run an Actor",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Notion - Create Database Page": {
      "main": [
        [
          {
            "node": "Code (Build Notion Blocks)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "RSS Feed (Competitor B): n8n Blog": {
      "main": [
        [
          {
            "node": "Merge (RSS): Mode Append",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Code - Robust Parser (Gemini JSON)": {
      "main": [
        [
          {
            "node": "Notion - Create Database Page",
            "type": "main",
            "index": 0
          },
          {
            "node": "Telegram: Send Message",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "RSS Feed (Competitor A): TechCrunch": {
      "main": [
        [
          {
            "node": "Merge (RSS): Mode Append",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "YouTube (Competitor A): Search Video": {
      "main": [
        [
          {
            "node": "Merge (YouTube) (Mode Append)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "YouTube (Competitor B): Search Video": {
      "main": [
        [
          {
            "node": "Merge (YouTube) (Mode Append)",
            "type": "main",
            "index": 1
          }
        ]
      ]
    }
  }
}