<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Bhagya Rathnasekara</title>
    <description>The latest articles on DEV Community by Bhagya Rathnasekara (@bhagyarathnasekara).</description>
    <link>https://dev.to/bhagyarathnasekara</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4067072%2F54f265ad-f677-4d67-a9fb-8d62583defb4.jpeg</url>
      <title>DEV Community: Bhagya Rathnasekara</title>
      <link>https://dev.to/bhagyarathnasekara</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/bhagyarathnasekara"/>
    <language>en</language>
    <item>
      <title>A scraper isn’t a monitor: detecting new Telegram posts with Apify and n8n</title>
      <dc:creator>Bhagya Rathnasekara</dc:creator>
      <pubDate>Tue, 01 Sep 2026 05:09:38 +0000</pubDate>
      <link>https://dev.to/apify/a-scraper-isnt-a-monitor-detecting-new-telegram-posts-with-apify-and-n8n-31f5</link>
      <guid>https://dev.to/apify/a-scraper-isnt-a-monitor-detecting-new-telegram-posts-with-apify-and-n8n-31f5</guid>
      <description>&lt;p&gt;My Actor run succeeded. It returned five recent Telegram posts, each with an ID, timestamp, and permalink.&lt;/p&gt;

&lt;p&gt;Then I asked the question the automation actually needed to answer:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which posts are new?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The honest answer was: &lt;strong&gt;the current run cannot know by itself.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Nothing had failed. The Actor had done its job. It had retrieved what was visible now. But “new” is not a property contained in one snapshot; it is the difference between the current snapshot and a previous one.&lt;/p&gt;

&lt;p&gt;That distinction is the difference between a scraper that runs on a schedule and a monitor you can trust.&lt;/p&gt;

&lt;p&gt;In this tutorial, I’ll show the state layer I added around an &lt;a href="https://apify.com/bgy_1203/telegram-public-channel-monitor" rel="noopener noreferrer"&gt;Apify Actor&lt;/a&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a stable identity for every record;&lt;/li&gt;
&lt;li&gt;state partitioned by Telegram channel;&lt;/li&gt;
&lt;li&gt;a deliberate first-run policy;&lt;/li&gt;
&lt;li&gt;duplicate prevention across executions;&lt;/li&gt;
&lt;li&gt;a warning when the polling window may be too small; and&lt;/li&gt;
&lt;li&gt;fail-closed handling for missing or malformed state.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The same design works for price trackers, job monitors, lead feeds, inventory checks, and any other workflow that repeatedly asks, “What changed?”&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Implementation note:&lt;/strong&gt; the code and evidence for this article are in the &lt;a href="https://github.com/bgyeth/apify-mcp-durable-state-companion" rel="noopener noreferrer"&gt;corrected companion package&lt;/a&gt;. That workflow deliberately stops at notification candidates; I have not treated untested delivery as finished.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retrieval and change detection are different jobs
&lt;/h2&gt;

&lt;p&gt;The Actor reads Telegram’s public &lt;code&gt;t.me/s/&lt;/code&gt; preview and returns recent messages in newest-first order. Each item includes fields such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"channel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"telegram"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;454&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://t.me/telegram/454"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-07-19T17:58:20+00:00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"For all the details on these new features..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"views"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1180000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scraped_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-08-05T14:13:17.444080+00:00"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is enough to answer:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What is visible now?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It is not enough to answer:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What appeared since the previous successful check?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The second question requires historical information. That history can live inside an Actor, in a database, in a caller-supplied cursor, or in the workflow that calls the Actor. For this implementation, I kept the Actor stateless and made the downstream n8n workflow own the comparison state.&lt;/p&gt;

&lt;p&gt;The boundary looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Schedule
   │
   ▼
Run Apify Actor ──► current message window
                         │
                         ▼
              validate and partition records
                         │
                         ▼
              compare with durable seen state
                         │
              ┌──────────┴──────────┐
              ▼                     ▼
      notification candidates   status and warnings
              │                     │
              └──────────┬──────────┘
                         ▼
                  persist new state
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The experiment that exposed the missing layer
&lt;/h2&gt;

