# Telegram Channel Scraper: Posts, Search, Profiles (`snow_leo_data/telegram-channel-scraper`) Actor

68 fields per message against 16 from the top Telegram Actor: reactions per emoji, polls, forwards, replies, buttons, media links. Channel posts, in-channel search and profile cards in one Actor. No API key, no account.

- **URL**: https://apify.com/snow\_leo\_data/telegram-channel-scraper.md
- **Developed by:** [Snow Leo Data](https://apify.com/snow_leo_data) (community)
- **Categories:** Social media, Marketing, News
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

$0.50 / 1,000 result rows

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## Telegram Channel Scraper: posts, in-channel search and profile cards

Read any public Telegram channel without an account, without a bot token and
without the MTProto API. This Actor opens Telegram's own public web preview —
the same pages you see at `t.me/s/durov` — walks the history backwards page by
page and returns **68 fields per message**, including the things every other
Telegram Actor on the store leaves on the floor: reactions with a count per
emoji, the channel's numeric id, the author signature, poll results, forwards,
replies, inline buttons and direct CDN links to every attachment.

Three jobs live in one Actor here, and on Apify they are usually three separate
paid Actors: reading a channel's posts, searching inside channels by keyword,
and resolving a handle into its profile card.

#### What you get, in numbers

Everything below was measured against live Telegram on 2026-09-13 and can be
reproduced with `python3 tools/measure.py`.

| Measured | Result |
|---|---|
| Fields per message row | **68** (69 with the HTML body) |
| Fields per profile row | 21 |
| Fields in the strongest rival's documented row | 16 |
| Live messages sampled across 6 channels | 356 |
| Of those, carrying reactions this Actor returns and the rival does not | 239 (**67.1%**) |
| Carrying a view count | 356 (100%) |
| Carrying the numeric channel id | 356 (100%) |
| Carrying an attachment | 250 (70.2%) |
| Carrying a link preview | 55 (15.4%) |
| Carrying an author signature | 119 (33.4%) |
| Distinct reaction emoji seen in that sample | 34 |
| Messages per page returned by Telegram | 20 |
| Throughput, one address, no proxy | 190 messages over 10 pages, about **38 per second** |

#### There is no history ceiling

The usual complaint about Telegram scrapers is that they hand you the latest
page and stop. This one walks all the way down. Measured on `meduzalive`, a
channel whose newest message id is **147827**:

- `before=1000` returned messages 980 to 999;
- `before=50000` returned 49980 to 49999;
- `before=20` returned 1 to 19 — the very first messages the channel ever
  published.

So the reachable depth is the whole channel, and the test suite proves the
walk really terminates at message 1 rather than looping on the same page.
`tests/test_live.py` also checks the opposite failure, the one that is easy to
miss: that page two is genuinely older than page one **and does not overlap
with it**. A source that answers 200 and silently repeats the first page looks
perfectly healthy until you count the rows.

#### Three Actors in one

On the store today you buy these separately:

- channel posts — `tri_angle/telegram-scraper`, 4502 users, store rating **2.81**
  on 13 reviews at the time of writing;
- profile cards — `truefetch/telegram-profile`, 1158 users;
- keyword search — `lofomachines/telegram-keyword-search-scraper`, 1031 users.

Here it is one input form and one dataset. Pick the mode:

**Channel posts.** Give one or more channels and walk the history, newest
message first. A link to a single post (`https://t.me/durov/536`) starts the
walk at that exact message, so pasting a post link and asking for one row is a
perfectly good way to fetch one post.

**Search inside channels.** Telegram's own in-channel search, the same one
behind the magnifier in the web preview. Every term is searched separately in
every channel, because the source accepts one query at a time. A term that
matches nothing returns an explicit "no posts found" marker, and this Actor
reports it as such rather than pretending the channel is empty.

**Profile cards.** Resolve a handle into title, description, photo, verified
badge, subscriber or member count and the entity type. This is the mode that
works where no post feed exists at all. Measured on a sample of 8 handles it
resolved 2 channels, 1 group with its member count, 2 bots, 2 user accounts and
correctly reported 1 handle as not found.

#### Fields you will not find in the other Telegram Actors

- **`reactions`** — a list of `{emoji, custom_emoji_id, is_paid_stars, count}`,
  plus `reactions_total` and `reaction_types`. Telegram Stars paid reactions
  are flagged separately. Two thirds of live messages carry them.
- **`channel_id`** — the numeric internal id, decoded from the page's own
  `data-view` payload. Stable across renames, unlike the @handle.
- **`author_signature`** — the name of the admin who signed the post, when the
  channel has signatures on.
- **`poll_question`, `poll_type`, `poll_options`** (text and percentage per
  option) and `poll_voters`.
- **`forwarded_from_name` / `forwarded_from_url`**, `via_bot`, `is_album`,
  `is_service_message`.
- **`reply_to_url`, `reply_to_author`, `reply_to_text`** — the quoted message,
  not just a flag saying a reply happened.
- **`inline_buttons`** — the text and URL of every button under the post.
- **`photos`, `videos`** with direct CDN URLs, thumbnails, aspect ratio and
  `video_duration_seconds`; `document_title`, `document_extra`,
  `document_kind`; `sticker_url`; `voice_url` and `voice_duration_seconds`.
- **`links`, `hashtags`, `mentions`** extracted from the message body.
- **`link_preview_url`, `_site`, `_title`, `_description`, `_image_url`**.

Counts that Telegram abbreviates arrive twice: `views_text` is exactly what the
page said (`5.87M`), `views` is the integer (5870000). Dropping the string
would pass an abbreviation off as an exact figure; dropping the number would
make you parse it yourself. An absent value is `null`, never zero and never an
empty string — an empty string in a numeric column breaks Excel, BigQuery and
pandas alike.

#### Monitor mode: pay once for each message

Turn on **Only posts new since the last run** and the Actor keeps, in its own
named key-value store, the highest message id it actually delivered per
channel. The next run stops the walk at that border. That saves you two things
at once: the rows you would be charged for a second time, and the requests
needed to fetch them. The lifecycle suite proves both — after a first run over
200 messages, a run that finds 2 new ones fetches at most 2 pages.

The order of operations matters here and it is deliberate: rows are pushed to
your dataset **first** and only then marked as delivered. Marking first is how
a scraper silently loses data — after an interrupted run its memory claims
messages were sent that never arrived, and they never arrive later either. The
test suite kills a run mid-push and asserts that the remembered id never runs
ahead of what reached the dataset, then resumes and asserts nothing was lost.

#### Filters run before you are charged

Every filter removes rows before they reach the dataset, so a filtered-out
message costs nothing. You can filter by date range or last N days, by text
(contains any of / contains none of), by attachment type, by minimum and
maximum views, by minimum total reactions, and you can skip service messages
(on by default) or forwarded messages.

Two rules are wired into the filters on purpose:

1. **Missing data is not a mismatch.** If Telegram publishes no view counter
   for a message, a minimum-views filter keeps that message instead of dropping
   it. A naive filter on a sometimes-absent field is the single most common way
   a green run ends with an empty dataset.
2. **Limits count delivered rows, not messages read.** Both
   `maxPostsPerChannel` and `maxItems` are counted in rows that actually reach
   your dataset. A limit that stops the crawl instead would combine with a
   narrow filter to hand you nothing at all while the log happily reports
   hundreds of messages collected.

There is also `maxScannedPerChannel`, a safety stop at 2000 messages read per
channel by default, so a filter that matches nothing ends the walk instead of
reading a 147827-message history to the bottom. The REPORT record tells you
`scanned` and `delivered` per channel, so you can always see which of the two
ended the run.

#### An empty dataset always says why

A successful run that returns nothing is the worst outcome for a buyer: paid
for, and unexplained. This Actor never does that silently. It knows **14**
named reasons and writes one of them into `empty_reason` in the REPORT record,
plus a plain-English warning in the log.

**7** of them are decided from your input alone, before a single byte is
fetched, so you are never charged the waiting time for a run whose result was
already determined: `no-channel-given`, `all-channel-names-invalid`,
`search-mode-without-search-terms`, `date-range-is-empty`,
`views-range-is-empty`, `media-types-not-recognised`, `charge-limit-reached`.

The other 7 can only be seen after the walk: `filtered-out`,
`nothing-new-since-last-run`, `no-search-match`, `no-public-preview`,
`source-returned-nothing`, `all-channels-unreachable`, `rate-limited`.

`nothing-new-since-last-run` is the legitimate one: a scheduled monitor run
with nothing to report is working correctly, and naming it is what lets a
watchdog tell it apart from a real breakage.

#### How the source lies, and what is done about it

Telegram's web preview answers **HTTP 200 to almost everything**. Ask for a
handle that does not exist and you get 200 with a ten-kilobyte "If you have
Telegram, you can contact @… right away" page. Ask for a private channel, or a
group with no public preview, and you get exactly the same thing. Nothing in
the status code distinguishes it from a healthy feed.

So this Actor never decides by status code. It looks for the markers of a real
feed and reports `no-public-preview` when they are missing, while Profile cards
mode still returns the card for those handles, because the card is real even
when the feed is not. Private invite links (`t.me/+…`, `/joinchat/…`,
`t.me/c/…`) are rejected at the input stage with the reason spelled out, rather
than fetched and silently returned empty.

One more real-world quirk worth naming: Telegram serves at least one live post
(`durov/406`) with a stray double quote inside an HTML attribute. A parser that
tries to respect quoting swallows the next block whole and silently loses the
reply information on every quoted post. The parser here scans attributes up to
the next `>` for exactly that reason, and a test pins it.

#### Runs from Apify with no proxy

Verified from Apify's own cloud, not from a laptop: probe run
`cCOERBkgmO36p2Pu4` fetched 12 different `t.me` addresses — channel feed, two
pagination pages, in-channel search, a profile card, a non-existent handle, a
single-post embed — and every one answered HTTP 200. No proxy, no browser, no
API key, no Telegram account. The Actor uses the Python standard library only
and runs in 1024 MB.

#### Input in short

`channels` accepts usernames, @handles, `t.me` links, `t.me/s/` links and
single-post links. `mode` picks posts, search or profiles. `searchTerms` feeds
search mode. `maxPostsPerChannel` (default 100) caps delivered rows per
channel, `maxScannedPerChannel` (default 2000) caps messages read per channel,
`maxItems` caps the whole run. `postedAfter`, `postedBefore` and `lastNDays`
bound the dates — and a date bound also stops the walk early, which makes it
the cheapest way to keep a run short. `mustContain`, `mustNotContain`,
`mediaTypes`, `minViews`, `maxViews`, `minReactions`, `skipServiceMessages`
and `skipForwarded` narrow the result. `onlyNewPosts` turns on monitor mode.
`includeChannelCard`, `includeTextHtml` and `compact` shape the row.
`inputDatasetId` plus `channelField` take the channel list from another
Actor's dataset, so this one can sit downstream of any other scraper.

#### Honest limitations

These are things the source genuinely does not offer, named here rather than
discovered after purchase.

- **Only public channels with the web preview switched on.** Private channels,
  invite-only channels and most groups have no `t.me/s/` feed at all. Profile
  cards mode still resolves them; posts do not exist to be read. A channel
  owner can also turn the preview off, and then the feed disappears even
  though the channel is public inside the app.
- **Group messages and member lists are out of reach.** Some rival Actors sell
  group member scraping; that needs a logged-in Telegram session, which this
  Actor deliberately does not have. Group **cards**, including the member
  count, do work.
- **Comments and discussion threads are not included.** The web preview shows
  a channel's own messages, not the linked discussion group's replies.
- **`is_album` marks an album, but Telegram serves an album as one block.**
  You get every photo of it in `photos`, and no separate row per image.
- **Bot versus user is a guess.** Telegram's public page renders a bot exactly
  like a person. `entity_type` says `bot` when the handle or title ends in
  "bot", otherwise `user`. On our 8-handle sample that got `QuizBot` and
  `stickers` right and called `BotFather` a user.
- **View and reaction counts are the abbreviated ones Telegram shows.**
  `5.87M` becomes 5870000; the exact figure behind the rounding is not
  published anywhere public.
- **No media download.** You get direct CDN URLs for photos, videos, voice
  messages and stickers; fetching the bytes is left to you, because a scraper
  that downloads video for you is a scraper that charges you for bandwidth.
- **Message edits are not detectable.** The preview does not expose an edited
  flag, so monitor mode reports genuinely new messages, not changed ones.
- **No search across all of Telegram.** The search mode is Telegram's
  *in-channel* search: it needs the channel list you give it. Actors that
  advertise global keyword discovery reach it through a web search engine,
  which is a different source with different failure modes.

#### FAQ

**Do I need a Telegram account, a bot token or API credentials?**
No. Nothing is logged in anywhere. The Actor reads the same public pages a
logged-out browser sees.

**Will it get my account banned?**
There is no account involved, so there is nothing to ban.

**How far back can it go?**
To the first message of the channel. Measured on a 147827-message channel: the
walk reaches messages 1 to 19.

**How fast is it?**
About 38 messages per second, measured as 190 messages over 10 consecutive
pages from a single address, with a small deliberate pause between requests so
that a long history walk does not get the address throttled.

**Why do I sometimes get fewer rows than I asked for?**
Either the channel does not have that many messages, or your filters removed
them, or `maxScannedPerChannel` ended the walk. The REPORT record at the end of
the dataset names which, with a count per filter.

**Why is the dataset empty?**
Read `empty_reason` in the REPORT record. One of 14 named reasons is always
there, along with a sentence in the log explaining it.

**Can I run it on a schedule and only get new posts?**
Yes — that is what `onlyNewPosts` is for. The first run gives you the history
you asked for, each later run gives you only what appeared since.

**Can I feed it a list of channels from another Actor?**
Yes. Put that run's dataset id into `inputDatasetId` and name the field holding
the handle in `channelField`.

**What does a row look like for a message with no text, like a photo post?**
`text` is an empty string, `text_length` is 0, and the media fields carry the
content. Nothing is invented to fill the gap.

**Is the output good for AI pipelines?**
Turn on `compact` to drop every null and empty field, which cuts the row down
to what a model actually needs. Keep it off for CSV or Excel, where stable
columns matter more than size.

**What happens if Telegram rate-limits the address?**
The Actor slows down and retries; if not one page came back it stops and
reports `rate-limited` rather than pretending the channels are empty.

# Actor input Schema

## `channels` (type: `array`):

Public channels to read. A username (durov), a @handle (@durov), a t.me link, or a link to one post (https://t.me/durov/536 starts the walk at that post). Private invite links (t.me/+…, /joinchat/…, /c/…) have no public web preview and are reported as skipped.

## `mode` (type: `string`):

Channel posts walks the history backwards. Search inside channels uses Telegram's own in-channel search for your terms. Profile cards only resolves each name into its card and works for entities that have no public post feed at all.

## `searchTerms` (type: `array`):

Used only when the mode is Search inside channels. Every term is searched separately in every channel, because Telegram's in-channel search takes one query at a time.

## `maxPostsPerChannel` (type: `integer`):

How many rows to deliver per channel. Counted in DELIVERED rows, not in messages read, so filters cannot leave you with an empty dataset. 0 means no limit.

## `maxScannedPerChannel` (type: `integer`):

Safety stop for narrow filters. If your filters match nothing, the walk still ends after this many messages instead of reading the whole history. 0 means no limit.

## `maxItems` (type: `integer`):

Hard cap across every channel. 0 means no limit. Counted in delivered rows, so it is also the most you can be charged for.

## `postedAfter` (type: `string`):

Keep messages published on this date or later. The walk also STOPS once a whole page is older than this, so a date bound is the cheapest way to limit a run.

## `postedBefore` (type: `string`):

Keep messages published before this date.

## `lastNDays` (type: `integer`):

Shortcut for Posted on or after. 7 means the last week. 0 turns it off.

## `mustContain` (type: `array`):

Keep a message when its text contains at least one of these (case-insensitive, plain substring).

## `mustNotContain` (type: `array`):

Drop a message when its text contains any of these.

## `mediaTypes` (type: `array`):

Keep only messages carrying one of these. 'No attachment' keeps plain text messages.

## `minViews` (type: `integer`):

Keep messages with at least this many views. Messages where Telegram shows no view counter at all are kept: an absent number is not a mismatch.

## `maxViews` (type: `integer`):

Keep messages with at most this many views. 0 turns it off.

## `minReactions` (type: `integer`):

Keep messages whose reaction counts add up to at least this. Messages with no reaction block at all are kept.

## `skipServiceMessages` (type: `boolean`):

Drop 'Channel photo updated', 'Channel name changed' and the like. They carry no text of their own and no views.

## `skipForwarded` (type: `boolean`):

Drop messages the channel forwarded from somewhere else.

## `onlyNewPosts` (type: `boolean`):

Monitor mode. The Actor remembers the highest message id it delivered per channel in its own named storage and, on the next run, stops the walk at that border instead of re-reading and re-charging the same history. An empty result here is legitimate and the run says so in plain words.

## `includeChannelCard` (type: `boolean`):

Title, @name, subscribers, description, photo and the verified badge on every message row. Turn off for a narrower table.

## `includeTextHtml` (type: `boolean`):

Adds text\_html with Telegram's own markup: bold, italic, links, code blocks and custom emoji ids. Roughly doubles the row size.

## `compact` (type: `boolean`):

Leave out every field that is null or empty. Smaller rows for AI agents and JSON pipelines; CSV exports lose their fixed columns.

## `inputDatasetId` (type: `string`):

Dataset id from any other Actor run. Its rows are read and the field below is used as the channel name.

## `channelField` (type: `string`):

Which field of that dataset carries the handle or t.me link.

## Actor input object example

```json
{
  "channels": [
    "telegram"
  ],
  "mode": "posts",
  "searchTerms": [],
  "maxPostsPerChannel": 100,
  "maxScannedPerChannel": 2000,
  "maxItems": 0,
  "postedAfter": "",
  "postedBefore": "",
  "lastNDays": 0,
  "mustContain": [],
  "mustNotContain": [],
  "mediaTypes": [],
  "minViews": 0,
  "maxViews": 0,
  "minReactions": 0,
  "skipServiceMessages": true,
  "skipForwarded": false,
  "onlyNewPosts": false,
  "includeChannelCard": true,
  "includeTextHtml": false,
  "compact": false,
  "inputDatasetId": "",
  "channelField": "channel_username"
}
```

# Actor output Schema

## `messages` (type: `string`):

No description

## `overview` (type: `string`):

No description

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "channels": [
        "telegram"
    ],
    "maxPostsPerChannel": 100
};

// Run the Actor and wait for it to finish
const run = await client.actor("snow_leo_data/telegram-channel-scraper").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "channels": ["telegram"],
    "maxPostsPerChannel": 100,
}

# Run the Actor and wait for it to finish
run = client.actor("snow_leo_data/telegram-channel-scraper").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "channels": [
    "telegram"
  ],
  "maxPostsPerChannel": 100
}' |
apify call snow_leo_data/telegram-channel-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,snow_leo_data/telegram-channel-scraper"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/Y41Gxe5GRvkV8IWXk/builds/actg4rQtM80BJCeI5/openapi.json
