Dink0 commited on
Commit
152ccef
·
verified ·
1 Parent(s): a48e17c

Add optional TGStat archive fallback and fractional K counters for ReportBridge

Browse files
Files changed (5) hide show
  1. reports.py +1 -3
  2. service.py +11 -1
  3. tests/test_reports.py +1 -1
  4. tests/test_tgstat_api.py +54 -0
  5. tgstat_api.py +35 -0
reports.py CHANGED
@@ -39,8 +39,6 @@ def normalize(raw):
39
 
40
 
41
  def human_views(value):
42
- if value < 1000:
43
- return str(value)
44
  divisor, suffix = (1_000_000, "M") if value >= 1_000_000 else (1000, "K")
45
  return f"{value / divisor:.1f}".rstrip("0").rstrip(".").replace(".", ",") + suffix
46
 
@@ -74,7 +72,7 @@ def render(records):
74
  ordered = sorted(groups.values(), key=lambda items: sum(r["views"] for r in items), reverse=True)
75
  for index, items in enumerate(ordered, 1):
76
  title = clean_title(items[0]["title"]) or items[0]["group"]
77
- links = [f'<a href="{html.escape(r["url"], quote=True)}">{html.escape(title if i == 0 else "ещё")}</a>' for i, r in enumerate(items)]
78
  views = " + ".join(human_views(r["views"]) for r in items)
79
  chunks.append(f'<li>{" + ".join(links)} — {views}</li>')
80
  plain.append(f'{index}. {title}{" + ещё" * (len(items) - 1)} — {views}')
 
39
 
40
 
41
  def human_views(value):
 
 
42
  divisor, suffix = (1_000_000, "M") if value >= 1_000_000 else (1000, "K")
43
  return f"{value / divisor:.1f}".rstrip("0").rstrip(".").replace(".", ",") + suffix
44
 
 
72
  ordered = sorted(groups.values(), key=lambda items: sum(r["views"] for r in items), reverse=True)
73
  for index, items in enumerate(ordered, 1):
74
  title = clean_title(items[0]["title"]) or items[0]["group"]
75
+ links = [f'<a href="{html.escape(r.get("display_url") or r["url"], quote=True)}">{html.escape(title if i == 0 else "ещё")}</a>' for i, r in enumerate(items)]
76
  views = " + ".join(human_views(r["views"]) for r in items)
77
  chunks.append(f'<li>{" + ".join(links)} — {views}</li>')
78
  plain.append(f'{index}. {title}{" + ещё" * (len(items) - 1)} — {views}')
service.py CHANGED
@@ -14,6 +14,7 @@ from pydantic import BaseModel, Field
14
 
15
  import legacy_parser as engine
16
  from reports import normalize, render
 
17
 
18
  log = logging.getLogger(__name__)
19
  API_KEY = os.getenv("BRIDGE_API_KEY", "")
@@ -51,6 +52,15 @@ def fetch_one(record):
51
  result.update(title=title, views=views)
52
  except Exception:
53
  result.update(title=record["group"], views=None, error="Не удалось получить просмотры публичного поста")
 
 
 
 
 
 
 
 
 
54
  return result
55
 
56
 
@@ -102,7 +112,7 @@ def root():
102
  @app.get("/health")
103
  def health():
104
  return {"status": "ok", "service": "ReportBridge", "configured": {
105
- "bridge": bool(API_KEY), "vk": bool(engine.VK_ACCESS_TOKEN), "max": bool(os.getenv("MAX_ACCESS_TOKEN"))}}
106
 
107
 
108
  @app.post("/jobs", dependencies=[Depends(authorize)])
 
14
 
15
  import legacy_parser as engine
16
  from reports import normalize, render
17
+ from tgstat_api import archive_link, archived_post
18
 
19
  log = logging.getLogger(__name__)
20
  API_KEY = os.getenv("BRIDGE_API_KEY", "")
 
52
  result.update(title=title, views=views)
53
  except Exception:
54
  result.update(title=record["group"], views=None, error="Не удалось получить просмотры публичного поста")
55
+ if record["platform"] == "telegram":
56
+ result["display_url"] = archive_link(record["a"], record["b"])
57
+ try:
58
+ fallback = archived_post(record["a"], record["b"], record["group"])
59
+ if fallback:
60
+ result.update(fallback, error=None)
61
+ except Exception:
62
+ # Never log API request URLs: TGStat credentials are query parameters.
63
+ pass
64
  return result
65
 
66
 
 
112
  @app.get("/health")
113
  def health():
114
  return {"status": "ok", "service": "ReportBridge", "configured": {
115
+ "bridge": bool(API_KEY), "vk": bool(engine.VK_ACCESS_TOKEN), "max": bool(os.getenv("MAX_ACCESS_TOKEN")), "tgstat": bool(os.getenv("TGSTAT_API_TOKEN"))}}
116
 
117
 
118
  @app.post("/jobs", dependencies=[Depends(authorize)])
tests/test_reports.py CHANGED
@@ -11,7 +11,7 @@ class ReportTests(unittest.TestCase):
11
  normalize(link)
12
 
13
  def test_format_without_zero(self):
14
- for value, expected in ((2000, '2K'), (10600, '10,6K'), (1000000, '1M'), (35, '35'), (0, '0')):
15
  self.assertEqual(human_views(value), expected)
16
 
17
  def test_errors_are_not_zero_views(self):
 
11
  normalize(link)
12
 
13
  def test_format_without_zero(self):