&lt;p&gt;I first called the Actor from an AI client through the &lt;a href="https://docs.apify.com/platform/integrations/mcp" rel="noopener noreferrer"&gt;Apify MCP server&lt;/a&gt;. I retained four one-call conditions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Condition&lt;/th&gt;
&lt;th&gt;What the caller knew before the run&lt;/th&gt;
&lt;th&gt;Could it determine new IDs?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Baseline&lt;/td&gt;
&lt;td&gt;No previous result&lt;/td&gt;
&lt;td&gt;Not asked; the result became the baseline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same conversation&lt;/td&gt;
&lt;td&gt;Previous IDs were still in context&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fresh session&lt;/td&gt;
&lt;td&gt;No reachable previous result&lt;/td&gt;
&lt;td&gt;No; newness was undetermined&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fresh session with prior IDs&lt;/td&gt;
&lt;td&gt;A small state file plus comparison rules&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All four Actor runs succeeded and returned the same five IDs, &lt;code&gt;454&lt;/code&gt; through &lt;code&gt;450&lt;/code&gt;. In the fresh session with no baseline, retrieval still worked, but comparison did not.&lt;/p&gt;

&lt;p&gt;That result matters because &lt;strong&gt;“no new records” and “I cannot determine whether records are new” are not the same answer.&lt;/strong&gt; A reliable monitor must preserve that distinction.&lt;/p&gt;

&lt;p&gt;The runs also exposed a tempting identity bug: every message’s &lt;code&gt;views&lt;/code&gt; value changed between two observations, while the IDs stayed stable. Comparing whole records would have classified all five messages as different. Comparing the stable identity classified them correctly as the same messages.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choose identity before choosing storage
&lt;/h2&gt;

&lt;p&gt;Telegram’s Bot API defines &lt;code&gt;message_id&lt;/code&gt; as unique inside a chat. That means &lt;code&gt;id&lt;/code&gt; alone is not a reusable global key. The monitor uses this partitioned identity instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(normalized channel, message ID)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;normalizeChannel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^@/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;messageKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;normalizedChannel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;normalizeChannel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;normalizedChannel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Channel is required.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isSafeInteger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Message ID must be a positive safe integer.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;normalizedChannel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;views&lt;/code&gt; and &lt;code&gt;scraped_at&lt;/code&gt; are deliberately excluded. They can change without creating a new message. Text can also be edited, so an ID-only state detects new messages but does not detect edits; edit detection is a separate contract.&lt;/p&gt;

&lt;p&gt;This is the first design rule:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Use immutable source identity for new-item detection. Do not let mutable attributes redefine the record.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Store a baseline, not a vague memory
&lt;/h2&gt;

&lt;p&gt;A minimal prior-state file for one channel can be as small as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"schemaVersion"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"channel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"telegram"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"identityField"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sourceRunId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"example-prior-run-id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"seenMessageIds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;454&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;453&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;452&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;451&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;450&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The n8n workflow uses the same contract in a more compact form. Its global workflow state is partitioned by normalized channel, and positive integer IDs are stored as merged ranges:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"schemaVersion"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"channels"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"telegram"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"initializedAt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;timestamp from the first successful run&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"baselineFloorId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;450&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"seenRanges"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="mi"&gt;450&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;454&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"highestObservedId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;454&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Range compression is an implementation optimization, not part of the general monitoring rule. Set comparison requires stable identifiers; range compression additionally assumes suitable integer IDs.&lt;/p&gt;

&lt;p&gt;The workflow reads this state with n8n’s &lt;code&gt;$getWorkflowStaticData('global')&lt;/code&gt;. n8n documents three important constraints: static data is intended to be small, it is saved after successful triggered production executions, and it can be unreliable under high-frequency execution. That makes it acceptable for this small serialized demonstration, not a universal production database.&lt;/p&gt;

&lt;p&gt;For growing state, overlapping executions, or operational querying, move the same identity and comparison contract to a Data Table or external database.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the first run a policy decision
&lt;/h2&gt;

&lt;p&gt;On the first successful execution, every returned ID is unseen. Calling all of them “new” may be mathematically consistent, but it is usually terrible monitoring behavior: the workflow sends a burst of alerts for posts that existed before monitoring started.&lt;/p&gt;

