# Telegram Channel Scraper — Posts, Views & Media (`thenetaji/telegram-channel-scraper`) Actor

Export what public Telegram channels post: the message text, when it went up, the view count, photos and video, and what each post forwarded or replied to. Many channels in one run, and a later run picks up only what is new. No login and no phone number; channel names come from the input list.

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

## Pricing

from $0.85 / 1,000 posts

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

Export what a public Telegram channel has posted, one row per post: the message text with its line
breaks intact, the permalink, when it went up, how many views it has, any photo or video, and what
the post was forwarded from or replying to. A run reads a whole list of channels into one dataset,
and a later run can continue from where the previous one stopped rather than re-reading a channel
from the top.

Nothing is signed into. No bot token, no phone number and no API credentials are involved at any
point, because the source is the public channel preview that any browser can open at
`t.me/s/<channel>`. The channel names come from the run's input; Telegram publishes no directory,
so there is nothing to search through here.

### What a run returns

```json
{
  "channel": "telegram",
  "channel_title": "Telegram News",
  "channel_url": "https://t.me/s/telegram",
  "id": "telegram/435",
  "message_number": 435,
  "url": "https://t.me/telegram/435",
  "published_at": "2026-04-09T07:01:44+00:00",
  "views_display": "1.56M",
  "text": "Hello world",
  "photo_url": null,
  "video_url": null,
  "forwarded_from_name": null,
  "reply_to_text": null
}
```

Every row carries the same thirteen fields: `channel`, `channel_title`, `channel_url`, `id`,
`message_number`, `url`, `published_at`, `views_display`, `text`, `photo_url`, `video_url`,
`forwarded_from_name` and `reply_to_text`. A field the post does not have is returned as `null`
rather than omitted, so every row has the same shape whatever mix of posts a run collected.

Each post names the channel it came from, because a run collects several channels into a single
dataset and a post that cannot say where it came from cannot be grouped or joined afterwards. The
channel's own title and address are published as `channel_title` and `channel_url`; the plain `url`
is always the post's own permalink. `id` is Telegram's own key for a post, `<channel>/<number>`,
and `message_number` is the second half of it.

### Accepted input

`channels` is required and takes one or more channels, one per line, in whatever form is closest to
hand. `telegram`, `@telegram`, `t.me/telegram`, `https://t.me/s/telegram` and
`https://t.me/telegram/435` all resolve to the same channel, and a channel listed twice is read
once.

`maxItems` caps how many posts are saved from each channel and defaults to `100`. A value of `0`
removes the cap and lets a run continue to the channel's first post.

`before` and `after` are optional and are how a run resumes. `before` starts just older than a given
post number and works backwards through the history; `after` collects only posts newer than a given
post number, which is how a run picks up what has been published since the last one. A run that
supplies neither starts at the channel's newest post.

```json
{
  "channels": ["telegram", "durov"],
  "maxItems": 40
}
```

### Questions

**Why did a run save fewer posts than `maxItems`?**
Because the channel ran out first. Telegram publishes no post total and no page count, so a run
cannot compare its position against anything and instead stops on the signals the channel actually
gives: a page that comes back with no posts on it, no position to continue from, or a position that
fails to advance. A short page part-way through is deliberately not one of those signals. Walking a
real channel backwards, the final populated page carried four posts, numbers 1 to 4, and the empty
call after it marked the end; treating a short page as the end would cut every such run off a page
early.

**What number does `before` want, and why is it the lowest on the page rather than the highest?**
It is a position in the stream, and the posts returned are the twenty older than that position, so
sending the highest number just read would return the same twenty again. Measured on one channel:
no cursor returned posts 435 to 454, `before=435` returned 414 to 433, and `before=414` returned
394 to 413, which is 80 posts across four calls with zero overlap. `before=5` returned posts 1 to
4, so the beginning of a channel is reachable. The value is the `message_number` on the oldest row
of the previous run, and a run stopped by `maxItems` prints it in the log, so nobody has to work it
out.

**Why did a cursor apply to the first channel only?**
Post numbers count from 1 inside each channel separately, so post 435 of one channel has nothing to
do with post 435 of another. A `before` or `after` value applies to the first channel in the list;
every channel after it starts at its own newest post. Resuming several channels at their own
positions means one run per channel.

**Can `before` and `after` be combined to select a range?**
No, and a run supplying both is refused before it spends a single request. Telegram honours one of
the two and silently ignores the other, so such a run would return twenty posts from one end of the
history while appearing to have asked for the span between the two bounds; a wrong answer that
looks exactly like a right one is worse than a rejection. A range is collected by walking one
direction and stopping at the bound.