14
+ for value, expected in ((2000, '2K'), (10600, '10,6K'), (1000000, '1M'), (845, '0,8K'), (100, '0,1K'), (35, '0K'), (0, '0K')):
15
  self.assertEqual(human_views(value), expected)
16
 
17
  def test_errors_are_not_zero_views(self):
tests/test_tgstat_api.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import unittest
2
+ from unittest.mock import Mock, patch
3
+
4
+ from reports import normalize, render
5
+ from tgstat_api import archive_link, archived_post
6
+ import service
7
+
8
+
9
+ class TGStatTests(unittest.TestCase):
10
+ def test_archive_link_and_validation(self):
11
+ self.assertEqual(archive_link('memtc', '1553'), 'https://tgstat.ru/channel/@memtc/1553')
12
+ with self.assertRaises(ValueError):
13
+ archive_link('../secret', '1')
14
+
15
+ def test_missing_token_does_not_request_anything(self):
16
+ get = Mock()
17
+ with patch.dict('os.environ', {'TGSTAT_API_TOKEN': ''}):
18
+ self.assertIsNone(archived_post('memtc', '1553', 'Канал', get))
19
+ get.assert_not_called()
20
+
21
+ def test_exact_counter_and_post_identity(self):
22
+ response = Mock()
23
+ response.json.return_value = {'status': 'ok', 'response': {'views': 845, 'link': 't.me/memtc/1553', 'is_deleted': 1}}
24
+ get = Mock(return_value=response)
25
+ with patch.dict('os.environ', {'TGSTAT_API_TOKEN': 'test-only'}):
26
+ post = archived_post('memtc', '1553', 'Канал', get)
27
+ self.assertEqual(post['views'], 845)
28
+ self.assertIn('(TGStat)', post['title'])
29
+ self.assertFalse(get.call_args.kwargs['allow_redirects'])
30
+ for views in (None, '845', True, -1):
31
+ response.json.return_value['response']['views'] = views
32
+ self.assertIsNone(archived_post('memtc', '1553', 'Канал', get))
33
+ response.json.return_value['response'].update(views=845, link='t.me/other/1')
34
+ self.assertIsNone(archived_post('memtc', '1553', 'Канал', get))
35
+
36
+ def test_fallback_only_after_telegram_failure_and_preserves_original_job_key(self):
37
+ record = normalize('https://t.me/memtc/1553')
38
+ fallback = dict(title='memtc (TGStat)', views=845, display_url=archive_link('memtc', '1553'), source='tgstat')
39
+ with patch.object(service.engine, 'process_telegram_link', return_value=('Ошибка', 0)), patch.object(service, 'archived_post', return_value=fallback):
40
+ result = service.fetch_one(record)
41
+ self.assertEqual(result['url'], record['url'])
42
+ self.assertIsNone(result['error'])
43
+ output = render([result])
44
+ self.assertIn('href="https://tgstat.ru/channel/@memtc/1553"', output['html'])
45
+ self.assertIn('0,8K', output['html'])
46
+ with patch.object(service.engine, 'process_telegram_link', return_value=('Канал', 845)), patch.object(service, 'archived_post') as archive:
47
+ service.fetch_one(record)
48
+ archive.assert_not_called()
49
+
50
+ def test_archive_api_failure_keeps_unknown_views_with_archive_link(self):
51
+ with patch.object(service.engine, 'process_telegram_link', side_effect=TimeoutError), patch.object(service, 'archived_post', side_effect=TimeoutError):
52
+ result = service.fetch_one(normalize('https://t.me/memtc/1553'))
53
+ self.assertIsNone(result['views'])
54
+ self.assertEqual(result['display_url'], archive_link('memtc', '1553'))
tgstat_api.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Optional archived Telegram counters from the official TGStat API."""
2
+ import os
3
+ import re
4
+
5
+ import requests
6
+
7
+
8
+ def archive_link(username, message_id):
9
+ if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]{2,}", username) or not re.fullmatch(r"[1-9]\d*", str(message_id)):
10
+ raise ValueError("Invalid public Telegram post")
11
+ return f"https://tgstat.ru/channel/@{username}/{message_id}"
12
+
13
+
14
+ def archived_post(username, message_id, fallback_title, get=requests.get):
15
+ link = archive_link(username, message_id)
16
+ token = os.getenv("TGSTAT_API_TOKEN", "").strip()
17
+ if not token:
18
+ return None
19
+ # Do not scrape challenge pages or guess a number from unrelated page content.
20
+ response = get("https://api.tgstat.ru/posts/get", params={
21
+ "token": token, "postId": f"https://t.me/{username}/{message_id}"},
22
+ timeout=(4, 8), allow_redirects=False)
23
+ response.raise_for_status()
24
+ data = response.json()
25
+ post = data.get("response")
26
+ if data.get("status") != "ok" or not isinstance(post, dict):
27
+ return None
28
+ views = post.get("views")
29
+ if type(views) is not int or views < 0:
30
+ return None
31
+ source = str(post.get("link", "")).removeprefix("https://").removeprefix("http://")
32
+ match = re.fullmatch(r"(?:t.me|telegram.me)/([A-Za-z][A-Za-z0-9_]{2,})/([1-9]\d*)/?", source)
33
+ if not match or match[1].lower() != username.lower() or match[2] != str(message_id):
34
+ return None
35
+ return {"views": views, "title": f"{fallback_title} (TGStat)", "display_url": link, "source": "tgstat"}