Telegram Channel Scraper & Monitor – Change Feed avatar

Telegram Channel Scraper & Monitor – Change Feed

Pricing

Pay per event

Go to Apify Store
Telegram Channel Scraper & Monitor – Change Feed

Telegram Channel Scraper & Monitor – Change Feed

Scrape public Telegram channel messages and get a change feed run over run: only new messages, edits, deletions and view-count moves versus last time, plus subscriber counts. No Telegram login, no phone number, no API ID — reads the public t.me/s preview.

Pricing

Pay per event

Rating

0.0

(0)

Developer

Changefeeds Tools

Changefeeds Tools

Maintained by Community

Actor stats

0

Bookmarked

2

Total users

1

Monthly active users

3 days ago

Last modified

Share

For anyone who needs to scrape public Telegram channel messages on a schedule — competitor trackers, market/news researchers, agencies building client digests — without paying for every message every time. Give it a list of public Telegram channels. Each run returns their newest messages compared with the previous run of the same list: which messages are new, which were edited, which view counters moved, and which messages were deleted. Plus the channel's title, description and subscriber count, with the change since last time.

Schedule it daily or weekly and the dataset becomes a change feed for competitor tracking, channel monitoring, or digest building — using only the public t.me/s/<channel> web preview. No Telegram login, no API ID, no phone number.

Use cases

Daily competitor/channel digest to Slack. Schedule the actor to run once a day against your watch list (Apify Schedules). Set webhookUrl to an Apify integration webhook endpoint, or point it at a Zapier/Make "Catch Hook" step, or an Apify Slack/Google Sheets integration run after the actor finishes. The run's OUTPUT summary (new/edited/deleted counts per channel) posts as JSON, so the next step in Zapier/Make can format it into a Slack message or append rows to a Google Sheet — only what changed, never a full re-dump of every message.

What you get

One channel row per channel (type: "channel"):

  • title, description, subscriber count
  • subscribers_before, subscribers_delta versus the previous run
  • is_baseline (first run of the list), previous_checked_at
  • messages_fetched and the per-type changes counts
  • status: ok, not_found (missing or private channel), error (a page failed to load) or invalid (the input entry is not a channel username)

One message row per message (type: "message"), with a change_type:

  • baseline — first run: every fetched message
  • new — not in the previous run's snapshot
  • edited — the text changed, or the "edited" marker appeared
  • views_changed — the view counter moved; carries views_before, views_after, views_delta
  • deleted — was in the previous snapshot, within the id range this run fetched, and is gone now (see limitations)
  • unchanged — nothing moved; only emitted when you set includeUnchanged: true

Each row carries id, url (a t.me link), date, text, views, edited, has_media, forwarded_from and the links found in the message text.

The key-value store record OUTPUT holds a per-channel summary plus totals (changes, messages_returned, first_run, charged_events, charge_limit_reached). If you set webhookUrl, the same summary is POSTed there as JSON when the run ends.

Input

FieldDefaultNotes
channelsrequiredChannel usernames: durov, @durov, t.me/durov, https://t.me/s/durov all work. Up to 1,000 per run. Invalid entries become an error row; the run continues.
maxMessagesPerChannel1001 to 2,000. The first run fetches up to this many; later runs only fetch what is new (plus the newest page for view re-checks).
includeUnchangedfalseAlso emit a (free) row for messages that did not change.
snapshotKeyderivedWhich saved state to compare against. By default it is derived from the sorted channel list, so the same list always compares with itself. Set it explicitly to keep history when you edit the list. Distinct explicit keys always get distinct storage records (the key is sanitised and suffixed with a hash of the full original), so watch/a and watch?a never share history.
webhookUrlnoneReceives the run summary as a JSON POST.
{
"channels": ["durov", "telegram"],
"maxMessagesPerChannel": 100,
"includeUnchanged": false
}

If a run finds nothing new, the dataset holds one no_changes row (not charged), so a quiet run is never mistaken for a broken one.

Sample output

{
"type": "channel",
"input": "durov",
"channel": "durov",
"change_type": "channel",
"status": "ok",
"title": "Pavel Durov",
"description": "Founder of Telegram.",
"subscribers": 10600000,
"subscribers_before": 10590000,
"subscribers_delta": 10000,
"is_baseline": false,
"messages_fetched": 3,
"changes": { "baseline": 0, "new": 1, "edited": 1, "views_changed": 1, "deleted": 0, "unchanged": 0 }
}
{
"type": "message",
"input": "durov",
"channel": "durov",
"change_type": "new",
"id": 549,
"url": "https://t.me/durov/549",
"date": "2026-09-05T10:00:00+00:00",
"text": "Something new happened today.",
"views": 812000,
"views_before": null,
"views_after": null,
"views_delta": null,
"edited": false,
"has_media": false,
"forwarded_from": null,
"links": ["https://telegram.org"]
}
{
"type": "message",
"channel": "durov",
"change_type": "views_changed",
"id": 528,
"url": "https://t.me/durov/528",
"views_before": 18900000,
"views_after": 19500000,
"views_delta": 600000
}

Pricing