&lt;p&gt;This implementation defaults to baseline mode:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Fetch the first non-empty window.&lt;/li&gt;
&lt;li&gt;Validate it.&lt;/li&gt;
&lt;li&gt;Store the IDs.&lt;/li&gt;
&lt;li&gt;Send no notification candidates.&lt;/li&gt;
&lt;li&gt;Begin new-item detection on the next successful run.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;An explicit &lt;code&gt;alert&lt;/code&gt; mode remains available for workflows that really do want the first window delivered.&lt;/p&gt;

&lt;p&gt;An empty result does &lt;strong&gt;not&lt;/strong&gt; initialize the baseline. Otherwise a transient empty response could create an empty history, and the next normal result could replay the entire window as new.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compare, classify, then persist
&lt;/h2&gt;

&lt;p&gt;The core comparison is set difference:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;new IDs = current IDs − previously seen IDs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But the order around that expression matters:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Validate the response shape and channel partition.&lt;/li&gt;
&lt;li&gt;Reject unusable IDs.&lt;/li&gt;
&lt;li&gt;Deduplicate the current window by the partitioned identity.&lt;/li&gt;
&lt;li&gt;Compare against previously seen identities.&lt;/li&gt;
&lt;li&gt;Apply the first-run and historical-backfill policies.&lt;/li&gt;
&lt;li&gt;Generate notification candidates.&lt;/li&gt;
&lt;li&gt;Persist every valid observed ID, including records filtered out by keywords.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That final choice gives keyword filters at-most-once behavior. Changing a keyword later does not replay posts the workflow already processed.&lt;/p&gt;

&lt;p&gt;State corruption also fails closed. If a non-empty state object has no supported schema or contains malformed channel state, the workflow stops instead of silently treating history as empty. Resetting broken state would turn old records into a fresh alert storm.&lt;/p&gt;

&lt;p&gt;The complete comparator and the n8n Code-node implementation are in the &lt;a href="https://github.com/bgyeth/apify-mcp-durable-state-companion" rel="noopener noreferrer"&gt;public companion repository&lt;/a&gt;. The comparator is dependency-free and accepts either a bare dataset array or an MCP-style &lt;code&gt;{ "items": [...] }&lt;/code&gt; envelope.&lt;/p&gt;

&lt;h2&gt;
  
  
  Treat a full polling window as a warning
&lt;/h2&gt;

&lt;p&gt;Suppose the workflow requests five messages and receives five. It does not know whether exactly five were available or whether older unseen messages fell outside the returned window.&lt;/p&gt;

&lt;p&gt;The workflow therefore sets &lt;code&gt;windowSaturated&lt;/code&gt; using the raw Actor response count:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;windowSaturated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;monitor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The check happens before deduplication. Duplicate or invalid rows should not hide the fact that the raw window reached its cap.&lt;/p&gt;

&lt;p&gt;Saturation means:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The configured window may be too small for the polling interval.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It does &lt;strong&gt;not&lt;/strong&gt; prove that a message was missed. It tells the operator to consider a larger limit, a shorter interval, or a proper catch-up strategy.&lt;/p&gt;

&lt;p&gt;The same reasoning applies in the other direction: an ID that was previously seen but is absent from a capped current window is not automatically deleted. It may simply have fallen beyond the window.&lt;/p&gt;

&lt;h2&gt;
  
  
  MCP proved the information boundary; n8n owns the recurring state
&lt;/h2&gt;

&lt;p&gt;The two caller paths in this project are intentionally separate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The experiment used an AI client and the Apify MCP server to test what could be determined with and without prior state.&lt;/li&gt;
&lt;li&gt;The recurring implementation uses n8n and Apify’s synchronous REST endpoint to fetch dataset items, compare them with n8n workflow state, and emit candidates.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They call the same Actor, but they are not one combined MCP-to-n8n pipeline.&lt;/p&gt;

&lt;p&gt;In n8n, the HTTP Request node calls:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST https://api.apify.com/v2/acts/{ACTOR_ID}/run-sync-get-dataset-items
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Apify API documents this endpoint as running an Actor synchronously and returning its dataset items. Store the Apify API token in an n8n Bearer Auth credential; do not paste it into the workflow JSON.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I verified
&lt;/h2&gt;

