BORG Telegram channel scraper avatar

BORG Telegram channel scraper

Pricing

from $3.60 / 1,000 results

Go to Apify Store
BORG Telegram channel scraper

BORG Telegram channel scraper

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.

Pricing

from $3.60 / 1,000 results

Rating

0.0

(0)

Developer

Daniel Yates

Daniel Yates

Maintained by Community

Actor stats

1

Bookmarked

124

Total users

31

Monthly active users

2 days ago

Last modified

Share

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:

statusmeaning
GREENevery requested target was attempted, reached, and positively classified; no input entry was rejected, no page walk was cut short, and no document arrived truncated
AMBERreal 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
REDat 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:

casebeforenow
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 rendered1 row0 rows
the same channel requested twice in different letter case8 rows4 rows — one observation
a mode or filter (dateRange, media-only) that matches nothing, in a message-only mode1 channel row stamped complete0 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:

ModePurpose
legacy_channel_scraperBackward-compatible with the original deployed actor (channels, maxMessages, dateFrom/dateTo).
historical_archiveExport public channel history into a normalized research archive.
research_dataset_exportBounded public-message dataset with stable fields for research/enrichment.
media_link_inventoryInventory public media and outbound links from a channel.
recent_monitorCheck recent public posts for fresh updates.
incremental_update_seedBounded latest-message seed for downstream delta comparison.
channel_summaryResolve a public profile into a compact channel summary.
message_probeSmall 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.