{
  "name": "Hacker News AI Tracker",
  "nodes": [
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 8 * * *"
            }
          ]
        }
      },
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.3,
      "position": [
        -272,
        96
      ],
      "id": "125e7176-1325-45d6-a320-443e9a52ee75",
      "name": "Schedule Trigger"
    },
    {
      "parameters": {},
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        -272,
        -96
      ],
      "id": "2894528d-7b7a-40da-a7c5-bd01cd9529f0",
      "name": "Manual Trigger"
    },
    {
      "parameters": {
        "language": "pythonNative",
        "pythonCode": "# Loop over input items and add a new field called 'my_new_field' to the JSON of each one\nimport time\n\nwindowHours = 24\n\nreturn [{\n    \"json\": {\n        \"sinceUnix\": int(time.time()) - windowHours * 3600\n    }\n}]"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        0,
        -96
      ],
      "id": "62fa4450-1dfb-42e0-88a8-e18686b39bfe",
      "name": "Get Day Ago Unix Time"
    },
    {
      "parameters": {
        "language": "pythonNative",
        "pythonCode": "# Loop over input items and add a new field called 'my_new_field' to the JSON of each one\nreturn [\n    { \n        \"entity\": \"NVIDIA\", \n        \"ticker\": \"NVDA\",\n        \"queryTerms\": [\"NVIDIA\", \"Nvidia\", \"CUDA\"],\n        \"frontPagePoints\": 100, \n        \"breakoutThreshold\": 70\n    },\n    {\n        \"entity\": \"OpenAI\",\n        \"ticker\": None,\n        \"queryTerms\": [\"OpenAI\",\"ChatGPT\",\"GPT-5\",\"Sora\"],\n        \"frontPagePoints\": 100,\n        \"breakoutThreshold\": 70\n    },\n    {\n        \"entity\": \"Microsoft (Copilot)\",\n        \"ticker\": \"MSFT\",\n        \"queryTerms\": [\"Microsoft Copilot\",\"GitHub Copilot\"],\n        \"frontPagePoints\": 100,\n        \"breakoutThreshold\": 70\n    },\n    {\n        \"entity\": \"Google (Gemini)\",\n        \"ticker\": \"GOOGL\",\n        \"queryTerms\": [\"Gemini\",\"DeepMind\",\"Google AI\"],\n        \"frontPagePoints\": 100,\n        \"breakoutThreshold\": 70\n    },\n    {\n        \"entity\": \"Meta (Llama)\",\n        \"ticker\": \"META\",\n        \"queryTerms\": [\"Llama\",\"Meta AI\"],\n        \"frontPagePoints\": 100,\n        \"breakoutThreshold\": 70\n    },\n    {\n        \"entity\": \"AMD\",\n        \"ticker\": \"AMD\",\n        \"queryTerms\": [\"AMD\",\"ROCm\",\"Instinct MI300\"],\n        \"frontPagePoints\": 100,\n        \"breakoutThreshold\": 70\n    },\n    {\n        \"entity\": \"Palantir\",\n        \"ticker\": \"PLTR\",\n        \"queryTerms\": [\"Palantir\"],\n        \"frontPagePoints\": 75,\n        \"breakoutThreshold\": 60\n    },\n    {\n        \"entity\": \"Amazon (AWS AI)\",\n        \"ticker\": \"AMZN\",\n        \"queryTerms\": [\"AWS Bedrock\",\"Amazon Bedrock\",\"Trainium\"],\n        \"frontPagePoints\": 100,\n        \"breakoutThreshold\": 70\n    },\n    {\n        \"entity\": \"Apple (Apple Intelligence)\",\n        \"ticker\": \"AAPL\",\n        \"queryTerms\": [\"Apple Intelligence\"],\n        \"frontPagePoints\": 75,\n        \"breakoutThreshold\": 60\n    },\n    {\n        \"entity\": \"Tesla (AI/FSD)\",\n        \"ticker\": \"TSLA\",\n        \"queryTerms\": [\"Tesla FSD\",\"Tesla Optimus\",\"Dojo\"],\n        \"frontPagePoints\": 100,\n        \"breakoutThreshold\": 70\n    },\n    {\n        \"entity\": \"Anthropic\",\n        \"ticker\": None,\n        \"queryTerms\": [\"Anthropic\",\"Claude\"],\n        \"frontPagePoints\": 75,\n        \"breakoutThreshold\": 60\n    },\n    {\n        \"entity\": \"Mistral\",\n        \"ticker\": None,\n        \"queryTerms\": [\"Mistral AI\",\"Mistral\"],\n        \"frontPagePoints\": 75,\n        \"breakoutThreshold\": 60\n    }\n]"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        0,
        96
      ],
      "id": "e24662e9-7a9c-477d-b179-6dc6d546d1d3",
      "name": "WatchList"
    },
    {
      "parameters": {
        "mode": "combineBySql",
        "options": {}
      },
      "type": "n8n-nodes-base.merge",
      "typeVersion": 3.2,
      "position": [
        272,
        -80
      ],
      "id": "902df6dc-27ef-46af-9c80-1684ce0eecf6",
      "name": "Merge"
    },
    {
      "parameters": {
        "url": "https://hn.algolia.com/api/v1/search_by_date",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "query",
              "value": "={{ $json.term }}"
            },
            {
              "name": "tags",
              "value": "story"
            },
            {
              "name": "=numericFilters",
              "value": "=created_at_i>{{ $json.sinceUnix }}"
            },
            {
              "name": "hitsPerPage",
              "value": "100"
            },
            {
              "name": "page",
              "value": "0"
            }
          ]
        },
        "options": {
          "pagination": {},
          "timeout": 15000
        }
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.4,
      "position": [
        704,
        208
      ],
      "id": "cf049feb-66fe-4ea2-9170-f5bad6d62b49",
      "name": "HTTP Request",
      "retryOnFail": true
    },
    {
      "parameters": {
        "options": {}
      },
      "type": "n8n-nodes-base.splitInBatches",
      "typeVersion": 3,
      "position": [
        496,
        -80
      ],
      "id": "3d493991-360a-4690-9b7c-69ecb2ce230c",
      "name": "Loop Over Entities"
    },
    {
      "parameters": {
        "language": "pythonNative",
        "pythonCode": "# # Loop over input items and add a new field called 'my_new_field' to the JSON of each one\n# for item in _items:\n#   item[\"json\"][\"my_new_field\"] = 1\n# return _items\n\n# tmp = []\n# for item in _items:\n#   item[\"json\"][\"tmp\"] = 1\n\n# return _items\n\nres = []\nfor item in _items:\n  for query in item[\"json\"][\"queryTerms\"]:\n    res.append({\n      \"entity\": item[\"json\"][\"entity\"],\n      \"term\": query,\n      \"sinceUnix\": item[\"json\"][\"sinceUnix\"],\n      \"ticker\": item[\"json\"][\"ticker\"],\n      \"frontPagePoints\": item[\"json\"][\"frontPagePoints\"],\n      \"breakoutThreshold\": item[\"json\"][\"breakoutThreshold\"],\n    })\n\nreturn res\n# for query in _input.item.json.queryTerms:\n#   print(query)"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        688,
        64
      ],
      "id": "b0688d19-8d05-4253-9540-0dd63c54dfb3",
      "name": "Entity Term Pair"
    },
    {
      "parameters": {
        "workflowInputs": {
          "values": [
            {
              "name": "entity"
            },
            {
              "name": "term"
            },
            {
              "name": "sinceUnix",
              "type": "number"
            },
            {
              "name": "ticker"
            },
            {
              "name": "frontPagePoints",
              "type": "number"
            },
            {
              "name": "breakoutThreshold",
              "type": "number"
            }
          ]
        }
      },
      "type": "n8n-nodes-base.executeWorkflowTrigger",
      "typeVersion": 1.2,
      "position": [
        528,
        224
      ],
      "id": "3b71acfb-7f89-438d-8630-4be6d34fd6aa",
      "name": "When Executed by Another Workflow"
    },
    {
      "parameters": {
        "workflowId": {
          "__rl": true,
          "value": "REPLACE_WITH_RESOURCE",
          "mode": "list"
        },
        "workflowInputs": {
          "mappingMode": "defineBelow",
          "value": {
            "sinceUnix": "={{ $json.sinceUnix }}",
            "entity": "={{ $json.entity }}",
            "term": "={{ $json.term }}",
            "frontPagePoints": "={{ $json.frontPagePoints }}",
            "breakoutThreshold": "={{ $json.breakoutThreshold }}",
            "ticker": "={{ $json.ticker }}"
          },
          "matchingColumns": [],
          "schema": [
            {
              "id": "entity",
              "displayName": "entity",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "canBeUsedToMatch": true,
              "type": "string",
              "removed": false
            },
            {
              "id": "term",
              "displayName": "term",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "canBeUsedToMatch": true,
              "type": "string",
              "removed": false
            },
            {
              "id": "sinceUnix",
              "displayName": "sinceUnix",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "canBeUsedToMatch": true,
              "type": "number",
              "removed": false
            },
            {
              "id": "ticker",
              "displayName": "ticker",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "canBeUsedToMatch": true,
              "type": "string",
              "removed": false
            },
            {
              "id": "frontPagePoints",
              "displayName": "frontPagePoints",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "canBeUsedToMatch": true,
              "type": "number",
              "removed": false
            },
            {
              "id": "breakoutThreshold",
              "displayName": "breakoutThreshold",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "canBeUsedToMatch": true,
              "type": "number",
              "removed": false
            }
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": true
        },
        "mode": "each",
        "options": {}
      },
      "type": "n8n-nodes-base.executeWorkflow",
      "typeVersion": 1.3,
      "position": [
        848,
        64
      ],
      "id": "ff6a12ec-151c-4d9d-a63c-669b7fb3d915",
      "name": "Call 'HackerNews'"
    },
    {
      "parameters": {
        "mode": "combine",
        "advanced": true,
        "mergeByFields": {
          "values": [
            {
              "field1": "query",
              "field2": "term"
            }
          ]
        },
        "options": {}
      },
      "type": "n8n-nodes-base.merge",
      "typeVersion": 3.2,
      "position": [
        848,
        400
      ],
      "id": "c284194b-9a09-4188-99cf-227cab47d0b3",
      "name": "Merge1"
    },
    {
      "parameters": {
        "language": "pythonNative",
        "pythonCode": "# Loop over input items and add a new field called 'my_new_field' to the JSON of each one\nfrom datetime import datetime, timezone\n\n# WINDOW_HOURS = 24  # fixed lookback window (see plan.md); sinceUnix is its start\n\n\ndef parse_hit(hit):\n    \"\"\"Keep only the fields we need; drop _highlightResult / _tags noise.\"\"\"\n    return {\n        \"title\": hit.get(\"title\"),\n        \"url\": hit.get(\"url\"),\n        \"points\": hit.get(\"points\") or 0,\n        \"num_comments\": hit.get(\"num_comments\") or 0,\n        \"created_at_i\": hit.get(\"created_at_i\"),\n        \"objectID\": hit.get(\"objectID\"),\n    }\n\n\ndef run_date_from_since(since):\n    \"\"\"Window-start date as YYYY-MM-DD (UTC). Falls back to None if unset.\"\"\"\n    if since is None:\n        return None\n    return datetime.fromtimestamp(since, tz=timezone.utc).strftime(\"%Y-%m-%d\")\n\n\ndef func(_items):\n    # Per-execution accumulators (local, so nothing leaks between entity runs).\n    hits_by_id = {}     # objectID -> parsed_hit  (dedupe: same story across terms counts once)\n    entity = None       # canonical entity name (same across all input items)\n    since = None        # smallest sinceUnix seen across this entity's terms\n    meta = {            # per-entity config, captured from the first item that has it\n        \"ticker\": None,\n        \"frontPagePoints\": 100,\n        \"breakoutThreshold\": 70,\n    }\n    meta_captured = False\n\n    for item in _items:\n        try:\n            data = item.get(\"json\", {})\n\n            # All items belong to the same entity; capture the name + meta once.\n            if entity is None and data.get(\"entity\"):\n                entity = data.get(\"entity\")\n            if not meta_captured and data.get(\"entity\"):\n                meta = {\n                    \"ticker\": data.get(\"ticker\"),\n                    \"frontPagePoints\": data.get(\"frontPagePoints\", 100),\n                    \"breakoutThreshold\": data.get(\"breakoutThreshold\", 70),\n                }\n                meta_captured = True\n\n            term_since = data.get(\"sinceUnix\")\n            if term_since is not None:\n                since = term_since if since is None else min(since, term_since)\n\n            for hit in (data.get(\"hits\") or []):\n                rec = parse_hit(hit)\n                oid = rec[\"objectID\"]\n                if oid is None:\n                    continue\n                hits_by_id[oid] = rec\n        except Exception:\n            # Empty / malformed response for one term: skip the term, keep going.\n            continue\n\n    fp_points = meta[\"frontPagePoints\"]\n    hits = list(hits_by_id.values())\n\n    story_count = len(hits)\n    total_points = sum(h[\"points\"] for h in hits)\n    total_comments = sum(h[\"num_comments\"] for h in hits)\n    front_page_count = sum(1 for h in hits if h[\"points\"] >= fp_points)\n\n    top_story = None\n    if hits:\n        top = max(hits, key=lambda h: h[\"points\"])\n        top_story = {\n            \"title\": top[\"title\"],\n            \"url\": top[\"url\"],\n            \"points\": top[\"points\"],\n            \"num_comments\": top[\"num_comments\"],\n            \"objectID\": top[\"objectID\"],\n            \"permalink\": f\"https://news.ycombinator.com/item?id={top['objectID']}\",\n        }\n\n    top_stories = []\n    for h in sorted(hits, key=lambda h: h[\"points\"],\n    reverse=True)[:3]:\n        top_stories.append({\n            \"title\": h[\"title\"],\n            \"url\": h[\"url\"],\n            \"points\": h[\"points\"],\n            \"num_comments\": h[\"num_comments\"],\n            \"objectID\": h[\"objectID\"],\n            \"permalink\":\n    f\"https://news.ycombinator.com/item?id={h['objectID']}\",\n        })\n\n    return {\n        \"json\": {\n            \"entity\": entity,\n            \"ticker\": meta[\"ticker\"],\n            \"breakoutThreshold\": meta[\"breakoutThreshold\"],\n            # \"windowHours\": WINDOW_HOURS,\n            \"runDate\": run_date_from_since(since),\n            \"rawMetrics\": {\n                \"storyCount\": story_count,        # = deduped hit count\n                \"totalPoints\": total_points,\n                \"totalComments\": total_comments,\n                \"frontPageCount\": front_page_count,\n            },\n            \"topStory\": top_story,\n            \"topStories\": top_stories,\n            \"lowConfidence\": story_count < 3,      # sparse-entity floor (plan.md)\n        }\n    }\n\nreturn func(_items)"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1056,
        384
      ],
      "id": "75902126-b940-4d94-b40b-5dee2b9ff329",
      "name": "Get Metrics"
    },
    {
      "parameters": {
        "language": "pythonNative",
        "pythonCode": "import math\n\n# --- calibration knobs (Week 3 deliverable; tune against fixtures)---\nVOLUME_REF = 30    # storyCount that earns ~full volume points\nENGAGEMENT_REF = 1000 # (points+comments) that earns ~full engagement points\nFRONTPAGE_REF = 5     # frontPageCount that earns full front-page points\n\ndef clamp(x, lo=0.0, hi=1.0):\n    return max(lo, min(hi, x))\n\ndef func(_items):\n    out = []\n    for item in _items:\n        d = item[\"json\"]\n        m = d[\"rawMetrics\"]\n        story_count = m[\"storyCount\"]\n        engagement = m[\"totalPoints\"] + m[\"totalComments\"]\n        fp_count = m[\"frontPageCount\"]\n        low_conf = d.get(\"lowConfidence\", False)\n\n        if low_conf:\n            # Sparse-entity floor: absolute scale, not the cross-entity log scale.\n            volume_score = 30.0 * clamp(story_count / 3.0)\n            engagement_score = 30.0 * clamp(engagement / 50.0)\n            front_page_score = 0.0\n        else:\n            volume_score = 30.0 * clamp(math.log1p(story_count) / math.log1p(VOLUME_REF))\n            engagement_score = 30.0 * clamp(math.log1p(engagement) / math.log1p(ENGAGEMENT_REF))\n            front_page_score = 20.0 * clamp(fp_count / FRONTPAGE_REF)\n\n        # Cold-start: no prior snapshot yet (Week 4 wires the DB) -> uniform hard zero.\n        accel_score = 0.0\n\n        buzz = round(volume_score + engagement_score + front_page_score + accel_score, 1)\n\n        buzz_score = clamp(buzz, 0, 100)\n        d[\"buzzScore\"] = buzz_score\n        d[\"scoreComponents\"] = {\n            \"volume\": round(volume_score, 1),\n            \"engagement\": round(engagement_score, 1),\n            \"frontPage\": round(front_page_score, 1),\n            \"acceleration\": accel_score,\n        }\n        d[\"coldStart\"] = True   # flipped to False in Week 4 once velocity has history\n        d[\"breakout\"] = buzz_score >= d.get(\"breakoutThreshold\", 70)\n        out.append({\"json\": d})\n\n    # Ranked leaderboard, highest first.\n    out.sort(key=lambda i: i[\"json\"][\"buzzScore\"], reverse=True)\n    return out\n\nreturn func(_items)"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        768,
        -96
      ],
      "id": "b6659bf9-2c32-4f56-833c-1ad96bd7ff44",
      "name": "Code in Python"
    },
    {
      "id": "f1f34937-60f5-5e96-be2b-e4fd1b241435",
      "name": "Publication · Overview and setup",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -1072,
        -96
      ],
      "parameters": {
        "content": "# Hacker News AI Tracker\n\n## Purpose and structure\nThis folder contains the **Hacker News AI Tracker** workflow. Its executable nodes use `code`, `executeWorkflow`, `executeWorkflowTrigger`, `httpRequest`, `manualTrigger`, `merge`, `scheduleTrigger`, `splitInBatches`. The table and connection map below are derived from the JSON, not an inferred guarantee of behavior.\n\n## Setup\nImport inactive. Configure your own credentials and replace REPLACE_WITH_* values. Review external destinations and Code nodes before running.\n\n## Entry points\nSchedule Trigger, Manual Trigger, When Executed by Another Workflow\n\n## Verification\nSanitized source candidate; not runtime-tested. This overview is generated from node metadata. Read the folder README for the connection map, credential types, and license.\n\n## Attribution\nNik Bear Brown; rights confirmed 2026-10-19\nMIT; preserve source notices.",
        "width": 700,
        "height": 800,
        "color": 1
      }
    }
  ],
  "connections": {
    "Schedule Trigger": {
      "main": [
        [
          {
            "node": "Get Day Ago Unix Time",
            "type": "main",
            "index": 0
          },
          {
            "node": "WatchList",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Manual Trigger": {
      "main": [
        [
          {
            "node": "Get Day Ago Unix Time",
            "type": "main",
            "index": 0
          },
          {
            "node": "WatchList",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Day Ago Unix Time": {
      "main": [
        [
          {
            "node": "Merge",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "WatchList": {
      "main": [
        [
          {
            "node": "Merge",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Merge": {
      "main": [
        [
          {
            "node": "Loop Over Entities",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "HTTP Request": {
      "main": [
        [
          {
            "node": "Merge1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Loop Over Entities": {
      "main": [
        [
          {
            "node": "Code in Python",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Entity Term Pair",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Entity Term Pair": {
      "main": [
        [
          {
            "node": "Call 'HackerNews'",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "When Executed by Another Workflow": {
      "main": [
        [
          {
            "node": "HTTP Request",
            "type": "main",
            "index": 0
          },
          {
            "node": "Merge1",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Call 'HackerNews'": {
      "main": [
        [
          {
            "node": "Get Metrics",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Metrics": {
      "main": [
        [
          {
            "node": "Loop Over Entities",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1",
    "binaryMode": "separate",
    "availableInMCP": false
  },
  "nodeGroups": []
}
