Spaces:
Sleeping
Sleeping
Add optional TGStat archive fallback and fractional K counters for ReportBridge
Browse files- reports.py +1 -3
- service.py +11 -1
- tests/test_reports.py +1 -1
- tests/test_tgstat_api.py +54 -0
- 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'), (
|
| 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"}
|