I run a visa agency in Bali. The other half of my week goes into Apify Actors, and one of them reads public Telegram channels: you hand it a list of usernames, it returns one row per post — text, date, views, reactions, media, forwards, with the channel card on every row. No account, no API key, no phone number. It reads t.me/s/<channel>, the public web preview any visitor sees, 20 posts per request, paging backwards with ?before=<message id>.

I spent today measuring it against 12 channels — 200 posts each, 2,400 posts total, 1 to 4.6 seconds per channel. The headline number is not a feature. It is a bug I shipped and did not notice for six weeks.

Three ways to draw a reaction, one of which broke everything

Telegram puts reaction chips under a post. In the HTML they come in three shapes, and I only tested against two of them.

A custom emoji, the kind channels with a Premium boost use:

<span class="tgme_reaction"><tg-emoji emoji-id="5265077361648368841"></tg-emoji>54.7K</span> 

A paid star reaction:

<span class="tgme_reaction"><i class="icon icon-telegram-stars"></i>6.3K</span> 

And the ordinary one — a plain unicode emoji:

<span class="tgme_reaction"><i class="emoji" style="background-image:url('...')"><b>👍</b></i>101</span> 

My parser read the count by stripping tags from the chip and handing the rest to a number parser anchored at the start of the string. For the first two shapes the emoji element is empty, so stripping leaves 54.7K and 6.3K and everything works. For the third, the emoji is in the HTML as text, inside <b>. Stripping leaves 👍 101. My parser wanted a digit at position zero, found an emoji, returned null, and I defaulted null to 0.

So the row said {"emoji": "👍", "count": 0}. The emoji was right. The number was sitting two characters away.

The fix is to read the tail after the last closed emoji element instead of the whole chip:

const tail = inner.replace(/^[\s\S]*<\/(?:i|tg-emoji)>/, ''); const count = parseCount(tail.replace(/<[^>]*>/g, ' ').trim()) ?? parseCount(inner.replace(/<[^>]*>/g, ' ').trim()); 

I saved one page per channel to disk and parsed the same bytes with both versions, so nothing could drift between runs:

Channel Chips on the page Counted zero before After reactionsTotal before → after
bloomberg 215 215 0 0 → 11,790
tginfo 232 217 0 40 → 15,867
binance_announcements 119 119 0 0 → 6,311
Bybit_Announcements 57 47 0 99 → 1,232
cointelegraph 57 57 0 0 → 823
varlamov 23 22 0 1 → 2,159
durov 82 0 0 2,483,290 → unchanged
total 785 677 0

Durov's channel is the reason I missed it. His reactions are almost all custom emoji and paid stars — 882 custom and 199 paid out of 1,156 chips — so the one channel in my demo input, the one my daily health check runs, was the one shape that worked. Across all 2,400 posts after the fix: 7,969 chips, zero of them counting zero, and not one chip lost a number it already had.

What reactionsTotal still is not

Having the counts back does not make the sum a measure of humans.

Paid stars are currency, not people. 429 of those 7,969 chips are star reactions, and they add up to 8,014,263 — 29% of Durov's entire reaction total. The record holder is t.me/durov/342: reactionsTotal 3,042,500, of which 2,490,000 is a single paid-star chip. One wallet can outvote a city. If you are ranking posts by engagement, sum reactions while skipping entries flagged paid: true.

Custom emoji are opaque. 1,142 chips come back as custom:5265077361648368841, because Telegram's web preview ships an id and no picture of the feeling. On Durov's channel that is 67% of the reaction total. You can count them; you cannot say what they mean, and you cannot compare them across channels.

Zero means two different things. 5 of my 12 channels — @telegram, @meduzalive, @bbcrussian, @whale_alert, @breakingmash — returned reactionsTotal: 0 on all 200 posts, and the markup has no chips at all. Reactions are switched off. That is not "nobody reacted", and if you divide by it you will get a very confident answer about nothing.

views is a running counter, and my own history proves it

My health check has been running this Actor on @durov and @telegram daily for a month, which accidentally gave me a longitudinal series on single posts. t.me/durov/552, published 11 October 2026, read 28 times:

2026-10-21 1,250,000 views 29,850 reactions 2026-10-24 1,390,000 31,960 2026-10-28 1,610,000 35,550 2026-11-01 1,820,000 38,180 2026-11-05 2,020,000 43,240 

+770,000 views in 15 days on a post that was already ten days old. @telegram's post 460, published 26 August, went from 1,680,000 on 6 October to 2,340,000 today across 40 reads — +39% on a post nobody was promoting.

Meanwhile the denominator moves the other way. @telegram's channelSubscribers in those same 40 reads: 9,390,000 → 9,180,000, falling monotonically, −210,000 in a month. @durov: 10,300,000 → 10,100,000.

My row carries viewsPerSubscriber, which I added because buyers asked for it. For durov/552 it read 0.121 on 21 October and 0.200 today — +65% with no new behaviour from anyone, just a numerator that only rises and a denominator that only falls. The field is only comparable between posts read on the same day.

