@acedatacloud/skills 2026.713.1 → 2026.713.3

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 (BYOC username; no login)
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 using a connected username as the default target |
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` (BYOC username) |
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.3",
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,136 @@
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 public audience/reach/activity, checking a known @username, or shortlisting ad and outreach sources. The connector supplies TGSTAT_USERNAME as the default profile. Read-only: does not log in, 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 visible metrics, and use
8
+ the connected Telegram username as the default target. Use the separate
9
+ telegram connector for reading or sending messages in the user's 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.1"
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 research. It uses
21
+ only the Python standard library. Resolve the script at the start of every Bash
22
+ call because each call runs in a fresh shell:
27
23
 
28
- **Always confirm the token + remaining quota first** (`usage/stat` is free and
29
- does not count against tariff quota):
24
+ ```bash
25
+ TGSTAT="$SKILL_DIR/scripts/tgstat.py"; [ -f "$TGSTAT" ] || TGSTAT=$(find /tmp -maxdepth 8 -path '*/skills/*/scripts/tgstat.py' 2>/dev/null | head -1)
26
+ [ -f "$TGSTAT" ] || { echo "tgstat script not found (SKILL_DIR=$SKILL_DIR)" >&2; exit 1; }
27
+ python3 "$TGSTAT" profile
28
+ ```
29
+
30
+ The connector injects `$TGSTAT_USERNAME`. It is not a login credential; it is
31
+ only the default public channel/group target. Never print the full environment
32
+ or treat the username as proof that the user owns the Telegram account.
33
+
34
+ ## Discover Sources by Topic
35
+
36
+ Run `search` first:
37
+
38
+ ```bash
39
+ TGSTAT="$SKILL_DIR/scripts/tgstat.py"; [ -f "$TGSTAT" ] || TGSTAT=$(find /tmp -maxdepth 8 -path '*/skills/*/scripts/tgstat.py' 2>/dev/null | head -1)
40
+ [ -f "$TGSTAT" ] || { echo "tgstat script not found (SKILL_DIR=$SKILL_DIR)" >&2; exit 1; }
41
+ python3 "$TGSTAT" search "Claude API" --type all --language English --country US
42
+ ```
43
+
44
+ In public mode the command returns `web_queries`. Call `web_search` once per
45
+ query, then:
46
+
47
+ 1. Keep only public `tgstat.com` regional-host and `t.me` results.
48
+ 2. Extract public `@username` values and deduplicate case-insensitively.
49
+ 3. Prefer results whose title/snippet directly matches the topic.
50
+ 4. Verify each shortlist entry with `info` or `stat` before reporting it.
51
+ 5. Include the source URL and say discovery may be incomplete because public
52
+ search-engine indexes are not TGStat's full database.
53
+
54
+ TGStat's own keyword-search result endpoint requires sign-in. Do not try to
55
+ bypass it, replay private AJAX endpoints, or claim public mode searches the
56
+ full TGStat index.
57
+
58
+ `--language` and `--country` are search terms for the web index, not guaranteed
59
+ TGStat database filters.
60
+
61
+ ## Browse Public Rankings
62
+
63
+ Use rankings when the user wants large or active public sources without a
64
+ precise keyword:
30
65
 
31
66
  ```bash
