# Telegram view-to-subscriber survey — methodology

**Survey:** 430 public channels requested, 325 usable, 5,166 posts.
Wave one ran **2026-09-09T20:26:38Z to 20:57:09Z**. Wave two re-read the same 430
channels **22:57:40Z to 23:31:18Z**, about 2.5 hours later, and wave three read them
again that night; waves two and three together produce the 9,976 same-post growth
pairs in `post_growth.csv` (wave two alone gave 5,015, the figure an earlier version of
this note quoted). Wave four read the same 430 channels **2026-09-13, 21:24:58Z to
21:55:30Z**, four days later; see section 6c. Every count here is produced by
`analyse.py` into `stats.json` and can be re-derived offline from `pages/`.

## 1. What this is

A measurement of two things that public Telegram channels publish about themselves
on the open web: how many people subscribe, and how many views each recent post has
collected. From those two numbers comes the **view-to-subscriber ratio**, and from
repeated visits to the same posts comes **how views accumulate as a post ages**.

Nothing was bought, joined, logged into or asked for. Every figure is read off a page
a person can open in a browser with no Telegram account.

## 2. The source, exactly

Telegram publishes two pages per public channel:

| URL | what it gives |
|---|---|
| `https://t.me/<channel>` | the contact card, which prints the **exact** subscriber count (`10 823 781 subscribers`) |
| `https://t.me/s/<channel>` | the public web preview: roughly the last 20 posts, each with a view counter, a timestamp, and markers for forwards and media |

That is the entire source. No API, no login, no account, no joining, no private or
restricted channel, and no message text is carried into the published dataset — the
columns are ids, timestamps, counts and two booleans. The gzipped HTML in `pages/`
is kept so the numbers can be re-derived, and it does contain post text as served;
it is retained for reproducibility, not published as a text corpus.

### robots.txt

`https://t.me/robots.txt` returned **HTTP 404** when checked at the start of this
survey (2026-09-09). There is no robots file, so no path is disallowed. We
rate-limited anyway.

### Politeness

- **One request per two seconds**, serialised, single-threaded. The constant is
  `RATE_LIMIT_SECONDS = 2.0` at the top of `harvest.py`.
- **Truthful User-Agent**: `Novamya-Research/1.0 (+https://novamya.com/about/)` —
  it names us and links to a page saying who we are. The constant is `USER_AGENT`,
  also at the top of `harvest.py`.
- Fetches ran direct from the workstation. t.me answers it fine, so no relay was
  needed and none was used.

## 3. How channels were chosen

Nobody publishes a list of all public Telegram channels, so there is no frame to
sample randomly from. Ours is built from public directories, and every step is in
`harvest.py` so the frame itself is re-derivable.

**Pass one** (`harvest.py frame`), 23 directory pages:

1. `combot.org/top/telegram/channels`, pages 1, 2, 3, 20, 40, 60, 80, 100, 120, 140,
   160, 180, 196 — one global activity ranking, 196 pages deep. Page depth is the
   only size signal available before fetching: shallow pages are big channels,
   deep pages are small ones.
2. `combot.org/top/telegram/channels/<lang>` for `en, es, fa, fr, pt, ru, tr, uk, zh`
   — how the sample reaches beyond English.
3. `tgstat.com/ratings/channels` — a second directory, built differently, so the
   frame does not rest on one company's ranking.

Candidates are every `t.me/<name>` and `/channel/@<name>` link on those pages that
looks like a channel username: 4–32 characters, starts with a letter, not a reserved
t.me path, not ending in `bot`, not the directory's own account.

**Pass two** (`harvest.py frame2`) was added *after* wave one, because pass one
produced a barbell — combot's shallow pages are very large channels and its deep
pages are very small ones, with little in between. Pass two adds 19 more combot
pages chosen to land between the two lumps, and 20 `tgstat.com/ratings/channels/<category>`
pages for topical spread. That this second pass exists, and why, is itself a finding
about the sampling frame and is reported rather than smoothed over.