The practical consequence is that sorting by views sorts by age. @telegram's 200 most recent posts, ranked by views:

8,250,000 2024-08-25 t.me/telegram/329 7,350,000 2024-09-09 t.me/telegram/333 6,390,000 2024-04-29 t.me/telegram/303 ... 2,340,000 2026-08-26 t.me/telegram/460 <- the newest post in the sample 

All ten of the top ten are from 2023–2024. Of the top 50 by views, 41 are in the older half of the sample. Median views by age on that channel: 1,280,000 for posts 30–90 days old, 2,050,000 at 90–365 days, 3,220,000 past a year. Nothing is broken; views simply never stop accruing. If you want "best post", divide by age or compare posts within a week of each other.

One more precision trap: Telegram serves views as text like 5.88M, 6.3M, 22.3K. Of 2,393 posts with a view count, 2,152 carry three significant digits, 216 carry two, and 25 carry one. All 399 posts above a million are multiples of 10,000. There were 1,309 distinct view values among 2,393 posts. A post reading 6,300,000 is somewhere in a ±50,000 band, and no scraper can do better, because the number is rounded before it reaches the page.

postId is not a post count — it is a message counter, and albums eat it

Tempting to compute "posts per week" from the id range. Don't. Taking 200 posts per channel and comparing the id span to the number of rows:

Channel Rows Id span Ids missing Multi-photo posts (photos)
whale_alert 200 200 0 0
bloomberg 200 201 1 —
breakingmash 200 297 97 15 (52)
cointelegraph 200 286 86 77 (155)
varlamov 200 286 86 17 (92)
durov 200 278 78 6 (45)

Most of the gap is albums: a grouped set of ten photos is ten messages with ten ids that render as one post. On Cointelegraph, 77 album posts carry 155 photos — roughly 78 swallowed ids against 86 missing. The remainder is deletions. So a gap in postId tells you nothing on its own, and videoUrl is worth checking too: 398 of my posts had a video, 386 gave a thumbnail, 335 gave a direct .mp4.

Every failure answers HTTP 200

I probed 18 usernames. All 18 returned 200. Six returned zero posts, for four different reasons — which my Actor used to report with one generic message. It now names them:

  • coindesk — 11,198 subscribers, and t.me/coindesk shows a "Preview channel" button pointing at /s/coindesk, which 302s straight back to t.me/coindesk. The page advertises a preview that does not exist.
  • reuters, bbcnews — a bare contact page with a "Send Message" button. Telegram serves byte-identical pages (~9.6 KB) for a real user account and for a name nobody has claimed, so no scraper can tell you which.
  • tginfochat, ru_python — groups, not channels. No preview, ever.
  • theblock__, 12345 — t.me redirects these to telegram.org. Probing told me why: a name ending in _ or containing no letter is not a username Telegram accepts. Those are now rejected before the request, as a free error row.

Worth a confession: my own input form suggested https://t.me/s/bbcnews as an example. That is a contact page. Fixed today, along with the above.

Calling it

{ "channels": ["durov", "https://t.me/s/bloomberg"], "maxPostsPerChannel": 200, "searchQuery": "", "postsNewerThan": "2026-10-01" } 

A real row, trimmed:

{ "type": "post", "channel": "bloomberg", "url": "https://t.me/bloomberg/3225", "date": "2026-09-22T03:26:17+00:00", "views": 64800, "reactionsTotal": 765, "reactions": [{"emoji":"❤","count":214},{"emoji":"🔥","count":165},{"emoji":"👍","count":160}], "channelTitle": "Bloomberg", "channelSubscribers": 186000, "viewsPerSubscriber": 0.348, "scrapedAt": "2026-11-05T20:52:07.411Z" } 

From Python, run and read in one call:

import requests TOKEN = "apify_api_..." rows = requests.post( "https://api.apify.com/v2/acts/lergassy~telegram-channel-scraper/run-sync-get-dataset-items", params={"token": TOKEN}, json={"channels": ["bloomberg", "tginfo"], "maxPostsPerChannel": 100}, timeout=300, ).json() posts = [r for r in rows if r.get("type") == "post"] human = sum(x["count"] for p in posts for x in p["reactions"] if not x.get("paid")) print(len(posts), "posts;", human, "unpaid reactions") 

The Actor is Telegram Channel Scraper. It bills per post row at $0.0002, with no start fee; error rows and posts cut by your date or minimum-views filter are free, which is why I can afford to argue with an input list.

When it is the wrong tool. There is no search across all of Telegram — you bring the channel names, and searchQuery searches inside a channel, using Telegram's own channel search. Private channels and groups are out, permanently: no web preview, nothing to read. Discussion comments under posts are not in the preview either. And if what you actually want is per-channel reach and posting rhythm rather than raw rows, I keep that in a separate Actor, Telegram Channel Analytics — partly because computing it from views requires knowing everything above, and I would rather do that arithmetic once than explain a 65% jump in engagement that was really just a month passing.