32
- curl -sS "https://api.tgstat.ru/usage/stat?token=$TGSTAT_TOKEN" \
33
- | jq '.status, (.response[]? | {serviceKey, title, spentChannels, spentRequests, expiredAt})'
67
+ TGSTAT="$SKILL_DIR/scripts/tgstat.py"; [ -f "$TGSTAT" ] || TGSTAT=$(find /tmp -maxdepth 8 -path '*/skills/*/scripts/tgstat.py' 2>/dev/null | head -1)
68
+ [ -f "$TGSTAT" ] || { echo "tgstat script not found (SKILL_DIR=$SKILL_DIR)" >&2; exit 1; }
69
+ python3 "$TGSTAT" rankings --type channel --limit 20
70
+ python3 "$TGSTAT" rankings --type chat --limit 20
71
+ python3 "$TGSTAT" rankings --type channel --query crypto --limit 10
34
72
  ```
35
73
 
36
- ## Find channels to advertise in (the main workflow)
74
+ Channel cards can expose subscribers, one-post reach, and citation index.
75
+ Chat cards can expose participants, recent message count, and MAU. Metrics are
76
+ a current public snapshot, not a historical series.
77
+
78
+ Public TGStat pages can intermittently return an authentication or rate-limit
79
+ interstitial. When `rankings` returns `status: unavailable`, try the emitted
80
+ `web_fetch_url`, then run its `web_queries` with `web_search`. Label those
81
+ results as web-index discoveries, not as an authoritative TGStat rank.
37
82
 
38
- `GET /channels/search` at least one of `q` (keyword, min 3 chars) or
39
- `category` is required.
83
+ ## Inspect a Known Channel or Group
40
84
 
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).
85
+ Accept a public `@username`, bare username, `t.me/<username>` link, or a TGStat
86
+ entity URL. Omit the target to inspect `$TGSTAT_USERNAME` from the connector:
44
87
 
45
88
  ```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}'
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" info
92
+ python3 "$TGSTAT" info @durov
93
+ python3 "$TGSTAT" stat https://t.me/example_public_chat
55
94
  ```
56
95
 
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).
96
+ In public mode, `info`/`stat` resolves whether the target is a channel or chat,
97
+ returns public metadata, and enriches it with ranking metrics when the entity
98
+ appears in the current public ranking. Empty metrics mean TGStat did not expose
99
+ them publicly; do not call that full statistics.
63
100
 
64
- ## Judge a channel's ad value
101
+ For ad selection, rank by relevant reach/activity rather than subscriber count
102
+ alone. High subscribers with weak reach or chat MAU can indicate an inactive or
103
+ inflated audience. Present metrics as evidence, not a guarantee of lead quality.
65
104
 
66
- `GET /channels/stat?channelId=<@username | t.me/username | tgstat id>` returns
67
- the numbers that actually matter for ad pricing:
105
+ ## Check the Connected Profile
68
106
 
69
107
  ```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