Of the 62 directory pages requested across both passes, 61 answered 200 and one did
not: `tgstat.com/ratings/channels/travel` returned **404** — that category does not
exist — and contributed no candidates. It is left in the source list and in
`fetchlog.tsv` rather than quietly removed, because the frame should be reproducible
including its misses.

One page of each tgstat category is fetched and no more: tgstat accepts a `?page=`
parameter and **ignores it**. `…/channels/news` and `…/channels/news?page=5` returned
the same 94 channels when checked, so paging it would have silently duplicated rows
rather than reaching further down the ranking. combot's `?page=` is real — its pages
return different channels — which is why depth comes from combot only.

**Drawing the sample.** `harvest.py sample` buckets every candidate into the rarest
stratum it appears in, shuffles each bucket with a fixed seed (`SAMPLE_SEED = 20260909`),
and takes an equal quota from each. Re-running it reproduces the same list byte for
byte. `sample2` tops the sample up from the pass-two strata without disturbing the
original draw.

**No search API was used.** Serper was available and was not needed: the directories
answer directly, and a frame built from URLs anyone can fetch is more reproducible
than one built from a paid search endpoint whose results cannot be republished.

## 4. What is measured, field by field

Per channel, from the contact card:

- `subscribers` — the exact integer. If the card does not render, the 3-significant-figure
  count from the preview header is used instead and the row is marked
  `subscribers_source = rounded-from-preview`.

Per post, from the preview:

- `post_id` — from `data-post="<channel>/<id>"`. Blocks whose `data-post` names a
  *different* channel are quoted posts and are skipped. That comparison is
  **case-insensitive on purpose**: Telegram usernames are case-insensitive, and the
  case a directory prints is often not the case Telegram serves — a channel listed as
  `daili58win` comes back as `DAILI58WIN` in its own `data-post` attributes. Matching
  case-sensitively would have silently discarded every post on those channels. The
  `channel` column keeps the spelling used to request the page, which is also the
  spelling in the stored filename.
- `posted_utc` — the `<time datetime="...">` inside the post's own date link, converted to UTC.
- `age_hours_at_capture` — capture time minus post time. Capture time is this
  channel's own fetch timestamp from `fetchlog.tsv`, not a survey-wide constant.
- `views` — parsed from the counter.
- `views_verbatim` — the counter **as printed**, kept because the printed form is lossy (§5).
- `is_forward` — the post carries a `tgme_widget_message_forwarded_from` header, i.e.
  it was forwarded *into* this channel from somewhere else.
- `has_media` — a photo, video, document, voice note, round video, sticker, audio,
  poll, location or album wrapper is present.
- `view_to_subscriber_ratio` — `views / subscribers`.

## 5. The precision limit, stated up front

`t.me` prints view counts to **three significant figures**: `24.2M`, `49.2K`, `47K`.
A post showing `24.2M` is somewhere in a 100,000-view band. Small counts are exact
integers; large ones are not.

Two consequences, both handled rather than ignored:

- Any single ratio on a large channel carries up to a few tenths of a percent of
  rounding error. Immaterial at the distribution level, which is all we publish.
- **Growth smaller than the counter's own step is invisible.** The longitudinal
  figures are therefore restricted to post pairs whose step is at most 20% of the
  reading (`detectable = 1` in `post_growth.csv`), and `stats.json` carries the
  median step as a percentage of the reading for every bucket so a reader can see
  the floor. Where we report "share of posts that increased", a post that gained
  less than one step counts as *not* increased — the direction of that bias is to
  understate growth, never to overstate it.

Subscriber counts do not have this problem: the contact card prints them exactly.

## 6. How the age question is answered — three cuts, only two of them evidence

The naive cut is to pool every post, bucket by age, and read off the rising line.
`stats.json` contains that table under `ratio_by_post_age`, with a `CAUTION` key
attached, because **it is confounded**: a busy large channel contributes many young
posts and a quiet small one contributes only old posts, so part of the rise across
buckets is a change in which channels populate each bucket, not ageing. It is
published because showing the naive cut is how we show it is not what we relied on.

