@acedatacloud/skills 2026.713.1 → 2026.713.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -29,6 +29,7 @@ Skills are located in the `skills/` directory (also mirrored to `.agents/skills/
29
29
  ### AI Chat & Tools
30
30
  - **ai-chat** — Unified LLM gateway — GPT, Claude, Gemini, Kimi, Grok (50+ models)
31
31
  - **google-search** — Search the web, images, news, maps, places, and videos via Google
32
+ - **tgstat** — Discover and analyze public Telegram channels/groups (no login; optional TGStat API Token)
32
33
  - **face-transform** — Face analysis, beautification, age/gender transform, swap, cartoon
33
34
  - **short-url** — Create and manage short URLs
34
35
  - **onepage-pdf** — Convert an HTML page into one tall single-page PDF (local; no API token; needs Python + pymupdf + Chrome/Edge)
package/README.md CHANGED
@@ -48,6 +48,7 @@ Compatible with **30+ AI coding agents** via the [agentskills.io](https://agents
48
48
  |-------|-------------|
49
49
  | [ai-chat](skills/ai-chat/) | Unified LLM gateway — GPT, Claude, Gemini, Kimi, Grok (50+ models) |
50
50
  | [google-search](skills/google-search/) | Search the web, images, news, maps, places, and videos via Google |
51
+ | [tgstat](skills/tgstat/) | Discover and analyze public Telegram channels/groups; no login required, optional TGStat API Token |
51
52
  | [face-transform](skills/face-transform/) | Face analysis, beautification, age/gender transform, swap, cartoon |
52
53
  | [short-url](skills/short-url/) | Create and manage short URLs |
53
54
  | [onepage-pdf](skills/onepage-pdf/) | Convert an HTML page into one tall single-page PDF — no pagination breaks (local, no token) |
@@ -75,6 +76,7 @@ These skills drive third-party connectors users wire up at [auth.acedata.cloud/u
75
76
  | [didi-ride](skills/didi-ride/) | Book DiDi rides, estimate fares, query/cancel orders, plan routes via the DiDi MCP | `didi` (BYOC) |
76
77
  | [wecom](skills/wecom/) | WeCom (企业微信) self-built app — contacts, app messages, WeDoc, schedules, meetings | `wecom` (BYOC) |
77
78
  | [tencent-docs](skills/tencent-docs/) | Create / read / list / search / manage Tencent Docs — docs, sheets, slides, mind maps, flowcharts | `tencentdocs` (BYOC) |
79
+ | [tgstat](skills/tgstat/) | Public Telegram source discovery, rankings, and audience research | `tgstat` (public; optional Token) |
78
80
 
79
81
  ## Prerequisites
80
82
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acedatacloud/skills",
3
- "version": "2026.713.1",
3
+ "version": "2026.713.2",
4
4
  "description": "Agent Skills for AceDataCloud AI services — music, image, video generation, LLM chat, web search. Compatible with Claude Code, GitHub Copilot, Gemini CLI, OpenAI Codex, and 30+ AI coding agents.",
5
5
  "keywords": [
6
6
  "agent-skills",
@@ -1,110 +1,166 @@
1
1
  ---
2
2
  name: tgstat
3
- description: Search Telegram channels/chats and pull audience + engagement stats (subscribers, reach, ER/ERR) from TGStat via its Stat API. Use when the user wants to find Telegram channels to advertise in (esp. Russian-language / RU market), research competitor channels, or check a channel's reach/engagement before buying an ad placement.
3
+ description: "Research public Telegram channels and groups with TGStat. Use when discovering communities by topic, comparing audience size/reach/activity, checking a known @username, shortlisting ad or outreach sources, or querying TGStat API quota. Works without a TGStat login using web search plus public TGStat pages; an optional TGSTAT_TOKEN enables official structured search and full statistics. Read-only: does not join groups, scrape members, or send messages."
4
4
  when_to_use: |
5
- Trigger when the user wants to discover Telegram channels by keyword /
6
- category / country / language, inspect a specific channel's subscriber
7
- count, average post reach and engagement (ER / ERR) to judge ad value,
8
- or check their TGStat API quota. This is a marketing-research /
9
- ad-channel-selection tool it does NOT post to Telegram (use the
10
- `telegram` connector for reading/sending messages).
5
+ Use for Telegram source discovery, competitor research, ad-channel
6
+ selection, and public audience analysis. It can find channels/chats by
7
+ keyword, inspect known public usernames, compare ranking metrics, and
8
+ check optional TGStat API quota. Use the separate telegram connector for
9
+ reading or sending messages in the user's own account.
11
10
  connections: [tgstat]
12
- allowed_tools: [Bash]
11
+ allowed_tools: [Bash, web_search, web_fetch]
13
12
  license: Apache-2.0
14
13
  metadata:
15
14
  author: acedatacloud
16
- version: "1.0"
15
+ version: "2.0"
17
16
  ---
18
17
 
19
- Call the **TGStat Stat API** with `curl + jq`. The user's token is in
20
- `$TGSTAT_TOKEN` and is passed as the `token` query parameter on every request.
21
- Base URL: `https://api.tgstat.ru`.
18
+ # TGStat Research
22
19
 
23
- Responses are JSON shaped `{"status":"ok","response": ...}`. Errors come back as
24
- `{"status":"error","error":"<message>"}` show the `error` verbatim. An invalid
25
- token or an inactive/expired API plan will surface here; tell the user to check
26
- their token / plan in the TGStat 个人中心 and re-connect the connector.
20
+ Use [scripts/tgstat.py](./scripts/tgstat.py) for public TGStat pages and the
21
+ optional official API. It uses only the Python standard library.
27
22
 
28
- **Always confirm the token + remaining quota first** (`usage/stat` is free and
29
- does not count against tariff quota):
23
+ ```bash
24
+ TGSTAT="$SKILL_DIR/scripts/tgstat.py"; [ -f "$TGSTAT" ] || TGSTAT=$(find /tmp -maxdepth 8 -path '*/skills/*/scripts/tgstat.py' 2>/dev/null | head -1)
25
+ [ -f "$TGSTAT" ] || { echo "tgstat script not found (SKILL_DIR=$SKILL_DIR)" >&2; exit 1; }
26
+ python3 "$TGSTAT" mode
27
+ ```
28
+
29
+ The connector has two modes:
30
+
31
+ - **Public research (default):** no TGStat account or token. Uses `web_search`
32
+ for discovery and public TGStat ranking/entity pages for verification.
33
+ - **TGStat API Token (optional):** when the connector injects
34
+ `$TGSTAT_TOKEN`, the same commands automatically use the official Stat API.
35
+
36
+ Commands default to `--access-mode auto`. Use `--access-mode public` before the
37
+ subcommand when a configured Token lacks access to a paid API method; use
38
+ `--access-mode api` to require API mode and fail clearly if no Token exists.
39
+
40
+ Never ask the user to paste a token into chat or pass it on the command line.
41
+ If they want API mode, ask them to add the Token through the TGStat connector.
42
+
43
+ ## Discover Sources by Topic
44
+
45
+ Run `search` first:
30
46
 
31
47
  ```bash
32
- curl -sS "https://api.tgstat.ru/usage/stat?token=$TGSTAT_TOKEN" \
33
- | jq '.status, (.response[]? | {serviceKey, title, spentChannels, spentRequests, expiredAt})'
48
+ TGSTAT="$SKILL_DIR/scripts/tgstat.py"; [ -f "$TGSTAT" ] || TGSTAT=$(find /tmp -maxdepth 8 -path '*/skills/*/scripts/tgstat.py' 2>/dev/null | head -1)
49
+ [ -f "$TGSTAT" ] || { echo "tgstat script not found (SKILL_DIR=$SKILL_DIR)" >&2; exit 1; }
50
+ python3 "$TGSTAT" search "Claude API" --type all --language english --country us
51
+ python3 "$TGSTAT" --access-mode api search --category technology --type channel
34
52
  ```
35
53
 
36
- ## Find channels to advertise in (the main workflow)
54
+ In public mode the command returns `web_queries`. Call `web_search` once per
55
+ query, then:
56
+
57
+ 1. Keep only public `tgstat.com` regional-host and `t.me` results.
58
+ 2. Extract public `@username` values and deduplicate case-insensitively.
59
+ 3. Prefer results whose title/snippet directly matches the topic.
60
+ 4. Verify each shortlist entry with `info` or `stat` before reporting it.
61
+ 5. Include the source URL and say discovery may be incomplete because public
62
+ search-engine indexes are not TGStat's full database.
63
+
64
+ TGStat's own keyword-search result endpoint requires sign-in. Do not try to
65
+ bypass it, replay private AJAX endpoints, or claim public mode searches the
66
+ full TGStat index.
37
67
 
38
- `GET /channels/search` at least one of `q` (keyword, min 3 chars) or
39
- `category` is required.
68
+ Official API mode also supports category-only search. Category values are
69
+ TGStat reference keys; if a key is rejected, query the user's intended topic by
70
+ keyword in public mode instead of guessing another category.
40
71
 
41
- Params: `q`, `category`, `country`, `language` (default `russian`),
42
- `peer_type` (`channel` | `chat` | `all`, default `channel`),
43
- `search_by_description` (`0`/`1`), `limit` (max 100).
72
+ With `$TGSTAT_TOKEN`, `search` calls the official `channels/search` endpoint
73
+ instead. That endpoint may require a paid Stat API plan. `--language` must be a
74
+ TGStat language key such as `english` or `russian`; `--country` must be a
75
+ two-letter country code such as `us` or `ru`.
76
+
77
+ If API search reports plan access denied, rerun explicitly in public mode:
44
78
 
45
79
  ```bash
46
- # Russian-language AI/ChatGPT channels, biggest first.
47
- curl -sS "https://api.tgstat.ru/channels/search" \
48
- --data-urlencode "token=$TGSTAT_TOKEN" \
49
- --data-urlencode "q=нейросети" \
50
- --data-urlencode "language=russian" \
51
- --data-urlencode "peer_type=channel" \
52
- --data-urlencode "limit=50" -G \
53
- | jq '.response.items | sort_by(-.participants_count)
54
- | .[] | {username, title, subs: .participants_count, ci_index, link}'
80
+ python3 "$TGSTAT" --access-mode public search "Claude API" --type all
55
81
  ```
56
82
 
57
- - Use `--data-urlencode ... -G` so Cyrillic / spaces in `q` are encoded correctly.
58
- - `ci_index` (индекс цитирования) is TGStat's citation/authority score — higher =
59
- more reposted/mentioned elsewhere, a useful quality signal beyond raw subs.
60
- - Try several keywords (`ChatGPT`, `нейросети`, `AI`, `разработка`, `API`) and
61
- merge results; TGStat matches title/username (add `search_by_description=1` to
62
- also match the channel description).
83
+ ## Browse Public Rankings
84
+
85
+ Use rankings when the user wants large or active public sources without a
86
+ precise keyword:
87
+
88
+ ```bash
89
+ TGSTAT="$SKILL_DIR/scripts/tgstat.py"; [ -f "$TGSTAT" ] || TGSTAT=$(find /tmp -maxdepth 8 -path '*/skills/*/scripts/tgstat.py' 2>/dev/null | head -1)
90
+ [ -f "$TGSTAT" ] || { echo "tgstat script not found (SKILL_DIR=$SKILL_DIR)" >&2; exit 1; }
91
+ python3 "$TGSTAT" rankings --type channel --limit 20
92
+ python3 "$TGSTAT" rankings --type chat --limit 20
93
+ python3 "$TGSTAT" rankings --type channel --query crypto --limit 10
94
+ ```
95
+
96
+ Channel cards can expose subscribers, one-post reach, and citation index.
97
+ Chat cards can expose participants, recent message count, and MAU. Metrics are
98
+ a current public snapshot, not a historical series.
99
+
100
+ Public TGStat pages can intermittently return an authentication or rate-limit
101
+ interstitial. When `rankings` returns `status: unavailable`, try the emitted
102
+ `web_fetch_url`, then run its `web_queries` with `web_search`. Label those
103
+ results as web-index discoveries, not as an authoritative TGStat rank.
104
+
105
+ ## Inspect a Known Channel or Group
106
+
107
+ Accept only a public `@username`, bare username, `t.me/<username>` link, or a
108
+ TGStat entity URL:
109
+
110
+ ```bash
111
+ TGSTAT="$SKILL_DIR/scripts/tgstat.py"; [ -f "$TGSTAT" ] || TGSTAT=$(find /tmp -maxdepth 8 -path '*/skills/*/scripts/tgstat.py' 2>/dev/null | head -1)
112
+ [ -f "$TGSTAT" ] || { echo "tgstat script not found (SKILL_DIR=$SKILL_DIR)" >&2; exit 1; }
113
+ python3 "$TGSTAT" info @durov
114
+ python3 "$TGSTAT" stat https://t.me/example_public_chat
115
+ ```
116
+
117
+ In public mode, `info`/`stat` resolves whether the target is a channel or chat,
118
+ returns public metadata, and enriches it with ranking metrics when the entity
119
+ appears in the current public ranking. Empty metrics mean TGStat did not expose
120
+ them publicly; do not call that full statistics.
121
+
122
+ With `$TGSTAT_TOKEN`:
123
+
124
+ - `info` uses `channels/get` for structured identity/category metadata.
125
+ - `stat` uses `channels/stat` for fuller channel reach/ER or chat activity.
63
126
 
64
- ## Judge a channel's ad value
127
+ For ad selection, rank by relevant reach/activity rather than subscriber count
128
+ alone. High subscribers with weak reach or chat MAU can indicate an inactive or
129
+ inflated audience. Present metrics as evidence, not a guarantee of lead quality.
65
130
 
66
- `GET /channels/stat?channelId=<@username | t.me/username | tgstat id>` returns
67
- the numbers that actually matter for ad pricing:
131
+ ## Check API Mode and Quota
68
132
 
69
133
  ```bash
70
- curl -sS "https://api.tgstat.ru/channels/stat?token=$TGSTAT_TOKEN&channelId=@durov" \
71
- | jq '.response | {
72
- subs: .participants_count,
73
- avg_post_reach, # средний охват публикации
74
- adv_reach_24h: .adv_post_reach_24h, # рекламный охват за 24ч — key for CPM
75
- er_percent, err_percent, err24_percent,
76
- daily_reach, ci_index, posts_count
77
- }'
134
+ TGSTAT="$SKILL_DIR/scripts/tgstat.py"; [ -f "$TGSTAT" ] || TGSTAT=$(find /tmp -maxdepth 8 -path '*/skills/*/scripts/tgstat.py' 2>/dev/null | head -1)
135
+ [ -f "$TGSTAT" ] || { echo "tgstat script not found (SKILL_DIR=$SKILL_DIR)" >&2; exit 1; }
136
+ python3 "$TGSTAT" mode
137
+ python3 "$TGSTAT" quota
78
138
  ```
79
139
 
80
- - For **ad CPM estimation** use `adv_post_reach_24h` (average *advertising* reach
81
- of a post over 24h), not raw subscriber count — subs are vanity, reach is what
82
- the ad actually gets seen by.
83
- - Low `err_percent` / `err24_percent` relative to subs = inflated/dead audience →
84
- skip it.
85
- - For a **chat** (not channel) the response instead has `dau`/`wau`/`mau` and
86
- `messages_count_*` fields.
87
-
88
- `GET /channels/get?channelId=...` returns descriptive info (title, about,
89
- `category`, `country`, `language`, `participants_count`, `ci_index`) when you
90
- just need to identify/verify a channel rather than full stats.
91
-
92
- ## Reference data
93
-
94
- `GET /database/categories?token=$TGSTAT_TOKEN` lists valid `category` values you
95
- can pass to `channels/search`. There are also `/database/countries` and
96
- `/database/languages`. (See the docs for the full parameter/response shape.)
97
-
98
- ## Gotchas
99
-
100
- - **Plan gating:** `channels/search` needs a **Stat API tariff S or higher**;
101
- `channels/stat` / `channels/get` work on all Stat tariffs. If a call returns an
102
- access error, the user's plan doesn't cover that method.
103
- - **Quota:** each unique channel and each request counts against the monthly
104
- tariff (visible via `usage/stat`). Don't loop over hundreds of channels
105
- blindly search, shortlist, then `stat` only the shortlist.
106
- - **`q` min length is 3 chars** → `{"error":"param q is too short"}`.
107
- - **Cyrillic:** always send `q` via `--data-urlencode` (or pre-URL-encode) so the
108
- keyword isn't mangled.
109
- - TGStat is a Russian service; the API and billing are RU-side — availability and
110
- payment are the account owner's responsibility.
140
+ Without a Token, `quota` returns `null`. With a Token it calls `usage/stat`.
141
+ If TGStat reports an inactive plan or insufficient access, explain that the
142
+ public workflow still works and that API search/full stats depend on the user's
143
+ TGStat plan.
144
+
145
+ ## Outreach Research Workflow
146
+
147
+ For prospecting or partnership research:
148
+
149
+ 1. Search several narrow problem/role keywords, not only broad `AI` terms.
150
+ 2. Shortlist sources by relevance first, then audience/reach/activity.
151
+ 3. Verify each public source and retain evidence URLs.
152
+ 4. Label each source as channel, group/chat, or uncertain.
153
+ 5. Produce a review queue with suggested angle and reason for fit.
154
+ 6. Require human approval before any message is sent through another connector.
155
+
156
+ ## Safety and Limits
157
+
158
+ - Read-only. This skill does not join channels/groups, import invite links,
159
+ send messages, add members, or download member lists.
160
+ - Reject private invite links (`t.me/+...`, `joinchat/...`) and message links.
161
+ - Do not automate unsolicited bulk outreach or evade Telegram/TGStat controls.
162
+ - Public pages and HTML can change; if parsing fails, use `web_fetch` for a
163
+ single public page and report only values visible in that page.
164
+ - Public web discovery is incomplete and regional TGStat pages may differ.
165
+ - Never print `$TGSTAT_TOKEN`, include it in reports, or expose command errors
166
+ containing secrets.
@@ -0,0 +1,652 @@
1
+ #!/usr/bin/env python3
2
+ """TGStat research CLI with a zero-auth public mode and optional API mode."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import os
9
+ import re
10
+ import sys
11
+ import urllib.error
12
+ import urllib.parse
13
+ import urllib.request
14
+ from html.parser import HTMLParser
15
+ from typing import Dict, List, Optional, Tuple, Union
16
+
17
+ DEFAULT_PUBLIC_BASE = "https://tgstat.com"
18
+ DEFAULT_API_BASE = "https://api.tgstat.ru"
19
+ USER_AGENT = (
20
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
21
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36"
22
+ )
23
+ PUBLIC_USERNAME_RE = re.compile(r"[A-Za-z0-9_]{3,}")
24
+ TGSTAT_HOST_RE = re.compile(r"^(?:[a-z]{2,3}\.)?tgstat\.com$", re.IGNORECASE)
25
+ TGSTAT_INPUT_HOST_RE = re.compile(r"^(?:(?:[a-z]{2,3}\.)?tgstat\.com|tgstat\.ru)$", re.IGNORECASE)
26
+ ENTITY_PATH_RE = re.compile(r"/(channel|chat)/(@[A-Za-z0-9_]{3,}|id\d+)/stat/?", re.IGNORECASE)
27
+ API_HOST = urllib.parse.urlparse(DEFAULT_API_BASE).hostname or ""
28
+
29
+
30
+ class TGStatError(RuntimeError):
31
+ pass
32
+
33
+
34
+ def _compact(value: str) -> str:
35
+ return re.sub(r"\s+", " ", value or "").strip()
36
+
37
+
38
+ def _metric_key(label: str) -> str:
39
+ key = re.sub(r"[^a-z0-9]+", "_", label.lower()).strip("_")
40
+ return key or "value"
41
+
42
+
43
+ def _metric_number(value: str) -> Optional[Union[int, float]]:
44
+ compact = _compact(value).replace(" ", "").replace(",", "")
45
+ match = re.fullmatch(r"(-?\d+(?:\.\d+)?)([kmb])?", compact, re.IGNORECASE)
46
+ if not match:
47
+ return None
48
+ number = float(match.group(1))
49
+ multiplier = {"k": 1_000, "m": 1_000_000, "b": 1_000_000_000}.get(
50
+ (match.group(2) or "").lower(), 1
51
+ )
52
+ result = number * multiplier
53
+ return int(result) if result.is_integer() else result
54
+
55
+
56
+ def _entity_from_url(url: str) -> Optional[Tuple[str, str]]:
57
+ path = urllib.parse.unquote(urllib.parse.urlparse(url).path)
58
+ match = ENTITY_PATH_RE.fullmatch(path)
59
+ if not match:
60
+ return None
61
+ return match.group(1).lower(), match.group(2)
62
+
63
+
64
+ def _safe_url(url: str) -> str:
65
+ parsed = urllib.parse.urlsplit(url)
66
+ return urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, parsed.path, "", ""))
67
+
68
+
69
+ def _safe_error_body(body: str, url: str) -> str:
70
+ compact = _compact(body)
71
+ token = urllib.parse.parse_qs(urllib.parse.urlsplit(url).query).get("token", [""])[0]
72
+ if token:
73
+ compact = compact.replace(token, "[REDACTED]")
74
+ return compact[:200]
75
+
76
+
77
+ def _validate_request_url(url: str, initial_host: Optional[str] = None) -> None:
78
+ parsed = urllib.parse.urlsplit(url)
79
+ host = parsed.hostname or ""
80
+ if parsed.scheme != "https":
81
+ raise TGStatError("TGStat requests and redirects must use HTTPS")
82
+ allowed_from = initial_host or host
83
+ if TGSTAT_HOST_RE.fullmatch(allowed_from):
84
+ if not TGSTAT_HOST_RE.fullmatch(host):
85
+ raise TGStatError(f"TGStat redirected to an unexpected host: {host}")
86
+ elif allowed_from == API_HOST:
87
+ if host != API_HOST:
88
+ raise TGStatError(f"TGStat API redirected to an unexpected host: {host}")
89
+ else:
90
+ raise TGStatError(f"unsupported TGStat request host: {allowed_from}")
91
+
92
+
93
+ class SafeRedirectHandler(urllib.request.HTTPRedirectHandler):
94
+ def __init__(self, initial_host: str) -> None:
95
+ super().__init__()
96
+ self.initial_host = initial_host
97
+
98
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
99
+ _validate_request_url(newurl, self.initial_host)
100
+ return super().redirect_request(req, fp, code, msg, headers, newurl)
101
+
102
+
103
+ def _open_url(request: urllib.request.Request, timeout: int):
104
+ initial_url = request.full_url
105
+ _validate_request_url(initial_url)
106
+ initial_host = urllib.parse.urlsplit(initial_url).hostname or ""
107
+ opener = urllib.request.build_opener(SafeRedirectHandler(initial_host))
108
+ return opener.open(request, timeout=timeout)
109
+
110
+
111
+ class RankingParser(HTMLParser):
112
+ def __init__(self) -> None:
113
+ super().__init__(convert_charrefs=True)
114
+ self.items: List[dict] = []
115
+ self.current: Optional[dict] = None
116
+ self.card_div_depth = 0
117
+ self.capture: Optional[dict] = None
118
+ self.pending_metric: Optional[str] = None
119
+
120
+ def handle_starttag(self, tag: str, attrs: List[Tuple[str, Optional[str]]]) -> None:
121
+ attr = {key: value or "" for key, value in attrs}
122
+ classes = set(attr.get("class", "").split())
123
+ if tag == "div" and "peer-item-row" in classes and self.current is None:
124
+ self.current = {"metrics": {}, "metrics_display": {}}
125
+ self.card_div_depth = 1
126
+ return
127
+ if self.current is None:
128
+ return
129
+ if tag == "div":
130
+ self.card_div_depth += 1
131
+ if self.capture is not None:
132
+ self.capture["depth"] += 1
133
+ return
134
+
135
+ if tag == "a" and attr.get("href"):
136
+ entity = _entity_from_url(attr["href"])
137
+ hostname = urllib.parse.urlparse(attr["href"]).hostname or ""
138
+ if entity and TGSTAT_HOST_RE.fullmatch(hostname):
139
+ peer_type, identifier = entity
140
+ self.current.setdefault("type", peer_type)
141
+ self.current.setdefault("identifier", identifier)
142
+ self.current.setdefault("tgstat_url", attr["href"])
143
+
144
+ kind = ""
145
+ if tag == "div" and "ribbon" in classes:
146
+ kind = "rank"
147
+ elif tag == "div" and {"text-truncate", "font-16", "text-dark"}.issubset(classes):
148
+ if self.current.get("tgstat_url") and not self.current.get("title"):
149
+ kind = "title"
150
+ elif tag == "span" and {"border", "rounded", "bg-light"}.issubset(classes):
151
+ if self.current.get("tgstat_url") and not self.current.get("category"):
152
+ kind = "category"
153
+ elif tag == "h4":
154
+ kind = "metric_value"
155
+ elif tag == "div" and {"text-muted", "text-truncate"}.issubset(classes):
156
+ if self.pending_metric is not None:
157
+ kind = "metric_label"
158
+ if kind:
159
+ self.capture = {"tag": tag, "kind": kind, "depth": 1, "parts": []}
160
+
161
+ def handle_endtag(self, tag: str) -> None:
162
+ if self.current is None:
163
+ return
164
+ if self.capture is not None:
165
+ self.capture["depth"] -= 1
166
+ if self.capture["depth"] == 0:
167
+ self._finish_capture()
168
+ if tag == "div":
169
+ self.card_div_depth -= 1
170
+ if self.card_div_depth == 0:
171
+ self._finish_card()
172
+
173
+ def handle_data(self, data: str) -> None:
174
+ if self.capture is not None:
175
+ self.capture["parts"].append(data)
176
+
177
+ def _finish_capture(self) -> None:
178
+ assert self.current is not None and self.capture is not None
179
+ kind = self.capture["kind"]
180
+ value = _compact("".join(self.capture["parts"]))
181
+ self.capture = None
182
+ if not value:
183
+ return
184
+ if kind == "rank":
185
+ match = re.search(r"\d+", value)
186
+ if match:
187
+ self.current["rank"] = int(match.group())
188
+ elif kind in {"title", "category"}:
189
+ self.current[kind] = value
190
+ elif kind == "metric_value":
191
+ self.pending_metric = value
192
+ elif kind == "metric_label" and self.pending_metric is not None:
193
+ key = _metric_key(value)
194
+ display = self.pending_metric
195
+ self.current["metrics_display"][key] = display
196
+ number = _metric_number(display)
197
+ self.current["metrics"][key] = number if number is not None else display
198
+ self.pending_metric = None
199
+
200
+ def _finish_card(self) -> None:
201
+ assert self.current is not None
202
+ item = self.current
203
+ identifier = item.get("identifier", "")
204
+ if identifier.startswith("@"):
205
+ item["username"] = identifier
206
+ item["telegram_url"] = f"https://t.me/{identifier[1:]}"
207
+ else:
208
+ item["username"] = None
209
+ item["telegram_url"] = None
210
+ if item.get("tgstat_url") and item.get("title"):
211
+ self.items.append(item)
212
+ self.current = None
213
+ self.card_div_depth = 0
214
+ self.capture = None
215
+ self.pending_metric = None
216
+
217
+
218
+ class DetailParser(HTMLParser):
219
+ def __init__(self) -> None:
220
+ super().__init__(convert_charrefs=True)
221
+ self.meta: Dict[str, str] = {}
222
+ self.capture: Optional[dict] = None
223
+ self.pending_metric: Optional[str] = None
224
+ self.metrics: Dict[str, Union[int, float, str]] = {}
225
+ self.metrics_display: Dict[str, str] = {}
226
+ self.text_parts: List[str] = []
227
+
228
+ def handle_starttag(self, tag: str, attrs: List[Tuple[str, Optional[str]]]) -> None:
229
+ attr = {key.lower(): value or "" for key, value in attrs}
230
+ if tag == "meta":
231
+ key = (attr.get("property") or attr.get("name") or "").lower()
232
+ if key in {"og:title", "og:description", "description"} and attr.get("content"):
233
+ self.meta[key] = _compact(attr["content"])
234
+ if self.capture is not None:
235
+ self.capture["depth"] += 1
236
+ return
237
+ classes = set(attr.get("class", "").split())
238
+ kind = ""
239
+ if tag == "title":
240
+ kind = "title"
241
+ elif tag == "h4":
242
+ kind = "metric_value"
243
+ elif tag == "div" and {"text-muted", "text-truncate"}.issubset(classes):
244
+ if self.pending_metric is not None:
245
+ kind = "metric_label"
246
+ if kind:
247
+ self.capture = {"kind": kind, "depth": 1, "parts": []}
248
+
249
+ def handle_endtag(self, tag: str) -> None:
250
+ if self.capture is None:
251
+ return
252
+ self.capture["depth"] -= 1
253
+ if self.capture["depth"] != 0:
254
+ return
255
+ kind = self.capture["kind"]
256
+ value = _compact("".join(self.capture["parts"]))
257
+ self.capture = None
258
+ if kind == "title" and value:
259
+ self.meta.setdefault("title", value)
260
+ elif kind == "metric_value" and value:
261
+ self.pending_metric = value
262
+ elif kind == "metric_label" and value and self.pending_metric is not None:
263
+ key = _metric_key(value)
264
+ self.metrics_display[key] = self.pending_metric
265
+ number = _metric_number(self.pending_metric)
266
+ self.metrics[key] = number if number is not None else self.pending_metric
267
+ self.pending_metric = None
268
+
269
+ def handle_data(self, data: str) -> None:
270
+ text = _compact(data)
271
+ if text:
272
+ self.text_parts.append(text)
273
+ if self.capture is not None:
274
+ self.capture["parts"].append(data)
275
+
276
+
277
+ def parse_rankings_html(html: str) -> List[dict]:
278
+ parser = RankingParser()
279
+ parser.feed(html)
280
+ return parser.items
281
+
282
+
283
+ def parse_detail_html(html: str, url: str) -> dict:
284
+ parser = DetailParser()
285
+ parser.feed(html)
286
+ text = " ".join(parser.text_parts)
287
+ entity = _entity_from_url(url)
288
+ title = parser.meta.get("og:title") or parser.meta.get("title")
289
+ recognized = bool(entity and parser.meta.get("og:title") and title and "tgstat" in title.casefold())
290
+ result = {
291
+ "status": (
292
+ "restricted"
293
+ if "authentication required" in text.lower()
294
+ else "ok" if recognized else "unrecognized"
295
+ ),
296
+ "type": entity[0] if entity else None,
297
+ "identifier": entity[1] if entity else None,
298
+ "title": title,
299
+ "description": parser.meta.get("og:description") or parser.meta.get("description"),
300
+ "metrics": parser.metrics,
301
+ "metrics_display": parser.metrics_display,
302
+ "tgstat_url": url,
303
+ }
304
+ identifier = result["identifier"]
305
+ result["telegram_url"] = f"https://t.me/{identifier[1:]}" if isinstance(identifier, str) and identifier.startswith("@") else None
306
+ return result
307
+
308
+
309
+ def _json_out(payload: object) -> None:
310
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
311
+
312
+
313
+ def _request_with_url(
314
+ url: str, timeout: int, data: Optional[bytes] = None, headers: Optional[dict] = None
315
+ ) -> Tuple[str, str]:
316
+ request_headers = {
317
+ "User-Agent": USER_AGENT,
318
+ "Accept": "application/json, text/html;q=0.9, */*;q=0.8",
319
+ "Accept-Language": "en-US,en;q=0.9",
320
+ **(headers or {}),
321
+ }
322
+ request = urllib.request.Request(url, data=data, headers=request_headers)
323
+ try:
324
+ with _open_url(request, timeout) as response:
325
+ return response.read().decode("utf-8", "replace"), response.geturl()
326
+ except urllib.error.HTTPError as exc:
327
+ body = exc.read().decode("utf-8", "replace")
328
+ raise TGStatError(f"HTTP {exc.code} from {_safe_url(url)}: {_safe_error_body(body, url)}") from exc
329
+ except urllib.error.URLError as exc:
330
+ raise TGStatError(f"network error for {_safe_url(url)}: {_safe_error_body(str(exc.reason), url)}") from exc
331
+
332
+
333
+ def _request(url: str, timeout: int, data: Optional[bytes] = None, headers: Optional[dict] = None) -> str:
334
+ return _request_with_url(url, timeout, data, headers)[0]
335
+
336
+
337
+ def _public_base(value: str) -> str:
338
+ parsed = urllib.parse.urlparse(value if "://" in value else f"https://{value}")
339
+ if parsed.scheme != "https" or not parsed.hostname or not TGSTAT_HOST_RE.fullmatch(parsed.hostname):
340
+ raise TGStatError("public host must be tgstat.com or a regional *.tgstat.com host")
341
+ return f"https://{parsed.hostname}"
342
+
343
+
344
+ def _api_get(args: argparse.Namespace, path: str, params: Optional[dict] = None) -> dict:
345
+ token = os.environ.get("TGSTAT_TOKEN", "")
346
+ if not token:
347
+ raise TGStatError("TGSTAT_TOKEN is required for this API-only operation")
348
+ query = {"token": token, **{key: value for key, value in (params or {}).items() if value not in (None, "")}}
349
+ url = f"{DEFAULT_API_BASE}/{path.lstrip('/')}?{urllib.parse.urlencode(query)}"
350
+ raw = _request(url, args.timeout)
351
+ try:
352
+ payload = json.loads(raw)
353
+ except json.JSONDecodeError as exc:
354
+ raise TGStatError("TGStat API returned a non-JSON response") from exc
355
+ if payload.get("status") != "ok":
356
+ error = str(payload.get("error") or "TGStat API request failed").replace(token, "[REDACTED]")
357
+ raise TGStatError(error)
358
+ return payload
359
+
360
+
361
+ def _normalize_target(target: str) -> Tuple[Optional[str], Optional[str], Optional[str]]:
362
+ return _normalize_target_for_mode(target, for_api=False)
363
+
364
+
365
+ def _normalize_target_for_mode(
366
+ target: str, *, for_api: bool
367
+ ) -> Tuple[Optional[str], Optional[str], Optional[str]]:
368
+ target = target.strip()
369
+ if target.startswith("http://") or target.startswith("https://"):
370
+ parsed = urllib.parse.urlparse(target)
371
+ if parsed.scheme != "https":
372
+ raise TGStatError("target URL must use HTTPS")
373
+ if parsed.hostname and TGSTAT_INPUT_HOST_RE.fullmatch(parsed.hostname):
374
+ entity = _entity_from_url(target)
375
+ if not entity:
376
+ raise TGStatError("TGStat target must be a channel or chat statistics URL")
377
+ identifier = entity[1]
378
+ if for_api and identifier.lower().startswith("id"):
379
+ identifier = identifier[2:]
380
+ canonical_url = target
381
+ if parsed.hostname.casefold() == "tgstat.ru":
382
+ canonical_url = urllib.parse.urlunsplit(("https", "tgstat.com", parsed.path, "", ""))
383
+ return identifier, entity[0], canonical_url
384
+ if parsed.hostname in {"t.me", "www.t.me"}:
385
+ parts = [part for part in parsed.path.split("/") if part]
386
+ if len(parts) != 1 or not PUBLIC_USERNAME_RE.fullmatch(parts[0]):
387
+ raise TGStatError("t.me target must be a public username link, not an invite or message link")
388
+ return f"@{parts[0]}", None, None
389
+ raise TGStatError("target URL must be a tgstat.com entity or t.me link")
390
+ if target.startswith("@") and PUBLIC_USERNAME_RE.fullmatch(target[1:]):
391
+ return target, None, None
392
+ if re.fullmatch(r"\d+", target):
393
+ return (target if for_api else f"id{target}"), None, None
394
+ if re.fullmatch(r"id\d+", target, re.IGNORECASE):
395
+ digits = target[2:]
396
+ return (digits if for_api else f"id{digits}"), None, None
397
+ if PUBLIC_USERNAME_RE.fullmatch(target):
398
+ return f"@{target}", None, None
399
+ raise TGStatError("target must be @username, a t.me link, TGStat entity URL, or id<number>")
400
+
401
+
402
+ def _normalize_api_filters(language: str, country: str) -> Tuple[str, str]:
403
+ normalized_language = language.strip().lower()
404
+ normalized_country = country.strip().lower()
405
+ if normalized_language and not re.fullmatch(r"[a-z][a-z0-9_-]*", normalized_language):
406
+ raise TGStatError("language must be a TGStat language key such as english or russian")
407
+ if normalized_country and not re.fullmatch(r"[a-z]{2}", normalized_country):
408
+ raise TGStatError("country must be a two-letter code such as us or ru")
409
+ return normalized_language, normalized_country
410
+
411
+
412
+ def command_mode(args: argparse.Namespace) -> None:
413
+ has_token = bool(os.environ.get("TGSTAT_TOKEN"))
414
+ effective_mode = "api" if _use_api(args) else "public"
415
+ _json_out(
416
+ {
417
+ "mode": effective_mode,
418
+ "access_mode": args.access_mode,
419
+ "token_configured": has_token,
420
+ "public_capabilities": ["web-index discovery", "public rankings", "public entity pages"],
421
+ "api_capabilities": ["quota", "structured channel/chat search", "full channel statistics"],
422
+ }
423
+ )
424
+
425
+
426
+ def command_quota(args: argparse.Namespace) -> None:
427
+ if not _use_api(args):
428
+ _json_out({"mode": "public", "token_configured": False, "quota": None})
429
+ return
430
+ payload = _api_get(args, "usage/stat")
431
+ _json_out({"mode": "api", "quota": payload.get("response")})
432
+
433
+
434
+ def _web_queries(query: str, peer_type: str, language: str, country: str) -> List[str]:
435
+ suffix = " ".join(part for part in (language, country) if part).strip()
436
+ quoted = f'"{query}"'
437
+ kinds = [peer_type] if peer_type != "all" else ["chat", "channel"]
438
+ queries = [f"site:tgstat.com/{kind}/ {quoted} {suffix}".strip() for kind in kinds]
439
+ queries.extend(f"site:t.me/ {quoted} {kind} {suffix}".strip() for kind in kinds)
440
+ return queries
441
+
442
+
443
+ def _use_api(args: argparse.Namespace) -> bool:
444
+ has_token = bool(os.environ.get("TGSTAT_TOKEN"))
445
+ if args.access_mode == "public":
446
+ return False
447
+ if args.access_mode == "api" and not has_token:
448
+ raise TGStatError("TGSTAT_TOKEN is required when --access-mode api is selected")
449
+ return has_token
450
+
451
+
452
+ def command_search(args: argparse.Namespace) -> None:
453
+ query = args.query.strip()
454
+ category = args.category.strip()
455
+ if not query and not category:
456
+ raise TGStatError("search requires a query or --category")
457
+ if query and len(query) < 3:
458
+ raise TGStatError("query must contain at least 3 characters")
459
+ if _use_api(args):
460
+ language, country = _normalize_api_filters(args.language, args.country)
461
+ payload = _api_get(
462
+ args,
463
+ "channels/search",
464
+ {
465
+ "q": query,
466
+ "category": category,
467
+ "peer_type": args.type,
468
+ "language": language,
469
+ "country": country,
470
+ "search_by_description": 1 if args.description else 0,
471
+ "limit": min(max(args.limit, 1), 100),
472
+ },
473
+ )
474
+ _json_out(
475
+ {
476
+ "mode": "api",
477
+ "query": query or None,
478
+ "category": category or None,
479
+ "response": payload.get("response"),
480
+ }
481
+ )
482
+ return
483
+ discovery_term = query or category
484
+ _json_out(
485
+ {
486
+ "mode": "public",
487
+ "query": query or None,
488
+ "category": category or None,
489
+ "requires_web_search": True,
490
+ "web_queries": _web_queries(discovery_term, args.type, args.language, args.country),
491
+ "instructions": (
492
+ "Run web_search for each query, keep only tgstat.com and t.me results, "
493
+ "deduplicate by username, then inspect shortlisted TGStat URLs with the info command."
494
+ ),
495
+ "limitations": "TGStat's own keyword-search results require sign-in; public web indexes may be incomplete.",
496
+ }
497
+ )
498
+
499
+
500
+ def _ranking_fallback(url: str, peer_type: str, query: str, reason: str) -> None:
501
+ subject = query.strip() or ("Telegram channels" if peer_type == "channel" else "Telegram groups")
502
+ _json_out(
503
+ {
504
+ "mode": "public",
505
+ "status": "unavailable",
506
+ "source": url,
507
+ "reason": reason,
508
+ "requires_web_fetch": True,
509
+ "web_fetch_url": url,
510
+ "requires_web_search": True,
511
+ "web_queries": [
512
+ f'site:tgstat.com/{peer_type}/ "{subject}"',
513
+ f'site:t.me/ "{subject}"',
514
+ ],
515
+ "limitations": "Fallback results are web-index discoveries, not an authoritative TGStat ranking.",
516
+ }
517
+ )
518
+
519
+
520
+ def command_rankings(args: argparse.Namespace) -> None:
521
+ base = _public_base(args.host)
522
+ plural = "channels" if args.type == "channel" else "chats"
523
+ url = f"{base}/ratings/{plural}"
524
+ try:
525
+ html = _request(url, args.timeout)
526
+ except TGStatError as exc:
527
+ _ranking_fallback(url, args.type, args.query, str(exc))
528
+ return
529
+ normalized_html = html.casefold()
530
+ if "authentication required" in normalized_html or "too many requests" in normalized_html:
531
+ _ranking_fallback(url, args.type, args.query, "TGStat returned an authentication or rate-limit interstitial")
532
+ return
533
+ items = parse_rankings_html(html)
534
+ if not items:
535
+ _ranking_fallback(url, args.type, args.query, "TGStat ranking HTML was empty or not recognized")
536
+ return
537
+ if args.query:
538
+ needle = args.query.casefold()
539
+ items = [
540
+ item
541
+ for item in items
542
+ if needle in " ".join(str(item.get(key) or "") for key in ("title", "username", "category")).casefold()
543
+ ]
544
+ _json_out({"mode": "public", "source": url, "count": min(len(items), args.limit), "items": items[: args.limit]})
545
+
546
+
547
+ def _public_info(args: argparse.Namespace, target: str, peer_type: str) -> dict:
548
+ identifier, inferred_type, exact_url = _normalize_target(target)
549
+ types = [inferred_type] if inferred_type else ([peer_type] if peer_type != "auto" else ["channel", "chat"])
550
+ urls = [exact_url] if exact_url else [f"{_public_base(args.host)}/{kind}/{identifier}/stat" for kind in types]
551
+ errors: List[str] = []
552
+ for url in urls:
553
+ if not url:
554
+ continue
555
+ try:
556
+ html, final_url = _request_with_url(url, args.timeout)
557
+ final_host = urllib.parse.urlparse(final_url).hostname or ""
558
+ if not TGSTAT_HOST_RE.fullmatch(final_host):
559
+ raise TGStatError(f"TGStat redirected to an unexpected host: {final_host}")
560
+ result = parse_detail_html(html, final_url)
561
+ except TGStatError as exc:
562
+ errors.append(str(exc))
563
+ continue
564
+ if result["status"] == "ok" and result.get("title"):
565
+ if not result["metrics"] and result.get("type") in {"channel", "chat"}:
566
+ plural = "channels" if result["type"] == "channel" else "chats"
567
+ ranking_url = f"{_public_base(args.host)}/ratings/{plural}"
568
+ try:
569
+ ranking_items = parse_rankings_html(_request(ranking_url, args.timeout))
570
+ except TGStatError:
571
+ ranking_items = []
572
+ normalized = str(result.get("identifier") or "").casefold()
573
+ snapshot = next(
574
+ (item for item in ranking_items if str(item.get("identifier") or "").casefold() == normalized),
575
+ None,
576
+ )
577
+ if snapshot:
578
+ result["metrics"] = snapshot["metrics"]
579
+ result["metrics_display"] = snapshot["metrics_display"]
580
+ result["ranking_snapshot"] = {
581
+ "rank": snapshot.get("rank"),
582
+ "category": snapshot.get("category"),
583
+ "source": ranking_url,
584
+ }
585
+ return result
586
+ errors.append(f"restricted or empty public page: {url}")
587
+ raise TGStatError("; ".join(errors) or "could not resolve target on public TGStat pages")
588
+
589
+
590
+ def command_info(args: argparse.Namespace) -> None:
591
+ if _use_api(args):
592
+ identifier, _, _ = _normalize_target_for_mode(args.target, for_api=True)
593
+ payload = _api_get(args, "channels/get", {"channelId": identifier})
594
+ _json_out({"mode": "api", "response": payload.get("response")})
595
+ return
596
+ _json_out({"mode": "public", **_public_info(args, args.target, args.type)})
597
+
598
+
599
+ def command_stat(args: argparse.Namespace) -> None:
600
+ if _use_api(args):
601
+ identifier, _, _ = _normalize_target_for_mode(args.target, for_api=True)
602
+ payload = _api_get(args, "channels/stat", {"channelId": identifier})
603
+ _json_out({"mode": "api", "response": payload.get("response")})
604
+ return
605
+ _json_out({"mode": "public", "limited_metrics": True, **_public_info(args, args.target, args.type)})
606
+
607
+
608
+ def build_parser() -> argparse.ArgumentParser:
609
+ parser = argparse.ArgumentParser(description=__doc__)
610
+ parser.add_argument("--access-mode", choices=("auto", "public", "api"), default="auto")
611
+ parser.add_argument("--host", default=os.environ.get("TGSTAT_PUBLIC_HOST", DEFAULT_PUBLIC_BASE))
612
+ parser.add_argument("--timeout", type=int, default=30)
613
+ sub = parser.add_subparsers(dest="command", required=True)
614
+
615
+ sub.add_parser("mode").set_defaults(func=command_mode)
616
+ sub.add_parser("quota").set_defaults(func=command_quota)
617
+
618
+ search = sub.add_parser("search")
619
+ search.add_argument("query", nargs="?", default="")
620
+ search.add_argument("--category", default="")
621
+ search.add_argument("--type", choices=("channel", "chat", "all"), default="all")
622
+ search.add_argument("--language", default="")
623
+ search.add_argument("--country", default="")
624
+ search.add_argument("--limit", type=int, default=20)
625
+ search.add_argument("--description", action="store_true")
626
+ search.set_defaults(func=command_search)
627
+
628
+ rankings = sub.add_parser("rankings")
629
+ rankings.add_argument("--type", choices=("channel", "chat"), default="channel")
630
+ rankings.add_argument("--query", default="", help="Filter the public top list locally")
631
+ rankings.add_argument("--limit", type=int, default=20)
632
+ rankings.set_defaults(func=command_rankings)
633
+
634
+ for name, func in (("info", command_info), ("stat", command_stat)):
635
+ command = sub.add_parser(name)
636
+ command.add_argument("target")
637
+ command.add_argument("--type", choices=("auto", "channel", "chat"), default="auto")
638
+ command.set_defaults(func=func)
639
+ return parser
640
+
641
+
642
+ def main() -> None:
643
+ args = build_parser().parse_args()
644
+ try:
645
+ args.func(args)
646
+ except TGStatError as exc:
647
+ _json_out({"error": str(exc)})
648
+ sys.exit(1)
649
+
650
+
651
+ if __name__ == "__main__":
652
+ main()