- }'
108
+ TGSTAT="$SKILL_DIR/scripts/tgstat.py"; [ -f "$TGSTAT" ] || TGSTAT=$(find /tmp -maxdepth 8 -path '*/skills/*/scripts/tgstat.py' 2>/dev/null | head -1)
109
+ [ -f "$TGSTAT" ] || { echo "tgstat script not found (SKILL_DIR=$SKILL_DIR)" >&2; exit 1; }
110
+ python3 "$TGSTAT" profile
78
111
  ```
79
112
 
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.
113
+ `profile` reports the normalized public username used by target-less `info` and
114
+ `stat`. It does not verify ownership or authenticate to Telegram/TGStat.
115
+
116
+ ## Outreach Research Workflow
117
+
118
+ For prospecting or partnership research:
119
+
120
+ 1. Search several narrow problem/role keywords, not only broad `AI` terms.
121
+ 2. Shortlist sources by relevance first, then audience/reach/activity.
122
+ 3. Verify each public source and retain evidence URLs.
123
+ 4. Label each source as channel, group/chat, or uncertain.
124
+ 5. Produce a review queue with suggested angle and reason for fit.
125
+ 6. Require human approval before any message is sent through another connector.
126
+
127
+ ## Safety and Limits
128
+
129
+ - Read-only. This skill does not join channels/groups, import invite links,
130
+ send messages, add members, or download member lists.
131
+ - Reject private invite links (`t.me/+...`, `joinchat/...`) and message links.
132
+ - Do not automate unsolicited bulk outreach or evade Telegram/TGStat controls.
133
+ - Public pages and HTML can change; if parsing fails, use `web_fetch` for a
134
+ single public page and report only values visible in that page.
135
+ - Public web discovery is incomplete and regional TGStat pages may differ.
136
+ - Treat `$TGSTAT_USERNAME` as public profile context, not authentication.
@@ -0,0 +1,561 @@
1
+ #!/usr/bin/env python3
2
+ """Public TGStat research CLI with a connected Telegram username."""
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
+ USER_AGENT = (
19
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
20
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36"
21
+ )
22
+ PUBLIC_USERNAME_RE = re.compile(r"[A-Za-z0-9_]{3,}")
23
+ TGSTAT_HOST_RE = re.compile(r"^(?:[a-z]{2,3}\.)?tgstat\.com$", re.IGNORECASE)
24
+ TGSTAT_INPUT_HOST_RE = re.compile(r"^(?:(?:[a-z]{2,3}\.)?tgstat\.com|tgstat\.ru)$", re.IGNORECASE)
25
+ ENTITY_PATH_RE = re.compile(r"/(channel|chat)/(@[A-Za-z0-9_]{3,}|id\d+)/stat/?", re.IGNORECASE)
26
+
27
+
28
+ class TGStatError(RuntimeError):
29
+ pass
30
+
31
+
32
+ def _compact(value: str) -> str:
33
+ return re.sub(r"\s+", " ", value or "").strip()
34
+
35
+
36
+ def _metric_key(label: str) -> str:
37
+ key = re.sub(r"[^a-z0-9]+", "_", label.lower()).strip("_")
38
+ return key or "value"
39
+
40
+
41
+ def _metric_number(value: str) -> Optional[Union[int, float]]:
42
+ compact = _compact(value).replace(" ", "").replace(",", "")
43
+ match = re.fullmatch(r"(-?\d+(?:\.\d+)?)([kmb])?", compact, re.IGNORECASE)
44
+ if not match:
45
+ return None
46
+ number = float(match.group(1))
47
+ multiplier = {"k": 1_000, "m": 1_000_000, "b": 1_000_000_000}.get(
48
+ (match.group(2) or "").lower(), 1
49
+ )
50
+ result = number * multiplier
51
+ return int(result) if result.is_integer() else result
52
+
53
+
54
+ def _entity_from_url(url: str) -> Optional[Tuple[str, str]]:
55
+ path = urllib.parse.unquote(urllib.parse.urlparse(url).path)
56
+ match = ENTITY_PATH_RE.fullmatch(path)
57
+ if not match:
58
+ return None
59
+ return match.group(1).lower(), match.group(2)
60
+
61
+
62
+ def _safe_url(url: str) -> str:
63
+ parsed = urllib.parse.urlsplit(url)
64
+ return urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, parsed.path, "", ""))
65
+
66
+
67
+ def _safe_error_body(body: str) -> str:
68
+ return _compact(body)[:200]
69
+
70
+
71
+ def _validate_request_url(url: str, initial_host: Optional[str] = None) -> None:
72
+ parsed = urllib.parse.urlsplit(url)
73
+ host = parsed.hostname or ""
74
+ if parsed.scheme != "https":
75
+ raise TGStatError("TGStat requests and redirects must use HTTPS")
76
+ allowed_from = initial_host or host
77
+ if TGSTAT_HOST_RE.fullmatch(allowed_from):
78
+ if not TGSTAT_HOST_RE.fullmatch(host):
79
+ raise TGStatError(f"TGStat redirected to an unexpected host: {host}")
80
+ else:
81
+ raise TGStatError(f"unsupported TGStat request host: {allowed_from}")
82
+
83
+
84
+ class SafeRedirectHandler(urllib.request.HTTPRedirectHandler):
85
+ def __init__(self, initial_host: str) -> None:
86
+ super().__init__()
87
+ self.initial_host = initial_host
88
+
89
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
90
+ _validate_request_url(newurl, self.initial_host)
91
+ return super().redirect_request(req, fp, code, msg, headers, newurl)
92
+
93
+
94
+ def _open_url(request: urllib.request.Request, timeout: int):
95
+ initial_url = request.full_url
96
+ _validate_request_url(initial_url)
97
+ initial_host = urllib.parse.urlsplit(initial_url).hostname or ""
98
+ opener = urllib.request.build_opener(SafeRedirectHandler(initial_host))
99
+ return opener.open(request, timeout=timeout)
100
+
101
+
102
+ class RankingParser(HTMLParser):
103
+ def __init__(self) -> None:
104
+ super().__init__(convert_charrefs=True)
105
+ self.items: List[dict] = []
106
+ self.current: Optional[dict] = None
107
+ self.card_div_depth = 0
108
+ self.capture: Optional[dict] = None
109
+ self.pending_metric: Optional[str] = None
110
+
111
+ def handle_starttag(self, tag: str, attrs: List[Tuple[str, Optional[str]]]) -> None:
112
+ attr = {key: value or "" for key, value in attrs}
113
+ classes = set(attr.get("class", "").split())
114
+ if tag == "div" and "peer-item-row" in classes and self.current is None:
115
+ self.current = {"metrics": {}, "metrics_display": {}}
116
+ self.card_div_depth = 1
117
+ return
118
+ if self.current is None:
119
+ return
120
+ if tag == "div":
121
+ self.card_div_depth += 1
122
+ if self.capture is not None:
123
+ self.capture["depth"] += 1
124
+ return
125
+
126
+ if tag == "a" and attr.get("href"):
127
+ entity = _entity_from_url(attr["href"])
128
+ hostname = urllib.parse.urlparse(attr["href"]).hostname or ""
129
+ if entity and TGSTAT_HOST_RE.fullmatch(hostname):
130
+ peer_type, identifier = entity
131
+ self.current.setdefault("type", peer_type)
132
+ self.current.setdefault("identifier", identifier)
133
+ self.current.setdefault("tgstat_url", attr["href"])
134
+
135
+ kind = ""
136
+ if tag == "div" and "ribbon" in classes:
137
+ kind = "rank"
138
+ elif tag == "div" and {"text-truncate", "font-16", "text-dark"}.issubset(classes):
139
+ if self.current.get("tgstat_url") and not self.current.get("title"):
140
+ kind = "title"
141
+ elif tag == "span" and {"border", "rounded", "bg-light"}.issubset(classes):
142
+ if self.current.get("tgstat_url") and not self.current.get("category"):
143
+ kind = "category"
144
+ elif tag == "h4":
145
+ kind = "metric_value"
146
+ elif tag == "div" and {"text-muted", "text-truncate"}.issubset(classes):
147
+ if self.pending_metric is not None:
148
+ kind = "metric_label"
149
+ if kind:
150
+ self.capture = {"tag": tag, "kind": kind, "depth": 1, "parts": []}
151
+
152
+ def handle_endtag(self, tag: str) -> None:
153
+ if self.current is None:
154
+ return
155
+ if self.capture is not None:
156
+ self.capture["depth"] -= 1
157
+ if self.capture["depth"] == 0:
158
+ self._finish_capture()
159
+ if tag == "div":
160
+ self.card_div_depth -= 1
161
+ if self.card_div_depth == 0:
162
+ self._finish_card()
163
+
164
+ def handle_data(self, data: str) -> None:
165
+ if self.capture is not None:
166
+ self.capture["parts"].append(data)
167
+
168
+ def _finish_capture(self) -> None:
169
+ assert self.current is not None and self.capture is not None
170
+ kind = self.capture["kind"]
171
+ value = _compact("".join(self.capture["parts"]))
172
+ self.capture = None
173
+ if not value:
174
+ return
175
+ if kind == "rank":
176
+ match = re.search(r"\d+", value)
177
+ if match:
178
+ self.current["rank"] = int(match.group())
179
+ elif kind in {"title", "category"}:
180
+ self.current[kind] = value
181
+ elif kind == "metric_value":
182
+ self.pending_metric = value
183
+ elif kind == "metric_label" and self.pending_metric is not None:
184
+ key = _metric_key(value)
185
+ display = self.pending_metric
186
+ self.current["metrics_display"][key] = display
187
+ number = _metric_number(display)
188
+ self.current["metrics"][key] = number if number is not None else display
189
+ self.pending_metric = None
190
+
191
+ def _finish_card(self) -> None:
192
+ assert self.current is not None
193
+ item = self.current
194
+ identifier = item.get("identifier", "")
195
+ if identifier.startswith("@"):
196
+ item["username"] = identifier
197
+ item["telegram_url"] = f"https://t.me/{identifier[1:]}"
198
+ else:
199
+ item["username"] = None
200
+ item["telegram_url"] = None
201
+ if item.get("tgstat_url") and item.get("title"):
202
+ self.items.append(item)
203
+ self.current = None
204
+ self.card_div_depth = 0
205
+ self.capture = None
206
+ self.pending_metric = None
207
+
208
+
209
+ class DetailParser(HTMLParser):
210
+ def __init__(self) -> None:
211
+ super().__init__(convert_charrefs=True)
212
+ self.meta: Dict[str, str] = {}
213
+ self.capture: Optional[dict] = None
214
+ self.pending_metric: Optional[str] = None
215
+ self.metrics: Dict[str, Union[int, float, str]] = {}
216
+ self.metrics_display: Dict[str, str] = {}
217
+ self.text_parts: List[str] = []
218
+
219
+ def handle_starttag(self, tag: str, attrs: List[Tuple[str, Optional[str]]]) -> None:
220
+ attr = {key.lower(): value or "" for key, value in attrs}
221
+ if tag == "meta":
222
+ key = (attr.get("property") or attr.get("name") or "").lower()
223
+ if key in {"og:title", "og:description", "description"} and attr.get("content"):
224
+ self.meta[key] = _compact(attr["content"])
225
+ if self.capture is not None:
226
+ self.capture["depth"] += 1
227
+ return
228
+ classes = set(attr.get("class", "").split())
229
+ kind = ""
230
+ if tag == "title":
231
+ kind = "title"
232
+ elif tag == "h4":
233
+ kind = "metric_value"
234
+ elif tag == "div" and {"text-muted", "text-truncate"}.issubset(classes):
235
+ if self.pending_metric is not None:
236
+ kind = "metric_label"
237
+ if kind:
238
+ self.capture = {"kind": kind, "depth": 1, "parts": []}
239
+
240
+ def handle_endtag(self, tag: str) -> None:
241
+ if self.capture is None:
242
+ return
243
+ self.capture["depth"] -= 1
244
+ if self.capture["depth"] != 0:
245
+ return
246
+ kind = self.capture["kind"]
247
+ value = _compact("".join(self.capture["parts"]))
248
+ self.capture = None
249
+ if kind == "title" and value:
250
+ self.meta.setdefault("title", value)
251
+ elif kind == "metric_value" and value:
252
+ self.pending_metric = value
253
+ elif kind == "metric_label" and value and self.pending_metric is not None:
254
+ key = _metric_key(value)
255
+ self.metrics_display[key] = self.pending_metric
256
+ number = _metric_number(self.pending_metric)
257
+ self.metrics[key] = number if number is not None else self.pending_metric
258
+ self.pending_metric = None
259
+
260
+ def handle_data(self, data: str) -> None:
261
+ text = _compact(data)
262
+ if text:
263
+ self.text_parts.append(text)
264
+ if self.capture is not None:
265
+ self.capture["parts"].append(data)
266
+
267
+
268
+ def parse_rankings_html(html: str) -> List[dict]:
269
+ parser = RankingParser()
270
+ parser.feed(html)
271
+ return parser.items
272
+
273
+
274
+ def parse_detail_html(html: str, url: str) -> dict:
275
+ parser = DetailParser()
276
+ parser.feed(html)
277
+ text = " ".join(parser.text_parts)
278
+ entity = _entity_from_url(url)
279
+ title = parser.meta.get("og:title") or parser.meta.get("title")
280
+ recognized = bool(entity and parser.meta.get("og:title") and title and "tgstat" in title.casefold())
281
+ result = {
282
+ "status": (
283
+ "restricted"
284
+ if "authentication required" in text.lower()
285
+ else "ok" if recognized else "unrecognized"
286
+ ),
287
+ "type": entity[0] if entity else None,
288
+ "identifier": entity[1] if entity else None,
289
+ "title": title,
290
+ "description": parser.meta.get("og:description") or parser.meta.get("description"),
291
+ "metrics": parser.metrics,
292
+ "metrics_display": parser.metrics_display,
293
+ "tgstat_url": url,
294
+ }
295
+ identifier = result["identifier"]
296
+ result["telegram_url"] = f"https://t.me/{identifier[1:]}" if isinstance(identifier, str) and identifier.startswith("@") else None
297
+ return result
298
+
299
+
300
+ def _json_out(payload: object) -> None:
301
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
302
+
303
+
304
+ def _request_with_url(
305
+ url: str, timeout: int, data: Optional[bytes] = None, headers: Optional[dict] = None
306
+ ) -> Tuple[str, str]:
307
+ request_headers = {
308
+ "User-Agent": USER_AGENT,
309
+ "Accept": "application/json, text/html;q=0.9, */*;q=0.8",
310
+ "Accept-Language": "en-US,en;q=0.9",
311
+ **(headers or {}),
312
+ }
313
+ request = urllib.request.Request(url, data=data, headers=request_headers)
314
+ try:
315
+ with _open_url(request, timeout) as response:
316
+ return response.read().decode("utf-8", "replace"), response.geturl()
317
+ except urllib.error.HTTPError as exc:
318
+ body = exc.read().decode("utf-8", "replace")
319
+ raise TGStatError(f"HTTP {exc.code} from {_safe_url(url)}: {_safe_error_body(body)}") from exc
320
+ except urllib.error.URLError as exc:
321
+ raise TGStatError(f"network error for {_safe_url(url)}: {_safe_error_body(str(exc.reason))}") from exc
322
+
323
+
324
+ def _request(url: str, timeout: int, data: Optional[bytes] = None, headers: Optional[dict] = None) -> str:
325
+ return _request_with_url(url, timeout, data, headers)[0]
326
+
327
+
328
+ def _public_base(value: str) -> str:
329
+ parsed = urllib.parse.urlparse(value if "://" in value else f"https://{value}")
330
+ if parsed.scheme != "https" or not parsed.hostname or not TGSTAT_HOST_RE.fullmatch(parsed.hostname):
331
+ raise TGStatError("public host must be tgstat.com or a regional *.tgstat.com host")
332
+ return f"https://{parsed.hostname}"
333
+
334
+
335
+ def _normalize_target(target: str) -> Tuple[Optional[str], Optional[str], Optional[str]]:
336
+ target = target.strip()
337
+ if target.startswith("http://") or target.startswith("https://"):
338
+ parsed = urllib.parse.urlparse(target)
339
+ if parsed.scheme != "https":
340
+ raise TGStatError("target URL must use HTTPS")
341
+ if parsed.hostname and TGSTAT_INPUT_HOST_RE.fullmatch(parsed.hostname):
342
+ entity = _entity_from_url(target)
343
+ if not entity:
344
+ raise TGStatError("TGStat target must be a channel or chat statistics URL")
345
+ identifier = entity[1]
346
+ canonical_url = target
347
+ if parsed.hostname.casefold() == "tgstat.ru":
348
+ canonical_url = urllib.parse.urlunsplit(("https", "tgstat.com", parsed.path, "", ""))
349
+ return identifier, entity[0], canonical_url
350
+ if parsed.hostname in {"t.me", "www.t.me"}:
351
+ parts = [part for part in parsed.path.split("/") if part]
352
+ if len(parts) != 1 or not PUBLIC_USERNAME_RE.fullmatch(parts[0]):
353
+ raise TGStatError("t.me target must be a public username link, not an invite or message link")
354
+ return f"@{parts[0]}", None, None
355
+ raise TGStatError("target URL must be a tgstat.com entity or t.me link")
356
+ if target.startswith("@") and PUBLIC_USERNAME_RE.fullmatch(target[1:]):
357
+ return target, None, None
358
+ if re.fullmatch(r"\d+", target):
359
+ return f"id{target}", None, None
360
+ if re.fullmatch(r"id\d+", target, re.IGNORECASE):
361
+ return f"id{target[2:]}", None, None
362
+ if PUBLIC_USERNAME_RE.fullmatch(target):
363
+ return f"@{target}", None, None
364
+ raise TGStatError("target must be @username, a t.me link, TGStat entity URL, or id<number>")
365
+
366
+
367
+ def _connected_username() -> str:
368
+ value = os.environ.get("TGSTAT_USERNAME", "").strip()
369
+ if not value:
370
+ raise TGStatError("TGSTAT_USERNAME is not configured; reconnect TGStat with a public Telegram username")
371
+ identifier, _, _ = _normalize_target(value)
372
+ if not identifier or not identifier.startswith("@"):
373
+ raise TGStatError("TGSTAT_USERNAME must be a public Telegram username such as @durov")
374
+ return identifier
375
+
376
+
377
+ def _target_or_profile(target: str) -> str:
378
+ return target.strip() or _connected_username()
379
+
380
+
381
+ def command_profile(args: argparse.Namespace) -> None:
382
+ username = _connected_username()
383
+ _json_out(
384
+ {
385
+ "mode": "public",
386
+ "username": username,
387
+ "telegram_url": f"https://t.me/{username[1:]}",
388
+ "verified": False,
389
+ }
390
+ )
391
+
392
+
393
+ def _web_queries(query: str, peer_type: str, language: str, country: str) -> List[str]:
394
+ suffix = " ".join(part for part in (language, country) if part).strip()
395
+ quoted = f'"{query}"'
396
+ kinds = [peer_type] if peer_type != "all" else ["chat", "channel"]
397
+ queries = [f"site:tgstat.com/{kind}/ {quoted} {suffix}".strip() for kind in kinds]
398
+ queries.extend(f"site:t.me/ {quoted} {kind} {suffix}".strip() for kind in kinds)
399
+ return queries
400
+
401
+
402
+ def command_search(args: argparse.Namespace) -> None:
403
+ query = args.query.strip()
404
+ if len(query) < 3:
405
+ raise TGStatError("query must contain at least 3 characters")
406
+ _json_out(
407
+ {
408
+ "mode": "public",
409
+ "query": query,
410
+ "requires_web_search": True,
411
+ "web_queries": _web_queries(query, args.type, args.language, args.country),
412
+ "instructions": (
413
+ "Run web_search for each query, keep only tgstat.com and t.me results, "
414
+ "deduplicate by username, then inspect shortlisted TGStat URLs with the info command."
415
+ ),
416
+ "limitations": "TGStat's own keyword-search results require sign-in; public web indexes may be incomplete.",
417
+ }
418
+ )
419
+
420
+
421
+ def _ranking_fallback(url: str, peer_type: str, query: str, reason: str) -> None:
422
+ subject = query.strip() or ("Telegram channels" if peer_type == "channel" else "Telegram groups")
423
+ _json_out(
424
+ {
425
+ "mode": "public",
426
+ "status": "unavailable",
427
+ "source": url,
428
+ "reason": reason,
429
+ "requires_web_fetch": True,
430
+ "web_fetch_url": url,
431
+ "requires_web_search": True,
432
+ "web_queries": [
433
+ f'site:tgstat.com/{peer_type}/ "{subject}"',
434
+ f'site:t.me/ "{subject}"',
435
+ ],
436
+ "limitations": "Fallback results are web-index discoveries, not an authoritative TGStat ranking.",
437
+ }
438
+ )
439
+
440
+
441
+ def command_rankings(args: argparse.Namespace) -> None:
442
+ base = _public_base(args.host)
443
+ plural = "channels" if args.type == "channel" else "chats"
444
+ url = f"{base}/ratings/{plural}"
445
+ try:
446
+ html = _request(url, args.timeout)
447
+ except TGStatError as exc:
448
+ _ranking_fallback(url, args.type, args.query, str(exc))
449
+ return
450
+ normalized_html = html.casefold()
451
+ if "authentication required" in normalized_html or "too many requests" in normalized_html:
452
+ _ranking_fallback(url, args.type, args.query, "TGStat returned an authentication or rate-limit interstitial")
453
+ return
454
+ items = parse_rankings_html(html)
455
+ if not items:
456
+ _ranking_fallback(url, args.type, args.query, "TGStat ranking HTML was empty or not recognized")
457
+ return
458
+ if args.query:
459
+ needle = args.query.casefold()
460
+ items = [
461
+ item
462
+ for item in items
463
+ if needle in " ".join(str(item.get(key) or "") for key in ("title", "username", "category")).casefold()
464
+ ]
465
+ _json_out({"mode": "public", "source": url, "count": min(len(items), args.limit), "items": items[: args.limit]})
466
+
467
+
468
+ def _public_info(args: argparse.Namespace, target: str, peer_type: str) -> dict:
469
+ identifier, inferred_type, exact_url = _normalize_target(target)
470
+ types = [inferred_type] if inferred_type else ([peer_type] if peer_type != "auto" else ["channel", "chat"])
471
+ urls = [exact_url] if exact_url else [f"{_public_base(args.host)}/{kind}/{identifier}/stat" for kind in types]
472
+ errors: List[str] = []
473
+ for url in urls:
474
+ if not url:
475
+ continue
476
+ try:
477
+ html, final_url = _request_with_url(url, args.timeout)
478
+ final_host = urllib.parse.urlparse(final_url).hostname or ""
479
+ if not TGSTAT_HOST_RE.fullmatch(final_host):
480
+ raise TGStatError(f"TGStat redirected to an unexpected host: {final_host}")
481
+ result = parse_detail_html(html, final_url)
482
+ except TGStatError as exc:
483
+ errors.append(str(exc))
484
+ continue
485
+ if result["status"] == "ok" and result.get("title"):
486
+ if not result["metrics"] and result.get("type") in {"channel", "chat"}:
487
+ plural = "channels" if result["type"] == "channel" else "chats"
488
+ ranking_url = f"{_public_base(args.host)}/ratings/{plural}"
489
+ try:
490
+ ranking_items = parse_rankings_html(_request(ranking_url, args.timeout))
491
+ except TGStatError:
492
+ ranking_items = []
493
+ normalized = str(result.get("identifier") or "").casefold()
494
+ snapshot = next(
495
+ (item for item in ranking_items if str(item.get("identifier") or "").casefold() == normalized),
496
+ None,
497
+ )
498
+ if snapshot:
499
+ result["metrics"] = snapshot["metrics"]
500
+ result["metrics_display"] = snapshot["metrics_display"]
501
+ result["ranking_snapshot"] = {
502
+ "rank": snapshot.get("rank"),
503
+ "category": snapshot.get("category"),
504
+ "source": ranking_url,
505
+ }
506
+ return result
507
+ errors.append(f"restricted or empty public page: {url}")
508
+ raise TGStatError("; ".join(errors) or "could not resolve target on public TGStat pages")
509
+
510
+
511
+ def command_info(args: argparse.Namespace) -> None:
512
+ _json_out({"mode": "public", **_public_info(args, _target_or_profile(args.target), args.type)})
513
+
514
+
515
+ def command_stat(args: argparse.Namespace) -> None:
516
+ _json_out(
517
+ {"mode": "public", "limited_metrics": True, **_public_info(args, _target_or_profile(args.target), args.type)}
518
+ )
519
+
520
+
521
+ def build_parser() -> argparse.ArgumentParser:
522
+ parser = argparse.ArgumentParser(description=__doc__)
523
+ parser.add_argument("--host", default=os.environ.get("TGSTAT_PUBLIC_HOST", DEFAULT_PUBLIC_BASE))
524
+ parser.add_argument("--timeout", type=int, default=30)
525
+ sub = parser.add_subparsers(dest="command", required=True)
526
+
527
+ sub.add_parser("profile").set_defaults(func=command_profile)
528
+
529
+ search = sub.add_parser("search")
530
+ search.add_argument("query")
531
+ search.add_argument("--type", choices=("channel", "chat", "all"), default="all")
532
+ search.add_argument("--language", default="")
533
+ search.add_argument("--country", default="")
534
+ search.add_argument("--limit", type=int, default=20)
535
+ search.set_defaults(func=command_search)
536
+
537
+ rankings = sub.add_parser("rankings")
538
+ rankings.add_argument("--type", choices=("channel", "chat"), default="channel")
539
+ rankings.add_argument("--query", default="", help="Filter the public top list locally")
540
+ rankings.add_argument("--limit", type=int, default=20)
541
+ rankings.set_defaults(func=command_rankings)
542
+
543
+ for name, func in (("info", command_info), ("stat", command_stat)):
544
+ command = sub.add_parser(name)
545
+ command.add_argument("target", nargs="?", default="")
546
+ command.add_argument("--type", choices=("auto", "channel", "chat"), default="auto")
547
+ command.set_defaults(func=func)
548
+ return parser
549
+
550
+
551
+ def main() -> None:
552
+ args = build_parser().parse_args()
553
+ try:
554
+ args.func(args)
555
+ except TGStatError as exc:
556
+ _json_out({"error": str(exc)})
557
+ sys.exit(1)
558
+
559
+
560
+ if __name__ == "__main__":
561
+ main()