@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 +1 -0
- package/README.md +2 -0
- package/package.json +1 -1
- package/skills/tgstat/SKILL.md +109 -83
- package/skills/tgstat/scripts/tgstat.py +561 -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 (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.
|
|
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",
|
package/skills/tgstat/SKILL.md
CHANGED
|
@@ -1,110 +1,136 @@
|
|
|
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 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
|
-
|
|
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 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
|
|
15
|
+
version: "2.1"
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
39
|
-
`category` is required.
|
|
83
|
+
## Inspect a Known Channel or Group
|
|
40
84
|
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
|
|
67
|
-
the numbers that actually matter for ad pricing:
|
|
105
|
+
## Check the Connected Profile
|
|
68
106
|
|
|
69
107
|
```bash
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
-
|
|
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()
|