This repository shows how to pull a public Telegram channel's history into Python with the Telegram Channel Scraper. You list channel usernames, for example telegram (Telegram's own news channel), and the script hands back a dataset of posts plus one summary row per channel. Each post arrives as a typed record: ID, timestamp, text, views, media, links and forward source. Collection happens on Apify's servers, so there is no Telegram account, phone number, API ID or session file involved on your side. What lands locally is results.json and results.csv, shaped for a DataFrame from the first line.
Actor: Telegram Channel Scraper Β· Publisher: Devil Scrapes Β· Also in JavaScript: telegram-channel-scraper-javascript
- Two row shapes in one dataset: a
channel_metarow per channel (display title, subscriber total, about text) sits next to thepostrows, androw_typetells them apart. - Posts with their context: each message keeps its numeric ID, UTC timestamp, plain text with line breaks, view total, media kind and link, the URLs it points to and the channel it was forwarded from.
- Any username format:
telegram,@telegram,t.me/telegramandhttps://t.me/s/telegramall resolve to the same channel, and repeats in the list are dropped. - Goes back in time: the Actor follows Telegram's older-posts cursor page by page until it reaches your per-channel cap or the cut-off date.
- Dead channels do not kill the batch: a private, renamed or missing channel is logged and skipped, and the run summary counts how many were skipped.
- Throttling is handled server-side: temporary errors are retried with growing pauses and a fresh proxy session, so a busy moment on Telegram's side rarely costs you a channel.
- Metadata rows are free: only
postrows trigger the per-result charge.
Divide each post's view_count by the channel's subscriber_count and you get a reach ratio that is comparable across channels of very different sizes. A notebook with a dozen industry or government channels shows quickly which ones still get read and which ones have a large but sleeping audience.
Ministries, transport operators, exchanges and software vendors often publish status updates on Telegram before anywhere else. A nightly cron job with sinceDate set to yesterday keeps an append-only archive in Parquet or SQLite, with a permalink on every row for citation.
forwarded_from names the channel a post was copied from, and outbound_links lists the sites it points to. Group by those two columns and you have the raw material for a source graph, which is the first step in most OSINT and media research on Telegram.
Bucket datetime by hour or weekday and split by media_type to see when a channel publishes and whether photo, video or text-only posts collect more views. It is a quick way to benchmark your own channel against peers before changing a content calendar.
You need an Apify account and an API token from Console β Settings β Integrations. Apify's free plan includes monthly usage credit; this Actor's event prices are listed under Pricing below.
git clone https://github.com/devilscrapes/telegram-channel-scraper-python.git
cd telegram-channel-scraper-python
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
export APIFY_TOKEN=your_token_here
python main.py # uses input.json
python main.py my-input.json --out data --max-charge 2The script starts the Telegram Channel Scraper, waits for it to finish, prints a link to the run in Apify Console and saves every result to results.json and results.csv. It exits with code 1 and the run's status message if the run fails.
Below is everything the integration needs. apify-client starts the run, blocks until it ends and then iterates over the dataset in pages, so even a 5,000-post backfill streams without a memory spike.
from apify_client import ApifyClient
client = ApifyClient("YOUR_APIFY_TOKEN")
run = client.actor("DevilScrapes/telegram-channel-scraper").call(run_input={'usernames': ['telegram'], 'maxPostsPerChannel': 10, 'proxyConfiguration': {'useApifyProxy': True}})
for item in client.dataset(run.default_dataset_id).iterate_items():
print(item)input.json is posted to the Actor exactly as written. It asks for the ten newest posts of Telegram's official news channel, which costs a few cents and finishes quickly. For a deeper backfill raise maxPostsPerChannel, or add sinceDate (for example "2026-01-01"; a value without a time zone is read as UTC) to stop at a fixed day.
{
"usernames": [
"telegram"
],
"maxPostsPerChannel": 10,
"proxyConfiguration": {
"useApifyProxy": true
}
}| Field | Type | Required | Default | Allowed values |
|---|---|---|---|---|
usernames |
array | yes | ["durov"] |
β |
maxPostsPerChannel |
integer | no | 100 |
β |
sinceDate |
string | no | β | β |
proxyConfiguration |
object | no | {"useApifyProxy": true} |
β |
The dataset mixes both row kinds, so filter on row_type first. Fields that belong to the other kind come back as null. View and subscriber numbers are expanded from Telegram's abbreviated display, so 3.47M becomes 3470000: precise to the digits Telegram shows, not to the single view. For videos, media_url is the preview image, not the video file. reply_count is always null because the public web view does not show it. Rows are written page by page while the Actor walks backward, so sort by datetime or post_id before any time-series work.
| Field | Type | Always present |
|---|---|---|
row_type |
string | yes |
channel |
string | yes |
post_id |
integer or null | no |
datetime |
string or null | no |
text |
string or null | no |
view_count |
integer or null | no |
media_type |
string or null | no |
media_url |
string or null | no |
outbound_links |
array or null | no |
forwarded_from |
string or null | no |
reply_count |
integer or null | no |
permalink |
string or null | no |
title |
string or null | no |
subscriber_count |
integer or null | no |
description |
string or null | no |
| Event | Price (USD) |
|---|---|
| Actor start | $0.2 |
| Post row emitted | $0.002 |
Prices as configured on 2026-08-20. The Actor page always shows the current price. Runs are billed to the Apify account that owns the token. Use --max-charge to cap what a single run can cost.
The pandas sketch below (pip install pandas) splits the two row kinds, computes a reach ratio per post, summarizes each channel, ranks the most-linked domains and shows median views by hour of day.
import json
from urllib.parse import urlparse
import pandas as pd
rows = pd.DataFrame(json.load(open("results.json", encoding="utf-8")))
meta = rows[rows["row_type"] == "channel_meta"].set_index("channel")
posts = rows[rows["row_type"] == "post"].copy()
posts["datetime"] = pd.to_datetime(posts["datetime"], utc=True)
posts = posts.sort_values("datetime")
subscribers = posts["channel"].map(meta["subscriber_count"])
posts["reach"] = posts["view_count"] / subscribers
posts["hour_utc"] = posts["datetime"].dt.hour
summary = posts.groupby("channel").agg(
posts=("post_id", "count"),
median_views=("view_count", "median"),
median_reach=("reach", "median"),
oldest=("datetime", "min"),
newest=("datetime", "max"),
)
print(summary.to_string())
links = posts.explode("outbound_links")["outbound_links"].dropna()
print(links.map(lambda url: urlparse(url).netloc).value_counts().head(10).to_string())
print(posts.groupby("hour_utc")["view_count"].median().to_string())
posts.to_csv("posts_sorted.csv", index=False)No. The Bot API only sees chats your bot was added to, and MTProto clients need a phone-verified account plus an API ID. This Actor reads the public t.me/s/ web view that Telegram serves to browsers for public channels, so it needs neither.
Any channel that has a public username and shows posts at t.me/s/<name>. Private channels, invite-link-only channels and group chats do not have that page and are skipped with a warning.
Store the highest post_id per channel after each run and drop anything at or below it next time, or pass sinceDate with the date of the previous run. Post IDs grow with every new message in a channel, so they make a reliable high-water mark.
No. Discussion threads live in a linked group that the public channel page does not render, so neither comment text nor comment totals are available here.
Set APIFY_TOKEN first: export the token in the same shell, or put it in a local.envfile your shell loads. Never commit it.- Run finished with
FAILEDorABORTED: open the printed Console link; the run log says why. Input validation errors name the field. - Fewer rows than expected: a
--max-chargecap also limits how many results can be charged. Raise the cap or lower the limits in your input. - Something looks wrong in the data: open an issue on the Actor page so the maintainers see it.
- Telegram Channel Scraper on Apify Store
- JavaScript version of this example
- All Devil Scrapes Actors
- Apify web scraping cookbook
Related Actors:
- Mastodon Posts Scraper β Hashtags & Accounts β the same kind of public timeline, collected from Mastodon hashtags and accounts.
- Bluesky Scraper β Feed Posts β posts and engagement counts from public Bluesky feeds.
- Discord Server Metadata Lookup β member and online counts for Discord communities from their invite links.
MIT licensed example code. The Actor itself runs on Apify and is maintained by Devil Scrapes. Not affiliated with Telegram or Telegram Messenger Inc..