android-watcher 1.0.0__py3-none-any.whl
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.
- android_watcher/__init__.py +10 -0
- android_watcher/catalog/__init__.py +32 -0
- android_watcher/catalog/catalog.toml +531 -0
- android_watcher/cli.py +161 -0
- android_watcher/config.py +262 -0
- android_watcher/detect/__init__.py +1 -0
- android_watcher/detect/_normalize.py +192 -0
- android_watcher/detect/android_sitemap.py +540 -0
- android_watcher/detect/base.py +14 -0
- android_watcher/detect/content.py +99 -0
- android_watcher/detect/feed.py +135 -0
- android_watcher/detect/sitemap.py +203 -0
- android_watcher/doctor.py +125 -0
- android_watcher/fetch.py +162 -0
- android_watcher/group.py +79 -0
- android_watcher/lock.py +32 -0
- android_watcher/models.py +156 -0
- android_watcher/notify/__init__.py +1 -0
- android_watcher/notify/base.py +21 -0
- android_watcher/notify/email.py +52 -0
- android_watcher/notify/html.py +114 -0
- android_watcher/notify/render.py +239 -0
- android_watcher/notify/slack.py +124 -0
- android_watcher/notify/telegram.py +46 -0
- android_watcher/rank.py +84 -0
- android_watcher/registry.py +38 -0
- android_watcher/run.py +283 -0
- android_watcher/schedule.py +488 -0
- android_watcher/seed/__init__.py +45 -0
- android_watcher/seed/seed.sql.gz +0 -0
- android_watcher/store.py +492 -0
- android_watcher/triage/__init__.py +1 -0
- android_watcher/triage/base.py +25 -0
- android_watcher/triage/claude_cli.py +185 -0
- android_watcher/triage/noop.py +24 -0
- android_watcher/tui/__init__.py +1 -0
- android_watcher/tui/app.py +163 -0
- android_watcher/tui/configio.py +215 -0
- android_watcher/tui/screens.py +927 -0
- android_watcher-1.0.0.dist-info/METADATA +310 -0
- android_watcher-1.0.0.dist-info/RECORD +44 -0
- android_watcher-1.0.0.dist-info/WHEEL +4 -0
- android_watcher-1.0.0.dist-info/entry_points.txt +2 -0
- android_watcher-1.0.0.dist-info/licenses/LICENSE +21 -0
android_watcher/lock.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import fcntl
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
|
|
8
|
+
from .models import AlreadyRunning # single definition lives in models.py
|
|
9
|
+
|
|
10
|
+
__all__ = ["AlreadyRunning", "run_lock"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@contextlib.contextmanager
|
|
14
|
+
def run_lock(data_dir: str) -> Iterator[None]:
|
|
15
|
+
"""Acquire an exclusive flock on ``data_dir/run.lock``.
|
|
16
|
+
|
|
17
|
+
Raises ``AlreadyRunning`` if the lock is already held by another process.
|
|
18
|
+
"""
|
|
19
|
+
os.makedirs(data_dir, exist_ok=True)
|
|
20
|
+
lock_path = os.path.join(data_dir, "run.lock")
|
|
21
|
+
fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o600)
|
|
22
|
+
try:
|
|
23
|
+
try:
|
|
24
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
25
|
+
except OSError as exc:
|
|
26
|
+
raise AlreadyRunning(f"another android-watcher run holds {lock_path}") from exc
|
|
27
|
+
try:
|
|
28
|
+
yield
|
|
29
|
+
finally:
|
|
30
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
31
|
+
finally:
|
|
32
|
+
os.close(fd)
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Shared types: dataclasses, type aliases, exceptions, and constants.
|
|
2
|
+
|
|
3
|
+
Everything here is defined ONCE and imported everywhere else. Keeping the four
|
|
4
|
+
exceptions, ``Check``, ``SignalType``, and ``INTERVAL_DELTA`` in this one module
|
|
5
|
+
gives the package a single source of truth and an acyclic import graph.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from datetime import UTC, datetime, timedelta
|
|
12
|
+
from typing import Literal
|
|
13
|
+
|
|
14
|
+
DetectorName = Literal["feed", "android_sitemap", "sitemap", "content"]
|
|
15
|
+
Verdict = Literal["substantive", "cosmetic"]
|
|
16
|
+
ChangeKind = Literal["new", "updated"]
|
|
17
|
+
# How a sitemap source treats reference docs:
|
|
18
|
+
# keep - no special handling
|
|
19
|
+
# drop - exclude any URL with a "reference" path segment
|
|
20
|
+
# index_only - keep only reference index/summary pages (Kotlin-preferred),
|
|
21
|
+
# dropping per-symbol class/function pages
|
|
22
|
+
ReferenceMode = Literal["keep", "drop", "index_only"]
|
|
23
|
+
SignalType = Literal["sitemap", "content"] # snapshots.signal_type
|
|
24
|
+
# android_sitemap + sitemap write "sitemap" (lastmod-confirmed-by-content);
|
|
25
|
+
# content detector writes "content". The feed detector writes NO snapshot
|
|
26
|
+
# (it dedupes per-item via seen_feed_items), so there is no "feed" signal_type.
|
|
27
|
+
|
|
28
|
+
# Shared schedule-interval mapping: defined ONCE here, imported by the catch-up
|
|
29
|
+
# gate (run.py), the scheduler (schedule.py), and doctor. cron => always due.
|
|
30
|
+
INTERVAL_DELTA = {
|
|
31
|
+
"hourly": timedelta(hours=1),
|
|
32
|
+
"daily": timedelta(days=1),
|
|
33
|
+
"weekly": timedelta(days=7),
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Shared exceptions: defined ONCE here, imported by lock/config/fetch/notify so
|
|
38
|
+
# there is exactly one class per error and no circular imports.
|
|
39
|
+
class ConfigError(ValueError):
|
|
40
|
+
"""Raised on a malformed or contradictory configuration."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class AlreadyRunning(RuntimeError):
|
|
44
|
+
"""Raised when another android-watcher run already holds the run lock."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Disallowed(RuntimeError):
|
|
48
|
+
"""Raised when robots.txt forbids fetching a URL."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class NotifyError(RuntimeError):
|
|
52
|
+
"""Raised when a Notifier fails to deliver a digest."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class Source:
|
|
57
|
+
id: str
|
|
58
|
+
name: str
|
|
59
|
+
category: str
|
|
60
|
+
detector: DetectorName
|
|
61
|
+
url: str
|
|
62
|
+
enabled: bool = True
|
|
63
|
+
path_prefix: str = ""
|
|
64
|
+
feed_url: str = ""
|
|
65
|
+
content_selector: str = ""
|
|
66
|
+
default_weight: int = 0 # 0 => use category weight
|
|
67
|
+
# Sitemap-source filters (host-agnostic android_sitemap detector):
|
|
68
|
+
exclude_prefixes: tuple[str, ...] = () # drop URLs under any of these paths
|
|
69
|
+
require_segment: str = "" # if set, keep only URLs with a matching path segment
|
|
70
|
+
# Default index_only: reference docs are filtered to index/summary pages site-
|
|
71
|
+
# wide (a no-op on sources with no /reference pages). Override per source.
|
|
72
|
+
reference_mode: ReferenceMode = "index_only"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class Change:
|
|
77
|
+
source_id: str
|
|
78
|
+
url: str
|
|
79
|
+
change_kind: ChangeKind
|
|
80
|
+
title: str = ""
|
|
81
|
+
raw_diff: str = "" # short excerpt / diff text
|
|
82
|
+
fetched_hash: str = "" # confirmed content hash
|
|
83
|
+
detected_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
|
84
|
+
id: int | None = None # set by Store.record_change
|
|
85
|
+
verdict: Verdict | None = None
|
|
86
|
+
description: str | None = None # filled by Triager for substantive
|
|
87
|
+
group_key: str | None = None # model-assigned grouping slug; same slug = same group
|
|
88
|
+
group_summary: str | None = None # merged one-line summary for the group, or None
|
|
89
|
+
group_title: str | None = None # short model headline naming the group, or None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class DigestItem:
|
|
94
|
+
change: Change
|
|
95
|
+
score: int
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class DigestGroup:
|
|
100
|
+
key: str
|
|
101
|
+
title: str
|
|
102
|
+
summary: str | None
|
|
103
|
+
category: str
|
|
104
|
+
source_id: str
|
|
105
|
+
change_kind: ChangeKind
|
|
106
|
+
members: list[Change] # newest-first; len >= 1
|
|
107
|
+
score: int = 0
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def primary_url(self) -> str:
|
|
111
|
+
return self.members[0].url
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def page_count(self) -> int:
|
|
115
|
+
return len(self.members)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass
|
|
119
|
+
class Digest:
|
|
120
|
+
groups: list[DigestGroup] # all groups, ranked (score DESC)
|
|
121
|
+
max_items: int = 10 # cap on groups shown in the on-channel message
|
|
122
|
+
tldr: str | None = None
|
|
123
|
+
ai_unavailable: str | None = None
|
|
124
|
+
sources_scanned: int = 0
|
|
125
|
+
pages_watched: int = 0
|
|
126
|
+
generated_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def is_empty(self) -> bool:
|
|
130
|
+
return not self.groups
|
|
131
|
+
|
|
132
|
+
def message_groups(self) -> list[DigestGroup]:
|
|
133
|
+
return self.groups[: self.max_items]
|
|
134
|
+
|
|
135
|
+
def carried_groups(self) -> list[DigestGroup]:
|
|
136
|
+
return self.groups[self.max_items :]
|
|
137
|
+
|
|
138
|
+
def change_count(self) -> int:
|
|
139
|
+
return sum(g.page_count for g in self.groups)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass(frozen=True)
|
|
143
|
+
class Check:
|
|
144
|
+
name: str # health-check result; lives here so doctor.py and schedule.py
|
|
145
|
+
ok: bool # both import it without a circular import
|
|
146
|
+
detail: str
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass
|
|
150
|
+
class FetchResult:
|
|
151
|
+
url: str
|
|
152
|
+
status: int # real HTTP status; 304 when not_modified
|
|
153
|
+
text: str # "" when not_modified
|
|
154
|
+
etag: str = ""
|
|
155
|
+
last_modified: str = ""
|
|
156
|
+
not_modified: bool = False # True on HTTP 304
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from . import email, slack, telegram # noqa: F401
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Notifier protocol, re-export of NotifyError, and NOTIFIERS registry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
from android_watcher.config import Config
|
|
8
|
+
from android_watcher.models import Digest, NotifyError # shared exception defined in models.py
|
|
9
|
+
from android_watcher.registry import Registry
|
|
10
|
+
|
|
11
|
+
__all__ = ["NotifyError", "Notifier", "NOTIFIERS"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@runtime_checkable
|
|
15
|
+
class Notifier(Protocol):
|
|
16
|
+
name: str
|
|
17
|
+
|
|
18
|
+
def send(self, digest: Digest, config: Config) -> set[int]: ...
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
NOTIFIERS: Registry[Notifier] = Registry("notifier")
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Email notifier: sends digest via SMTP with TLS enforced (fail closed)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import smtplib
|
|
6
|
+
import ssl
|
|
7
|
+
from email.message import EmailMessage
|
|
8
|
+
|
|
9
|
+
from android_watcher.config import Config
|
|
10
|
+
from android_watcher.models import Digest
|
|
11
|
+
from android_watcher.notify.base import NOTIFIERS, NotifyError
|
|
12
|
+
from android_watcher.notify.render import render_email
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@NOTIFIERS.register("email")
|
|
16
|
+
class EmailNotifier:
|
|
17
|
+
name = "email"
|
|
18
|
+
|
|
19
|
+
def send(self, digest: Digest, config: Config) -> set[int]:
|
|
20
|
+
ec = config.email
|
|
21
|
+
html, plaintext = render_email(digest)
|
|
22
|
+
|
|
23
|
+
msg = EmailMessage()
|
|
24
|
+
msg["From"] = ec.sender
|
|
25
|
+
msg["To"] = ec.recipient
|
|
26
|
+
msg["Subject"] = "android-watcher digest"
|
|
27
|
+
msg.set_content(plaintext)
|
|
28
|
+
msg.add_alternative(html, subtype="html")
|
|
29
|
+
|
|
30
|
+
context = ssl.create_default_context()
|
|
31
|
+
try:
|
|
32
|
+
if ec.smtp_port == 465:
|
|
33
|
+
with smtplib.SMTP_SSL(ec.smtp_host, ec.smtp_port, context=context) as s:
|
|
34
|
+
s.login(ec.username, ec.password)
|
|
35
|
+
s.send_message(msg)
|
|
36
|
+
else:
|
|
37
|
+
with smtplib.SMTP(ec.smtp_host, ec.smtp_port) as s:
|
|
38
|
+
s.ehlo()
|
|
39
|
+
if not s.has_extn("starttls"):
|
|
40
|
+
raise NotifyError(
|
|
41
|
+
f"SMTP server {ec.smtp_host}:{ec.smtp_port} does not advertise "
|
|
42
|
+
"STARTTLS; refusing to send over plaintext"
|
|
43
|
+
)
|
|
44
|
+
s.starttls(context=context)
|
|
45
|
+
s.ehlo()
|
|
46
|
+
s.login(ec.username, ec.password)
|
|
47
|
+
s.send_message(msg)
|
|
48
|
+
except NotifyError:
|
|
49
|
+
raise
|
|
50
|
+
except (smtplib.SMTPException, OSError, ssl.SSLError) as exc:
|
|
51
|
+
raise NotifyError(f"email send failed: {exc}") from exc
|
|
52
|
+
return {m.id for g in digest.groups for m in g.members if m.id is not None}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Render a Digest into a standalone full-digest HTML page (all groups)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html as _html
|
|
6
|
+
|
|
7
|
+
from android_watcher.models import Digest, DigestGroup
|
|
8
|
+
from android_watcher.rank import by_category
|
|
9
|
+
|
|
10
|
+
_HEAD = """<!doctype html>
|
|
11
|
+
<html lang="en"><head><meta charset="utf-8">
|
|
12
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
13
|
+
<title>Android Watcher Digest</title>
|
|
14
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
15
|
+
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@500&display=swap" rel="stylesheet">
|
|
16
|
+
<style>
|
|
17
|
+
:root{--ink:#14171f;--paper:#faf9f5;--muted:#6b7180;--faint:#9aa0ac;--hair:#e7e6df;--signal:#1f9d57;--signal-soft:#e6f4ec;--card:#fff;--display:"Space Grotesk",system-ui,sans-serif;--body:"Inter",system-ui,sans-serif;--mono:"JetBrains Mono",ui-monospace,Menlo,monospace}
|
|
18
|
+
@media(prefers-color-scheme:dark){:root{--ink:#e9e8e2;--paper:#101218;--muted:#9aa0ac;--faint:#6b7180;--hair:#262932;--signal:#3ddc84;--signal-soft:#15271d;--card:#171a22}}
|
|
19
|
+
*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font-family:var(--body);font-size:15px;line-height:1.55;-webkit-font-smoothing:antialiased}
|
|
20
|
+
.wrap{max-width:720px;margin:0 auto;padding:0 20px 72px}
|
|
21
|
+
header.mast{text-align:center;padding:48px 0 28px}
|
|
22
|
+
.mast h1{font-family:var(--display);font-weight:700;font-size:40px;letter-spacing:-.02em;margin:0;line-height:1.05}
|
|
23
|
+
.scan{width:64px;height:2px;margin:22px auto 18px;border-radius:2px;background:linear-gradient(90deg,transparent,var(--signal),transparent)}
|
|
24
|
+
.mast .stats{font-family:var(--mono);font-size:12.5px;color:var(--muted);display:flex;gap:14px;justify-content:center;flex-wrap:wrap}
|
|
25
|
+
.mast .stats b{color:var(--ink);font-weight:600}
|
|
26
|
+
.cat{display:flex;align-items:baseline;gap:12px;margin:38px 0 10px}
|
|
27
|
+
.cat h2{font-family:var(--mono);font-size:12px;letter-spacing:.18em;text-transform:uppercase;color:var(--muted);font-weight:500;margin:0;white-space:nowrap}
|
|
28
|
+
.cat .rule{flex:1;height:1px;background:var(--hair)}.cat .n{font-family:var(--mono);font-size:12px;color:var(--faint)}
|
|
29
|
+
.row{background:var(--card);border:1px solid var(--hair);border-radius:12px;margin:8px 0;padding:16px 18px}
|
|
30
|
+
.titleline{display:flex;align-items:center;gap:10px}.tick{width:7px;height:7px;border-radius:50%;background:var(--signal);flex:none}
|
|
31
|
+
.title{font-family:var(--display);font-weight:600;font-size:17px;letter-spacing:-.01em}
|
|
32
|
+
.src{font-family:var(--mono);font-size:11px;color:var(--faint);margin:7px 0 0 17px}
|
|
33
|
+
.point{margin:9px 0 0 17px;color:var(--ink)}
|
|
34
|
+
.lbl{font-family:var(--mono);font-size:10.5px;letter-spacing:.14em;text-transform:uppercase;color:var(--faint);margin:13px 0 6px 17px}
|
|
35
|
+
ol.sources{margin:0 0 0 17px;padding-left:22px}ol.sources li{margin:4px 0}
|
|
36
|
+
.src-one{margin:11px 0 0 17px;font-size:14px}
|
|
37
|
+
a{color:var(--signal)}a:hover{text-decoration:underline}.sources a,.src-one a{text-decoration:underline}
|
|
38
|
+
.banner{margin:0 0 24px;padding:12px 18px;border:1px solid #e6a817;border-radius:10px;background:#fffbee;color:#7a4f00;font-family:var(--mono);font-size:12.5px;text-align:center}
|
|
39
|
+
@media(prefers-color-scheme:dark){.banner{border-color:#7a4f00;background:#1e1600;color:#f5c842}}
|
|
40
|
+
.coverage{margin-top:44px;padding:24px;border:1px solid var(--hair);border-radius:14px;background:var(--card);display:flex;text-align:center}
|
|
41
|
+
.coverage .cell{flex:1;padding:4px 8px}.coverage .cell+.cell{border-left:1px solid var(--hair)}
|
|
42
|
+
.coverage .num{font-family:var(--display);font-weight:700;font-size:34px;letter-spacing:-.02em;color:var(--signal);line-height:1}
|
|
43
|
+
.coverage .lbl{font-family:var(--mono);font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--muted);margin-top:8px}
|
|
44
|
+
footer{text-align:center;margin-top:22px;font-family:var(--mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--faint)}
|
|
45
|
+
</style></head><body><div class="wrap">"""
|
|
46
|
+
|
|
47
|
+
_FOOT = "</div></body></html>"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _esc(s: str) -> str:
|
|
51
|
+
return _html.escape(s or "")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def clean_label(title: str) -> str:
|
|
55
|
+
"""Display form of a page title: drop a trailing ' | Site' / ' — Site' suffix
|
|
56
|
+
so link text reads as the page name, not 'Page | Android Open Source Project'."""
|
|
57
|
+
if not title:
|
|
58
|
+
return ""
|
|
59
|
+
for sep in (" | ", " — ", " – ", " · "):
|
|
60
|
+
if sep in title:
|
|
61
|
+
return title.split(sep)[0].strip()
|
|
62
|
+
return title.strip()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _link(m) -> str:
|
|
66
|
+
label = clean_label(m.title) or m.url
|
|
67
|
+
return f'<a href="{_esc(m.url)}">{_esc(label)}</a>'
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _row(g: DigestGroup) -> str:
|
|
71
|
+
parts = [
|
|
72
|
+
'<div class="row">',
|
|
73
|
+
f'<div class="titleline"><span class="tick"></span>'
|
|
74
|
+
f'<span class="title">{_esc(clean_label(g.title))}</span></div>',
|
|
75
|
+
f'<div class="src">{_esc(g.source_id)} · {_esc(g.change_kind)}</div>',
|
|
76
|
+
]
|
|
77
|
+
if g.summary:
|
|
78
|
+
parts.append(f'<p class="point">{_esc(g.summary)}</p>')
|
|
79
|
+
if g.page_count > 1:
|
|
80
|
+
items = "".join(f"<li>{_link(m)}</li>" for m in g.members)
|
|
81
|
+
parts.append(f'<div class="lbl">Sources</div><ol class="sources" type="i">{items}</ol>')
|
|
82
|
+
else:
|
|
83
|
+
parts.append(f'<div class="src-one">Source: {_link(g.members[0])}</div>')
|
|
84
|
+
parts.append("</div>")
|
|
85
|
+
return "".join(parts)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def render_html(digest: Digest) -> str:
|
|
89
|
+
date = digest.generated_at.strftime("%d %b %Y")
|
|
90
|
+
parts = [_HEAD]
|
|
91
|
+
parts.append(
|
|
92
|
+
'<header class="mast"><h1>Android Watcher Digest</h1><div class="scan"></div>'
|
|
93
|
+
f'<div class="stats"><span><b>{date}</b></span>'
|
|
94
|
+
f"<span><b>{len(digest.groups)}</b> groups</span>"
|
|
95
|
+
f"<span><b>{digest.change_count()}</b> changes</span></div></header>"
|
|
96
|
+
)
|
|
97
|
+
if digest.ai_unavailable:
|
|
98
|
+
parts.append(f'<div class="banner">AI unavailable: {_esc(digest.ai_unavailable)}</div>')
|
|
99
|
+
for _cid, label, groups in by_category(digest.groups):
|
|
100
|
+
parts.append(
|
|
101
|
+
f'<div class="cat"><h2>{_esc(label)}</h2><span class="rule"></span>'
|
|
102
|
+
f'<span class="n">{len(groups)}</span></div>'
|
|
103
|
+
)
|
|
104
|
+
parts.extend(_row(g) for g in groups)
|
|
105
|
+
parts.append(
|
|
106
|
+
'<div class="coverage">'
|
|
107
|
+
f'<div class="cell"><div class="num">{digest.sources_scanned}</div>'
|
|
108
|
+
'<div class="lbl">sources scanned</div></div>'
|
|
109
|
+
f'<div class="cell"><div class="num">{digest.pages_watched:,}</div>'
|
|
110
|
+
'<div class="lbl">pages watched</div></div></div>'
|
|
111
|
+
"<footer>Android Watcher Digest</footer>"
|
|
112
|
+
)
|
|
113
|
+
parts.append(_FOOT)
|
|
114
|
+
return "".join(parts)
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Render a Digest into email (HTML + plaintext), Telegram, and Slack blocks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html as _html
|
|
6
|
+
|
|
7
|
+
from android_watcher.models import Digest, DigestGroup
|
|
8
|
+
from android_watcher.notify.html import clean_label, render_html
|
|
9
|
+
from android_watcher.rank import by_category
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _banner_text(digest: Digest) -> str | None:
|
|
13
|
+
return f"AI unavailable: {digest.ai_unavailable}" if digest.ai_unavailable else None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _scan_footer(digest: Digest) -> str | None:
|
|
17
|
+
"""Scan-scope line shown at the foot of every delivered digest, or None."""
|
|
18
|
+
if not digest.sources_scanned:
|
|
19
|
+
return None
|
|
20
|
+
return f"Scanned {digest.sources_scanned} Sources · Watching {digest.pages_watched:,} Pages"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _points_plaintext(digest: Digest) -> list[str]:
|
|
24
|
+
lines: list[str] = []
|
|
25
|
+
for _cid, label, groups in by_category(digest.groups):
|
|
26
|
+
lines.append(f"## {label}")
|
|
27
|
+
for g in groups:
|
|
28
|
+
pages = f" ({g.page_count} pages)" if g.page_count > 1 else ""
|
|
29
|
+
lines.append(f"- {g.title}{pages} [{g.source_id}/{g.change_kind}]")
|
|
30
|
+
if g.summary:
|
|
31
|
+
lines.append(f" {g.summary}")
|
|
32
|
+
return lines
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def render_email(digest: Digest) -> tuple[str, str]:
|
|
36
|
+
html = render_html(digest)
|
|
37
|
+
p_lines: list[str] = []
|
|
38
|
+
banner = _banner_text(digest)
|
|
39
|
+
if banner:
|
|
40
|
+
p_lines.append(banner)
|
|
41
|
+
p_lines.append("Android Watcher Digest")
|
|
42
|
+
if digest.is_empty:
|
|
43
|
+
p_lines.append("Nothing notable changed.")
|
|
44
|
+
else:
|
|
45
|
+
p_lines.extend(_points_plaintext(digest))
|
|
46
|
+
footer = _scan_footer(digest)
|
|
47
|
+
if footer:
|
|
48
|
+
p_lines.append(footer)
|
|
49
|
+
return html, "\n".join(p_lines)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
_TELEGRAM_LIMIT = 4096
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def render_telegram(digest: Digest) -> str:
|
|
56
|
+
"""Render a digest as an HTML-formatted Telegram message.
|
|
57
|
+
|
|
58
|
+
Uses parse_mode=HTML. User/content text is html-escaped. If the assembled
|
|
59
|
+
message would exceed Telegram's 4096-character limit, groups are dropped
|
|
60
|
+
(trailing ones first) and a "…(N more)" note is appended.
|
|
61
|
+
"""
|
|
62
|
+
parts: list[str] = ["<b>Android Watcher Digest</b>"]
|
|
63
|
+
banner = _banner_text(digest)
|
|
64
|
+
if banner:
|
|
65
|
+
parts.insert(0, f"<b>{_html.escape(banner)}</b>")
|
|
66
|
+
scan = _scan_footer(digest)
|
|
67
|
+
scan_line = f"<i>{_html.escape(scan)}</i>" if scan else None
|
|
68
|
+
if digest.is_empty:
|
|
69
|
+
parts.append("Nothing notable changed.")
|
|
70
|
+
if scan_line:
|
|
71
|
+
parts.append(scan_line)
|
|
72
|
+
return "\n".join(parts)
|
|
73
|
+
|
|
74
|
+
item_lines: list[str] = []
|
|
75
|
+
for g in digest.message_groups():
|
|
76
|
+
pages = f" ({g.page_count} pages)" if g.page_count > 1 else ""
|
|
77
|
+
label = _html.escape(g.title)
|
|
78
|
+
src_tag = f"[{_html.escape(g.source_id)}/{_html.escape(g.change_kind)}]{pages}"
|
|
79
|
+
line = f'<a href="{_html.escape(g.primary_url)}">{label}</a> {src_tag}'
|
|
80
|
+
if g.summary:
|
|
81
|
+
line += f"\n{_html.escape(g.summary)}"
|
|
82
|
+
item_lines.append(line)
|
|
83
|
+
|
|
84
|
+
carried = digest.carried_groups()
|
|
85
|
+
footer_lines = [f"+{len(carried)} more groups"] if carried else []
|
|
86
|
+
header = "\n".join(parts)
|
|
87
|
+
footer = "\n".join(footer_lines)
|
|
88
|
+
|
|
89
|
+
def _assemble(items: list[str], dropped: int) -> str:
|
|
90
|
+
sections = [header, *items]
|
|
91
|
+
if dropped:
|
|
92
|
+
sections.append(f"…({dropped} more)")
|
|
93
|
+
if footer:
|
|
94
|
+
sections.append(footer)
|
|
95
|
+
if scan_line:
|
|
96
|
+
sections.append(scan_line)
|
|
97
|
+
return "\n".join(sections)
|
|
98
|
+
|
|
99
|
+
msg = _assemble(item_lines, 0)
|
|
100
|
+
if len(msg) <= _TELEGRAM_LIMIT:
|
|
101
|
+
return msg
|
|
102
|
+
|
|
103
|
+
# Drop groups from the end until it fits.
|
|
104
|
+
dropped = 0
|
|
105
|
+
while item_lines and len(_assemble(item_lines, dropped)) > _TELEGRAM_LIMIT:
|
|
106
|
+
item_lines.pop()
|
|
107
|
+
dropped += 1
|
|
108
|
+
|
|
109
|
+
return _assemble(item_lines, dropped)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
_MEMBER_LINK_CAP = 12 # most member links to inline before summarizing the rest
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _slack_link_label(text: str) -> str:
|
|
116
|
+
"""Clean a page title for display, then escape what would break a Slack
|
|
117
|
+
`<url|label>` link: a raw `|` ends the label, and `& < >` need escaping."""
|
|
118
|
+
label = clean_label(text)
|
|
119
|
+
return label.replace("&", "&").replace("<", "<").replace(">", ">").replace("|", "/")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
_ROMAN = [
|
|
123
|
+
(1000, "m"),
|
|
124
|
+
(900, "cm"),
|
|
125
|
+
(500, "d"),
|
|
126
|
+
(400, "cd"),
|
|
127
|
+
(100, "c"),
|
|
128
|
+
(90, "xc"),
|
|
129
|
+
(50, "l"),
|
|
130
|
+
(40, "xl"),
|
|
131
|
+
(10, "x"),
|
|
132
|
+
(9, "ix"),
|
|
133
|
+
(5, "v"),
|
|
134
|
+
(4, "iv"),
|
|
135
|
+
(1, "i"),
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _roman(n: int) -> str:
|
|
140
|
+
"""Lowercase roman numeral for the source list (i, ii, iii, …)."""
|
|
141
|
+
out: list[str] = []
|
|
142
|
+
for value, sym in _ROMAN:
|
|
143
|
+
while n >= value:
|
|
144
|
+
out.append(sym)
|
|
145
|
+
n -= value
|
|
146
|
+
return "".join(out)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _group_section(g: DigestGroup, number: int | None = None) -> dict:
|
|
150
|
+
"""One section per group: bold 'Title:' (numbered when its category holds
|
|
151
|
+
more than one group), an optional normal-weight summary, then the source
|
|
152
|
+
link(s). A single-source group shows '*Source:* <link>'; a multi-source group
|
|
153
|
+
shows '*Sources:*' then a lowercase roman-numeral list (capped). No buttons:
|
|
154
|
+
one button could never represent a group's N urls."""
|
|
155
|
+
clean = clean_label(g.title)
|
|
156
|
+
title = clean if clean.endswith(":") else f"{clean}:"
|
|
157
|
+
head = f"{number}. {title}" if number else title
|
|
158
|
+
text = f"*{head}*"
|
|
159
|
+
if g.summary:
|
|
160
|
+
text += f"\n{g.summary}"
|
|
161
|
+
if g.page_count == 1:
|
|
162
|
+
m = g.members[0]
|
|
163
|
+
text += f"\n\n*Source:* <{m.url}|{_slack_link_label(m.title or m.url)}>"
|
|
164
|
+
else:
|
|
165
|
+
shown = g.members[:_MEMBER_LINK_CAP]
|
|
166
|
+
rows = "\n".join(
|
|
167
|
+
f"{_roman(i)}. <{m.url}|{_slack_link_label(m.title or m.url)}>"
|
|
168
|
+
for i, m in enumerate(shown, 1)
|
|
169
|
+
)
|
|
170
|
+
text += f"\n\n*Sources:*\n{rows}"
|
|
171
|
+
if g.page_count > _MEMBER_LINK_CAP:
|
|
172
|
+
text += f"\n…+{g.page_count - _MEMBER_LINK_CAP} more"
|
|
173
|
+
return {"type": "section", "text": {"type": "mrkdwn", "text": text}}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def render_slack(digest: Digest, *, thread_page: bool = False) -> dict:
|
|
177
|
+
"""Render the capped Slack message. ``thread_page`` is True when the notifier
|
|
178
|
+
will also upload the full-digest HTML page into a thread; the footer then
|
|
179
|
+
mentions it. False omits the thread reference."""
|
|
180
|
+
blocks: list[dict] = []
|
|
181
|
+
banner = _banner_text(digest)
|
|
182
|
+
if banner:
|
|
183
|
+
blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": f"*{banner}*"}})
|
|
184
|
+
date = digest.generated_at.strftime("%d %b %Y")
|
|
185
|
+
blocks.append(
|
|
186
|
+
{
|
|
187
|
+
"type": "header",
|
|
188
|
+
"text": {
|
|
189
|
+
"type": "plain_text",
|
|
190
|
+
"text": f"Android Watcher Digest · {date}",
|
|
191
|
+
"emoji": True,
|
|
192
|
+
},
|
|
193
|
+
}
|
|
194
|
+
)
|
|
195
|
+
if digest.is_empty:
|
|
196
|
+
blocks.append(
|
|
197
|
+
{"type": "section", "text": {"type": "mrkdwn", "text": "Nothing notable changed."}}
|
|
198
|
+
)
|
|
199
|
+
footer = _scan_footer(digest)
|
|
200
|
+
if footer:
|
|
201
|
+
blocks.append({"type": "context", "elements": [{"type": "mrkdwn", "text": footer}]})
|
|
202
|
+
return {"blocks": blocks}
|
|
203
|
+
|
|
204
|
+
shown = digest.message_groups()
|
|
205
|
+
# A native divider opens each category (Slack has no thick/thin rule, so the
|
|
206
|
+
# divider is the category separator and there is no inner rule). Groups in a
|
|
207
|
+
# category with more than one group are numbered 1., 2., …
|
|
208
|
+
for _cid, label, groups in by_category(shown):
|
|
209
|
+
blocks.append({"type": "divider"})
|
|
210
|
+
blocks.append(
|
|
211
|
+
{"type": "section", "text": {"type": "mrkdwn", "text": f"*{label} · {len(groups)}*"}}
|
|
212
|
+
)
|
|
213
|
+
numbered = len(groups) > 1
|
|
214
|
+
for i, g in enumerate(groups, 1):
|
|
215
|
+
blocks.append(_group_section(g, i if numbered else None))
|
|
216
|
+
|
|
217
|
+
carried = digest.carried_groups()
|
|
218
|
+
footer_bits: list[str] = []
|
|
219
|
+
if thread_page:
|
|
220
|
+
footer_bits.append(
|
|
221
|
+
f"Full digest - *{len(digest.groups)} groups"
|
|
222
|
+
f" · {digest.change_count()} changes* - Attached in :thread:"
|
|
223
|
+
)
|
|
224
|
+
if carried:
|
|
225
|
+
names = ", ".join(g.title for g in carried[:3])
|
|
226
|
+
footer_bits.append(f"+{len(carried)} more groups: {names}")
|
|
227
|
+
scan = _scan_footer(digest)
|
|
228
|
+
tail: list[dict] = []
|
|
229
|
+
if footer_bits:
|
|
230
|
+
tail.append(
|
|
231
|
+
{"type": "context", "elements": [{"type": "mrkdwn", "text": " ".join(footer_bits)}]}
|
|
232
|
+
)
|
|
233
|
+
if scan:
|
|
234
|
+
tail.append({"type": "context", "elements": [{"type": "mrkdwn", "text": scan}]})
|
|
235
|
+
# Closing divider before the footer region (only when there is a footer).
|
|
236
|
+
if tail:
|
|
237
|
+
blocks.append({"type": "divider"})
|
|
238
|
+
blocks.extend(tail)
|
|
239
|
+
return {"blocks": blocks}
|