The two cuts that are evidence:

- **Cross-sectional, normalised within channel** (`age_curve_cross_sectional`).
  For each channel with at least 3 posts older than 48 hours, that channel's median
  mature view count is its settled level; every post is then expressed as a fraction
  of its *own* channel's level. Channel size and audience drop out.

  It has a bias of its own, and `stats.json` reports it: a channel that posts twenty
  times a day shows twenty posts all younger than 48 hours, so it has no settled
  level and cannot enter this curve at all. The channels dropped for that reason are
  the busier, larger ones — see `channels_excluded_all_posts_under_48h` and the two
  median-subscriber figures beside it. The curve therefore describes slower-posting
  channels better than fast ones, which is precisely the gap the longitudinal cut fills.
  **The 48-hour line is a judgement call, and it moves the answer.**
  `age_curve_threshold_sensitivity` recomputes the whole curve with the cut-off at
  24, 48, 72, 120 and 168 hours. The shape is not stable: a post one to two days old
  reads as roughly 94% finished when "settled" means 24h, and roughly 44% finished
  when it means 168h. That is not noise — it is the same fact seen twice. Demanding
  an older baseline keeps raising the baseline, which is what "views are still
  arriving" looks like from the inside.

  So the claim this curve supports is the **broad ordering**: views rise with post age
  at every threshold, except that the 6–12h and 12–24h buckets swap places at the 72h,
  120h and 168h cut-offs, and a post's first hours are a small fraction of what it will
  end up with. (An earlier version of this note, and of the article, said the rise was
  monotonic at every threshold; the published table contradicts that, and both were
  corrected on 2026-09-14.) The claim it does not support is a
  precise "a post has X% of its views after Y hours" — that number depends on a line
  we drew, and the table showing how much is published beside it.
- **Longitudinal, the same post read twice** (`age_curve_longitudinal`). Every wave
  re-reads the same channels; post ids seen in more than one wave give an observed
  growth ratio for one fixed post, with its age at first reading. Nothing about
  channel mix or post mix can confound this — it is the same post, hours later.

A fourth, paired check (`oldest_vs_newest_within_channel`) asks only whether a
channel's oldest listed post out-views its newest, counted where the two are at
least a day apart. It throws away magnitude and keeps the sign, and it needs no
normalisation assumption at all.

## 6b. The size gradient, and the check it had to survive

Small channels show a far higher view-to-subscriber ratio than large ones. That result
has an obvious way to be wrong: busy channels are large, a busy channel's visible posts
are all recent, and recent posts have not finished collecting their views. The gradient
could be an artefact of comparing the fresh posts of large channels with the settled
posts of quiet ones.

So `stats.json` reports each size band three times:

- `post_ratio` — every visible post.
- `post_ratio_mature_only` — posts at least 48 hours old. If the gradient were an
  artefact of freshness it would collapse here. It does not. Every band moves up when
  fresh posts are dropped, which is the expected direction, but the small and mid
  bands move up proportionally more than the large band, so the gap widens rather
  than closes.
- `post_ratio_age_2_to_30_days` — the strict cut, and the one to quote. There is a
  second problem the mature-only column does not fix: the twenty posts a quiet channel
  shows can span **years**, and a two-year-old post has had two years to collect views
  while the subscriber count is today's. Small channels have much longer visible spans
  than large ones (`median_days_of_history_visible`), so that alone would manufacture
  a gradient. Restricting every band to posts between 2 and 30 days old removes both
  effects at once.

The gradient survives all three. It is weakest — and therefore most honestly stated —
in the third: the small band's figure drops by roughly half once its years-old posts
are excluded, which is exactly the correction those posts deserved, and the ordering
across bands does not move.

The gradient is also reported as a Spearman rank correlation between subscriber count
and channel median ratio (`size_vs_ratio`), and as a ten-decile table, so it does not
rest on where the band boundaries were drawn.

## 6c. The four-day follow-up (wave four)