**What happens to a channel that is private, misspelled, or actually a group?**
It is skipped, and the run carries on with the rest of the list. Telegram serves the same join page,
with HTTP 200, for a channel that does not exist, for a private channel, for a group and for a
chat, so those four cases are one answer from outside and no amount of work on this side can
separate them. The run logs a warning naming the channel, and a channel that produced no rows is
not charged for. An entry that is not a Telegram name at all is dropped the same way, before any
request: a name is 5 to 32 characters, starts with a letter, and then uses letters, digits or
underscores.

**Why is `views_display` text such as `1.56M` rather than a number?**
Because that is what the page states. `1.56M` places the true figure somewhere between 1,555,000
and 1,565,000, and there is no exact view count anywhere a public preview can reach. Publishing
`1560000` would invent three digits Telegram never gave, and nothing downstream could tell an
invented digit from a measured one, so the string is passed through as it arrived and the
`_display` suffix states the type before anyone parses it. It is `null` on posts too recent to have
a view counter yet.

**Are reactions or the comment thread under a post available?**
No. Neither appears on the public preview page, so neither is reachable without an account, and I
would rather say so here than ship a column that is null on every row. What the preview does carry
is listed in full under **What a run returns**.

**Why is `text` null on a post that clearly has a caption in the app?**
`text` is null rather than an empty string when a media post carries no text node on the preview
page at all, which is the honest answer for a photo posted without a caption; an empty string would
claim the author published an empty message. Where a caption is rendered, it arrives in `text` with
its line breaks preserved.

**Can channels be found by keyword or topic rather than named?**
No. Telegram publishes no public directory of channels and no list this Actor could walk, so the
names have to come from somewhere else: a newsletter, a competitor's link list, a spreadsheet
already in hand. This is a property of Telegram rather than a gap I intend to close later.

**Why is `forwarded_from_name` not something this Actor accepts back as input?**
Because the preview renders a forwarded post's source as a display title rather than as a handle,
and a channel's title and its username are set independently by its owner. The field reports what
the page states; it is not promised to be a name that resolves.

### Related Actors

[Telegram Channel Info Scraper](https://apify.com/thenetaji/telegram-channel-info-scraper) returns a
channel's title, subscriber count and header counters as a single row and saves no posts. It is the
better first step for a long list of channels: it says how big each one is and whether anything has
been posted since last time, so only the channels worth exporting are exported.

# Actor input Schema

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

Public Telegram channels to read, one per line. Any form is accepted: a username, an @handle, a t.me link, a preview link, or a link to a single post. `telegram`, `@telegram`, `https://t.me/telegram`, `https://t.me/s/telegram` and `https://t.me/telegram/435` all resolve to the same channel, and a channel listed twice is read once.

The channel name has to be known in advance. Telegram publishes no directory, no sitemap and no robots.txt, so channels cannot be enumerated and no list can be offered here.

Only public channels with a web preview can be read. Private channels, groups and user chats all serve the same join page; a run logs a warning naming each one and continues through the rest of the list.

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

Maximum posts saved from each channel. A value of 0 removes the cap and lets a run continue to the channel's first post. Telegram serves 20 posts per request and states no way to ask for more, so a cap of 100 is five requests per channel.

## `before` (type: `integer`):

Optional. The walk starts just older than this post number and continues backwards; left empty, it starts at the channel's newest post.

Telegram pages by post number rather than by page index, and publishes no page count and no post total. This cursor takes the LOWEST post number on the page last read, which is the opposite of the usual intuition; the value appears as `message_number` on the oldest row of a previous run and is printed in the run log, so it does not have to be computed.

Measured on a real channel: no cursor returned posts 435-454, 435 returned 414-433 and 414 returned 394-413, giving 80 posts across four calls with zero overlap; 5 returned posts 1-4, so the beginning of a channel is reachable. Applies to the first channel in the list only.

## `after` (type: `integer`):

Optional. Only posts newer than this post number are collected, walking forwards. Supplying `newest_message_number` from a previous run picks up exactly what has been posted since.

Cannot be combined with the backwards cursor: Telegram honours one cursor and silently ignores the other, so a run carrying both would return posts from one side of the stream rather than the span between the two bounds. Applies to the first channel in the list only.

## Actor input object example

```json
{
  "channels": [
    "telegram"
  ],
  "maxItems": 40
}
```

# Actor output Schema

## `dataset` (type: `string`):

All records scraped by this run

# 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"
    ],
    "maxItems": 40
};

// Run the Actor and wait for it to finish
const run = await client.actor("thenetaji/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"],
    "maxItems": 40,
}

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

```

## MCP server setup

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