Spaces:
Sleeping
Sleeping
Dink0 commited on
Commit ·
72f0375
1
Parent(s): 04c44bc
Read public MAX post views through Bot API
Browse files- Dockerfile +2 -0
- app.py +23 -89
- certs/README.md +10 -0
- certs/russian_trusted_root_ca.pem +33 -0
- max_api.py +143 -0
- requirements.txt +1 -0
- tests/test_max_api.py +98 -0
Dockerfile
CHANGED
|
@@ -6,6 +6,8 @@ COPY requirements.txt ./
|
|
| 6 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 7 |
|
| 8 |
COPY app.py ./
|
|
|
|
|
|
|
| 9 |
|
| 10 |
EXPOSE 7860
|
| 11 |
|
|
|
|
| 6 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 7 |
|
| 8 |
COPY app.py ./
|
| 9 |
+
COPY max_api.py ./
|
| 10 |
+
COPY certs/ ./certs/
|
| 11 |
|
| 12 |
EXPOSE 7860
|
| 13 |
|
app.py
CHANGED
|
@@ -13,6 +13,8 @@ from fastapi import FastAPI
|
|
| 13 |
from fastapi.middleware.cors import CORSMiddleware
|
| 14 |
from pydantic import BaseModel
|
| 15 |
|
|
|
|
|
|
|
| 16 |
APP_NAME = "pr-tool-backend"
|
| 17 |
|
| 18 |
VK_API_VERSION = os.getenv("VK_API_VERSION", "5.131")
|
|
@@ -105,7 +107,7 @@ class ParseResponse(BaseModel):
|
|
| 105 |
telegram_total: int
|
| 106 |
vk_total: int
|
| 107 |
errors: List[str]
|
| 108 |
-
max_count: int = 0
|
| 109 |
|
| 110 |
|
| 111 |
# ---------- Вспомогательные функции ----------
|
|
@@ -306,27 +308,11 @@ def process_telegram_link(channel_username: str, message_id: str, canonical_link
|
|
| 306 |
return title, views
|
| 307 |
|
| 308 |
|
| 309 |
-
def process_max_link(slug: str, post_id: str, canonical_link: str) -> Tuple[str,
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
session = _get_session()
|
| 315 |
-
resp = session.get(canonical_link, timeout=MAX_HTTP_TIMEOUT)
|
| 316 |
-
if resp.status_code == 200 and resp.text:
|
| 317 |
-
page_html = resp.text
|
| 318 |
-
m = re.search(
|
| 319 |
-
r"<meta[^>]*property=[\"']og:title[\"'][^>]*content=[\"']([^\"']+)[\"']",
|
| 320 |
-
page_html,
|
| 321 |
-
re.IGNORECASE,
|
| 322 |
-
)
|
| 323 |
-
if not m:
|
| 324 |
-
m = re.search(r"<title[^>]*>(.*?)</title>", page_html, re.IGNORECASE | re.DOTALL)
|
| 325 |
-
if m:
|
| 326 |
-
title = clean_max_title(m.group(1), slug)
|
| 327 |
-
except Exception: # noqa: BLE001
|
| 328 |
-
title = slug # тихо откатываемся к слагу из URL
|
| 329 |
-
return title or slug, None
|
| 330 |
|
| 331 |
|
| 332 |
def process_vk_link(owner_id: int, post_id: str, canonical_link: str) -> Tuple[str, int]:
|
|
@@ -508,8 +494,7 @@ def resolve_max(max_tasks, deadline):
|
|
| 508 |
fetched = _resolve_pool(
|
| 509 |
tasks, process_max_link, MAX_CONCURRENCY, deadline, None, defaults=defaults
|
| 510 |
)
|
| 511 |
-
|
| 512 |
-
return {c: (t, None) for c, (t, _v) in fetched.items()}
|
| 513 |
|
| 514 |
|
| 515 |
def resolve_vk(vk_tasks, deadline):
|
|
@@ -654,13 +639,15 @@ def build_output(lines, deadline):
|
|
| 654 |
max_groups.setdefault(key, {"title": "MAX", "items": []})
|
| 655 |
max_groups[key]["items"].append((canonical, None))
|
| 656 |
else:
|
| 657 |
-
title,
|
| 658 |
key = slug
|
| 659 |
if key not in max_groups:
|
| 660 |
max_groups[key] = {"title": title, "items": []}
|
| 661 |
if not max_groups[key].get("title"):
|
| 662 |
max_groups[key]["title"] = title
|
| 663 |
-
max_groups[key]["items"].append((canonical,
|
|
|
|
|
|
|
| 664 |
|
| 665 |
else:
|
| 666 |
errors.append(f"Неизвестная платформа: {canonical}")
|
|
@@ -668,6 +655,8 @@ def build_output(lines, deadline):
|
|
| 668 |
tg_total_views = sum(v for g in tg_groups.values() for _, v in g["items"] if v)
|
| 669 |
vk_total_views = sum(v for g in vk_groups.values() for _, v in g["items"] if v)
|
| 670 |
max_count = sum(len(g["items"]) for g in max_groups.values())
|
|
|
|
|
|
|
| 671 |
|
| 672 |
html_lines: List[str] = []
|
| 673 |
text_lines: List[str] = []
|
|
@@ -705,10 +694,15 @@ def build_output(lines, deadline):
|
|
| 705 |
# --- MAX (секцию показываем только если есть ссылки MAX) ---
|
| 706 |
if max_groups:
|
| 707 |
html_lines.append("<h2>MAX</h2>")
|
| 708 |
-
html_lines.append("<i>Просмотры из MAX недоступны.</i>")
|
| 709 |
text_lines.append("MAX")
|
| 710 |
-
|
| 711 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 712 |
html_lines.append(h)
|
| 713 |
text_lines.extend(t)
|
| 714 |
html_lines.append("<br/>")
|
|
@@ -739,66 +733,6 @@ def health_check():
|
|
| 739 |
return {"status": "ok"}
|
| 740 |
|
| 741 |
|
| 742 |
-
@app.get("/_max_public_probe")
|
| 743 |
-
def max_public_probe():
|
| 744 |
-
import base64
|
| 745 |
-
import hashlib
|
| 746 |
-
import json
|
| 747 |
-
import ssl
|
| 748 |
-
import urllib.error
|
| 749 |
-
import urllib.request
|
| 750 |
-
|
| 751 |
-
token = os.getenv("MAX_ACCESS_TOKEN", "").strip()
|
| 752 |
-
if not token:
|
| 753 |
-
return {"configured": False}
|
| 754 |
-
try:
|
| 755 |
-
cert_response = requests.get(
|
| 756 |
-
"https://gu-st.ru/content/Other/doc/russian_trusted_root_ca.cer",
|
| 757 |
-
timeout=(5, 10),
|
| 758 |
-
)
|
| 759 |
-
cert_response.raise_for_status()
|
| 760 |
-
pem = cert_response.text
|
| 761 |
-
fingerprint = hashlib.sha256(ssl.PEM_cert_to_DER_cert(pem)).hexdigest()
|
| 762 |
-
if fingerprint != "d26d2d0231b7c39f92cc738512ba54103519e4405d68b5bd703e9788ca8ecf31":
|
| 763 |
-
return {"configured": True, "certificate": "unexpected_fingerprint"}
|
| 764 |
-
context = ssl.create_default_context(cadata=pem)
|
| 765 |
-
except Exception as exc:
|
| 766 |
-
return {"configured": True, "certificate_error": type(exc).__name__}
|
| 767 |
-
|
| 768 |
-
def check(path):
|
| 769 |
-
request = urllib.request.Request(
|
| 770 |
-
"https://platform-api2.max.ru" + path,
|
| 771 |
-
headers={"Authorization": token},
|
| 772 |
-
)
|
| 773 |
-
try:
|
| 774 |
-
with urllib.request.urlopen(request, context=context, timeout=8) as response:
|
| 775 |
-
data = json.load(response)
|
| 776 |
-
stat = data.get("stat") or {}
|
| 777 |
-
return {"http": response.status, "code": data.get("code"),
|
| 778 |
-
"views": stat.get("views"),
|
| 779 |
-
"message_count": len(data.get("messages", [])),
|
| 780 |
-
"is_bot": data.get("is_bot")}
|
| 781 |
-
except urllib.error.HTTPError as exc:
|
| 782 |
-
try:
|
| 783 |
-
data = json.load(exc)
|
| 784 |
-
except Exception:
|
| 785 |
-
data = {}
|
| 786 |
-
return {"http": exc.code, "code": data.get("code")}
|
| 787 |
-
except Exception as exc:
|
| 788 |
-
return {"error": type(exc).__name__}
|
| 789 |
-
|
| 790 |
-
post_id = "AaE-viFyB3E"
|
| 791 |
-
post_hex = base64.urlsafe_b64decode(post_id + "=").hex()
|
| 792 |
-
channel_id = 68067500157571
|
| 793 |
-
negative_hex = ((-channel_id) & ((1 << 64) - 1)).to_bytes(8, "big").hex()
|
| 794 |
-
positive_hex = channel_id.to_bytes(8, "big").hex()
|
| 795 |
-
return {"configured": True, "auth": check("/me"),
|
| 796 |
-
"channel": check(f"/messages?chat_id={-channel_id}&count=1"),
|
| 797 |
-
"negative_mid": check(f"/messages/mid.{negative_hex}{post_hex}"),
|
| 798 |
-
"positive_mid": check(f"/messages/mid.{positive_hex}{post_hex}"),
|
| 799 |
-
"short_id": check(f"/messages/{post_id}")}
|
| 800 |
-
|
| 801 |
-
|
| 802 |
@app.post("/parse", response_model=ParseResponse)
|
| 803 |
def parse_links(payload: ParseRequest):
|
| 804 |
deadline = time.monotonic() + TOTAL_TIME_BUDGET
|
|
|
|
| 13 |
from fastapi.middleware.cors import CORSMiddleware
|
| 14 |
from pydantic import BaseModel
|
| 15 |
|
| 16 |
+
from max_api import get_public_post
|
| 17 |
+
|
| 18 |
APP_NAME = "pr-tool-backend"
|
| 19 |
|
| 20 |
VK_API_VERSION = os.getenv("VK_API_VERSION", "5.131")
|
|
|
|
| 107 |
telegram_total: int
|
| 108 |
vk_total: int
|
| 109 |
errors: List[str]
|
| 110 |
+
max_count: int = 0
|
| 111 |
|
| 112 |
|
| 113 |
# ---------- Вспомогательные функции ----------
|
|
|
|
| 308 |
return title, views
|
| 309 |
|
| 310 |
|
| 311 |
+
def process_max_link(slug: str, post_id: str, canonical_link: str) -> Tuple[str, Optional[int]]:
|
| 312 |
+
title, views = get_public_post(
|
| 313 |
+
_get_session(), slug, post_id, canonical_link, MAX_HTTP_TIMEOUT,
|
| 314 |
+
)
|
| 315 |
+
return clean_max_title(title, slug), views
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 316 |
|
| 317 |
|
| 318 |
def process_vk_link(owner_id: int, post_id: str, canonical_link: str) -> Tuple[str, int]:
|
|
|
|
| 494 |
fetched = _resolve_pool(
|
| 495 |
tasks, process_max_link, MAX_CONCURRENCY, deadline, None, defaults=defaults
|
| 496 |
)
|
| 497 |
+
return fetched
|
|
|
|
| 498 |
|
| 499 |
|
| 500 |
def resolve_vk(vk_tasks, deadline):
|
|
|
|
| 639 |
max_groups.setdefault(key, {"title": "MAX", "items": []})
|
| 640 |
max_groups[key]["items"].append((canonical, None))
|
| 641 |
else:
|
| 642 |
+
title, views = max_results.get(canonical, (slug, None))
|
| 643 |
key = slug
|
| 644 |
if key not in max_groups:
|
| 645 |
max_groups[key] = {"title": title, "items": []}
|
| 646 |
if not max_groups[key].get("title"):
|
| 647 |
max_groups[key]["title"] = title
|
| 648 |
+
max_groups[key]["items"].append((canonical, views))
|
| 649 |
+
if views is None:
|
| 650 |
+
errors.append(f"Не удалось получить просмотры MAX: {canonical}")
|
| 651 |
|
| 652 |
else:
|
| 653 |
errors.append(f"Неизвестная платформа: {canonical}")
|
|
|
|
| 655 |
tg_total_views = sum(v for g in tg_groups.values() for _, v in g["items"] if v)
|
| 656 |
vk_total_views = sum(v for g in vk_groups.values() for _, v in g["items"] if v)
|
| 657 |
max_count = sum(len(g["items"]) for g in max_groups.values())
|
| 658 |
+
max_total = sum(v for g in max_groups.values() for _, v in g["items"] if v is not None)
|
| 659 |
+
max_missing = sum(v is None for g in max_groups.values() for _, v in g["items"])
|
| 660 |
|
| 661 |
html_lines: List[str] = []
|
| 662 |
text_lines: List[str] = []
|
|
|
|
| 694 |
# --- MAX (секцию показываем только если есть ссылки MAX) ---
|
| 695 |
if max_groups:
|
| 696 |
html_lines.append("<h2>MAX</h2>")
|
|
|
|
| 697 |
text_lines.append("MAX")
|
| 698 |
+
if max_missing < max_count:
|
| 699 |
+
html_lines.append(f'Суммарно посты собрали <b>{human_format_views(max_total)}</b> просмотров.')
|
| 700 |
+
text_lines.append(f"Суммарно посты собрали {human_format_views(max_total)} просмотров.")
|
| 701 |
+
if max_missing:
|
| 702 |
+
note = f"Постов без данных о просмотрах: {max_missing}. Они не включены в сумму."
|
| 703 |
+
html_lines.append(f"<i>{note}</i>")
|
| 704 |
+
text_lines.append(note)
|
| 705 |
+
h, t = _render_groups(max_groups, has_views=True, default_title="MAX")
|
| 706 |
html_lines.append(h)
|
| 707 |
text_lines.extend(t)
|
| 708 |
html_lines.append("<br/>")
|
|
|
|
| 733 |
return {"status": "ok"}
|
| 734 |
|
| 735 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 736 |
@app.post("/parse", response_model=ParseResponse)
|
| 737 |
def parse_links(payload: ParseRequest):
|
| 738 |
deadline = time.monotonic() + TOTAL_TIME_BUDGET
|
certs/README.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# MAX API Certificate
|
| 2 |
+
|
| 3 |
+
Public Russian Trusted Root CA certificate downloaded from:
|
| 4 |
+
https://gu-st.ru/content/Other/doc/russian_trusted_root_ca.cer
|
| 5 |
+
|
| 6 |
+
SHA-256 certificate fingerprint:
|
| 7 |
+
`D2:6D:2D:02:31:B7:C3:9F:92:CC:73:85:12:BA:54:10:35:19:E4:40:5D:68:B5:BD:70:3E:97:88:CA:8E:CF:31`
|
| 8 |
+
|
| 9 |
+
Used only for HTTPS requests to `platform-api2.max.ru`. Certificate validation
|
| 10 |
+
remains enabled; other HTTP clients retain their default trust settings.
|
certs/russian_trusted_root_ca.pem
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-----BEGIN CERTIFICATE-----
|
| 2 |
+
MIIFwjCCA6qgAwIBAgICEAAwDQYJKoZIhvcNAQELBQAwcDELMAkGA1UEBhMCUlUx
|
| 3 |
+
PzA9BgNVBAoMNlRoZSBNaW5pc3RyeSBvZiBEaWdpdGFsIERldmVsb3BtZW50IGFu
|
| 4 |
+
ZCBDb21tdW5pY2F0aW9uczEgMB4GA1UEAwwXUnVzc2lhbiBUcnVzdGVkIFJvb3Qg
|
| 5 |
+
Q0EwHhcNMjIwMzAxMjEwNDE1WhcNMzIwMjI3MjEwNDE1WjBwMQswCQYDVQQGEwJS
|
| 6 |
+
VTE/MD0GA1UECgw2VGhlIE1pbmlzdHJ5IG9mIERpZ2l0YWwgRGV2ZWxvcG1lbnQg
|
| 7 |
+
YW5kIENvbW11bmljYXRpb25zMSAwHgYDVQQDDBdSdXNzaWFuIFRydXN0ZWQgUm9v
|
| 8 |
+
dCBDQTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMfFOZ8pUAL3+r2n
|
| 9 |
+
qqE0Zp52selXsKGFYoG0GM5bwz1bSFtCt+AZQMhkWQheI3poZAToYJu69pHLKS6Q
|
| 10 |
+
XBiwBC1cvzYmUYKMYZC7jE5YhEU2bSL0mX7NaMxMDmH2/NwuOVRj8OImVa5s1F4U
|
| 11 |
+
zn4Kv3PFlDBjjSjXKVY9kmjUBsXQrIHeaqmUIsPIlNWUnimXS0I0abExqkbdrXbX
|
| 12 |
+
YwCOXhOO2pDUx3ckmJlCMUGacUTnylyQW2VsJIyIGA8V0xzdaeUXg0VZ6ZmNUr5Y
|
| 13 |
+
Ber/EAOLPb8NYpsAhJe2mXjMB/J9HNsoFMBFJ0lLOT/+dQvjbdRZoOT8eqJpWnVD
|
| 14 |
+
U+QL/qEZnz57N88OWM3rabJkRNdU/Z7x5SFIM9FrqtN8xewsiBWBI0K6XFuOBOTD
|
| 15 |
+
4V08o4TzJ8+Ccq5XlCUW2L48pZNCYuBDfBh7FxkB7qDgGDiaftEkZZfApRg2E+M9
|
| 16 |
+
G8wkNKTPLDc4wH0FDTijhgxR3Y4PiS1HL2Zhw7bD3CbslmEGgfnnZojNkJtcLeBH
|
| 17 |
+
BLa52/dSwNU4WWLubaYSiAmA9IUMX1/RpfpxOxd4Ykmhz97oFbUaDJFipIggx5sX
|
| 18 |
+
ePAlkTdWnv+RWBxlJwMQ25oEHmRguNYf4Zr/Rxr9cS93Y+mdXIZaBEE0KS2iLRqa
|
| 19 |
+
OiWBki9IMQU4phqPOBAaG7A+eP8PAgMBAAGjZjBkMB0GA1UdDgQWBBTh0YHlzlpf
|
| 20 |
+
BKrS6badZrHF+qwshzAfBgNVHSMEGDAWgBTh0YHlzlpfBKrS6badZrHF+qwshzAS
|
| 21 |
+
BgNVHRMBAf8ECDAGAQH/AgEEMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsF
|
| 22 |
+
AAOCAgEAALIY1wkilt/urfEVM5vKzr6utOeDWCUczmWX/RX4ljpRdgF+5fAIS4vH
|
| 23 |
+
tmXkqpSCOVeWUrJV9QvZn6L227ZwuE15cWi8DCDal3Ue90WgAJJZMfTshN4OI8cq
|
| 24 |
+
W9E4EG9wglbEtMnObHlms8F3CHmrw3k6KmUkWGoa+/ENmcVl68u/cMRl1JbW2bM+
|
| 25 |
+
/3A+SAg2c6iPDlehczKx2oa95QW0SkPPWGuNA/CE8CpyANIhu9XFrj3RQ3EqeRcS
|
| 26 |
+
AQQod1RNuHpfETLU/A2gMmvn/w/sx7TB3W5BPs6rprOA37tutPq9u6FTZOcG1Oqj
|
| 27 |
+
C/B7yTqgI7rbyvox7DEXoX7rIiEqyNNUguTk/u3SZ4VXE2kmxdmSh3TQvybfbnXV
|
| 28 |
+
4JbCZVaqiZraqc7oZMnRoWrXRG3ztbnbes/9qhRGI7PqXqeKJBztxRTEVj8ONs1d
|
| 29 |
+
WN5szTwaPIvhkhO3CO5ErU2rVdUr89wKpNXbBODFKRtgxUT70YpmJ46VVaqdAhOZ
|
| 30 |
+
D9EUUn4YaeLaS8AjSF/h7UkjOibNc4qVDiPP+rkehFWM66PVnP1Msh93tc+taIfC
|
| 31 |
+
EYVMxjh8zNbFuoc7fzvvrFILLe7ifvEIUqSVIC/AzplM/Jxw7buXFeGP1qVCBEHq
|
| 32 |
+
391d/9RAfaZ12zkwFsl+IKwE/OZxW8AHa9i1p4GO0YSNuczzEm4=
|
| 33 |
+
-----END CERTIFICATE-----
|
max_api.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Read public MAX posts and their counters without joining channels."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import base64
|
| 6 |
+
import logging
|
| 7 |
+
import os
|
| 8 |
+
import re
|
| 9 |
+
from html.parser import HTMLParser
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Optional, Tuple
|
| 12 |
+
from urllib.parse import urlsplit
|
| 13 |
+
|
| 14 |
+
import json5
|
| 15 |
+
import requests
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
LOGGER = logging.getLogger(__name__)
|
| 19 |
+
API_BASE_URL = "https://platform-api2.max.ru"
|
| 20 |
+
CA_FILE = str(Path(__file__).parent / "certs" / "russian_trusted_root_ca.pem")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class PublicPostParser(HTMLParser):
|
| 24 |
+
def __init__(self):
|
| 25 |
+
super().__init__(convert_charrefs=True)
|
| 26 |
+
self.title = None
|
| 27 |
+
self.scripts = []
|
| 28 |
+
self._script = None
|
| 29 |
+
|
| 30 |
+
def handle_starttag(self, tag, attrs):
|
| 31 |
+
attrs = dict(attrs)
|
| 32 |
+
if tag == "meta" and attrs.get("property") == "og:title":
|
| 33 |
+
self.title = attrs.get("content")
|
| 34 |
+
if tag == "script":
|
| 35 |
+
self._script = []
|
| 36 |
+
|
| 37 |
+
def handle_data(self, data):
|
| 38 |
+
if self._script is not None:
|
| 39 |
+
self._script.append(data)
|
| 40 |
+
|
| 41 |
+
def handle_endtag(self, tag):
|
| 42 |
+
if tag == "script" and self._script is not None:
|
| 43 |
+
self.scripts.append("".join(self._script))
|
| 44 |
+
self._script = None
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def parse_public_post(page_html: str, fallback: str) -> Tuple[str, Optional[int]]:
|
| 48 |
+
parser = PublicPostParser()
|
| 49 |
+
parser.feed(page_html)
|
| 50 |
+
title = parser.title or fallback
|
| 51 |
+
|
| 52 |
+
# Svelte serializes page data as JS literals, not strict JSON. JSON5 reads
|
| 53 |
+
# those literals without executing any code from the remote page.
|
| 54 |
+
for script in parser.scripts:
|
| 55 |
+
for match in re.finditer(r"\bdata\s*:\s*(\[)", script):
|
| 56 |
+
try:
|
| 57 |
+
nodes = json5.loads(
|
| 58 |
+
script, start=match.start(1), consume_trailing=False,
|
| 59 |
+
)
|
| 60 |
+
except ValueError:
|
| 61 |
+
continue
|
| 62 |
+
if not isinstance(nodes, list):
|
| 63 |
+
continue
|
| 64 |
+
for node in nodes:
|
| 65 |
+
if not isinstance(node, dict):
|
| 66 |
+
continue
|
| 67 |
+
data = node.get("data") or {}
|
| 68 |
+
if not isinstance(data, dict):
|
| 69 |
+
continue
|
| 70 |
+
link_info = data.get("linkInfo") or {}
|
| 71 |
+
if not isinstance(link_info, dict):
|
| 72 |
+
continue
|
| 73 |
+
channel = link_info.get("channel") or {}
|
| 74 |
+
if not isinstance(channel, dict):
|
| 75 |
+
continue
|
| 76 |
+
channel_id = channel.get("channelId")
|
| 77 |
+
if type(channel_id) is int and channel_id != 0:
|
| 78 |
+
return channel.get("title") or title, channel_id
|
| 79 |
+
return title, None
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def message_id_from_link(channel_id: int, post_id: str) -> str:
|
| 83 |
+
if not re.fullmatch(r"[A-Za-z0-9_-]+", post_id):
|
| 84 |
+
raise ValueError("invalid_post_id")
|
| 85 |
+
message_bytes = base64.b64decode(
|
| 86 |
+
post_id + "=" * (-len(post_id) % 4), altchars=b"-_", validate=True,
|
| 87 |
+
)
|
| 88 |
+
if len(message_bytes) != 8:
|
| 89 |
+
raise ValueError("unsupported_post_id")
|
| 90 |
+
chat_bytes = (-abs(channel_id)).to_bytes(8, "big", signed=True)
|
| 91 |
+
return "mid." + chat_bytes.hex() + message_bytes.hex()
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def get_public_post(session, slug, post_id, canonical_link, timeout):
|
| 95 |
+
title = slug
|
| 96 |
+
try:
|
| 97 |
+
page = session.get(canonical_link, timeout=timeout)
|
| 98 |
+
page.raise_for_status()
|
| 99 |
+
title, channel_id = parse_public_post(page.text, slug)
|
| 100 |
+
token = os.getenv("MAX_ACCESS_TOKEN", "").strip()
|
| 101 |
+
if not token:
|
| 102 |
+
LOGGER.warning("MAX views unavailable: token is not configured")
|
| 103 |
+
return title, None
|
| 104 |
+
if channel_id is None:
|
| 105 |
+
LOGGER.warning("MAX channel ID is absent for %s", canonical_link)
|
| 106 |
+
return title, None
|
| 107 |
+
|
| 108 |
+
mid = message_id_from_link(channel_id, post_id)
|
| 109 |
+
response = session.get(
|
| 110 |
+
f"{API_BASE_URL}/messages/{mid}",
|
| 111 |
+
headers={"Authorization": token},
|
| 112 |
+
timeout=timeout,
|
| 113 |
+
verify=CA_FILE,
|
| 114 |
+
allow_redirects=False,
|
| 115 |
+
)
|
| 116 |
+
if response.status_code != 200:
|
| 117 |
+
LOGGER.warning(
|
| 118 |
+
"MAX API returned HTTP %s for %s", response.status_code, canonical_link,
|
| 119 |
+
)
|
| 120 |
+
return title, None
|
| 121 |
+
message = response.json()
|
| 122 |
+
if not isinstance(message, dict):
|
| 123 |
+
return title, None
|
| 124 |
+
body = message.get("body") or {}
|
| 125 |
+
stat = message.get("stat") or {}
|
| 126 |
+
if not isinstance(body, dict) or not isinstance(stat, dict):
|
| 127 |
+
return title, None
|
| 128 |
+
if body.get("mid") != mid:
|
| 129 |
+
LOGGER.warning("MAX API returned a different message for %s", canonical_link)
|
| 130 |
+
return title, None
|
| 131 |
+
if message.get("url"):
|
| 132 |
+
returned_path = urlsplit(message["url"]).path.rstrip("/")
|
| 133 |
+
if returned_path != urlsplit(canonical_link).path.rstrip("/"):
|
| 134 |
+
LOGGER.warning("MAX API returned a different post URL for %s", canonical_link)
|
| 135 |
+
return title, None
|
| 136 |
+
views = stat.get("views")
|
| 137 |
+
if type(views) is int and views >= 0:
|
| 138 |
+
return title, views
|
| 139 |
+
LOGGER.warning("MAX API response has no views for %s", canonical_link)
|
| 140 |
+
except (requests.RequestException, ValueError, OverflowError) as exc:
|
| 141 |
+
# Log only the exception class: credential values must not enter logs.
|
| 142 |
+
LOGGER.warning("MAX lookup failed for %s (%s)", canonical_link, type(exc).__name__)
|
| 143 |
+
return title, None
|
requirements.txt
CHANGED
|
@@ -2,3 +2,4 @@ fastapi==0.110.0
|
|
| 2 |
uvicorn[standard]==0.27.1
|
| 3 |
requests==2.31.0
|
| 4 |
pydantic==2.6.1
|
|
|
|
|
|
| 2 |
uvicorn[standard]==0.27.1
|
| 3 |
requests==2.31.0
|
| 4 |
pydantic==2.6.1
|
| 5 |
+
json5==0.17.3
|
tests/test_max_api.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import time
|
| 3 |
+
import unittest
|
| 4 |
+
from unittest.mock import Mock, patch
|
| 5 |
+
|
| 6 |
+
import app
|
| 7 |
+
import max_api
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
URL = "https://max.ru/vkontakte/AaE-viFyB3E"
|
| 11 |
+
MID = "mid.ffffc217ccd5157d01a13ebe21720771"
|
| 12 |
+
PAGE = '''<meta content="Public & news" property="og:title">
|
| 13 |
+
<script>app.start(element, {
|
| 14 |
+
data: [null,{type:"data",data:{linkInfo:{channel:{
|
| 15 |
+
title:"Public & news",description:"a } and [ inside text",
|
| 16 |
+
participantsCount:5287,channelId:68067500157571},message:{text:"post"}}}}],
|
| 17 |
+
form:null});</script>'''
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def page_response():
|
| 21 |
+
return Mock(status_code=200, text=PAGE)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class MaxPublicPostTests(unittest.TestCase):
|
| 25 |
+
def test_hydration_and_message_id(self):
|
| 26 |
+
title, channel_id = max_api.parse_public_post(PAGE, "vkontakte")
|
| 27 |
+
self.assertEqual(title, "Public & news")
|
| 28 |
+
self.assertEqual(channel_id, 68067500157571)
|
| 29 |
+
self.assertEqual(max_api.message_id_from_link(channel_id, "AaE-viFyB3E"), MID)
|
| 30 |
+
|
| 31 |
+
def test_missing_hydration_preserves_meta_title(self):
|
| 32 |
+
title, channel_id = max_api.parse_public_post(
|
| 33 |
+
'<meta property="og:title" content="Public & news">', "vkontakte",
|
| 34 |
+
)
|
| 35 |
+
self.assertEqual(title, "Public & news")
|
| 36 |
+
self.assertIsNone(channel_id)
|
| 37 |
+
|
| 38 |
+
def test_bad_post_id_is_rejected(self):
|
| 39 |
+
with self.assertRaises(ValueError):
|
| 40 |
+
max_api.message_id_from_link(68067500157571, "bad")
|
| 41 |
+
|
| 42 |
+
@patch.dict(os.environ, {"MAX_ACCESS_TOKEN": "test-token"})
|
| 43 |
+
def test_public_post_views_and_credentials(self):
|
| 44 |
+
session = Mock()
|
| 45 |
+
session.get.side_effect = [page_response(), Mock(status_code=200, json=lambda: {
|
| 46 |
+
"body": {"mid": MID}, "stat": {"views": 1197}, "url": URL,
|
| 47 |
+
})]
|
| 48 |
+
self.assertEqual(max_api.get_public_post(
|
| 49 |
+
session, "vkontakte", "AaE-viFyB3E", URL, (5, 8),
|
| 50 |
+
), ("Public & news", 1197))
|
| 51 |
+
public_request, api_request = session.get.call_args_list
|
| 52 |
+
self.assertNotIn("headers", public_request.kwargs)
|
| 53 |
+
self.assertEqual(api_request.args[0], max_api.API_BASE_URL + "/messages/" + MID)
|
| 54 |
+
self.assertEqual(api_request.kwargs["headers"], {"Authorization": "test-token"})
|
| 55 |
+
self.assertEqual(api_request.kwargs["verify"], max_api.CA_FILE)
|
| 56 |
+
self.assertFalse(api_request.kwargs["allow_redirects"])
|
| 57 |
+
|
| 58 |
+
@patch.dict(os.environ, {"MAX_ACCESS_TOKEN": "test-token"})
|
| 59 |
+
def test_denied_and_missing_counter_are_unknown_not_zero(self):
|
| 60 |
+
for response in [Mock(status_code=403), Mock(status_code=200, json=lambda: {
|
| 61 |
+
"body": {"mid": MID}, "stat": {},
|
| 62 |
+
}), Mock(status_code=200, json=lambda: {
|
| 63 |
+
"body": {"mid": "different"}, "stat": {"views": 9999},
|
| 64 |
+
})]:
|
| 65 |
+
with self.subTest(status=response.status_code):
|
| 66 |
+
session = Mock()
|
| 67 |
+
session.get.side_effect = [page_response(), response]
|
| 68 |
+
self.assertEqual(max_api.get_public_post(
|
| 69 |
+
session, "vkontakte", "AaE-viFyB3E", URL, (5, 8),
|
| 70 |
+
), ("Public & news", None))
|
| 71 |
+
|
| 72 |
+
@patch.dict(os.environ, {"MAX_ACCESS_TOKEN": "test-token"})
|
| 73 |
+
def test_real_zero_is_preserved(self):
|
| 74 |
+
session = Mock()
|
| 75 |
+
session.get.side_effect = [page_response(), Mock(status_code=200, json=lambda: {
|
| 76 |
+
"body": {"mid": MID}, "stat": {"views": 0},
|
| 77 |
+
})]
|
| 78 |
+
self.assertEqual(max_api.get_public_post(
|
| 79 |
+
session, "vkontakte", "AaE-viFyB3E", URL, (5, 8),
|
| 80 |
+
)[1], 0)
|
| 81 |
+
|
| 82 |
+
def test_output_keeps_max_counters_and_partial_sum(self):
|
| 83 |
+
lines = [("max", URL, "vkontakte", "AaE-viFyB3E"),
|
| 84 |
+
("max", "https://max.ru/other/AaE-viFyB3E", "other", "AaE-viFyB3E")]
|
| 85 |
+
with patch.object(app, "resolve_max", return_value={
|
| 86 |
+
URL: ("Public & news", 1197), lines[1][1]: ("Other", None),
|
| 87 |
+
}):
|
| 88 |
+
html, text, _, _, errors, max_count = app.build_output(lines, time.monotonic() + 10)
|
| 89 |
+
self.assertIn("<b>1,2K</b>", html)
|
| 90 |
+
self.assertIn("Public & news", html)
|
| 91 |
+
self.assertIn("1,2K", text)
|
| 92 |
+
self.assertIn("Постов без данных о просмотрах: 1.", text)
|
| 93 |
+
self.assertEqual(len(errors), 1)
|
| 94 |
+
self.assertEqual(max_count, 2)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
if __name__ == "__main__":
|
| 98 |
+
unittest.main()
|