I built this actor; it's a paid tool on Apify with a free trial credit.
If you want posts from a public Telegram channel, the usual route is Telethon or Pyrogram. They work, but you need a Telegram account, a phone number, an API ID and a session file, and you're logging in as a user to do it. For reading public channels that's a lot of setup.
Every public channel with a web preview is also readable at t.me/s/<channel> in a normal browser. I'm an 18-year-old engineering student, and I built Telegram Channel Scraper on top of that page. This post shows how to call it, what the output looks like, and a small daily digest project.
How it works
t.me/s/<channel> returns server-rendered HTML with up to 20 posts per page and a ?before=N cursor to the older ones. The actor fetches those pages over plain HTTP (no headless browser), parses each message block with cheerio, and walks back through the history. In my tests, 250 posts took 7 seconds and 480 posts took 12 seconds.
Python example
pip install apify-client
from apify_client import ApifyClient
client = ApifyClient("<YOUR_APIFY_TOKEN>")
run = client.actor("rel8ble/telegram-channel-scraper").call(run_input={
"channels": ["durov", "bloomberg"],
"maxPostsPerChannel": 100,
})
for item in client.dataset(run["defaultDatasetId"]).iterate_items():
if item["type"] == "post":
print(item["date"], item["views"], item["text"][:80] if item["text"] else "")
The dataset mixes two kinds of rows: type: "post" for each post and type: "channel" for one info row per channel. Filter on type.
Node example
import { ApifyClient } from "apify-client";
const client = new ApifyClient({ token: "<YOUR_APIFY_TOKEN>" });
const run = await client.actor("rel8ble/telegram-channel-scraper").call({
channels: ["https://t.me/telegram"],
onlyPostsNewerThan: "30 days",
searchQuery: "update",
});
const { items } = await client.dataset(run.defaultDatasetId).listItems();
const posts = items.filter((i) => i.type === "post");
console.log(posts.map((p) => `${p.url} (${p.views} views)`));
What comes back
A real post row from a run on 24 Sep 2026, trimmed (the CDN URL is shortened and I kept 2 of 8 reactions):
{
"type": "post",
"channel": "bloomberg",
"channelId": "1488156064",
"postId": 3225,
"url": "https://t.me/bloomberg/3225",
"date": "2026-09-22T03:26:17.000Z",
"text": "Alibaba unveils new AI chip it says is China's most powerful and targets 20 gigawatts of data center capacity by 2032. https://bloom.bg/4ydroys ...",
"views": 13400,
"viewsText": "13.4K",
"reactionsTotal": 173,
"reactions": [
{ "emoji": "🔥", "isPaid": false, "count": 62 },
{ "emoji": "❤", "isPaid": false, "count": 33 }
],
"mediaType": "photo",
"photos": [{ "url": "https://cdn5.telesco.pe/file/...jpg" }],
"links": ["https://bloom.bg/4ydroys"],
"hashtags": [],
"forwardedFrom": null,
"edited": false
}
And the channel row:
{
"type": "channel",
"username": "bloomberg",
"title": "Bloomberg",
"subscribers": 175000,
"verified": true,
"previewAvailable": true
}
Posts also carry videos (with duration and thumbnail), files, polls with option percentages, forwarded-from and reply-to info when present.
Use case: a daily digest of new posts
Say you follow a handful of news or project channels and want one daily summary with the most-viewed posts, stored so you never see the same post twice.
import sqlite3
from apify_client import ApifyClient
CHANNELS = ["bloomberg", "durov", "telegram"]
db = sqlite3.connect("telegram.db")
db.execute("""CREATE TABLE IF NOT EXISTS posts (
url TEXT PRIMARY KEY, channel TEXT, date TEXT, views INTEGER, text TEXT)""")
client = ApifyClient("<YOUR_APIFY_TOKEN>")
run = client.actor("rel8ble/telegram-channel-scraper").call(run_input={
"channels": CHANNELS,
"onlyPostsNewerThan": "1 day",
"includeChannelInfo": False,
})
new = []
for p in client.dataset(run["defaultDatasetId"]).iterate_items():
if p["type"] != "post":
continue
cur = db.execute("INSERT OR IGNORE INTO posts VALUES (?,?,?,?,?)",
(p["url"], p["channel"], p["date"], p["views"], p.get("text") or ""))
if cur.rowcount:
new.append(p)
db.commit()
for p in sorted(new, key=lambda x: x["views"] or 0, reverse=True)[:10]:
first_line = (p.get("text") or "").split("\n")[0][:100]
print(f'{p["views"]:>8} {p["channel"]:<10} {first_line} {p["url"]}')
Run it once a day. onlyPostsNewerThan: "1 day" makes the scraper stop as soon as posts get older than a day, so you only pay for new posts, and the url primary key handles any overlap between runs.
What it costs
$1.50 per 1,000 results. A result is a post or a channel-info row. Failed requests and duplicates aren't charged.
- The digest above: if the three channels post ~30 times a day combined, that's ~900 posts a month ≈ $1.35/month
- Archiving a 10,000-post channel history = $15
- Apify's free plan gives $5 of monthly credit, about 3,300 posts
Limits
-
Public channels with a web preview only. No private channels, invite links (
t.me/+...), groups or bots. Those return just a channel row withpreviewAvailable: false. - No comments. Discussion-group replies aren't in the web preview.
-
Counts are rounded the way Telegram shows them.
13.4Kbecomes13400; the raw text is kept inviewsText. - Media URLs are signed CDN links that expire. Download files soon after the run if you need them.
-
Very large videos have no direct file URL (
tooLargeForPreview: true), just the thumbnail, duration and post link. - Custom premium emoji reactions come back as a
customEmojiIdwithout the emoji character. The counts are still right.
If a channel returns 0 posts, the RUN_SUMMARY record in the key-value store says why. Anything else, the Issues tab on the actor page reaches me.
Telegram Channel Scraper on Apify
This article was drafted with AI and published by me.
Top comments (0)