# BORG Telegram channel scraper (`acid-base/borg-telegram-scraper`) Actor

Extracts data without a Telegram account or API key.

Collect channel metadata, posts, media links, view counts, forwards, replies, timestamps, and date-filtered history from public channels.

Good for market research, community monitoring, content archiving, lead intelligence, competitive analysis.

- **URL**: https://apify.com/acid-base/borg-telegram-scraper.md
- **Developed by:** [Daniel Yates](https://apify.com/acid-base) (community)
- **Categories:** Social media, Lead generation, Other
- **Stats:** 124 total users, 31 monthly users, 79.9% runs succeeded, 1 bookmarks
- **User rating**: No ratings yet

## Pricing

from $3.60 / 1,000 results

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

Learn more: https://docs.apify.com/platform/actors/running/actors-in-store#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 Historical Archive / Research Dataset Export

Export public Telegram channel profiles and message history as a normalized, reusable
research dataset — for archival analysis, indexing, and downstream intelligence
workflows.

Public and **credential-free**: the actor reads only Telegram's server-rendered public
web preview (`https://t.me/s/<channel>`) parsed by Cheerio. No login, no cookies, no
API keys, no proxy escalation. It never accesses private content and never asks for
your Telegram credentials.

Transport is an HTTP fetch with browser-level TLS fingerprinting (impit). When that
is blocked or returns an unusable page, the actor falls back to a headless Chromium
(Playwright) against the same public preview URL — still credential-free, still
public-only. Every **document** comes from a canonical `https://t.me` origin —
both transports refuse anything else, and every redirect hop is validated before
it is issued rather than after the fact.

**Subresources are deliberately wider**, and saying otherwise would be untrue:
Telegram's own preview loads its CSS, images and scripts from `telesco.pe` and
`telegram.org` — on a live `t.me/s/telegram` most referenced URLs are off-`t.me`
(the exact count depends on what you count and drifts with Telegram's content;
independent measurements have put it at 23 of 50 distinct static references and
55 of 62 actually-loaded requests). Blocking those stops the page rendering,
which is what made the browser fallback execute no JavaScript at all. They are allowed by an explicit host list,
over HTTPS, on the default port, with no embedded credentials — and only the
**document** is ever parsed, so this does not widen where DATA can come from.

Only a page positively recognised as a Telegram surface is ever sold: rendered
message widgets, Telegram's own empty-state marker, or a profile/unavailable page.
Anything else is reported as unreachable and is not billed.

**`OUTPUT.status` changed meaning in this version, and integrations should read
this.** It was always the constant `GREEN`. It is now derived:

| status | meaning |
|---|---|
| `GREEN` | every requested target was attempted, reached, and positively classified; no input entry was rejected, no page walk was cut short, and no document arrived truncated |
| `AMBER` | real rows were delivered, but something was incomplete — a target unreached or unattempted, a pagination halt, a rejected input entry, or a document that arrived truncated |
| `RED` | at least one target failed and nothing was delivered |

**`GREEN` does not mean the channel is exhausted.** A run that stops because it
reached `messageLimit` is `GREEN` with more history still available — that is the
default and the ordinary case. `GREEN` means nothing went wrong, not that you
hold the whole channel; to know whether more exists, compare the rows you got
against the limit you set. A run whose filters (`dateRange`, `includeReplies`, a
media-only mode) match nothing is likewise `GREEN` with zero message rows: every
target was reached, the filter simply excluded everything.

`outages`, `paginationHalts`, `unattemptedTargets`, `invalidTargets` and
`partialTargets` say which condition applied.

#### Row counts change in this version

A run that previously reported `GREEN` while delivering a partial export now
reports `AMBER`. **The rows change too** — which matters, because rows are the
billing unit:

| case | before | now |
|---|---|---|
| a target whose page leaves `t.me` entirely (e.g. `a`, `zz`) | 1 row, `completeness: "empty"` | 0 rows, recorded in `outages` |
| a response truncated before any message was rendered | 1 row | 0 rows |
| the same channel requested twice in different letter case | 8 rows | 4 rows — one observation |
| a mode or filter (`dateRange`, media-only) that matches nothing, in a message-only mode | 1 channel row stamped `complete` | 0 rows |

Each removed row was an assertion about a channel that was not true.

Two limits on the first and last rows, measured live rather than assumed. A
handle that is merely *nonexistent* but well formed still bills one row: Telegram
redirects it to a real profile page, which is a positively recognised surface, so
`borg_nonexistent_zzq7x` is 1 row on both sides. And `legacy_channel_scraper`
always emits its channel row, so the filter-matches-nothing saving applies to the
message-only modes, not to legacy.

Three kinds of input yield fewer rows, and none of them loses a message you
asked for:

- **the same channel requested more than once** — case-variant duplicates are now
  one observation, so you are billed once for data you were previously billed
  twice for. Measured live: `addemoji` + `ADDEMOJI` at limit 40 goes **8 rows →
  4**. Note this only costs you anything when the first target does not already
  exhaust `messageLimit` — with a busy channel and a small limit the run stops
  before reaching the duplicate, and there is no difference either way;
- **inputs that were never a channel request** — an action link such as
  `t.me/boost/durov` (which boosts a channel rather than naming one) or
  `t.me/joinchat/<hash>` used to be read as the channel `@boost` / `@joinchat`
  and billed against it. Those are refused now.
- **a mode or filter that matches nothing** — a `dateRange` excluding every post,
  or a media-only mode over text-only posts, used to emit one channel row stamped
  `completeness: "complete"`; it now emits none, because that row asserted a
  complete observation of something it had not observed.

Otherwise, **measured across 58 populated live channels (independent audit,
2026-07-31), a genuine channel request delivers what it did before** — including `t.me/boost/5` and other
handles that collide with a Telegram action prefix, and including channels that
own a second username (`t.me/s/finance` serves the channel whose posts are
authored as `@money`; all 20 rows are delivered).

#### A run with no usable target now fails

Previously an empty, blank, or absent target silently became the literal
`@telegram` and billed 20 rows of a channel the caller never asked for. Now the
actor writes `OUTPUT {status: 'RED', refused: true, reason}` and **the run
itself fails**.

This changes behaviour for callers who send no usable target: API calls posting
`{}` or `{"telegramChannelOrProfile": ""}`, and saved Tasks whose target field
was cleared. Those runs go from SUCCEEDED to FAILED. The Console UI is
unaffected — the input schema prefills `https://t.me/telegram`.

### Task modes (polymorphic templates)

One extraction engine serves several Telegram surfaces. Pick a `mode`:

| Mode | Purpose |
| --- | --- |
| `legacy_channel_scraper` | Backward-compatible with the original deployed actor (`channels`, `maxMessages`, `dateFrom`/`dateTo`). |
| `historical_archive` | Export public channel history into a normalized research archive. |
| `research_dataset_export` | Bounded public-message dataset with stable fields for research/enrichment. |
| `media_link_inventory` | Inventory public media and outbound links from a channel. |
| `recent_monitor` | Check recent public posts for fresh updates. |
| `incremental_update_seed` | Bounded latest-message seed for downstream delta comparison. |
| `channel_summary` | Resolve a public profile into a compact channel summary. |
| `message_probe` | Small public-message sample for schema validation / triage. |

### Input

See `.actor/INPUT_SCHEMA.json`. Common fields: `telegramChannelOrProfile` (or legacy
`channels`), `mode`, `messageLimit` (or legacy `maxMessages`), `includeMedia`,
`includeReplies`, `dateRange` (or legacy `dateFrom`/`dateTo`). `proxyConfiguration` is
accepted for compatibility but non-escalating.

### Output

Records are pushed to the dataset (`.actor/dataset_schema.json`) with `Messages`,
`Media & Links`, and `Channels` views. Each message row carries text, author, date,
views, edited/album flags, media references (photo/video/document/voice/sticker/poll),
link previews, and outbound links. A per-run summary is written to the default
key-value store under `OUTPUT` (`.actor/output_schema.json`).

### Standby mode

The actor supports the Apify Standby Actor spec: when started in Standby mode it runs a
warm HTTP server instead of a one-shot run, answering requests without cold starts.

```
GET  /?channel=<@handle|url>&mode=<task>&messageLimit=<n>
POST /            (JSON body = actor input)
```

The server answers the platform readiness probe and returns scraped records as JSON in
the HTTP response (it does not push to the shared dataset). Enable Standby in the
Actor's **Settings** tab in the Apify Console.

### Deployment

Deployment-ready Apify Actor scaffold. Runs on `apify/actor-node-playwright-chrome:22`
via `npm start` (`tsx src/main.ts`).

The base image ships Chromium, which is what makes the Playwright fallback
described above a real code path in production rather than a dead one — the
Dockerfile relies on it and skips Playwright's own browser download.

# Actor input Schema

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

Legacy deployed actor task input. List of public Telegram channels or groups. Can be @username, t.me/username, or just the username.

## `dateFrom` (type: `string`):

Legacy deployed actor task input. Only scrape messages after this date (ISO format: YYYY-MM-DD).

## `dateRange` (type: `object`):

Optional date range filter (ISO YYYY-MM-DD start/end). The public web preview exposes a channel's most recent messages, so this narrows within recent history; leave empty to export the latest messages.

## `dateTo` (type: `string`):

Legacy deployed actor task input. Only scrape messages before this date (ISO format: YYYY-MM-DD).

## `includeMedia` (type: `boolean`):

Include media references when present.

## `includeReplies` (type: `boolean`):

Include reply threads when present.

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

Legacy deployed actor task input. Maximum number of messages to scrape per channel.

## `messageLimit` (type: `integer`):

Maximum number of messages to export.

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

Choose the polymorphic task template to run on top of the Telegram public-preview schema.

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

Accepted for backward compatibility. Public preview access remains unauthenticated and avoids proxy escalation by default.

## `telegramChannelOrProfile` (type: `string`):

Telegram channel, group, or profile URL or handle.

## Actor input object example

```json
{
  "dateRange": {
    "start": "2020-01-01",
    "end": "2099-12-31"
  },
  "includeMedia": true,
  "includeReplies": true,
  "messageLimit": 25,
  "mode": "historical_archive",
  "telegramChannelOrProfile": "https://t.me/telegramtips"
}
```

# Actor output Schema

## `datasetItems` (type: `string`):

All scraped Telegram message and channel records in the default dataset.

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

Compact run summary record (mode, transport, counts, custody flags) in the default key-value store.

# 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 = {
    "dateRange": {
        "start": "2020-01-01",
        "end": "2099-12-31"
    },
    "includeMedia": true,
    "includeReplies": true,
    "messageLimit": 25,
    "mode": "historical_archive",
    "telegramChannelOrProfile": "https://t.me/telegramtips"
};

// Run the Actor and wait for it to finish
const run = await client.actor("acid-base/borg-telegram-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 = {
    "dateRange": {
        "start": "2020-01-01",
        "end": "2099-12-31",
    },
    "includeMedia": True,
    "includeReplies": True,
    "messageLimit": 25,
    "mode": "historical_archive",
    "telegramChannelOrProfile": "https://t.me/telegramtips",
}

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

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

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

```

## CLI example

```bash
echo '{
  "dateRange": {
    "start": "2020-01-01",
    "end": "2099-12-31"
  },
  "includeMedia": true,
  "includeReplies": true,
  "messageLimit": 25,
  "mode": "historical_archive",
  "telegramChannelOrProfile": "https://t.me/telegramtips"
}' |
apify call acid-base/borg-telegram-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "command": "npx",
            "args": [
                "mcp-remote",
                "https://mcp.apify.com/?tools=acid-base/borg-telegram-scraper",
                "--header",
                "Authorization: Bearer <YOUR_API_TOKEN>"
            ]
        }
    }
}

```

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/acts/8ScAgnvsbOpw7g7Pn/builds/fOgzMkmR7fPmDiQsg/openapi.json
