@acedatacloud/skills 2026.713.6 → 2026.714.0
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/package.json +1 -1
- package/skills/tgstat/SKILL.md +14 -7
- package/skills/tgstat/scripts/tgstat.py +127 -60
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@acedatacloud/skills",
|
|
3
|
-
"version": "2026.
|
|
3
|
+
"version": "2026.714.0",
|
|
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
|
@@ -12,14 +12,16 @@ allowed_tools: [Bash, web_search, web_fetch]
|
|
|
12
12
|
license: Apache-2.0
|
|
13
13
|
metadata:
|
|
14
14
|
author: acedatacloud
|
|
15
|
-
version: "2.
|
|
15
|
+
version: "2.2"
|
|
16
16
|
---
|
|
17
17
|
|
|
18
18
|
# TGStat Research
|
|
19
19
|
|
|
20
20
|
Use [scripts/tgstat.py](./scripts/tgstat.py) for public TGStat research. It uses
|
|
21
|
-
only the Python standard library.
|
|
22
|
-
|
|
21
|
+
only the Python standard library. Under the hood it fetches TGStat pages through
|
|
22
|
+
the platform render service (headless Chromium), which bypasses the public
|
|
23
|
+
site's bot protection, so each lookup can take several seconds. Resolve the
|
|
24
|
+
script at the start of every Bash call because each call runs in a fresh shell:
|
|
23
25
|
|
|
24
26
|
```bash
|
|
25
27
|
TGSTAT="$SKILL_DIR/scripts/tgstat.py"; [ -f "$TGSTAT" ] || TGSTAT=$(find /tmp -maxdepth 8 -path '*/skills/*/scripts/tgstat.py' 2>/dev/null | head -1)
|
|
@@ -93,10 +95,15 @@ python3 "$TGSTAT" info @durov
|
|
|
93
95
|
python3 "$TGSTAT" stat https://t.me/example_public_chat
|
|
94
96
|
```
|
|
95
97
|
|
|
96
|
-
In public mode, `info`/`stat` resolves whether the target is a channel or chat
|
|
97
|
-
returns public
|
|
98
|
-
|
|
99
|
-
|
|
98
|
+
In public mode, `info`/`stat` resolves whether the target is a channel or chat
|
|
99
|
+
and returns its public identity (title, description) plus the visible
|
|
100
|
+
subscriber / participant count. Deeper engagement metrics (average post reach,
|
|
101
|
+
citation index, ER) are charted client-side on TGStat, so they are only
|
|
102
|
+
available for entities that appear in the public top ratings — for those the
|
|
103
|
+
command enriches the result from the ranking snapshot. Empty or missing metrics
|
|
104
|
+
mean TGStat did not expose them on the public page; do not call that full
|
|
105
|
+
statistics. Use `rankings` when you need reach / citation index for large
|
|
106
|
+
public sources.
|
|
100
107
|
|
|
101
108
|
For ad selection, rank by relevant reach/activity rather than subscriber count
|
|
102
109
|
alone. High subscribers with weak reach or chat MAU can indicate an inactive or
|
|
@@ -8,6 +8,7 @@ import json
|
|
|
8
8
|
import os
|
|
9
9
|
import re
|
|
10
10
|
import sys
|
|
11
|
+
import time
|
|
11
12
|
import urllib.error
|
|
12
13
|
import urllib.parse
|
|
13
14
|
import urllib.request
|
|
@@ -15,14 +16,17 @@ from html.parser import HTMLParser
|
|
|
15
16
|
from typing import Dict, List, Optional, Tuple, Union
|
|
16
17
|
|
|
17
18
|
DEFAULT_PUBLIC_BASE = "https://tgstat.com"
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
# Public tgstat.com sits behind Cloudflare, so pages are fetched through the
|
|
20
|
+
# platform WebExtrator render service (headless Chromium) instead of directly.
|
|
21
|
+
RENDER_ENDPOINT = os.environ.get("TGSTAT_RENDER_ENDPOINT", "https://api.acedata.cloud/webextrator/render")
|
|
22
|
+
RENDER_TOKEN_ENV = "TGSTAT_RENDER_TOKEN"
|
|
23
|
+
RENDER_DELAY_SECONDS = 2
|
|
24
|
+
RENDER_BLOCK_RESOURCES = ["image", "font", "media", "stylesheet"]
|
|
22
25
|
PUBLIC_USERNAME_RE = re.compile(r"[A-Za-z0-9_]{3,}")
|
|
23
26
|
TGSTAT_HOST_RE = re.compile(r"^(?:[a-z]{2,3}\.)?tgstat\.com$", re.IGNORECASE)
|
|
24
27
|
TGSTAT_INPUT_HOST_RE = re.compile(r"^(?:(?:[a-z]{2,3}\.)?tgstat\.com|tgstat\.ru)$", re.IGNORECASE)
|
|
25
|
-
|
|
28
|
+
# The /stat suffix is optional: the summary metrics live on the main entity page.
|
|
29
|
+
ENTITY_PATH_RE = re.compile(r"/(channel|chat)/(@[A-Za-z0-9_]{3,}|id\d+)(?:/stat)?/?", re.IGNORECASE)
|
|
26
30
|
|
|
27
31
|
|
|
28
32
|
class TGStatError(RuntimeError):
|
|
@@ -81,24 +85,6 @@ def _validate_request_url(url: str, initial_host: Optional[str] = None) -> None:
|
|
|
81
85
|
raise TGStatError(f"unsupported TGStat request host: {allowed_from}")
|
|
82
86
|
|
|
83
87
|
|
|
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
88
|
class RankingParser(HTMLParser):
|
|
103
89
|
def __init__(self) -> None:
|
|
104
90
|
super().__init__(convert_charrefs=True)
|
|
@@ -215,9 +201,12 @@ class DetailParser(HTMLParser):
|
|
|
215
201
|
self.metrics: Dict[str, Union[int, float, str]] = {}
|
|
216
202
|
self.metrics_display: Dict[str, str] = {}
|
|
217
203
|
self.text_parts: List[str] = []
|
|
204
|
+
self._tag_index = 0
|
|
205
|
+
self._pending_at = -100
|
|
218
206
|
|
|
219
207
|
def handle_starttag(self, tag: str, attrs: List[Tuple[str, Optional[str]]]) -> None:
|
|
220
208
|
attr = {key.lower(): value or "" for key, value in attrs}
|
|
209
|
+
self._tag_index += 1
|
|
221
210
|
if tag == "meta":
|
|
222
211
|
key = (attr.get("property") or attr.get("name") or "").lower()
|
|
223
212
|
if key in {"og:title", "og:description", "description"} and attr.get("content"):
|
|
@@ -229,10 +218,12 @@ class DetailParser(HTMLParser):
|
|
|
229
218
|
kind = ""
|
|
230
219
|
if tag == "title":
|
|
231
220
|
kind = "title"
|
|
232
|
-
elif tag
|
|
221
|
+
elif tag in {"h2", "h3", "h4"} and "text-dark" in classes:
|
|
233
222
|
kind = "metric_value"
|
|
234
|
-
elif tag == "div" and {"text-
|
|
235
|
-
|
|
223
|
+
elif tag == "div" and {"text-uppercase", "font-12"}.issubset(classes):
|
|
224
|
+
# Only pair a label that sits right after its value inside the same
|
|
225
|
+
# stat card, so a stray numeric heading can't grab a distant label.
|
|
226
|
+
if self.pending_metric is not None and self._tag_index - self._pending_at <= 6:
|
|
236
227
|
kind = "metric_label"
|
|
237
228
|
if kind:
|
|
238
229
|
self.capture = {"kind": kind, "depth": 1, "parts": []}
|
|
@@ -248,13 +239,15 @@ class DetailParser(HTMLParser):
|
|
|
248
239
|
self.capture = None
|
|
249
240
|
if kind == "title" and value:
|
|
250
241
|
self.meta.setdefault("title", value)
|
|
251
|
-
elif kind == "metric_value" and value:
|
|
242
|
+
elif kind == "metric_value" and _metric_number(value) is not None:
|
|
252
243
|
self.pending_metric = value
|
|
244
|
+
self._pending_at = self._tag_index
|
|
253
245
|
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
246
|
number = _metric_number(self.pending_metric)
|
|
257
|
-
|
|
247
|
+
if number is not None:
|
|
248
|
+
key = _metric_key(value)
|
|
249
|
+
self.metrics_display[key] = self.pending_metric
|
|
250
|
+
self.metrics[key] = number
|
|
258
251
|
self.pending_metric = None
|
|
259
252
|
|
|
260
253
|
def handle_data(self, data: str) -> None:
|
|
@@ -277,13 +270,24 @@ def parse_detail_html(html: str, url: str) -> dict:
|
|
|
277
270
|
text = " ".join(parser.text_parts)
|
|
278
271
|
entity = _entity_from_url(url)
|
|
279
272
|
title = parser.meta.get("og:title") or parser.meta.get("title")
|
|
280
|
-
|
|
273
|
+
# A real entity page echoes the requested @username / id in its title or
|
|
274
|
+
# emits an og:title; tgstat error / search / interstitial pages do neither
|
|
275
|
+
# (the bare "TGStat" brand in <title> is not proof the entity exists).
|
|
276
|
+
identifier_token = str(entity[1]).casefold() if entity else ""
|
|
277
|
+
recognized = bool(
|
|
278
|
+
entity and title and (identifier_token in title.casefold() or bool(parser.meta.get("og:title")))
|
|
279
|
+
)
|
|
280
|
+
lowered_title = (title or "").casefold()
|
|
281
|
+
if "not found" in lowered_title or "404" in lowered_title:
|
|
282
|
+
status = "not_found"
|
|
283
|
+
elif "authentication required" in text.lower():
|
|
284
|
+
status = "restricted"
|
|
285
|
+
elif recognized:
|
|
286
|
+
status = "ok"
|
|
287
|
+
else:
|
|
288
|
+
status = "unrecognized"
|
|
281
289
|
result = {
|
|
282
|
-
"status":
|
|
283
|
-
"restricted"
|
|
284
|
-
if "authentication required" in text.lower()
|
|
285
|
-
else "ok" if recognized else "unrecognized"
|
|
286
|
-
),
|
|
290
|
+
"status": status,
|
|
287
291
|
"type": entity[0] if entity else None,
|
|
288
292
|
"identifier": entity[1] if entity else None,
|
|
289
293
|
"title": title,
|
|
@@ -301,28 +305,88 @@ def _json_out(payload: object) -> None:
|
|
|
301
305
|
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
302
306
|
|
|
303
307
|
|
|
304
|
-
def
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
308
|
+
def _render_token() -> str:
|
|
309
|
+
token = os.environ.get(RENDER_TOKEN_ENV, "").strip()
|
|
310
|
+
if not token:
|
|
311
|
+
raise TGStatError("TGStat rendering is not configured: the platform render token is missing")
|
|
312
|
+
return token
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# Render statuses worth retrying: Cloudflare/anti-bot interstitials surface as
|
|
316
|
+
# 422 antibot_blocked, and the render service can transiently 429/5xx.
|
|
317
|
+
_RETRYABLE_RENDER_STATUS = frozenset({422, 425, 429, 500, 502, 503, 504})
|
|
318
|
+
_RENDER_ATTEMPTS = 3
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _request_with_url(url: str, timeout: int) -> Tuple[str, str]:
|
|
322
|
+
# Public tgstat.com is behind Cloudflare; render it through the platform
|
|
323
|
+
# WebExtrator service (headless Chromium) rather than fetching directly.
|
|
324
|
+
# Anti-bot blocks are intermittent, so retry with a longer settle and a
|
|
325
|
+
# cache bypass before giving up.
|
|
326
|
+
_validate_request_url(url)
|
|
327
|
+
render_timeout = max(20, min(timeout, 45))
|
|
328
|
+
token = _render_token()
|
|
329
|
+
last_reason = ""
|
|
330
|
+
for attempt in range(_RENDER_ATTEMPTS):
|
|
331
|
+
payload = json.dumps(
|
|
332
|
+
{
|
|
333
|
+
"url": url,
|
|
334
|
+
"wait_until": "domcontentloaded",
|
|
335
|
+
"delay": RENDER_DELAY_SECONDS + 2 * attempt,
|
|
336
|
+
"timeout": render_timeout,
|
|
337
|
+
"block_resources": RENDER_BLOCK_RESOURCES,
|
|
338
|
+
"bypass_cache": attempt > 0,
|
|
339
|
+
}
|
|
340
|
+
).encode()
|
|
341
|
+
request = urllib.request.Request(
|
|
342
|
+
RENDER_ENDPOINT,
|
|
343
|
+
data=payload,
|
|
344
|
+
headers={
|
|
345
|
+
"Authorization": f"Bearer {token}",
|
|
346
|
+
"Content-Type": "application/json",
|
|
347
|
+
"Accept": "application/json",
|
|
348
|
+
},
|
|
349
|
+
)
|
|
350
|
+
try:
|
|
351
|
+
with urllib.request.urlopen(request, timeout=render_timeout + 75) as response:
|
|
352
|
+
envelope = json.loads(response.read().decode("utf-8", "replace"))
|
|
353
|
+
except urllib.error.HTTPError as exc:
|
|
354
|
+
body = exc.read().decode("utf-8", "replace")
|
|
355
|
+
if exc.code in _RETRYABLE_RENDER_STATUS or "antibot" in body.lower():
|
|
356
|
+
last_reason = f"render blocked (HTTP {exc.code}): {_safe_error_body(body)}"
|
|
357
|
+
_render_backoff(attempt)
|
|
358
|
+
continue
|
|
359
|
+
raise TGStatError(f"render service HTTP {exc.code}: {_safe_error_body(body)}") from exc
|
|
360
|
+
except urllib.error.URLError as exc:
|
|
361
|
+
last_reason = f"render service unreachable: {_safe_error_body(str(exc.reason))}"
|
|
362
|
+
_render_backoff(attempt)
|
|
363
|
+
continue
|
|
364
|
+
except json.JSONDecodeError as exc:
|
|
365
|
+
raise TGStatError(f"render service returned invalid JSON: {_safe_error_body(str(exc))}") from exc
|
|
366
|
+
render = envelope.get("data") if isinstance(envelope, dict) else None
|
|
367
|
+
if isinstance(render, dict):
|
|
368
|
+
html = render.get("html") or ""
|
|
369
|
+
final_url = render.get("finalUrl") or render.get("url") or url
|
|
370
|
+
if html.strip() and "challenge-platform" not in html and "Just a moment" not in html:
|
|
371
|
+
final_host = urllib.parse.urlparse(final_url).hostname or ""
|
|
372
|
+
if not (TGSTAT_HOST_RE.fullmatch(final_host) or TGSTAT_INPUT_HOST_RE.fullmatch(final_host)):
|
|
373
|
+
raise TGStatError(f"render redirected to an unexpected host: {final_host}")
|
|
374
|
+
return html, final_url
|
|
375
|
+
last_reason = f"empty or interstitial render (upstream status {render.get('status')})"
|
|
376
|
+
else:
|
|
377
|
+
last_reason = "render service returned no page data"
|
|
378
|
+
_render_backoff(attempt)
|
|
379
|
+
raise TGStatError(f"could not render {_safe_url(url)}: {last_reason}")
|
|
380
|
+
|
|
322
381
|
|
|
382
|
+
def _render_backoff(attempt: int) -> None:
|
|
383
|
+
# Skip the wait after the final attempt; give Cloudflare a moment otherwise.
|
|
384
|
+
if attempt < _RENDER_ATTEMPTS - 1:
|
|
385
|
+
time.sleep(min(2 + 2 * attempt, 6))
|
|
323
386
|
|
|
324
|
-
|
|
325
|
-
|
|
387
|
+
|
|
388
|
+
def _request(url: str, timeout: int) -> str:
|
|
389
|
+
return _request_with_url(url, timeout)[0]
|
|
326
390
|
|
|
327
391
|
|
|
328
392
|
def _public_base(value: str) -> str:
|
|
@@ -468,7 +532,10 @@ def command_rankings(args: argparse.Namespace) -> None:
|
|
|
468
532
|
def _public_info(args: argparse.Namespace, target: str, peer_type: str) -> dict:
|
|
469
533
|
identifier, inferred_type, exact_url = _normalize_target(target)
|
|
470
534
|
types = [inferred_type] if inferred_type else ([peer_type] if peer_type != "auto" else ["channel", "chat"])
|
|
471
|
-
|
|
535
|
+
if exact_url:
|
|
536
|
+
urls = [re.sub(r"/stat/?$", "", exact_url)]
|
|
537
|
+
else:
|
|
538
|
+
urls = [f"{_public_base(args.host)}/{kind}/{identifier}" for kind in types]
|
|
472
539
|
errors: List[str] = []
|
|
473
540
|
for url in urls:
|
|
474
541
|
if not url:
|
|
@@ -476,7 +543,7 @@ def _public_info(args: argparse.Namespace, target: str, peer_type: str) -> dict:
|
|
|
476
543
|
try:
|
|
477
544
|
html, final_url = _request_with_url(url, args.timeout)
|
|
478
545
|
final_host = urllib.parse.urlparse(final_url).hostname or ""
|
|
479
|
-
if not TGSTAT_HOST_RE.fullmatch(final_host):
|
|
546
|
+
if not (TGSTAT_HOST_RE.fullmatch(final_host) or TGSTAT_INPUT_HOST_RE.fullmatch(final_host)):
|
|
480
547
|
raise TGStatError(f"TGStat redirected to an unexpected host: {final_host}")
|
|
481
548
|
result = parse_detail_html(html, final_url)
|
|
482
549
|
except TGStatError as exc:
|
|
@@ -504,7 +571,7 @@ def _public_info(args: argparse.Namespace, target: str, peer_type: str) -> dict:
|
|
|
504
571
|
"source": ranking_url,
|
|
505
572
|
}
|
|
506
573
|
return result
|
|
507
|
-
errors.append(f"
|
|
574
|
+
errors.append(f"{result['status']} public page: {url}")
|
|
508
575
|
raise TGStatError("; ".join(errors) or "could not resolve target on public TGStat pages")
|
|
509
576
|
|
|
510
577
|
|
|
@@ -521,7 +588,7 @@ def command_stat(args: argparse.Namespace) -> None:
|
|
|
521
588
|
def build_parser() -> argparse.ArgumentParser:
|
|
522
589
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
523
590
|
parser.add_argument("--host", default=os.environ.get("TGSTAT_PUBLIC_HOST", DEFAULT_PUBLIC_BASE))
|
|
524
|
-
parser.add_argument("--timeout", type=int, default=
|
|
591
|
+
parser.add_argument("--timeout", type=int, default=45)
|
|
525
592
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
526
593
|
|
|
527
594
|
sub.add_parser("profile").set_defaults(func=command_profile)
|