BORG Telegram channel scraper
Pricing
from $3.60 / 1,000 results
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
Maintained by CommunityActor stats
1
Bookmarked
124
Total users
31
Monthly active users
2 days ago
Last modified
Categories
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:
| 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+ADDEMOJIat limit 40 goes 8 rows → 4. Note this only costs you anything when the first target does not already exhaustmessageLimit— 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) ort.me/joinchat/<hash>used to be read as the channel@boost/@joinchatand billed against it. Those are refused now. - a mode or filter that matches nothing — a
dateRangeexcluding every post, or a media-only mode over text-only posts, used to emit one channel row stampedcompleteness: "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.