# Telegram Channel Scraper - Posts, Views & Exact Subscribers (`neverempty/telegram-channel-scraper`) Actor

For OSINT, crypto and market research and channel vetting: posts, views, reactions and forwards from any public Telegram channel, plus the exact subscriber count from the profile page. On one 10.9M channel the rounded preview hid 43,313 people. Median views, ERR and cadence are measured.

- **URL**: https://apify.com/neverempty/telegram-channel-scraper.md
- **Developed by:** [NeverEmpty](https://apify.com/neverempty) (community)
- **Categories:** Social media, Lead generation, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.83 / 1,000 post returneds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

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, views and the **exact subscriber count**

**For people buying or selling channel advertising, media monitoring and ranking channels**: any public Telegram channel read from the pages Telegram serves without a login, returned as two kinds of row — one per post, and one per channel.

**The channel row carries the exact subscriber count, which is what the other Telegram scrapers do not give you.** Telegram's public preview page shows a rounded figure — Bloomberg's channel reads **172K** there — while the channel's own profile page publishes the exact count, **171,899**. This Actor reads both, returns both, and puts the difference in its own column. On a large channel the gap is wide enough to matter: Pavel Durov's channel shows **10.9M** on the preview and **10,856,687** on the profile — a difference of **43,313** people that a rounded figure hides for weeks at a time.

**On top of that, the channel row carries statistics measured from the posts the run actually read** — median, mean, highest and lowest views, the top post, median reactions, **views per subscriber**, posts per day over the window read, the share of posts carrying a photo, a video, a link preview or a forward, and the busiest posting hour in UTC. These are the numbers an advertising buyer asks for, and they are measured here rather than estimated.

**A name that is not a public channel is reported as one.** Telegram answers the preview URL with a **302** for a bot, a personal account, a group and a channel with no public preview, and answers "no more posts" with a **200** page rather than an error — both are read correctly instead of being passed on as an empty result.

Measured from Apify on 2026-09-07: `t.me` answered with the full page on **3 of 3** direct requests (145 KB, **217–449 ms**), and `t.me/robots.txt` is a **404** — nothing is declared as disallowed. No API key, no login, no proxy needed. Export as JSON, CSV or Excel.

### What one run gives you

**Post rows** (`rowType: "message"`)

| Column | What it is |
|---|---|
| `channel`, `messageId`, `postUrl` | the channel and the post, with its permanent t.me link |
| `publishedAt` | the post time, exactly as Telegram publishes it (UTC, with offset) |
| `text`, `textLength` | the post text with its line breaks kept |
| `views`, `viewsText`, `viewsAreRounded` | the view count as a number, the text Telegram printed, and a flag |
| `reactionsTotal`, `reactions[]` | each reaction with its emoji or custom emoji id, its count, and whether it is a paid (Stars) reaction |
| `isForwarded`, `forwardedFromChannel`, `forwardedFromUrl`, `forwardedFromName` | where a forwarded post came from, and the link to the original |
| `authorSignature`, `isEdited` | the signature on signed posts, and whether the post was edited |
| `hasPhoto`, `hasVideo`, `hasLinkPreview`, `linkPreviewUrl/Title/Site` | the attachments this Actor has measured on the public preview |
| `links[]`, `mentions[]`, `hashtags[]` | outgoing links, @channel mentions and hashtags, told apart |

**Channel rows** (`rowType: "channel"`, one per channel, on by default)

`channelTitle`, `description`, `verified`, `subscribers` (exact), `subscribersDisplay` (rounded),
`subscribersDisplayValue`, `subscribersDisplayDiff`, `photosDisplay`, `videosDisplay`, `linksDisplay`,
`messagesRead`, `oldestPostAt`, `newestPostAt`, `windowDays`, `postsPerDay`, `viewsMedian`,
`viewsMean`, `viewsMax`, `viewsMin`, `topPostUrl`, `topPostViews`, `viewsPerSubscriberPct`,
`reactionsMedian`, `forwardedPct`, `withPhotoPct`, `withVideoPct`, `withLinkPreviewPct`,
`busiestHourUtc`.

### What is measured, and what is not

- **View counts are rounded by Telegram**, to three significant figures (`24.2M`, `53.1K`). There is
  no exact figure published anywhere on the public pages, so this Actor does not pretend to have one:
  every post row carries `viewsAreRounded`, and every channel row carries `viewsAreApproximate`. The
  rounding is at most about 0.5% of the value. Reaction counts are printed the same way.
- **The subscriber count on the profile page is not rounded**, and that is the one in `subscribers`.
  If Telegram ever answers with a rounded figure there, the column is left empty rather than filled
  with a rounded number labelled exact.
- Telegram serves **20** posts per page. Reading more pages costs more requests, not more accuracy.
- Photos, videos and link previews are the attachment types measured on the public preview pages.
  Other kinds are not claimed either way rather than guessed at.
- Invite links (`t.me/+...`, `t.me/joinchat/...`) and `t.me/c/...` are private entrances. They are
  rejected rather than read, and no login or account is ever used.
- **Post text is returned as published**, with one exception: email addresses, numbers written with
  an international `+` prefix, and numbers directly after phone/tel/call/whatsapp/viber are replaced
  with `[email removed]` / `[phone removed]`. Plain long numbers - block heights, transaction
  amounts, order ids - are left alone, and `textLength` is the length of what you receive.
- `postsPerDay` is measured over `windowDays`, the span between the oldest and newest post the run
  read, using the gaps between posts. It needs at least two posts; with one it is empty rather than
  guessed. `busiestHourUtc` is empty when two hours tie rather than picking the earlier one.
- `subscribers` is filled only when Telegram labels the figure "subscribers". A group that reports
  "members", or a page that reports "online", leaves it empty rather than passing off a different
  number as a subscriber count.
- A name nobody has taken and a name that exists without a public preview are **two different
  answers**, and this Actor tells them apart by asking for the profile page as well - one extra
  request, so a typo comes back as a typo rather than as "this channel is private".
- **One channel can hold several usernames.** `t.me/techmeme` and `t.me/arstechnica` both lead to the
  same channel, and the preview page shows whichever name you asked for - so a list containing both
  looks like two channels. Every row carries `channel` (the name Telegram puts on the posts),
  `inputChannel` (what you asked for) and `channelMatchesInput`, and a second name for a channel
  already read in the run comes back as a free `duplicate-channel` row instead of the same posts
  charged twice.

### Monitoring: never pay for the same post twice

Turn `monitoringMode` on and the Actor remembers the newest post id **per channel** and, on the next
run, asks Telegram only for posts published after it. Adding a channel to your list does not
re-charge the ones already being watched, because nothing about the list is part of what is
remembered.

Posts that could not be delivered - because the run hit its charge limit - are **not** marked as
returned, so the next run delivers them instead of skipping them.

On a run where a channel has posted nothing new, that channel produces **no rows at all** - not even
its channel-statistics row - and a single free `no-change` row says so. Telegram answers such a
request with a 200 page carrying "No posts found" rather than an error, and this Actor reads that as
"nothing new", never as "this channel has no posts".

Monitoring adds a flat access fee of **$5**, charged **once per calendar month (UTC)**, on the first
monitoring run of that month **that manages to read at least one channel** - a run where every name
was mistyped costs nothing. That fee is what pays for the days when the channel posted nothing: those
runs return no rows and cost nothing else. Runs with monitoring off never trigger it.

Two things worth knowing before you schedule it:

- What is remembered per channel is **the newest post already processed**, and that includes posts
  your filters dropped. A task with `keywords` and a task without them share that memory, so a post
  filtered out by one will not be delivered by the other later.
- Two runs watching **the same channel at the same moment** both see the same "last post" and will
  both return it. Runs watching different channels do not interfere - the memory is stored per
  channel, not as one shared blob.

### Rows that are never charged

When there is nothing to sell, you get a row that says why, and it is free:

| `status` | What happened |
|---|---|
| `no-such-channel` | nobody has taken the name. Telegram answers a free name with its generic download page - no title, no subscriber count - instead of a profile |
| `not-a-public-channel` | the name exists, but has no public post preview: Telegram answers the preview URL with a **302** back to the plain profile. It does that for a bot, a personal account, a group, and a channel whose owner turned the preview off, so this row does not claim which |
| `no-results` | the channel page loaded and Telegram published no posts on it |
| `blocked` | Telegram answered with a bot check instead of the page |
| `unreadable` | the page could not be read; nothing is guessed |
| `no-change` | monitoring mode, and nothing has been posted since the last run |
| `no-match` | posts were read and none matched your filters |
| `duplicate-channel` | the name is another username for a channel already read in this run, so its posts are not returned twice |
| `budget-reached` | the run hit the maximum total charge you set for it and stopped; the row says how many posts were left behind |

A `no-such-channel` row is never dressed up as an empty channel, and an empty channel is never dressed
up as an error.

### Input

| Field | What it does |
|---|---|
| `channels` | the public channels to read, as `@durov`, `durov`, `https://t.me/durov` or a link to a single post |
| `maxMessagesPerChannel` | how many of the most recent posts to read per channel (Telegram serves 20 per page) |
| `maxMessages` | a cap across all channels together, applied after the filters |
| `includeChannelStats` | add the one channel row per channel with the exact subscriber count and the measured statistics |
| `monitoringMode` | return only posts you have not been given before |
| `resetMonitoringState` | forget the remembered post ids and start the history over |
| `postedWithinDays`, `minViews`, `onlyWithMedia`, `onlyWithLinks`, `excludeForwarded` | filters on the posts |
| `keywords`, `keywordMatch`, `excludeKeywords` | keyword filters |
| `useProxy` | switch to a residential proxy and retry if Telegram ever answers with a bot check |

### Filters

`keywords` / `keywordMatch` / `excludeKeywords` (searched in the text, channel title, forwarded
source, link preview title and hashtags), `postedWithinDays`, `minViews`, `onlyWithMedia`,
`onlyWithLinks`, `excludeForwarded`. Posts whose view count or timestamp cannot be read are **kept**,
not dropped - a filter never silently empties a result because one column was missing.

### Connection

Measured from Apify on 2026-09-07: `t.me` answered with the full page on **3** of 3 direct requests
(145 KB, 217-449 ms), and `t.me/robots.txt` is a **404** - nothing is declared as disallowed. So no
proxy is used by default. Turn `useProxy` on to have the run retry through a proxy if Telegram ever
answers with a bot check; a residential proxy is used when your account has one, and a datacenter
proxy otherwise.

A request for posts newer than the newest one comes back as a **200** page of **18,684** bytes whose
body is "No posts found" rather than an error - which is why monitoring reports `no-change` there
instead of an empty channel.

# Actor input Schema

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

Public channels as @durov, durov, https://t.me/durov or https://t.me/s/durov. A link to a single post (t.me/durov/527) works too - the channel is taken from it. Only channels with a public web preview can be read without logging in; invite links (t.me/+..., t.me/joinchat/...) and t.me/c/... are private entrances and are rejected rather than read. A name that has no public preview is reported as its own row that says whether the name exists at all, and is not charged.

## `maxMessagesPerChannel` (type: `integer`):

How many of the most recent posts to read from each channel. Telegram serves 20 posts per page, so this is rounded up to whole pages while reading and then cut to this number. You are charged for the rows you actually receive.

## `maxMessages` (type: `integer`):

A cap across all channels together, applied after the filters. Use it to keep a run predictable when you pass many channels.

## `includeChannelStats` (type: `boolean`):

On = each channel also gets a single summary row: title, description, verified flag, the exact subscriber count read from the profile page (Telegram's preview page only shows a rounded figure such as 172K, and the exact figure is 171,899), the rounded figure and the difference between them, plus statistics measured from the posts this run read - median, mean, highest and lowest views, the top post, median reactions, views per subscriber, posts per day over the window read, the share of posts with a photo, a video, a link preview or a forward, and the busiest posting hour in UTC. This row costs one channel-summary charge and needs one extra request per channel. In monitoring mode it is only produced for channels that actually posted something new, so a quiet run stays free.

## `monitoringMode` (type: `boolean`):

Off = return the most recent posts of every channel you listed, charged per row. On = remember the newest post id per channel and, on later runs, ask Telegram only for posts published after it, so you are never charged twice for the same post. The id is remembered per channel, not per list, so adding a channel does not re-charge the ones already being watched. A channel that has posted nothing new produces no rows at all on that run, not even its statistics row. **Monitoring adds a flat access fee of $5, charged once per calendar month (UTC)** on the first monitoring run of that month that manages to read at least one channel, no matter how many lists or scheduled tasks you run. That fee is what pays for watching on the days when nothing was posted - those runs return no rows and cost nothing else. Runs with monitoring off never trigger it.

## `resetMonitoringState` (type: `boolean`):

Clears the remembered post id for the channels in this run, so the next run returns their recent posts once again and starts a fresh history. **Turn it back off afterwards**: left on in a scheduled task it re-delivers, and re-charges, the same posts on every single run.

## `postedWithinDays` (type: `integer`):

Drop posts older than this many days. 0 = no limit. Posts whose timestamp cannot be read are kept rather than dropped, so a change on Telegram's side never silently empties the result.

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

Drop posts below this view count. Telegram publishes views rounded to three significant figures (24.2M, 53.1K), so this is compared against that rounded figure and every row says so in viewsAreRounded. Posts with no view count at all are kept rather than dropped. 0 = no minimum.

## `onlyWithMedia` (type: `boolean`):

Keep only posts whose preview carries a photo or a video. Photos, videos and link previews are the attachment types this Actor has measured on the public preview; other kinds are left unclaimed rather than guessed.

## `onlyWithLinks` (type: `boolean`):

Keep only posts whose text contains at least one outgoing link. Hashtag links and @channel mentions are not counted as links - they come back in their own columns.

## `excludeForwarded` (type: `boolean`):

Keep only posts the channel wrote itself. Forwarded posts carry the source channel and the link to the original post, so leaving them in is useful when you are mapping which channels republish which.

## `keywords` (type: `array`):

Keep only posts whose text, channel title, forwarded source, link preview title or hashtags contain these words. Case is ignored. Leave empty for no keyword filter.

## `keywordMatch` (type: `string`):

Any = keep a post that contains at least one of the keywords. All = keep a post only when it contains every keyword.

## `excludeKeywords` (type: `array`):

Drop posts that contain any of these words in the same fields the keyword filter searches. Applied after the keyword filter.

## `useProxy` (type: `boolean`):

Off by default because t.me answered with the full page on every direct request measured from Apify (three of three, 217-449 ms). Turn it on to have the run retry through a proxy if Telegram ever answers with a bot check - a residential proxy when your account has one, a datacenter proxy otherwise. A proxy makes each run slower and more expensive, so it is not used unless it is needed.

## Actor input object example

```json
{
  "channels": [
    "@durov",
    "@telegram"
  ],
  "maxMessagesPerChannel": 40,
  "maxMessages": 200,
  "includeChannelStats": true,
  "monitoringMode": false,
  "resetMonitoringState": false,
  "postedWithinDays": 0,
  "minViews": 0,
  "onlyWithMedia": false,
  "onlyWithLinks": false,
  "excludeForwarded": false,
  "keywords": [],
  "keywordMatch": "any",
  "excludeKeywords": [],
  "useProxy": false
}
```

# Actor output Schema

## `results` (type: `string`):

Two kinds of row, told apart by rowType. A post row carries the channel, the post id and its t.me link, the publication time, the text with its line breaks, the view count as Telegram publishes it (rounded to three significant figures, with the original text and a flag saying so), the reactions with their counts, whether the post was forwarded and from which channel, the author signature, whether it was edited, whether it carries a photo, a video or a link preview with that preview's title and site, and the links, @mentions and hashtags found in the text. A channel row carries the title, description and verified flag, the exact subscriber count from the profile page next to the rounded figure the preview shows and the difference between them, and statistics measured from the posts read: how many were read and over what window, posts per day, median, mean, highest and lowest views, the top post, median reactions, views per subscriber, the share of posts with a photo, a video, a link preview or a forward, and the busiest posting hour in UTC. Channels with no public preview, names that do not exist, pages that came back as a bot check and runs where nothing new was posted come back as their own rows and are not charged.

# 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": [
        "@durov",
        "@telegram"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("neverempty/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": [
        "@durov",
        "@telegram",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("neverempty/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": [
    "@durov",
    "@telegram"
  ]
}' |
apify call neverempty/telegram-channel-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,neverempty/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/esAaVMCQEt45dNGFU/builds/79ciqDvigWtrhBDHN/openapi.json
