@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acedatacloud/skills",
3
- "version": "2026.713.6",
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",
@@ -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.1"
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. Resolve the script at the start of every Bash
22
- call because each call runs in a fresh shell:
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 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.
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
- 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
- )
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
- ENTITY_PATH_RE = re.compile(r"/(channel|chat)/(@[A-Za-z0-9_]{3,}|id\d+)/stat/?", re.IGNORECASE)
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 == "h4":
221
+ elif tag in {"h2", "h3", "h4"} and "text-dark" in classes:
233
222
  kind = "metric_value"
234
- elif tag == "div" and {"text-muted", "text-truncate"}.issubset(classes):
235
- if self.pending_metric is not None:
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
- self.metrics[key] = number if number is not None else self.pending_metric
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
- recognized = bool(entity and parser.meta.get("og:title") and title and "tgstat" in title.casefold())
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 _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
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
- 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]
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
- urls = [exact_url] if exact_url else [f"{_public_base(args.host)}/{kind}/{identifier}/stat" for kind in types]
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"restricted or empty public page: {url}")
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=30)
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)