Dink0 commited on
Commit
72f0375
·
1 Parent(s): 04c44bc

Read public MAX post views through Bot API

Browse files
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 # число ссылок MAX (просмотры из MAX недоступны)
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, None]:
310
- """MAX: подхватываем название канала из страницы поста.
311
- Просмотры из MAX недоступны, поэтому всегда возвращаем None."""
312
- title = slug
313
- try:
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
- # просмотры из MAX недоступны всегда
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, _views = max_results.get(canonical, (slug, None))
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, None))
 
 
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
- text_lines.append("Просмотры из MAX недоступны.")
711
- h, t = _render_groups(max_groups, has_views=False, default_title="MAX")
 
 
 
 
 
 
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 &amp; 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 &amp; 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 &amp; 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()