Pay per event, nothing else:

  • $0.002 per channel checked (a channel that was fetched and compared). Missing, private, invalid or failed channels are not charged.
  • $0.001 per message returned with a change: baseline, new, edited, views_changed or deleted. Unchanged rows and the channel row are free.

What it costs

Baseline (first) run, 10 channels, 100 messages each: 10 channels × $0.002 = $0.02 channel charges, plus every fetched message is a baseline row: 10 × 100 × $0.001 = $1.00. Total: $1.02.

Steady-state daily run, same 10 channels, once history exists: a channel typically shows 2–5 new/edited/view-changed messages a day. 10 × $0.002 = $0.02 channel charges, plus (2–5) × 10 × $0.001 = $0.02–$0.05 message charges. Total: $0.04–$0.07 per run.

Per month, running the steady-state case once a day (30 runs): 30 × $0.04–$0.07 ≈ $1.20–$2.10/month for those 10 channels, plus the one-time $1.02 baseline the first time you watch that list.

First runs are the expensive ones; the routine is the cheap part — that's the whole point of the change feed.

If you set a maximum total charge for the run, the actor returns only as many rows as fit, saves state for what it returned, stops fetching further channels, and says so in OUTPUT (

stopped_reason: "max_total_charge_reached"
). It never charges for a row it did not deliver.

How the change feed works

  • State lives in the named key-value store tgwatch-snapshots, one record per channel and snapshot key. The record holds the channel info and, per message id, the text's SHA-256 hash, the view count and the edited flag. The storage key for an explicit snapshot key is the sanitised original (max 60 chars) plus a 16-hex-digit hash of the full original, keeping every key within Apify's 256-character, [a-zA-Z0-9!\-_.'()] limit and collision-free.
  • The first run of a snapshot key is the baseline: it walks the preview pages (newest first, then older via ?before=<id>) up to maxMessagesPerChannel, and every message is a baseline row.
  • Later runs fetch page 1 (the ~20 newest messages). If every id on it is already known, they stop there — but those messages are still re-checked for view-count and edit changes. If there are new messages, paging continues until it reaches known territory or the message limit.
  • A known id inside the fetched id range that is missing from the pages is reported as deleted. Outside the fetched range, deletions are invisible (the preview only shows ~20 newest messages per page), so they are never guessed.
  • The first run charges the most; steady-state runs charge only what changed.
  • If a page fails after earlier pages' rows were already delivered and charged, those rows are still saved to the snapshot — an identical retry is not billed twice for the same messages. A failed check never infers deletions and never updates the channel's title/subscriber metadata or its "last checked" time.
  • Snapshot saves merge with what is currently stored instead of overwriting it, so two overlapping runs of the same snapshot key (for example a manual run started while a scheduled one is still going) cannot erase each other's history: the union of message ids is kept, and channel metadata comes from whichever run checked the channel later.

Limits, stated plainly

  • Public channels only. The actor reads https://t.me/s/<channel> — exactly what a browser without a Telegram account sees. Private channels and groups are not readable this way and come back as not_found. Channels that don't exist also come back as not_found (not an error, not charged).
  • The preview is what you get. The web preview shows a limited recent window and may differ subtly from what clients render (e.g. some polls or paid media show placeholders). Message text is parsed from the page's HTML, stripped of markup.
  • No media download. Photos and videos only set has_media: true; no files are fetched.
  • Deletions are only detected within the fetched id range. A message older than what this run read can be deleted without this actor noticing — and a run in which a page failed never reports deletions at all. Reads are also per-run: between two runs you see one delta, not every intermediate step.
  • View counts are approximate by nature. Telegram rounds the public counter (that is why the parser understands 2.67M); a changed counter may reflect hundreds of real views, and the counter may lag.
  • Deleted-message ids are remembered, not full messages. A deleted row cannot show the old text — only the id, url and last known view count.
  • It stays polite: at most 2 channels fetched at a time, at least 1.5 s between page requests of the same channel, HTTP 429 honoured (Retry-After, capped at 60 s, max 3 retries; a longer Retry-After skips that channel for this run), a normal browser-like User-Agent, 20 s timeouts. A channel needs roughly 1 page per 20 messages, so a fresh watch of 10 channels × 100 messages takes about a minute.
  • One run at a time per snapshot key. If a scheduled run starts while the previous one with the same snapshotKey is still going, the new run fails immediately, before fetching or charging anything. A run that crashed without cleaning up blocks its key for at most 30 minutes. (The lock is best effort: two runs started within the same couple of seconds can, rarely, both proceed; their saved history is merged, not overwritten.)
  • If saved history can't be read, the channel is skipped, with a free error row, rather than re-sent as a paid baseline. If it can't be saved after 3 attempts, the run is marked failed and names the channels, because the next run would report those messages again.
  • A page that fails mid-history is picked up next time. Rows delivered before the failure are remembered; the next run pages back past them to the messages it missed.

Local development

pnpm --filter @mmnm/tgwatch test # unit tests, no network
pnpm --filter @mmnm/tgwatch build

node src/main.ts runs the actor locally with Apify's local storage (./storage); ACTOR_TEST_PAY_PER_EVENT=true ACTOR_MAX_TOTAL_CHARGE_USD=1 exercises the charging path with the SDK's $1 local test price.