**Why.** The same-night waves show how fast a post fills over hours. The article
said a post "is most of the way there within a day"; nothing read hours apart could
test that, and the cross-sectional threshold table in section 6 already hinted it was
too strong (a one-to-two-day-old post reads as 44% finished when "settled" means 168h).

**How.** `harvest.py`'s own `fetch()` — same rate limit, same User-Agent, same two
pages per channel — for all 430 sampled channels, run in time-boxed chunks that
never interrupt a request (the chunking driver is not part of the dataset; it only
calls `fetch()` in the same order `wave` does and skips channels already stored).
860 requests, all HTTP 200, 2026-09-13T21:24:56Z to 21:55:30Z (the first posts page
at 21:24:58Z, which is the time the article quotes). The published `harvest.py wave 4`
does the same thing in a single run; it was split only because the machine running it
limits one command to ten minutes. As in every wave, `bytes` in `fetchlog.tsv` is
curl's transfer size, which with `--compressed` is the compressed size on the wire, not
the length of the stored response.

**Kept apart.** `analyse.py` treats waves 1–3 as the study (`SAME_NIGHT_WAVES`) and
analyses any later wave separately in `build_followup`, so every same-night figure,
including the 2,642 request count, is unchanged. Checked: `stats.json` without the new
`age_curve_followup` key is identical to the version published on 10 September, apart
from one corrected sentence in `age_curve_threshold_sensitivity.why_it_is_published`,
which had claimed a monotonic rise at every threshold.

**Pairs.** A first-wave post id found again in wave four, on a channel usable in both.
Growth is the later count over the first; `share_of_later_views_present_at_first` is
the inverse, and it is an upper bound on the share of a post's eventual views present
at the first reading, because the later count is still rising. As in section 5, only
pairs whose counter step is at most 20% of the first reading enter the buckets. Over
days the "older than 7 days" bucket is split into 7–14 days, 14–30 days and over 30
days (`age_band_followup`), because the first two were still growing and the third was
not.

**Selection, which is the main limit.** The preview lists about the last 20 posts, so
a first-night post is usually still listed four days later only on a channel that
posted little since. 244 of 325 channels contributed. Of the 81 that did not
(`why_no_pair`), 76 had pushed every first-night post out of the preview, 4 were not
usable in wave four, and 1 had overlapping ids but no pair with a view count on both
readings. The contrast is large: a median of 0.43 posts a day for the 244 against 13.99
for the 81 (the same definition as `posts_per_day` in `channels.csv`), and a median of
1,007.5 subscribers against 58,808. The follow-up describes slow, small channels.

As a check on whether the 244 fill differently, their same-night growth over the first
gap only (wave 1 → 2, about 2.5 hours, so each post counts once) is set beside the other
81's in `same_night_growth_subset_check`, every bucket published: faster when very young
(1–3h 24.9% against 17.7%; under 1h 144.4% against 108.3%, on 7 and 61 pairs), a little
slower at 6–24h (3.1% against 4.3%; 1.7% against 2.4%), and the same from a day on. That
is not proof that fast channels behave the same over days.

**Thin rows.** Posts under one hour old at the first reading: 5 pairs from 4 channels.
One to three hours: 18 pairs from 11 channels, 5 of them from one channel. They are
published and should be read for direction only.

## 7. What was excluded, and why

Exclusions are recorded per channel in `exclusions.csv` and tallied in
`stats.json → exclusions`. Nothing is dropped silently. The categories:

| reason | what it means |
|---|---|
| no public web preview: t.me/s/ served the contact card instead | `t.me/s/<name>` redirected to `t.me/<name>`. Telegram declines to render some channels' content on the web. `fetchlog.tsv` carries both the URL requested and the URL that answered, so every one of these is checkable. |
| no public preview (private, deleted or username free) | the contact card itself carries no subscriber line — the username no longer resolves to a public channel |
| is a group, not a channel | the card says "members", not "subscribers". Groups have no per-post view counter. |
| not a channel (user/bot/other account) | the username is a person or a bot |
| no listed post carries a view counter (all service messages) | the preview lists only "pinned"/"joined" service rows, which have no views |
| fewer than 5 posts carry a view counter | too thin to take a channel median from |
| preview rendered but lists no posts | the preview loaded and was empty |

