{
  "id": "n8PjlpfC2Fudq3QA",
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "name": "Hacker News 24H Trend to Telegram & AI Translator",
  "tags": [
    {
      "id": "tQPxiOWM4dsxfpPF",
      "name": "hacker news",
      "createdAt": "2026-01-07T16:53:06.456Z",
      "updatedAt": "2026-01-07T16:53:06.456Z"
    }
  ],
  "nodes": [
    {
      "id": "57574ba6-49e8-4d19-b7d0-1ea4a7b747af",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        352,
        -224
      ],
      "parameters": {
        "color": 5,
        "width": 592,
        "height": 352,
        "content": "# Hacker News Trend Tracking\rTrack popular via https://hn.algolia.com/api\r\r## Sliding Window\rTimer (Trigger): Run every 4 hours. Lookback: Fetch data from the past 24 hours.\r\r- Define a rule\u2014\"Posts published less than 1 hour ago and with extremely low interaction (<3 points) are considered noise and should be discarded directly.\"\r- If a post receives 20 likes in just 10 minutes (extremely explosive), we should keep it; but if it only gets 1 like after 10 minutes (normal situation), we temporarily ignore it and wait for the next scheduled task (e.g., 4 hours later) to evaluate it when it \"matures.\""
      },
      "typeVersion": 1
    },
    {
      "id": "6a607aac-b15f-46d8-901c-a53d9099a08d",
      "name": "Filter",
      "type": "n8n-nodes-base.filter",
      "position": [
        1200,
        176
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "f3c26f60-2368-4461-9f2e-3072acb1f7b1",
              "operator": {
                "type": "number",
                "operation": "gt"
              },
              "leftValue": "={{ $json.analysis.velocity_score }}",
              "rightValue": 1
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "f0fbb4ac-164c-483c-b0eb-2bfedab2e2a0",
      "name": "Schedule Trigger",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        368,
        176
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "hours",
              "hoursInterval": 4
            }
          ]
        }
      },
      "typeVersion": 1.3
    },
    {
      "id": "ba782266-418a-4d19-90bf-255f0f955cb9",
      "name": "Send a text message",
      "type": "n8n-nodes-base.telegram",
      "position": [
        2096,
        176
      ],
      "parameters": {
        "text": "={{ $json.text }}",
        "chatId": "YourChatID",
        "additionalFields": {
          "parse_mode": "HTML"
        }
      },
      "credentials": {
        "telegramApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "fd96243b-229e-436d-91ea-b31438f175a8",
      "name": "OpenRouter Chat Model1",
      "type": "@n8n/n8n-nodes-langchain.lmChatOpenRouter",
      "position": [
        1248,
        416
      ],
      "parameters": {
        "model": "google/gemini-2.5-flash-lite",
        "options": {}
      },
      "credentials": {
        "openRouterApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "7eaefa3f-577d-4809-895b-49d03e23495a",
      "name": "Algolia parameters",
      "type": "n8n-nodes-base.code",
      "position": [
        576,
        176
      ],
      "parameters": {
        "jsCode": "// @see https://hn.algolia.com/api\n\nconst now = DateTime.now();\n// Observation window (past X hours)\nconst lookBackHours = 24;\nconst startTime = Math.floor(now.minus({hours: lookBackHours}).toSeconds())\n\nconst numericFilters = [\n  `created_at_i>${startTime}`,\n  // Filter out noise/spam (posts with zero engagement)\n  \"points>1\",\n];\n\nreturn [{\n  json: {\n    tags: \"(ask_hn,show_hn)\",\n    startTime: now.minus({hours: lookBackHours}),\n    numericFilters: numericFilters.join(\",\"),\n    // Fetch maximum allowed items per page\n    hitsPerPage: 1000\n  }\n}]"
      },
      "typeVersion": 2
    },
    {
      "id": "b346f0d8-f5c1-4161-af9f-75059735f0f4",
      "name": "Recalculate popularity score",
      "type": "n8n-nodes-base.code",
      "position": [
        992,
        176
      ],
      "parameters": {
        "jsCode": "const hits = items[0].json.hits;\n\nconst now = DateTime.now();\n\nconst cleanData = hits.map(item => {\n  const postedTime = DateTime.fromISO(item.created_at);\n  // Calculate post age (in hours)\n  const ageInHours = now.diff(postedTime, 'hours').hours;\n  const points = item.points || 0;\n  const comments = item.num_comments || 0;\n\n  // --- Strategy 1: Infancy Filtering (The Buffer) ---\n  // If the post is less than 1 hour old and has fewer than 5 points, it is still \"baking\"\n  // and lacks analysis value for now. Skip it.\n  if (ageInHours < 1.0 && points < 5) {\n        return null; // Mark for removal\n  }\n\n  // --- Strategy 2: Recalculate Heat Score (Gravity Score) ---\n  const rawScore = points + (comments * 2);\n  const gravity = 1.8;\n  const normalizedScore = (rawScore - 1) / Math.pow(ageInHours + 2, gravity);\n\n  return {\n      ...item,\n      analysis: {\n          age_hours: parseFloat(ageInHours.toFixed(2)),\n          velocity_score: parseFloat(normalizedScore.toFixed(4)),\n      }\n  };\n})\n.filter(item => item !== null) // Filter out the new posts marked as null above\n.sort((a, b) => b.analysis.velocity_score - a.analysis.velocity_score);\n\nreturn cleanData.map(item => ({ json: item }));"
      },
      "typeVersion": 2
    },
    {
      "id": "105d6b96-622a-4956-bf77-77c69b814ef0",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1376,
        352
      ],
      "parameters": {
        "color": 6,
        "width": 272,
        "content": "### \ud83d\udc46 Replace with your target language\nMust be Spanish, French, Chinese, Japanese..."
      },
      "typeVersion": 1
    },
    {
      "id": "19c8577a-7191-45c5-b6b8-60a8be70ed5d",
      "name": "Translate",
      "type": "@n8n/n8n-nodes-langchain.agent",
      "onError": "continueRegularOutput",
      "position": [
        1376,
        176
      ],
      "parameters": {
        "text": "=title: {{ JSON.stringify($json.title) }}\nstory_text: {{ JSON.stringify($json.story_text) }}",
        "options": {
          "systemMessage": "You are a professional news media translation API.\n\nYour Task:\nTranslate the 'title' and 'story_text' into Chinese.\nIf 'story_text' is empty, keep the original text.\n\nHTML Handling:\n1. Remove all HTML tags.\n2. If an HTML tag is a link (<a href=\"...\">), convert it to Markdown format: [text](url).\n\nOutput Rules:\nReturn ONLY a valid JSON object. Do not include markdown formatting (like ```json) or any conversational text.\n\nExample:\nInput:\ntitle: \"hi\"\nstory_text: \"hello world\"\n\nOutput:\n{\n  \"title\": \"\u55e8\",\n  \"story_text\": \"\u4f60\u597d\u4e16\u754c\"\n}"
        },
        "promptType": "define"
      },
      "typeVersion": 3.1
    },
    {
      "id": "aeefe4c7-1603-4e57-b7a0-d373dcddf67d",
      "name": "Request Algolia",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        784,
        176
      ],
      "parameters": {
        "url": "http://hn.algolia.com/api/v1/search_by_date",
        "options": {},
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "tags",
              "value": "={{ $json.tags }}"
            },
            {
              "name": "numericFilters",
              "value": "={{ $json.numericFilters }}"
            },
            {
              "name": "hitsPerPage",
              "value": "={{ $json.hitsPerPage }}"
            }
          ]
        }
      },
      "typeVersion": 4.3
    },
    {
      "id": "48997584-e40b-4a2d-bcaa-f1de24a6ad6b",
      "name": "Merge translation results",
      "type": "n8n-nodes-base.code",
      "position": [
        1680,
        176
      ],
      "parameters": {
        "jsCode": "// Retrieve all original data from the 'Filter' node (since the AI Agent node replaces the original input data)\n// Note: Ensure the upstream node is named 'Filter'. If you renamed it, update the reference here accordingly.\nconst originalItems = $('Filter').all();\n\nreturn items.map((item, index) => {\n  // 1. Get the output text from the AI Agent\n  const aiOutputString = item.json.output || \"\";\n\n  // 2. Sanitize the data (remove Markdown formatting like ```json ... ``` if added by the AI)\n  const cleanJsonString = aiOutputString.replace(/```json/g, '').replace(/```/g, '').trim();\n\n  let translatedData = {};\n\n  // 3. Attempt to parse the JSON string\n  try {\n    translatedData = JSON.parse(cleanJsonString);\n  } catch (error) {\n    // If parsing fails, retain the raw text for debugging purposes\n    translatedData = {\n      translation_error: true,\n      raw_ai_response: aiOutputString\n    };\n  }\n\n  // 4. Merge original data with the translated data\n  const originalData = originalItems[index] ? originalItems[index].json : {};\n\n  return {\n    json: {\n      ...originalData,   // Preserve original fields (e.g., url, objectID)\n      ...translatedData  // Add new translated fields (e.g., title_cn, story_cn)\n    }\n  };\n});"
      },
      "typeVersion": 2
    },
    {
      "id": "28dba153-23d5-4c12-a786-605cb236040d",
      "name": "Combine message templates",
      "type": "n8n-nodes-base.code",
      "position": [
        1872,
        176
      ],
      "parameters": {
        "jsCode": "const items = $input.all();\n\n// --- Helper Functions ---\n\n// HTML escape (prevents < > & errors)\nfunction escapeHtml(text) {\n    if (!text) return \"\";\n    return text\n        .replace(/&/g, \"&amp;\")\n        .replace(/</g, \"&lt;\")\n        .replace(/>/g, \"&gt;\");\n}\n\n// Clean and truncate text\nfunction cleanAndTruncate(story_text = \"\") {\n    if (!story_text) return \"\";\n    \n    // 1. Convert HTML tags to newlines or empty strings\n    let cleaned = story_text\n        .replace(/<p>/g, \"\\n\")      // p tag to newline\n        .replace(/<br>/g, \"\\n\")     // br tag to newline\n        .replace(/<[^>]+>/g, \"\")    // Remove all other HTML tags\n        .replace(/&quot;/g, '\"')\n        .replace(/&#x27;/g, \"'\")\n        .replace(/&amp;/g, \"&\");\n\n    // 2. Truncation logic (limit to 120 characters)\n    if (cleaned.length > 120) {\n        cleaned = cleaned.substring(0, 120) + \"...\";\n    }\n\n    // 3. Escape output\n    return escapeHtml(cleaned);\n}\n\n// --- Main Logic ---\n\nconst count = items.length;\nlet message = \"\";\n\n// 1. Header Information\nmessage += `<b>\ud83d\udea8 Hacker News 24H Trending Alert</b>\\n`;\nmessage += `\ud83d\udcc9 Top stories captured: <b>${count}</b>\\n`; \nmessage += `\\n`; // Extra empty line to separate the header\n\nconst contents = [];\n\nfor (const item of items) {\n    const data = item.json;\n    let content = \"\";\n\n    // A. Title (with link)\n    const safeTitle = escapeHtml(data.title);\n    const hnLink = `https://news.ycombinator.com/item?id=${data.objectID}`;\n    content += `\ud83d\udd25 <a href=\"${hnLink}\">${safeTitle}</a>\\n`;\n\n    // B. Data row (aligned using Code style)\n    const score = parseFloat(data.analysis.velocity_score || 0).toFixed(2);\n    const age = parseFloat(data.analysis.age_hours || 0).toFixed(1);\n    \n    content += `<code>\ud83d\udcc8 ${score} | \ud83d\udcac ${data.num_comments} | \u2b50\ufe0f ${data.points}</code>\\n`;\n    content += `\ud83d\udd52 Posted ${age} hours ago\\n`;\n\n    // C. Content preview (only shown if story_text exists and is not empty)\n    if (data.story_text && data.story_text.trim().length > 0) {\n        content += `<blockquote>${cleanAndTruncate(data.story_text)}</blockquote>`;\n    }\n    \n    contents.push(content);\n}\n\n// --- Combine Output ---\nconst separator = \"\\n\";\n\nreturn {\n    json: {\n        // If no stories, do not join, just output header\n        text: message + (contents.length > 0 ? contents.join(separator) : \"\")\n    }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "ff3c82ac-4846-4744-96a3-0d70c20008be",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2096,
        336
      ],
      "parameters": {
        "color": 6,
        "width": 448,
        "height": 208,
        "content": "### \ud83d\udc46 Replace this with your ChatID\n1. **Credentials**: Create a bot via @BotFather and save the Token in the credentials.\n2. **Chat ID**:\n   - For **Personal**: Search @userinfobot to get your ID.\n   - For **Channel**: Add your bot as an **Administrator** to the channel. The Chat ID is usually the channel link suffix (e.g., -100xxxxxxx).\n3. **Important**: Ensure the bot has permission to post messages!"
      },
      "typeVersion": 1
    }
  ],
  "active": false,
  "settings": {
    "timezone": "Etc/UTC",
    "callerPolicy": "workflowsFromSameOwner",
    "timeSavedMode": "fixed",
    "availableInMCP": false,
    "executionOrder": "v1"
  },
  "versionId": "44526615-7197-46a5-bbdf-91512e5059ee",
  "connections": {
    "Filter": {
      "main": [
        [
          {
            "node": "Translate",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Translate": {
      "main": [
        [
          {
            "node": "Merge translation results",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Request Algolia": {
      "main": [
        [
          {
            "node": "Recalculate popularity score",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Schedule Trigger": {
      "main": [
        [
          {
            "node": "Algolia parameters",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Algolia parameters": {
      "main": [
        [
          {
            "node": "Request Algolia",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "OpenRouter Chat Model1": {
      "ai_languageModel": [
        [
          {
            "node": "Translate",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Combine message templates": {
      "main": [
        [
          {
            "node": "Send a text message",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge translation results": {
      "main": [
        [
          {
            "node": "Combine message templates",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Recalculate popularity score": {
      "main": [
        [
          {
            "node": "Filter",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}