# Telegram Channel Scraper - Posts, Views, Forwards & Watchlists (`yasaslive/telegram-intel`) Actor

Extract public Telegram channel posts, view counts, forwards, reactions, media and outbound links — no bot token, no MTProto, no phone number. Adds a cross-channel mention graph, keyword watchlists across scheduled runs, and subscriber-delta change events.

- **URL**: https://apify.com/yasaslive/telegram-intel.md
- **Developed by:** [Eonix Pvt Ltd](https://apify.com/yasaslive) (community)
- **Categories:** Lead generation, Automation, Other
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.00005 / actor start

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 Public Channel Intelligence — Posts, Views, Forwards & Watchlists

Extract everything a **public** Telegram channel exposes to the open web, and turn it into a
monitored feed.

**No bot token. No MTProto. No phone number. No session string. No login of any kind.**

This actor reads `https://t.me/s/{channel}` — the fully server-rendered public preview that
Telegram serves to anyone — and parses it with Cheerio. There is no browser, no API key, and
nothing to authorize.

***

### Why this one

A raw post dump is table stakes; several well-rated actors already do it. Three layers that
none of them ship are the reason this exists:

| Layer | What it gives you |
|---|---|
| **Cross-channel mention graph** | Weighted, directed `{source, target, weight, kinds}` edges built from forwards and @mentions. Written to `GRAPH.json`. Shows who amplifies whom. |
| **Incremental watchlists** | Keywords and regexes matched on every scheduled run. New matches fire typed `change` records — a real alerting primitive, not a one-off search. |
| **Subscriber-delta events** | Growth and collapse detected across runs and emitted as typed `change` records when the move exceeds your threshold. |

Plus the thing this category gets wrong most often: **when a channel has no public preview, this
actor says so.** See [Reliability](#reliability).

***

### Free plan limits

Free-plan runs are capped to **1 channel, 25 messages, no AI, and no channel expansion**, and the
run logs:

```
Free-plan run: capped to 25 records. Paid plans run uncapped.
```

Paid plans run uncapped, up to your configured limits.

***

### What a run costs you

Before any work begins, the run computes an estimate from your resolved input, logs it, and
writes it to the key-value store as `COST-ESTIMATE.json`.

| Event | When it fires | Price |
|---|---|---|
| `channel-resolved` | Once per channel successfully fetched, after its record is pushed | $0.02 |
| `message-returned` | Once per message pushed | $0.0004 |
| `watchlist-hit` | Once per watchlist match | $0.005 |
| `ai-brief` | Once per successful AI brief | $0.02 |
| `request-served` | Standby HTTP requests only | $0.001 |

**You are never charged for:**

- a channel with no public preview (`status: "unreachable"`)
- a channel whose markup drifted (`kind: "health"`)
- a failed or malformed AI call
- an incremental run that finds nothing new (beyond the per-channel event)

Example — the shipped default input (2 channels × 200 messages, 2 keywords):

```
  channel-resolved           2 x $0.020000 = $0.040000
  message-returned         400 x $0.000400 = $0.160000
  watchlist-hit             20 x $0.005000 = $0.100000
  ESTIMATED MAXIMUM TOTAL: $0.300000
```

That is an **upper bound**. With `incremental: true` (the default), later scheduled runs only
fetch new posts and typically cost a small fraction of it.

***

### Input

| Field | Type | Default | Notes |
|---|---|---|---|
| `channels` | string\[] | `["telegram","telegramtips"]` | Accepts `@handle`, `t.me/x`, `t.me/s/x`, a full URL, or a bare name |
| `maxMessagesPerChannel` | integer | `200` | Max 5000 |
| `sinceDate` | string | — | ISO date; stop walking once posts are older |
| `incremental` | boolean | `true` | Only fetch posts newer than the last run (see "Runs are never empty") |
| `keywords` | string\[] | — | Case-insensitive plain-text terms |
| `regexes` | string\[] | — | JavaScript regexes; unsafe patterns are rejected |
| `expandMentions` | boolean | `false` | Queue newly discovered channels |
| `maxDiscoveredChannels` | integer | `0` | Ceiling for expansion |
| `subscriberDeltaPct` | number | `1` | Threshold for a subscriber change event |
| `openaiApiKey` | string (secret) | — | Enables one AI brief per channel |
| `model` | string | `gpt-4o-mini` | Model for the brief |
| `proxyConfiguration` | object | Apify Proxy | |

***

### Output

Every record carries the same envelope, shared across this portfolio:

```
{ kind, target, status, collectedAt, source, ...payload }
```

`kind` is `record` | `change` | `summary` | `health`.
`status` is `ok` | `partial` | `unreachable` | `endpoint_unavailable` | `skipped`.

#### Real sample output

Copied verbatim from a local run on 16 Aug 2026 — not hand-written.

**Channel record**

```json
{
  "kind": "record",
  "target": "telegram",
  "status": "ok",
  "collectedAt": "2026-08-16T16:56:58.762Z",
  "source": "https://t.me/s/telegram",
  "recordType": "channel",
  "channel": "telegram",
  "title": "Telegram News",
  "description": "The official Telegram on Telegram. Much recursion. Very Telegram. Wow.",
  "subscribers": 9810000,
  "photosCount": 14,
  "videosCount": 225,
  "linksCount": 372,
  "avatarUrl": "https://cdn1.telesco.pe/file/LvdKdKHGD-oGHuw...jpg",
  "isVerified": true,
  "contentAdvisory": false
}
```

**Message record**

```json
{
  "kind": "record",
  "target": "telegram",
  "status": "ok",
  "collectedAt": "2026-08-16T16:56:58.762Z",
  "source": "https://t.me/s/telegram",
  "recordType": "message",
  "channel": "telegram",
  "messageId": 454,
  "url": "https://t.me/telegram/454",
  "datetime": "2026-07-19T17:58:20+00:00",
  "text": "For all the details on these new features, check out our blog:\nhttps://telegram.org/blog/communities-editor-invisible-messages\n\nJuly Features\n1 • 2 • 3 • 4 • More",
  "views": 1500000,
  "forwardedFromChannel": null,
  "forwardedFromName": null,
  "isForwarded": false,
  "replyToId": null,
  "hasMedia": false,
  "mediaTypes": [],
  "mediaUrls": [],
  "outboundLinks": ["https://telegram.org/blog/communities-editor-invisible-messages"],
  "mentionedChannels": [],
  "hashtags": [],
  "reactions": [],
  "totalReactions": 0,
  "edited": false,
  "editedAt": null,
  "authorSignature": null
}
```

**Watchlist hit**

```json
{
  "kind": "change",
  "target": "telegram",
  "status": "ok",
  "collectedAt": "2026-08-16T16:56:58.762Z",
  "source": "https://t.me/s/telegram",
  "type": "watchlist_hit",
  "channel": "telegram",
  "messageId": 450,
  "url": "https://t.me/telegram/450",
  "matched": "privacy",
  "matchKind": "keyword",
  "snippet": "…in a visual editor that supports dozens of formatting options and allows you to generate text with privacy-conscious AI tools. July Features 1 • 2 • 3 • 4 • More",
  "views": 944000,
  "datetime": "2026-07-19T17:57:15+00:00"
}
```

#### Runs are never empty

In incremental mode a run over a quiet channel legitimately finds nothing new — many channels
post weekly or monthly, so a daily run would otherwise return an empty dataset and look broken.

When a run finds no new posts on **any** channel, it re-delivers the 25 most recent posts from
the first reachable channel and sets `fallbackUsed: true` on the run summary. Watchlists are
**not** re-evaluated for those posts: they were already matched on an earlier run, so firing the
same alerts again would spam you and charge you twice for one event.

Turn this off by setting `incremental: false`, which always walks history up to your message
limit.

#### Artifacts written to the key-value store

| Key | Contents |
|---|---|
| `COST-ESTIMATE.json` | What this run is expected to charge you |
| `SUMMARY.json` | Run totals, per-channel outcomes, charge counts |
| `GRAPH.json` | Mention/forward graph: nodes and weighted edges |
| `UNIT-ECONOMICS.json` | Developer-facing cost and margin measurement |
| `{channel}-brief.md` | AI brief, when an OpenAI key is supplied |

#### Field notes

- **`editedAt` is always `null`.** The public preview exposes only *that* a post was edited,
  never *when*. Rather than invent a timestamp, `edited` is a boolean and `editedAt` is kept
  null for schema stability.
- **`htmlText` is sanitized.** Telegram's own markup ships inline `onclick` handlers on every
  outbound link. Script/iframe elements, `on*` attributes and `javascript:`/`data:` URLs are
  stripped so the field is safe to render.
- **`views` may be `null`.** Absent is not zero, and the two are never conflated.
- **`mentionedChannels` excludes self-mentions**, which would otherwise dominate the graph.

***

### Reliability

Measured during the build, **16 August 2026**:

- **17 distinct public channel names tested.**
- **9 exposed a public preview — all 9 parsed successfully (100%).**
- **8 did not expose a preview — all 8 were correctly reported as `unreachable`,
  with no charge, and the run still succeeded (100%).**
- **No run failed.** Full-history walks, incremental re-runs, standby requests and
  all-unreachable runs all completed successfully.
- Default input completes in **~24 seconds** (400 messages, 2 channels).
- A repeat incremental run completes in **~3 seconds** and logs `0 new messages`.

The volume leader in this niche is rated 2.11/5 across 16 reviews precisely because it reports
SUCCESS while returning nothing. That failure mode is designed out here:

- A page with no preview is detected explicitly (Telegram serves these with **HTTP 200**) and
  reported as `unreachable`.
- A 2xx response whose shape no longer matches emits
  `SOURCE DRIFT: <source> returned unexpected shape` and a `kind: "health"` record, so breakage
  is visible **in your dataset** rather than as a silently empty result.
- If every source fails, the run still pushes health records and a summary. It never exits empty.

***

### What this actor does NOT do

Honest coverage limits, so you can judge fit before you spend anything:

- **It cannot read channels without a public preview.** Many well-known names — `bbcnews`,
  `reuters`, `nasa`, `techcrunch` among those tested — do not serve one. They return
  `unreachable` and cost you nothing, but no amount of retrying will produce their posts.
- **No private channels, no groups, no DMs, no member lists.** There is no login, so anything
  behind one is out of reach by construction.
- **No comment threads or discussion-group replies.** Only in-channel reply references.
- **No reaction-per-user data**, only aggregate counts per emoji.
- **No edit timestamps** — see Field notes.
- **No media file downloads.** Media URLs are extracted; the bytes are not fetched.
- **No historical view-count series.** Views are whatever the page shows at collection time.
- **Deleted posts are invisible**, and gaps in message ids are not reconstructed.
- **Not a real-time firehose.** It is a polling actor; latency is your schedule interval.

***

### Ethics and compliance

- **Public channels only.** This reads the same page any logged-out browser can load.
- **No member lists, no private groups, no direct messages, no user profiling.**
- No authentication is performed or required, so no account is impersonated.
- When a channel self-labels as restricted, the channel record carries
  `contentAdvisory: true` rather than the content being silently filtered — you decide.
- You remain responsible for how you use collected data, including GDPR obligations where
  posts contain personal data.

***

### Use from an AI agent

**Apify MCP.** This actor is callable as a tool through the Apify MCP server. Point your MCP
client at `https://mcp.apify.com` and include this actor; the input schema above is the tool
schema.

**Standby mode.** When `ACTOR_STANDBY_PORT` is set, the actor stays warm and answers HTTP
requests, running the identical pipeline and charging the identical events plus
`request-served`:

```
GET https://<your-actor>.apify.actor/?channels=telegram,telegramtips&maxMessagesPerChannel=50&keywords=privacy
GET https://<your-actor>.apify.actor/health
```

Query parameters mirror the input fields; list fields accept repeats or comma-separated values.
The response is the same JSON envelope, plus `charged`, `economics` and `warnings`.

`openaiApiKey` is **deliberately ignored** in standby mode — secrets must not travel in a query
string, where they land in access logs and browser history. Use a normal run for AI briefs.

***

### Maintenance

- **Broken-source issues are triaged within 2 business days**, with a fix or a written
  workaround within 5 business days.
- Endpoint drift is detected automatically and surfaced in the dataset, so breakage is visible
  immediately rather than after a silent empty run.
- Report issues on the actor's Apify page.

***

### Running it locally

```bash
npm install
npm run verify        # lint + typecheck + 73 unit tests
npm run acceptance    # live run against the shipped INPUT.json
```

Useful scripts:

| Script | Purpose |
|---|---|
| `npm run verify` | Lint, type-check and the full unit suite |
| `npm run acceptance` | Live run using `INPUT.json`, snapshots economics |
| `npm run acceptance:scaled` | 10x-scaled run for cost-per-item comparison |
| `npm run acceptance:free` | Exercises the free-plan caps |
| `npm run calibrate` | Prints the pricing calibration report |

***

### For the developer: unit-economics calibration

`profit = (0.8 × revenue) − platform usage cost`, so prices must be validated, not guessed.
Every run writes `UNIT-ECONOMICS.json` and logs a verdict.

**Set your own rates.** Read them off your [Apify billing page](https://console.apify.com/billing)
and put them in `.env`:

```
COST_CU_USD=0.25
COST_PROXY_GB_USD=8.0
```

The values in `.env.example` are Apify's commonly published list rates, used as a **documented
assumption** so calibration produces an arguable number. They vary by plan and change over time —
verify them. If they are unset, platform cost resolves to $0 and the report correctly prints
`CALIBRATION NOT PERFORMED — rates unset` instead of a fake healthy margin.

#### Measured result (16 Aug 2026, local estimate at the rates above)

| Event | Hypothesis | Measured cost | Floor (3× cost) | Recommended |
|---|---|---|---|---|
| `channel-resolved` | $0.020000 | $0.000825 | $0.003096 | **$0.020000** |
| `message-returned` | $0.000400 | $0.000054 | $0.000201 | **$0.000400** |

**Both spec prices survive calibration.** `message-returned` sits at 2.0× its cost-covering
floor and `channel-resolved` at 6.5×. Run margin was **94.9%**, verdict **HEALTHY**.

Scale comparison (default vs 10×):

| Scale | Driver events | Cost/event | Margin |
|---|---|---|---|
| default | 400 | $0.000054 | 94.9% |
| scaled | 869 | $0.000053 | 95.4% |

Cost per message **fell** as volume rose, so fixed cost amortizes correctly and there is no
per-item fixed-cost problem the pricing needs to absorb.

**On residential proxy.** Residential is the default proxy tier and it is affordable here
because a page is ~134 KB and yields ~20 messages (~6.7 KB/message). Telegram serves this
surface **uncompressed** (no `content-encoding`), so the byte accounting equals real transfer
rather than under-counting it. The pay-per-event **+ usage** toggle is deliberately **not**
enabled: Apify warns it reduces pricing transparency and hurts the quality score, and this
actor does not need it to be profitable.

***

### License

MIT

# Actor input Schema

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

Public Telegram channels to scan. Accepts @handle, t.me/name, t.me/s/name, a full URL, or a bare name. Private channels and invite links are not supported — only channels that expose the public web preview.

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

Upper bound on messages fetched per channel. History is walked backwards in pages of ~20.

## `sinceDate` (type: `string`):

Stop walking history once posts older than this ISO date are reached, e.g. 2026-01-01. Leave empty to rely on the message limit alone.

## `incremental` (type: `boolean`):

Only fetch posts newer than the last run for each channel. Makes scheduled runs cheap and fast. Turn off to always re-walk full history.

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

Plain-text terms matched case-insensitively against post text. Every hit emits a watchlist change record.

## `regexes` (type: `array`):

JavaScript regular expressions matched against post text. Patterns using nested quantifiers are rejected to protect the run from catastrophic backtracking.

## `expandMentions` (type: `boolean`):

Queue newly discovered channels found via mentions and forwards. Off by default so the default run stays fast.

## `maxDiscoveredChannels` (type: `integer`):

Ceiling on how many newly discovered channels to scan when expansion is on.

## `subscriberDeltaPct` (type: `number`):

Emit a change record when a channel's subscriber count moves by more than this percentage since the previous run.

## `openaiApiKey` (type: `string`):

Enables one structured AI brief per channel over its newest 50 posts. Leave empty to skip AI entirely. Failed AI calls are never charged.

## `model` (type: `string`):

Model used for the AI brief.

## `proxyConfiguration` (type: `object`):

Proxy used for requests to the Telegram public preview.

## Actor input object example

```json
{
  "channels": [
    "telegram",
    "telegramtips"
  ],
  "maxMessagesPerChannel": 200,
  "incremental": true,
  "expandMentions": false,
  "maxDiscoveredChannels": 0,
  "subscriberDeltaPct": 1,
  "model": "gpt-4o-mini",
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}
```

# Actor output Schema

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

Every record from the run: channel metadata, messages, watchlist hits, subscriber changes, health records and summaries. All share the envelope { kind, target, status, collectedAt, source }.

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

Posts only, with view counts, reactions, media, outbound links, hashtags, mentioned channels and forward attribution.

## `changes` (type: `string`):

Typed change events emitted across scheduled runs: keyword and regex matches on new posts, and subscriber counts moving beyond your threshold.

## `channels` (type: `string`):

Channel-level metadata: title, description, subscribers, photo/video/link counts, verification and content advisory.

## `mentionGraph` (type: `string`):

Weighted directed graph of cross-channel relationships built from forwards and @mentions, as { nodes, edges: \[{ source, target, weight, kinds }] }.

## `runSummary` (type: `string`):

Totals and per-channel outcomes for the run, including which channels were unreachable and how many events were charged.

## `costEstimate` (type: `string`):

The estimated charge for this run, computed from the resolved input before any work began, with the assumptions behind it.

# 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",
        "telegramtips"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("yasaslive/telegram-intel").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",
        "telegramtips",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("yasaslive/telegram-intel").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",
    "telegramtips"
  ]
}' |
apify call yasaslive/telegram-intel --silent --output-dataset

```

## MCP server setup

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

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/jKkBQDxiRPjpdQ0jJ/builds/XZLOcjdCw9fDVx6Lt/openapi.json
