# Telegram Channel Search Scraper — Fresh Posts by Keyword (`scrapersdelight/telegram-channel-search-scraper`) Actor

Search Telegram public channels by keyword and get the NEWEST matching posts from Telegram's own preview: channel title, @username, subscribers, description, post text, exact date, views, media, links and forward source. No login, no browser, 256 MB. Date filters for monitoring.

- **URL**: https://apify.com/scrapersdelight/telegram-channel-search-scraper.md
- **Developed by:** [Scrapers Delight](https://apify.com/scrapersdelight) (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

$2.00 / 1,000 per matching post returneds

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 Channel Search Scraper — Fresh Posts by Keyword

Search public [Telegram](https://t.me) channels by keyword and get one clean row per **matching
post**, not a channel directory. Every row carries the post *and* the channel it lives in:
**channelUsername, channelTitle, channelSubscribers, channelDescription, channelVerified,
postId, postUrl, text, textHtml, matchedTerms, publishedAt, postAgeDays, views, reactions,
reactionsTotal, author, mediaType, photos, videoCount, documents, links, mentions, hashtags,
linkPreviewUrl, forwardedFromUsername, replyToUrl, isPoll, pollQuestion, isEdited** — plus the
**keyword it matched and how the channel was discovered**. No login, no phone number, no bot
token, no Chromium: plain HTTP in **256 MB**.

**The freshness claim, and why it holds.** Third-party Telegram search sites index posts once and
then rot — spot-checked on 2026-08-22 against the index this Actor uses for *channel discovery*,
**most dated post hits on that index were years old**. So this Actor takes only **channel
identities** from that index and then asks **Telegram's own in-channel search** —
`t.me/s/<channel>?q=<keyword>` — for the posts. Telegram answers newest-first with the exact UTC
timestamp, view count, media and forward source. That is the whole trick: the index only has to
know a channel *exists*; Telegram supplies the posts, so "what was posted about this keyword this
week" is answerable without an account.

```json
{
  "keywords": ["bitcoin"],
  "maxResultsPerKeyword": 50,
  "maxChannelsPerKeyword": 10,
  "maxPostsPerChannel": 5
}
```

Click **Try for free** and hit **Start** — that is the input the Actor ships with. It caps a first
click at **50 posts = $0.10**.

***

### The wedge: fresh posts, channel context on every row, and a date window that stops the crawl

- **Telegram's own search, not a stale third-party post index.** Every post in your dataset comes
  from `t.me/s/<channel>?q=<keyword>`, which is Telegram's server-side search inside that channel's
  public preview. It returns the **newest 20 matches per page** and paginates backwards with
  `before=<postId>`. The keyword hits come back wrapped in Telegram's own `<mark class="highlight">`
  markup, and the Actor lifts them into **`matchedTerms`** so you can see exactly what Telegram
  matched.
- **A row is useful on its own.** Most Telegram scrapers give you either a channel list *or* a
  message dump you then have to join back to a channel. Here the channel's preview header —
  `channelTitle`, `channelUsername`, `channelSubscribers`, `channelDescription`, `channelVerified`,
  `channelPhoto` and the channel's photo/video/link counters — is denormalised onto **every post
  row**. Sort your CSV by `channelSubscribers` and you are done; no second lookup, no second
  Actor, no second bill.
- **A date window that makes the run cheaper, not just smaller.** `afterDate` accepts
  `YYYY-MM-DD`, a full ISO timestamp, or a relative window like `"7 days"`, `"24 hours"`,
  `"2 weeks"`. Because Telegram serves matches newest-first, the Actor **stops paginating a channel
  the moment its matches fall out of your window** instead of walking the archive and throwing rows
  away. Out-of-window posts are dropped **before billing**. This is the setting that turns the
  Actor into a monitor.
- **Discovery that follows live channels, not just catalogued ones.** With `expandViaForwards` on
  (the default), any channel that appears as a *"Forwarded from"* source inside a matching post is
  queued and searched too — and it goes to the **front** of the queue, because a channel that was
  just forwarded is provably alive, unlike an index entry. Those rows carry
  `discoveredVia: "forwarded-from"` so you can tell them apart from `"search-index"` and `"input"`.
- **Filters that run before the meter.** `minSubscribers` (read live from the channel header),
  `matchInChannelMeta` (the keyword must also appear in the channel's own title or description) and
  the date window all remove rows **before** anything is charged. Channels with no public preview
  cost nothing at all.

***

### Read this before you buy rows

1. **The public discovery index is full of dead, tiny and private handles.** Spot-checked
   2026-08-22: **most of the first 10 "bitcoin" hits had no public preview** — private channels,
   groups, or handles that never opened a preview. That is why the Actor pulls a candidate pool
   several times larger than `maxChannelsPerKeyword` and why `maxChannelsPerKeyword` counts
   channels it could actually **search**, not names it tried. Unreachable handles are logged
   (`@name: no public preview … skipped, not billed`) and never billed.
2. **Set `minSubscribers` on any keyword worth money.** The same index is stuffed with
   3-subscriber spam channels that will happily match a broad keyword. `minSubscribers: 1000`
   removes that long tail before billing. On the shipped default it is `0`, which is deliberate —
   a first click should show you the whole shape of the lane, spam included.
3. **Private channels and groups have no public preview, and nothing can change that.** There is
   no input that makes them readable; reading them requires a Telegram account that has joined
   them. They are skipped, reported in the log with their subscriber/member count where the public
   card publishes it, and never charged.
4. **`reactions` / `reactionsTotal` are best-effort.** Telegram's public preview renders reaction
   pills on some posts and not others. When none are rendered, `reactions` is `[]` and
   `reactionsTotal` is **`null` — never a fake `0`**. `views` is the reliable engagement number on
   a channel post.
5. **`author` is a post signature, not a person.** It is filled only when the channel has
   Telegram's "sign messages" setting on. It is not the channel owner's account, and this Actor
   collects nothing about the human behind a channel.
6. **Keyword search happens per channel, so breadth is your choice.** `maxPostsPerChannel: 5` with
   `maxChannelsPerKeyword: 50` spreads a keyword thinly across many channels; `maxPostsPerChannel:
   200` with `maxChannelsPerKeyword: 5` goes deep into a few. Same money, very different dataset.

***

### What you get

One row per unique public Telegram post, de-duplicated run-wide on `channelUsername/postId`
**before** anything is billed. `publishedAt` is Telegram's own timestamp with a UTC offset;
`scrapedAt` is ISO-8601 UTC.

| Group | Fields |
|---|---|
| **Post identity** | `postId`, `postUrl`, `channelUsername` |
| **Content** | `text`, `textHtml`, `matchedTerms`, `author` |
| **Time** | `publishedAt`, `postAgeDays`, `isEdited`, `scrapedAt` |
| **Engagement** | `views`, `reactions[]` (`emoji`, `count`), `reactionsTotal` |
| **Media** | `mediaType`, `hasMedia`, `photos[]`, `videoCount`, `documents[]` |
| **Outbound** | `links[]`, `linkPreviewUrl`, `linkPreviewTitle`, `mentions[]`, `hashtags[]` |
| **Threading** | `forwardedFromUsername`, `forwardedFromTitle`, `forwardedFromUrl`, `replyToUrl` |
| **Poll** | `isPoll`, `pollQuestion` |
| **Channel context** | `channelTitle`, `channelUrl`, `channelSubscribers`, `channelDescription`, `channelVerified`, `channelPhoto`, `channelPhotosCount`, `channelVideosCount`, `channelLinksCount` |
| **Provenance** | `keyword`, `discoveredVia` |

`mediaType` is one of `text`, `photo`, `video`, `document`, `voice`, `sticker`, `poll`.
`discoveredVia` is one of `search-index` (the keyword discovery index), `forwarded-from` (found
inside a matching post) or `input` (a channel you listed yourself).

The dataset ships with a saved **table view**: Keyword, Channel, Channel title, Subscribers,
Published (UTC), Post, Views, Reactions, Media, Forwarded from, Found via, Post URL. The full
field set above is in the JSON/CSV export; the view is just the 12 columns worth eyeballing in the
Console.

#### Which fields are always there, and which are not

Rather than quote a fill percentage from one keyword and let you plan against it, here is the
mechanism — and **every run prints its own measured field fill in the log**, on that run's rows
(`Field fill on N rows: …`), so you can check the exact numbers for *your* keyword. Note: the fill
line counts a field as filled only when it is non-null, non-empty and not `false`, so boolean
fields (`channelVerified`, `isPoll`, `isEdited`, `hasMedia`) read below 100% even though the key is
always present.

**Always present on a delivered row** (the parser sets them on every row it emits): `keyword`
(except in feed mode, where it is `null` by design), `channelUsername`, `channelUrl`, `postId`,
`postUrl`, `mediaType`, `hasMedia`, `videoCount`, `isPoll`, `isEdited`, `channelVerified`,
`discoveredVia`, `scrapedAt`.

**Present whenever Telegram publishes them** — near-universal on real channels, `null` where the
channel or post genuinely lacks them: `channelSubscribers`, `channelDescription`, `channelPhoto`,
`views`, `text` and `textHtml` (a caption-less media post has no text at all), `publishedAt` /
`postAgeDays` (`null` on the rare post whose preview markup carries no timestamp — such rows are
still delivered and pass the date filter), `channelTitle` (`null` when the channel header title
does not parse and the channel came from your own `channels` list).

**Genuinely conditional — expect `null` or `[]` on most rows, and that is correct, not a gap:**
`matchedTerms` (empty in feed mode, where there is no keyword to highlight), `reactions` /
`reactionsTotal` (see gotcha 4), `author` (signed channels only), `photos` / `documents` (media
posts only), `linkPreviewUrl` / `linkPreviewTitle` (posts with a link card), `links`, `mentions`,
`hashtags` (only what the post actually contains), `forwardedFromUsername` / `forwardedFromTitle` /
`forwardedFromUrl` (forwards only), `replyToUrl` (replies only), `pollQuestion` (polls only),
`channelPhotosCount` / `channelVideosCount` / `channelLinksCount` (Telegram shows these counters on
some channels and not others).

***

### How to run it

#### 1. Keyword sweep across the public channel space (the usual choice)

```json
{
  "keywords": ["bitcoin", "airdrop"],
  "maxChannelsPerKeyword": 50,
  "maxPostsPerChannel": 5,
  "minSubscribers": 1000,
  "maxResultsPerKeyword": 250
}
```

Each keyword discovers its own channel pool, then every channel with a public preview is searched
with that keyword. `minSubscribers: 1000` throws away the spam tail before billing. Total here is
capped at 250 rows per keyword = **$0.50 per keyword**.

#### 2. Keyword monitor — the last 7 days only

```json
{
  "keywords": ["rug pull", "presale live"],
  "afterDate": "7 days",
  "maxChannelsPerKeyword": 100,
  "maxPostsPerChannel": 20,
  "minSubscribers": 500,
  "maxItems": 500
}
```

`afterDate` accepts the relative form, so the input never needs editing: save it as a **Task**,
attach a daily or weekly **Schedule**, and every run pulls the window ending now. Pagination stops
per channel as soon as its matches go older than the window, so a tight window is both faster and
cheaper. `maxItems` is the hard ceiling on the whole run.

#### 3. Keywords inside channels you already trust

```json
{
  "keywords": ["hiring", "we are looking for"],
  "channels": ["durov", "@telegram", "https://t.me/s/bloomberg"],
  "maxPostsPerChannel": 50,
  "afterDate": "30 days"
}
```

When `channels` is set **and** `keywords` is set, index discovery is skipped entirely. Note that
`expandViaForwards` still applies: set it to `false` if you want the keywords searched *only*
inside the channels you named, otherwise channels seen as a "Forwarded from" source are queued and
searched too. Handles are accepted as `name`, `@name`, `t.me/name` or `t.me/s/name`.

#### 4. Feed mode — the newest posts from named channels, no keyword

```json
{
  "keywords": [],
  "channels": ["bloomberg", "cointelegraph"],
  "channelMaxMessages": 100
}
```

Leave `keywords` empty and the Actor stops searching and simply returns the newest
`channelMaxMessages` posts from each listed channel (`20` = one preview page). `keyword` and
`matchedTerms` are empty on these rows; everything else is identical.

#### 5. A window between two dates

```json
{
  "keywords": ["etf approval"],
  "afterDate": "2026-07-01",
  "beforeDate": "2026-07-31",
  "maxChannelsPerKeyword": 40,
  "maxPostsPerChannel": 30
}
```

A bare `YYYY-MM-DD` in `beforeDate` includes that whole day. Both bounds are applied to
`publishedAt` before billing.

#### Scheduling and integrations

Save any input as a **Task** (Console → the Actor → *Save as Task*), then attach an Apify
**Schedule**. The dataset is available over the REST API and through the standard Apify
integrations (Zapier, Make, n8n, webhooks, MCP) like any other Actor. From the API:

```bash
curl -X POST "https://api.apify.com/v2/acts/scrapersdelight~telegram-channel-search-scraper/runs?token=YOUR_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"keywords":["bitcoin"],"afterDate":"7 days","minSubscribers":1000,"maxItems":200}'
```

***

### Sample row

A row from a 2026-08-22 run, with `matchedTerms` and `postAgeDays` added from the current build;
`text` and `textHtml` truncated for the README.

```jsonc
{
  "keyword": "bitcoin",
  "channelUsername": "Bitco_info",
  "channelUrl": "https://t.me/bitco_info",
  "channelTitle": "Bitcoin info",
  "channelSubscribers": 7040,
  "channelVerified": false,
  "channelDescription": "🌐Best bitcoin information🥇📈 Bitcoin and Cryptocurrency earning Guru 💰💌On business issues and proposal write to the📩@magicmanage ⬇️Click Here more #news📊https://www.instagram.com/bitcoin.info.9",
  "channelPhoto": "https://cdn4.telesco.pe/file/dvuwps1hwZHEcDV4BSFi9rXEHsagfztDu15AtNbGnurCC5_vLQu--51aEPY_s4IeBQuEL4fmA-0hZle3RYdc2nDqrLdlLgcABTNKb5KQdynUszRHgUZMjNbZm7cpliykc6chOhgftlhkZNN22K1s6p01T2FKE_re9msLlR0tA61OE2CqlUrk9PbJ_ErPjzwy0Fe9eAahwZzG5DKKm02dOREV955RbVi8SszyD4GCqfHkzbdKULGrUEyljzjgVcVX9ye5T3_Jb2GjETirUXTeIPwhdi03qq89J35d3X08m7M0D3muwAf0_veVc5aOCREgP_tOHMo63ncVrgMcUe3iWg.jpg",
  "channelPhotosCount": 4270,
  "channelVideosCount": 112,
  "channelLinksCount": 11800,
  "discoveredVia": "search-index",
  "postId": 16595,
  "postUrl": "https://t.me/Bitco_info/16595",
  "text": "Bitcoin tags $65K as S&P 500 rebounds from 2-week lows on US-Iran rhetoric\nBitcoin reached $65,000 as the S&P 500 rebounded from two-week lows, despite diverging from US stocks amid escalating US-Iran tensions. …",
  "textHtml": "<mark class=\"highlight\"><b>Bitcoin</b></mark><b> tags $65K as S&amp;P 500 rebounds from 2-week lows on US-Iran rhetoric</b><br><br><mark class=\"highlight\">Bitcoin</mark> reached $65,000 as the S&amp;P 500 rebounded … ",
  "matchedTerms": ["Bitcoin"],
  "publishedAt": "2026-08-18T16:44:32+00:00",
  "postAgeDays": 4,
  "views": 241,
  "reactions": [],
  "reactionsTotal": null,
  "author": null,
  "forwardedFromTitle": null,
  "forwardedFromUsername": null,
  "forwardedFromUrl": null,
  "replyToUrl": null,
  "mediaType": "text",
  "hasMedia": false,
  "photos": [],
  "videoCount": 0,
  "documents": [],
  "linkPreviewUrl": null,
  "linkPreviewTitle": null,
  "links": [
    "https://instagram.com/bitcoin.info.9/",
    "https://instagram.com/bitcoin.info/"
  ],
  "mentions": [],
  "hashtags": [],
  "isPoll": false,
  "pollQuestion": null,
  "isEdited": false,
  "scrapedAt": "2026-08-22T17:28:17.811Z"
}
```

Fields people misread:

- **`matchedTerms` is what Telegram highlighted, in Telegram's casing** — here `"Bitcoin"` for the
  keyword `"bitcoin"`. It is the honest record of what the match actually was, so a keyword that
  Telegram stemmed or matched as a phrase shows up as the surface form in the post, not as your
  input string.
- **`channelUsername` is the casing Telegram stamps on the post; `channelUrl` uses the casing from
  the channel header.** In the row above that is `Bitco_info` and `t.me/bitco_info`. Telegram
  handles are case-insensitive — de-duplicate on the lowercased handle if you join across runs.
  (Inside a run, the Actor already does exactly that.)
- **`reactionsTotal: null` means "no reaction pills were rendered", not "zero reactions".** A real
  zero would be a rendered pill with a count of zero, which Telegram does not do.
- **`postAgeDays` is measured at scrape time**, to one decimal place, from `publishedAt`. It is a
  convenience for sorting; `publishedAt` is the fact.
- **`text` is `null` on a caption-less media post.** Check `mediaType` / `hasMedia` before
  filtering on text length.
- **`views` counts views of the post**, which on a big channel is routinely larger than
  `channelSubscribers` (forwards and public links count too). That is Telegram's number, unmodified.

***

### Input

| Field | Type | Default | What it does |
|---|---|---|---|
| **🔎 What to search** | | | |
| `keywords` | string list | `["bitcoin"]` *(prefill)* | One keyword or phrase per line. Each one discovers channels, then is searched inside every channel found. A multi-word entry is matched by Telegram as a phrase. Empty + `channels` set = feed mode. |
| `channels` | string list | `[]` | Public handles or `t.me` links. **With** keywords: index discovery is skipped and the keywords are searched inside these — plus any channel `expandViaForwards` adds, unless you turn it off. **Without** keywords: newest posts from each. Private channels and groups are skipped and never billed. |
| `afterDate` | string | `""` | Drop posts older than this. `YYYY-MM-DD`, an ISO timestamp, or `"7 days"` / `"24 hours"` / `"2 weeks"`. Also stops pagination early — the monitor setting. |
| `beforeDate` | string | `""` | Drop posts newer than this. A bare `YYYY-MM-DD` includes that whole day. |
| **💰 Limits & cost** | | | |
| `maxResultsPerKeyword` | integer | `50` | Hard row cap per keyword. 50 = $0.10, 500 = $1.00. |
| `maxChannelsPerKeyword` | integer | `10` | Channels *searched* per keyword (max 500). 10 is a sample; 50–100 is a real sweep. Channels with no preview cost nothing. |
| `maxPostsPerChannel` | integer | `5` | Newest-first cap per channel (schema max 2000, but pagination stops at 50 pages ≈ **1,000 posts** per channel per run). Telegram serves 20 matches per page; above 20 the Actor pages further back. |
| `channelMaxMessages` | integer | `20` | Feed mode only: newest N posts per listed channel (max 5000). 20 = one preview page. |
| `maxItems` | integer | `0` | **Overall cost cap across everything.** `0` leaves the per-keyword cap in charge. |
| **🎚️ Filters** | | | |
| `minSubscribers` | integer | `0` | Skip posts from channels below this, read live from the channel header. Applied before billing. |
| `expandViaForwards` | boolean | `true` | Also queue and search channels found as a *"Forwarded from"* source. Their rows carry `discoveredVia: "forwarded-from"`. |
| `matchInChannelMeta` | boolean | `false` | Require the keyword in the channel's own title or description too — a stricter "channels about X" pull. |
| **⚙️ Advanced** | | | |
| `maxConcurrency` | integer | `5` | Parallel channel fetches. 5 measured clean through Apify proxies; 10 is the ceiling. |
| `proxyConfiguration` | proxy | Apify proxy on, no group pinned | Apify Proxy is on by default with no group pinned (Apify's automatic pool). Both t.me and the discovery index answered over Apify Proxy in our 2026-08-22 test run; switch to RESIDENTIAL if HTTP errors show in the log. |

Run defaults: **256 MB memory, 3600 s timeout.**

***

### Pricing

**$0.002 per post returned — $2 per 1,000 —** charged on the **`post-scraped`** event. There is no
monthly fee from this Actor and no second event: one row, one charge.

| Run | Posts | Cost |
|---|---|---|
| The shipped default | 50 | **$0.10** |
| One keyword, 50 channels × 5 posts | 250 | **$0.50** |
| A weekly 2-keyword monitor | 500 | **$1.00** |
| A deep sweep | 1,000 | **$2.00** |

- **You are charged per delivered row, once.** Rows are de-duplicated run-wide on
  `channelUsername/postId` **before** the charge, so the same post matching two of your keywords is
  delivered and billed once.
- **Everything the Actor discards is free.** Channels with no public preview, private channels and
  groups, posts outside `afterDate`/`beforeDate`, channels under `minSubscribers`, rows removed by
  `matchInChannelMeta`, and service messages are all dropped **before** the meter — they never
  appear in your bill.
- **Rows are pushed and charged in the same call**, so a run that hits your maximum charge stops on
  a whole row and ends **SUCCEEDED** with a status message telling you it stopped there — never a
  half-billed dataset, and never a platform abort mid-channel.
- **`maxItems` is your hard cost cap.** Set it and multiply by $0.002 to know the ceiling before
  you click Start.

***

### Honest limits

- **Private channels, groups and channels with no public preview cannot be read.** This is
  Telegram's boundary, not a missing feature: those posts only exist behind a logged-in account
  that has joined. The Actor detects them from the public card, logs
  *"no public preview … posts cannot be read without a login; skipped, not billed"*, and moves on.
  There is no input that changes this and no plan to add one.
- **Keyword discovery depends on a third-party index.** Channel *identities* come from a public
  Telegram search index (`lyzem.com`). If that index is down or answers with an error, the run logs
  *"Discovery index … -> HTTP …"* and that keyword yields nothing — **pass `channels` directly and
  the Actor never touches the index at all.** Modes 3 and 4 above are fully independent of it;
  modes 1, 2 and 5 use the index for channel discovery. Telegram itself has no public global
  post-search endpoint to use instead; that is why the split exists.
- **Discovery quality is the index's, not ours.** Spot-checked 2026-08-22, **most of the first 10
  "bitcoin" hits had no public preview**, and the surviving channels included 3-subscriber spam.
  The Actor compensates by pulling a candidate pool several times larger than
  `maxChannelsPerKeyword` and by promoting forward-sourced channels, but a broad keyword still
  needs `minSubscribers` to be worth reading.
- **You get the channels the index and the forward graph reach, not "all of Telegram".** Telegram
  publishes no channel directory. Two runs of the same keyword weeks apart can surface different
  channels as the index and the forward graph change.
- **Per-channel depth is capped.** A single channel is paginated at most 50 preview pages per
  keyword (Telegram serves 20 matches per page), so **about 1,000 matching posts is the most one
  channel can yield in one run**. Split a deep archive pull across date windows.
- **No comments, no reactions-by-user, no member lists, no channel analytics.** Telegram's public
  preview does not publish them, and neither does this Actor.
- **`reactions` are inconsistent** — see gotcha 4. Use `views` for engagement.
- **No search operators.** Telegram's in-channel search takes a keyword or a phrase. There is no
  `OR`, no `-exclude`, no field prefix. Run several keywords instead; duplicates across them are
  de-duplicated before billing.
- **A time-budget stop is possible on a big sweep.** The Actor reserves the last ~25 seconds of the
  run to push and charge what it has, then stops fetching and ends SUCCEEDED with
  *"Stopped early to stay inside this run's time limit."* Raise the run timeout or lower the caps
  to get the rest.
- **Dedupe is per run.** Across scheduled runs, move `afterDate` forward, or de-duplicate on
  `postUrl` on your side.
- **A date that cannot be parsed is ignored, not an error.** `afterDate`/`beforeDate` accept
  `YYYY-MM-DD`, an ISO timestamp, or `"7 days"`. Anything else is logged as *Could not parse date
  "…" — ignored* and the run proceeds with **no** date window, so it can bill far more rows than
  you expected. Check the log line on the first run of a new schedule.
- **Proxies.** Apify Proxy is on by default with no group pinned (Apify's automatic pool); both
  t.me and the index answered over it in our 2026-08-22 test run. RESIDENTIAL is one switch away if
  HTTP errors show in the log. A preview page is attempted up to 3 times, each on a fresh proxy
  session, before the channel is skipped and logged.

***

### How it works

Two public, logged-out surfaces, both plain HTTP — **no Chromium, no login, no cookies, no bot
token, no phone-number account.** Telegram's own APIs would need one of the last two; the public
web preview needs neither.

| Step | Surface | What is taken |
|---|---|---|
| **1. Discovery** | `lyzem.com/search?q=<kw>&f=channels` | **Channel identities only** (`t.me/<username>`, title, description). Never its post index — spot-checked 2026-08-22, most dated post hits on it were years old. |
| **1b. Widening** | the `forwardedFromUsername` of every matching post | Live channels the index has not catalogued, queued to the **front** (`expandViaForwards`). |
| **2. Freshness** | `t.me/s/<username>?q=<kw>[&before=<postId>]` | **Telegram's own in-channel search**: newest 20 matches per page, exact UTC datetime, views, media, forward source, reactions; pages older with `before=`. The same page's header gives title / subscribers / description / verified. |
| **2b. Fallback identity** | the same `t.me/s/<username>` response, when it falls back to the plain channel card | Subscriber count and description are still read from it, so the log can say *what* was skipped instead of dropping it silently. |

Channels are fetched in parallel (`maxConcurrency`, default 5, ceiling 10). Every fetch is gated on
the run's wall-clock budget and its timeout clamped to what remains, so the emit path always has
time to deliver.

**Reliability contract.** The run **never ends FAILED for a transient reason** — a time budget, an
empty keyword list, an index hiccup, an unhandled throw. All of those end **SUCCEEDED** with a
status message naming the cause. `unhandledRejection` and `uncaughtException` are both trapped and
converted into a clean exit for the same reason: a FAILED run is permanent on the public scorecard
and feeds Apify's auto-maintenance flag, and none of those causes mean your data is wrong.

**The duplicate-leak check.** Before the run is allowed to finish, the Actor asserts that rows
pushed == unique `channel/postId` keys seen. If they diverge the run stops immediately and the
status message names the assertion, so you can see a billing discrepancy rather than have it
hidden — note the rows already pushed are already in the dataset.

**Every run logs its own numbers**: HTTP ok/failed split by surface, channels searched, channels
with rows, channels with no public preview, channels that failed (channels dropped by an anti-bot
challenge page currently surface only as a `worker error` warning), and the exact count dropped by
each filter (duplicates, date window, `minSubscribers`, `matchInChannelMeta`) — plus the measured
field fill across the rows it delivered.

***

### When a run ends without the rows you expected

Every one of these ends **SUCCEEDED** with a status message, so a scheduled monitor on a quiet
keyword is never flagged as broken:

- **0 posts** → the message names the cause: *t.me preview pages could not be fetched* /
  *the discovered channels have no public preview* / *nothing matched inside the date window —
  widen `afterDate` or add channels* / *no matching posts — try a broader keyword or pass channels
  directly*. **Nothing is charged.**
- **Charge cap reached** → *"Stopped at your maximum charge for this run — N post(s) delivered and
  charged. Raise 'Maximum total charge' for the rest."* Every delivered row was billed; no partial
  rows.
- **Run time budget** → *"Stopped early to stay inside this run's time limit."* Raise the timeout
  or lower the caps.
- **No input at all** (`{}` from an API call) → the Actor runs the documented sample, keyword
  `"bitcoin"`, and says so in the log and the status message, rather than failing.

***

### Who buys this

- **Crypto, trading and airdrop desks** — Telegram is where the announcement lands first. A
  keyword monitor with `afterDate: "24 hours"` and `minSubscribers` on a real floor is an alerting
  feed, not an archive dig.
- **Brand and threat monitoring** — your brand, product or domain mentioned across public channels,
  with the channel's subscriber count on the same row so you can triage by reach immediately.
- **OSINT and journalism** — `forwardedFromUsername` plus `expandViaForwards` maps how a claim
  propagates between public channels, with exact timestamps and view counts on every hop.
- **Market and competitor research** — what public channels in a vertical are actually saying this
  week, with `links` and `mentions` already extracted for a network view.
- **AI / RAG pipelines** — clean JSON with stable ids (`postUrl`), ISO timestamps, both plain
  `text` and `textHtml`, keyword provenance on every row, de-duplicated, nothing to unpick.

***

### FAQ

**Does this need a Telegram account, a phone number, a bot token or an API key?**
No. It reads only what a logged-out visitor sees on `t.me`. No account of yours is used or asked
for, and nothing behind a login is touched.

**How fresh are the posts?**
As fresh as Telegram's own in-channel search, which is the same surface the channel's public
preview serves — the newest matches come back first, with an exact UTC timestamp. That is the
entire reason the Actor uses a third-party index only to find *channels* and never to find posts.

**Can it read private channels or groups?**
No — they have no public preview. They are detected, logged, skipped and never billed.

**Can I search a keyword only inside channels I care about?**
Yes: put them in `channels` alongside your `keywords` — the index is never contacted. Set
`expandViaForwards` to `false` as well if you want *only* those channels, since by default a
channel seen as a "Forwarded from" source is queued and searched too.

**Can I get a channel's latest posts without a keyword?**
Yes: leave `keywords` empty, fill `channels`, and set `channelMaxMessages`.

**How do I make a scheduled monitor cheap?**
`afterDate: "24 hours"` (or `"7 days"`), a real `minSubscribers`, and a `maxItems` ceiling.
Pagination stops per channel the moment the window closes, and every filtered row is free.

**Will two runs bill me twice for the same post?**
Within a run, never — dedupe is on `channelUsername/postId` and the count is asserted before the
run is allowed to finish. Across runs, move `afterDate` forward, or de-duplicate on `postUrl` your
side.

**Do I need a residential proxy?**
Not by default: Apify Proxy is on with no group pinned (Apify's automatic pool), and both surfaces
answered over it in our 2026-08-22 test run. RESIDENTIAL is available in the proxy input if you
ever see HTTP errors in the log.

**Why did I get spam channels?**
Because the public discovery index is full of them and `minSubscribers` defaults to `0`. Set it to
500 or 1000 — those rows are then removed before billing.

**Will a run ever succeed with zero rows?**
Yes, deliberately, with a status message naming the cause and no charge — so a quiet keyword on a
schedule does not look like a broken Actor.

***

### Legal & fair use

This Actor reads **public Telegram channel previews** as a logged-out visitor. It does not log in,
does not use any Telegram account, bot token, phone number or API key, and collects nothing behind
authentication. Private channels and groups are skipped by design.

Neither `t.me` nor `telegram.org` serves a `robots.txt` at all — both return **HTTP 404** (checked
2026-08-26); the same is true of the discovery index used for channel names. There is therefore no
crawl directive to honour or breach, and the Actor instead paces itself with a low default
concurrency (5) and per-request retry backoff.

Telegram's Terms of Service govern how Telegram content may be used. Channel posts are
user-generated content and can contain personal data, handles and contact details — **you are
responsible for how you use what you collect**, including GDPR/CCPA obligations and the rules on
unsolicited contact.

Telegram® is a trademark of its owner. This Actor is not affiliated with, endorsed by, or connected
to Telegram Messenger Inc.

***

### Feedback

Missing a field, or want a filter that is not here? Open an issue on the **Issues** tab — and if the
Actor earns it, a review on the **Reviews** tab helps other buyers find it.

# Actor input Schema

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

One keyword or phrase per line. For each one the Actor discovers public channels whose title/description/handle matches, then runs Telegram's own in-channel post search on every channel it found, newest posts first. A multi-word phrase is matched by Telegram as a phrase. Leave empty and fill "Channels" to pull the latest posts from channels you already know.

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

Public channel handles or t.me links, one per line (e.g. "telegram", "@durov", "https://t.me/s/bloomberg"). With keywords: index discovery is skipped and the keywords are searched inside these channels — plus any channel added by "Also search channels found via 'Forwarded from'", so turn that off if you want ONLY the channels you listed. Without keywords: the newest posts from each channel are returned, up to "Posts per channel (feed mode)". Private channels and groups have no public preview and are skipped, never billed.

## `afterDate` (type: `string`):

Drop posts older than this. Accepts YYYY-MM-DD, a full ISO timestamp, or a relative window like "7 days", "24 hours", "2 weeks". Pagination stops as soon as a channel's matches get older than this, so a tight window is also a faster, cheaper run — the monitor setting.

## `beforeDate` (type: `string`):

Drop posts newer than this. YYYY-MM-DD (inclusive of that day) or an ISO timestamp. Combine with "Only posts after" to cut a window.

## `maxResultsPerKeyword` (type: `integer`):

Hard cap on rows for each keyword. 50 = $0.10, 500 = $1.00. The run stops searching further channels for that keyword once it is reached.

## `maxChannelsPerKeyword` (type: `integer`):

How many discovered channels to open per keyword (in the order the search index ranks them, plus channels found via "Forwarded from" when that option is on). Channels with no public preview count toward this but cost nothing. 10 is a quick sample; 50–100 is a real sweep.

## `maxPostsPerChannel` (type: `integer`):

Newest-first cap per channel for keyword searches. Pagination stops at 50 preview pages, so about 1,000 posts per channel per run is the most this can deliver whatever you set. Telegram returns 20 matches per page; anything above 20 pages further back with "before=". Keep it small to spread a keyword across many channels, large to go deep into a few.

## `channelMaxMessages` (type: `integer`):

Used only when "Keywords" is empty and "Channels" is set: the newest N posts from each listed channel, no keyword filter. 20 = one preview page per channel.

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

Overall cost cap across all keywords and channels. 0 leaves the per-keyword cap in charge.

## `minSubscribers` (type: `integer`):

Skip posts from channels below this subscriber count (read live from the channel's preview header). 1000 removes the long tail of dead and spam channels the search index is full of.

## `expandViaForwards` (type: `boolean`):

When a matching post was forwarded from another public channel, that channel is queued and searched too (inside "Max channels searched per keyword"). This is how currently-active channels that no index has catalogued yet get into your results. Rows from them carry discoveredVia: "forwarded-from".

## `matchInChannelMeta` (type: `boolean`):

Off (default): any public channel the index associates with the keyword is searched, and the keyword only has to appear in the POST. On: the channel's own title or description must also contain the keyword — a stricter "channels about X" pull.

## `maxConcurrency` (type: `integer`):

Parallel channel fetches. 5 measured clean through Apify proxies; 10 is the ceiling.

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

Apify Proxy is on by default with no group pinned (Apify's automatic pool). Both t.me and the discovery index answered over Apify Proxy in our 2026-08-22 test run; switch to RESIDENTIAL if you see HTTP errors in the log.

## Actor input object example

```json
{
  "keywords": [
    "bitcoin"
  ],
  "channels": [],
  "afterDate": "",
  "beforeDate": "",
  "maxResultsPerKeyword": 50,
  "maxChannelsPerKeyword": 10,
  "maxPostsPerChannel": 5,
  "channelMaxMessages": 20,
  "maxItems": 0,
  "minSubscribers": 0,
  "expandViaForwards": true,
  "matchInChannelMeta": false,
  "maxConcurrency": 5,
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}
```

# Actor output Schema

## `items` (type: `string`):

One row per unique public Telegram channel post that matched a keyword (or, in feed mode, one of the newest posts of a listed channel): keyword, channel @username/title/subscribers/description/verified flag, post id and URL, text (plain + HTML), exact UTC publish time, views, reactions, media type and photo/video/document details, outbound links, mentions, hashtags, forward source and discovery route. Deduplicated run-wide on channel/postId before billing.

# 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 = {
    "keywords": [
        "bitcoin"
    ],
    "proxyConfiguration": {
        "useApifyProxy": true
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("scrapersdelight/telegram-channel-search-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 = {
    "keywords": ["bitcoin"],
    "proxyConfiguration": { "useApifyProxy": True },
}

# Run the Actor and wait for it to finish
run = client.actor("scrapersdelight/telegram-channel-search-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 '{
  "keywords": [
    "bitcoin"
  ],
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}' |
apify call scrapersdelight/telegram-channel-search-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,scrapersdelight/telegram-channel-search-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/mvkTRMytxSpGS5Vkh/builds/mqKJSIEwIvojB3REO/openapi.json