**Request-level failures are logged too, not just channel-level ones.** In wave two,
6 of 860 requests came back with curl status `000` — a connection that never
completed — affecting 4 channels. Those rows are in `fetchlog.tsv` with status `000`
and no stored page. The 4 channels keep their wave-one measurements and simply
contribute no growth pairs; nothing was retried into a nicer-looking number.

The exclusion rate is high and concentrated in the small-channel stratum, because
the deep pages of an activity ranking are full of throwaway and abandoned usernames.
That is a property of the frame and is reported as one.

## 8. What this can and cannot support

**Can support.** What the public web preview showed for the channels we could read,
on the dates in `fetchlog.tsv`: the distribution of view-to-subscriber ratios across
them, how that distribution differs by channel size, how views on a fixed post changed
over the hours between our visits, and how much a single channel's own recent posts
vary from each other.

**Cannot support — a ranked list or any claim about a named channel.** This dataset
is about a distribution. Channel usernames appear in `posts.csv` and `channels.csv`
only so the numbers can be re-derived from `pages/`. A high ratio is not evidence of
bought views: forwarding, sharing outside Telegram, search traffic, the web preview
itself, and a subscriber count that has fallen since the post went up all produce the
same reading. We do not characterise any named channel, and neither should anyone
reusing this.

**Cannot support — anything about in-app numbers.** `t.me/s/` counters may lag or
differ from what the Telegram app shows. We never saw the app.

**Cannot support — a channel's history.** The preview shows roughly the last 20
posts. For a busy channel that is a day; for a quiet one, a year. The posts we
measured are a channel's *recent* posts, not a sample of its history, and the busy
and quiet channels are therefore not being asked the same question.

**Cannot support — the market.** The sampling frame is channels that appear in public
directories. Channels nobody has listed are absent by construction, and directories
over-represent whatever they were built to rank. The honest form of every headline is
"across the N channels we could read on DATE", and that is the form used.

**Cannot support — advice about how often to post.** `posting_cadence` shows that
channels posting more often have lower view-to-subscriber ratios (Spearman rho is
reported with it). It is an association in observational data and it is badly
entangled with size, because the busy channels are also the big ones — the median
subscriber count is printed beside each cadence quartile so the entanglement is
visible. Nothing here shows that posting less often would raise anybody's view rate.
The cadence figure is also a rate over the visible window rather than a habit: a
channel that posted twenty times in ten minutes and stopped scores in the thousands.

**On "why did my views drop".** The within-channel variation figures describe how much
a channel's own consecutive posts differ from each other. That is a baseline for
normal variance, not a diagnosis of any particular drop.

## 9. Reproducing this

```
python3 harvest.py frame      # public directory pages -> candidates.tsv
python3 harvest.py sample     # deterministic draw     -> sample.tsv
python3 harvest.py frame2     # second frame pass      -> candidates.tsv (appended)
python3 harvest.py sample2 N  # top up the draw        -> sample.tsv (appended)
python3 harvest.py wave 1     # fetch both pages per channel -> pages/w1/, fetchlog.tsv
python3 harvest.py wave 2     # ...hours later, same channels
python3 harvest.py wave 3     # ...again the same night
python3 harvest.py wave 4     # four days later (section 6c)
python3 analyse.py            # pages/ -> posts.csv, channels.csv, post_growth.csv,
                              #           post_growth_followup.csv, exclusions.csv, stats.json
```

`analyse.py` touches the network never. Run it against the stored `pages/` and it
reproduces every number we publish. `wave` skips channels it already has, so it is
safe to re-run, and each wave writes its own `pages/wN/` directory — nothing is
overwritten in place.

If a figure in the article does not fall out of `analyse.py`, the article is wrong
and we want to be told.