&lt;p&gt;The corrected companion package includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a dependency-free comparator with &lt;strong&gt;23 deterministic tests&lt;/strong&gt;;&lt;/li&gt;
&lt;li&gt;an importable n8n workflow whose embedded state engine has &lt;strong&gt;11 deterministic tests&lt;/strong&gt;;&lt;/li&gt;
&lt;li&gt;a fail-closed state schema;&lt;/li&gt;
&lt;li&gt;first-run baseline suppression;&lt;/li&gt;
&lt;li&gt;per-channel state partitions;&lt;/li&gt;
&lt;li&gt;duplicate prevention across triggered runs; and&lt;/li&gt;
&lt;li&gt;saturation reporting based on the raw response count.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I also retained &lt;a href="https://github.com/bgyeth/apify-mcp-durable-state-companion/blob/main/n8n-execution-evidence.md" rel="noopener noreferrer"&gt;two successful triggered n8n executions&lt;/a&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Execution&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;First triggered run&lt;/td&gt;
&lt;td&gt;Created independent baselines for two channels; zero notification candidates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Second triggered run&lt;/td&gt;
&lt;td&gt;Reused the persisted state; all ten returned IDs were already seen&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The second execution reading the first execution’s state is the important result. It demonstrates persistence across triggered production runs rather than only inside one editor session.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I did not verify
&lt;/h2&gt;

&lt;p&gt;The evidence has limits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No genuinely new live Telegram ID appeared during the retained runs. The positive new-ID path is covered by deterministic constructed tests.&lt;/li&gt;
&lt;li&gt;The corrected public workflow stops at notification candidates; Telegram delivery is intentionally disabled and untested.&lt;/li&gt;
&lt;li&gt;Every observed result window reached the configured limit of five, so the experiment does not establish catch-up completeness.&lt;/li&gt;
&lt;li&gt;n8n workflow static data was tested only with small state and serialized executions.&lt;/li&gt;
&lt;li&gt;The MCP experiment and n8n workflow ran at different times and are not a one-variable performance benchmark.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those boundaries do not weaken the state contract. They define exactly what the implementation proves—and what the next production test must cover.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist I now use for every scheduled Actor
&lt;/h2&gt;

&lt;p&gt;Before turning any recurring Actor call into a monitor, I answer these questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;What is the stable identity?&lt;/strong&gt; Partition it by source, tenant, channel, or account where necessary.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Which fields are mutable?&lt;/strong&gt; Keep them out of identity unless change detection is explicitly the goal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Where does the baseline live?&lt;/strong&gt; Give one layer clear ownership.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What happens on the first non-empty run?&lt;/strong&gt; Baseline or alert—never accidental behavior.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What happens when state is missing or malformed?&lt;/strong&gt; Fail closed instead of replaying history.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Can the polling window saturate?&lt;/strong&gt; Surface that condition before deduplication.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What does absence mean?&lt;/strong&gt; In a capped window, it does not prove deletion.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Can executions overlap?&lt;/strong&gt; If yes, use storage with explicit concurrency guarantees.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A scheduled scraper repeatedly tells you what exists. A monitor tells you what changed without inventing certainty, replaying old records, or hiding gaps.&lt;/p&gt;

&lt;p&gt;The difference is not another API call. It is a state contract.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://apify.com/bgy_1203/telegram-public-channel-monitor" rel="noopener noreferrer"&gt;Telegram Public Channel Monitor Actor&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.apify.com/platform/integrations/mcp" rel="noopener noreferrer"&gt;Apify MCP server documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.apify.com/api/v2/actor-run-sync-get-dataset-items-post" rel="noopener noreferrer"&gt;Apify synchronous Actor endpoint&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.n8n.io/code/cookbook/builtin/get-workflow-static-data/" rel="noopener noreferrer"&gt;n8n workflow static data documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://core.telegram.org/bots/api#message" rel="noopener noreferrer"&gt;Telegram Bot API: Message&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/bgyeth/apify-mcp-durable-state-companion" rel="noopener noreferrer"&gt;Tested companion repository&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;AI assistance disclosure: I used AI tools during drafting, editing, and QA. I reviewed the final article and supporting evidence and take responsibility for its claims and conclusions.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>apify</category>
      <category>automation</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
