@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 +1 -0
- package/README.md +2 -0
- package/package.json +1 -1
- package/skills/tgstat/SKILL.md +139 -83
- package/skills/tgstat/scripts/tgstat.py +652 -0
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.
|
|
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",
|
package/skills/tgstat/SKILL.md
CHANGED
|
@@ -1,110 +1,166 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: tgstat
|
|
3
|
-
description:
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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: "
|
|
15
|
+
version: "2.0"
|
|
17
16
|
---
|
|
18
17
|
|
|
19
|
-
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
67
|
-
the numbers that actually matter for ad pricing:
|
|
131
|
+
## Check API Mode and Quota
|
|
68
132
|
|
|
69
133
|
```bash
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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()
|