youpdated 0.1.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.
- youpdated/__init__.py +3 -0
- youpdated/__main__.py +6 -0
- youpdated/cleanup.py +117 -0
- youpdated/cli.py +251 -0
- youpdated/config.py +214 -0
- youpdated/http.py +216 -0
- youpdated/models.py +63 -0
- youpdated/py.typed +0 -0
- youpdated/registry.py +49 -0
- youpdated/render/__init__.py +5 -0
- youpdated/render/json_out.py +29 -0
- youpdated/render/rss_out.py +58 -0
- youpdated/render/terminal.py +142 -0
- youpdated/runner.py +134 -0
- youpdated/sources/__init__.py +5 -0
- youpdated/sources/base.py +44 -0
- youpdated/sources/browser.py +397 -0
- youpdated/sources/feed.py +111 -0
- youpdated/sources/generic.py +76 -0
- youpdated/sources/github.py +158 -0
- youpdated/sources/itch.py +210 -0
- youpdated/sources/npm.py +88 -0
- youpdated/sources/steam.py +88 -0
- youpdated/sources/youtube.py +335 -0
- youpdated/state.py +154 -0
- youpdated-0.1.0.dist-info/METADATA +452 -0
- youpdated-0.1.0.dist-info/RECORD +31 -0
- youpdated-0.1.0.dist-info/WHEEL +5 -0
- youpdated-0.1.0.dist-info/entry_points.txt +2 -0
- youpdated-0.1.0.dist-info/licenses/LICENSE +21 -0
- youpdated-0.1.0.dist-info/top_level.txt +1 -0
youpdated/http.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""HTTP path for the tool.
|
|
2
|
+
|
|
3
|
+
All outbound go through :class:`Client`
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import random
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any, Iterable, Sequence
|
|
13
|
+
from urllib.parse import urlsplit
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from .config import PrivacyConfig
|
|
18
|
+
from .state import State
|
|
19
|
+
|
|
20
|
+
# Small pool of current desktop UAs. Rotating inside a plausible set
|
|
21
|
+
# it blends in better, but can be changed here
|
|
22
|
+
USER_AGENTS: tuple[str, ...] = (
|
|
23
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
|
|
24
|
+
"(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
|
|
25
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
|
26
|
+
"(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
|
|
27
|
+
"Mozilla/5.0 (X11; Linux x86_64; rv:127.0) Gecko/20100101 Firefox/127.0",
|
|
28
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 "
|
|
29
|
+
"(KHTML, like Gecko) Version/17.5 Safari/605.1.15",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
RETRY_STATUSES = frozenset({429, 500, 502, 503, 504})
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class FetchError(Exception):
|
|
36
|
+
"""Network or unexpected-status failure for one URL."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class Fetched:
|
|
41
|
+
url: str
|
|
42
|
+
status: int
|
|
43
|
+
content: bytes
|
|
44
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def text(self) -> str:
|
|
48
|
+
return self.content.decode("utf-8", errors="replace")
|
|
49
|
+
|
|
50
|
+
def json(self) -> Any:
|
|
51
|
+
import json
|
|
52
|
+
|
|
53
|
+
return json.loads(self.content)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Client:
|
|
57
|
+
"""Paced, proxied, no cookie HTTP client with conditional-GET support."""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
privacy: PrivacyConfig | None = None,
|
|
62
|
+
state: State | None = None,
|
|
63
|
+
*,
|
|
64
|
+
test: bool = False,
|
|
65
|
+
verbose: bool = False,
|
|
66
|
+
use_conditional: bool = True,
|
|
67
|
+
transport: httpx.BaseTransport | None = None,
|
|
68
|
+
logger=None,
|
|
69
|
+
):
|
|
70
|
+
self.privacy = privacy or PrivacyConfig()
|
|
71
|
+
self.state = state
|
|
72
|
+
self.test = test
|
|
73
|
+
self.verbose = verbose
|
|
74
|
+
# Conditional GETs save requests, but a 304 doesn't return items
|
|
75
|
+
self.use_conditional = use_conditional
|
|
76
|
+
self._log = logger
|
|
77
|
+
self.requested_urls: list[str] = []
|
|
78
|
+
|
|
79
|
+
self._host_locks: dict[str, threading.Lock] = {}
|
|
80
|
+
self._host_last: dict[str, float] = {}
|
|
81
|
+
self._registry_lock = threading.Lock()
|
|
82
|
+
|
|
83
|
+
self._client = httpx.Client(
|
|
84
|
+
proxy=self.privacy.proxy,
|
|
85
|
+
timeout=self.privacy.timeout,
|
|
86
|
+
follow_redirects=True,
|
|
87
|
+
transport=transport,
|
|
88
|
+
headers={"Accept-Language": "en-US,en;q=0.9"},
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# lifecycle
|
|
92
|
+
|
|
93
|
+
def close(self) -> None:
|
|
94
|
+
self._client.close()
|
|
95
|
+
|
|
96
|
+
def __enter__(self) -> "Client":
|
|
97
|
+
return self
|
|
98
|
+
|
|
99
|
+
def __exit__(self, *exc: object) -> None:
|
|
100
|
+
self.close()
|
|
101
|
+
|
|
102
|
+
def describe(self) -> str:
|
|
103
|
+
proxy = self.privacy.proxy or "none (direct)"
|
|
104
|
+
ua = "rotating" if self.privacy.user_agent == "rotate" else self.privacy.user_agent
|
|
105
|
+
return (
|
|
106
|
+
f"proxy={proxy} user_agent={ua} "
|
|
107
|
+
f"jitter={self.privacy.jitter[0]}-{self.privacy.jitter[1]}s "
|
|
108
|
+
f"concurrency={self.privacy.concurrency} timeout={self.privacy.timeout}s"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# internals
|
|
112
|
+
|
|
113
|
+
def _user_agent(self) -> str:
|
|
114
|
+
if self.privacy.user_agent == "rotate":
|
|
115
|
+
return random.choice(USER_AGENTS)
|
|
116
|
+
return self.privacy.user_agent
|
|
117
|
+
|
|
118
|
+
def _pace(self, host: str) -> None:
|
|
119
|
+
"""Serialize per host and leave a rand gap, so traffic to one site never looks like a burst."""
|
|
120
|
+
with self._registry_lock:
|
|
121
|
+
lock = self._host_locks.setdefault(host, threading.Lock())
|
|
122
|
+
with lock:
|
|
123
|
+
low, high = self.privacy.jitter
|
|
124
|
+
last = self._host_last.get(host)
|
|
125
|
+
now = time.monotonic()
|
|
126
|
+
if last is not None:
|
|
127
|
+
wait = random.uniform(low, high) - (now - last)
|
|
128
|
+
if wait > 0:
|
|
129
|
+
time.sleep(wait)
|
|
130
|
+
self._host_last[host] = time.monotonic()
|
|
131
|
+
|
|
132
|
+
def note(self, message: str) -> None:
|
|
133
|
+
if self.verbose and self._log:
|
|
134
|
+
self._log(message)
|
|
135
|
+
|
|
136
|
+
# request method
|
|
137
|
+
|
|
138
|
+
def get(
|
|
139
|
+
self,
|
|
140
|
+
url: str,
|
|
141
|
+
*,
|
|
142
|
+
headers: dict[str, str] | None = None,
|
|
143
|
+
conditional: bool = False,
|
|
144
|
+
soft_statuses: Sequence[int] | Iterable[int] = (),
|
|
145
|
+
retries: int = 2,
|
|
146
|
+
) -> Fetched | None:
|
|
147
|
+
"""GET ``url``.
|
|
148
|
+
|
|
149
|
+
Returns ``None`` when the server answers 304 Not Modified, or with ``test``.
|
|
150
|
+
Statuses listed in ``soft_statuses`` come back as a :class:`Fetched` for interpretation
|
|
151
|
+
(itch.io answers 404 for a game that simply has no devlog)
|
|
152
|
+
"""
|
|
153
|
+
self.requested_urls.append(url)
|
|
154
|
+
if self.test:
|
|
155
|
+
self.note(f"[test]] GET {url}")
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
soft = frozenset(soft_statuses)
|
|
159
|
+
conditional = conditional and self.use_conditional
|
|
160
|
+
request_headers = {"User-Agent": self._user_agent()}
|
|
161
|
+
if headers:
|
|
162
|
+
request_headers.update(headers)
|
|
163
|
+
if conditional and self.state is not None:
|
|
164
|
+
request_headers.update(self.state.conditional_headers(url))
|
|
165
|
+
|
|
166
|
+
host = urlsplit(url).netloc
|
|
167
|
+
last_error: Exception | None = None
|
|
168
|
+
|
|
169
|
+
for attempt in range(retries + 1):
|
|
170
|
+
self._pace(host)
|
|
171
|
+
try:
|
|
172
|
+
# Cookies dropped every request
|
|
173
|
+
self._client.cookies.clear()
|
|
174
|
+
response = self._client.get(url, headers=request_headers)
|
|
175
|
+
except httpx.HTTPError as exc:
|
|
176
|
+
last_error = exc
|
|
177
|
+
self.note(f"GET {url} -> {type(exc).__name__}: {exc}")
|
|
178
|
+
if attempt < retries:
|
|
179
|
+
time.sleep(1.5 * (attempt + 1))
|
|
180
|
+
continue
|
|
181
|
+
raise FetchError(f"{url}: {type(exc).__name__}: {exc}") from exc
|
|
182
|
+
|
|
183
|
+
self.note(f"GET {url} -> {response.status_code}")
|
|
184
|
+
|
|
185
|
+
if response.status_code == 304:
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
if response.status_code == 200:
|
|
189
|
+
if conditional and self.state is not None:
|
|
190
|
+
self.state.remember_validators(
|
|
191
|
+
url,
|
|
192
|
+
response.headers.get("etag"),
|
|
193
|
+
response.headers.get("last-modified"),
|
|
194
|
+
)
|
|
195
|
+
return Fetched(
|
|
196
|
+
url=str(response.url),
|
|
197
|
+
status=response.status_code,
|
|
198
|
+
content=response.content,
|
|
199
|
+
headers=dict(response.headers),
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
if response.status_code in soft:
|
|
203
|
+
return Fetched(
|
|
204
|
+
url=str(response.url),
|
|
205
|
+
status=response.status_code,
|
|
206
|
+
content=response.content,
|
|
207
|
+
headers=dict(response.headers),
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
if response.status_code in RETRY_STATUSES and attempt < retries:
|
|
211
|
+
time.sleep(1.5 * (attempt + 1))
|
|
212
|
+
continue
|
|
213
|
+
|
|
214
|
+
raise FetchError(f"{url}: HTTP {response.status_code}")
|
|
215
|
+
|
|
216
|
+
raise FetchError(f"{url}: {last_error}")
|
youpdated/models.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Core data types shared by sources and renderer"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class Target:
|
|
12
|
+
"""Target watched, normalized from a config entry.
|
|
13
|
+
|
|
14
|
+
``key`` scopes dedupe and labels the item in reports
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
source: str
|
|
18
|
+
key: str
|
|
19
|
+
label: str | None = None
|
|
20
|
+
params: dict[str, Any] = field(default_factory=dict)
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def display(self) -> str:
|
|
24
|
+
return self.label or self.key
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class Update:
|
|
29
|
+
source: str
|
|
30
|
+
target: str
|
|
31
|
+
uid: str
|
|
32
|
+
title: str
|
|
33
|
+
url: str
|
|
34
|
+
published: datetime | None = None
|
|
35
|
+
version: str | None = None
|
|
36
|
+
body: str | None = None
|
|
37
|
+
tags: tuple[str, ...] = ()
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def dedupe_key(self) -> tuple[str, str, str]:
|
|
41
|
+
return (self.source, self.target, self.uid)
|
|
42
|
+
|
|
43
|
+
def to_dict(self) -> dict[str, Any]:
|
|
44
|
+
return {
|
|
45
|
+
"source": self.source,
|
|
46
|
+
"target": self.target,
|
|
47
|
+
"uid": self.uid,
|
|
48
|
+
"title": self.title,
|
|
49
|
+
"url": self.url,
|
|
50
|
+
"published": self.published.isoformat() if self.published else None,
|
|
51
|
+
"version": self.version,
|
|
52
|
+
"body": self.body,
|
|
53
|
+
"tags": list(self.tags),
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class RunError:
|
|
59
|
+
"""A source failure, collected not raised"""
|
|
60
|
+
|
|
61
|
+
source: str
|
|
62
|
+
target: str
|
|
63
|
+
message: str
|
youpdated/py.typed
ADDED
|
File without changes
|
youpdated/registry.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Source registry
|
|
2
|
+
|
|
3
|
+
Built-in sources register with the decorator; third-parties can ship a ``youpdated.sources`` entry point and appear without touching this.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from .sources.base import Source
|
|
12
|
+
|
|
13
|
+
_SOURCES: dict[str, "Source"] = {}
|
|
14
|
+
_ENTRY_POINTS_LOADED = False
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def register(cls):
|
|
18
|
+
"""Class decorator: instantiate and register a source by ``name``."""
|
|
19
|
+
instance = cls()
|
|
20
|
+
_SOURCES[cls.name] = instance
|
|
21
|
+
return cls
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _load_entry_points() -> None:
|
|
25
|
+
global _ENTRY_POINTS_LOADED
|
|
26
|
+
if _ENTRY_POINTS_LOADED:
|
|
27
|
+
return
|
|
28
|
+
_ENTRY_POINTS_LOADED = True
|
|
29
|
+
from importlib.metadata import entry_points
|
|
30
|
+
|
|
31
|
+
for ep in entry_points(group="youpdated.sources"):
|
|
32
|
+
try:
|
|
33
|
+
obj = ep.load()
|
|
34
|
+
except Exception: # a broken plugin can't stop all
|
|
35
|
+
continue
|
|
36
|
+
instance = obj() if isinstance(obj, type) else obj
|
|
37
|
+
name = getattr(instance, "name", ep.name)
|
|
38
|
+
_SOURCES.setdefault(name, instance)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def all_sources() -> dict[str, "Source"]:
|
|
42
|
+
from . import sources # noqa: F401 (imports register built-ins)
|
|
43
|
+
|
|
44
|
+
_load_entry_points()
|
|
45
|
+
return dict(_SOURCES)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_source(name: str) -> "Source | None":
|
|
49
|
+
return all_sources().get(name)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
|
|
6
|
+
from ..runner import RunResult
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def render(result: RunResult) -> str:
|
|
10
|
+
payload = {
|
|
11
|
+
"generated": datetime.now(timezone.utc).isoformat(),
|
|
12
|
+
"baseline": result.baseline,
|
|
13
|
+
"counts": {
|
|
14
|
+
"targets": len(result.targets),
|
|
15
|
+
"fetched": result.total_fetched,
|
|
16
|
+
"new": len(result.updates),
|
|
17
|
+
"errors": len(result.errors),
|
|
18
|
+
},
|
|
19
|
+
# Resolved display names live on targets, expose them
|
|
20
|
+
"targets": [
|
|
21
|
+
{"source": t.source, "key": t.key, "label": t.display} for t in result.targets
|
|
22
|
+
],
|
|
23
|
+
"updates": [u.to_dict() for u in result.updates],
|
|
24
|
+
"errors": [
|
|
25
|
+
{"source": e.source, "target": e.target, "message": e.message}
|
|
26
|
+
for e in result.errors
|
|
27
|
+
],
|
|
28
|
+
}
|
|
29
|
+
return json.dumps(payload, indent=2, ensure_ascii=False)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Aggregated Atom feed"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from xml.etree import ElementTree as ET
|
|
8
|
+
|
|
9
|
+
from ..runner import RunResult
|
|
10
|
+
|
|
11
|
+
ATOM = "http://www.w3.org/2005/Atom"
|
|
12
|
+
FEED_ID = "urn:youpdated:feed"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _stamp(value: datetime | None) -> str:
|
|
16
|
+
return (value or datetime.now(timezone.utc)).astimezone(timezone.utc).isoformat()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def render(result: RunResult, title: str = "Youpdated") -> str:
|
|
20
|
+
ET.register_namespace("", ATOM)
|
|
21
|
+
feed = ET.Element(f"{{{ATOM}}}feed")
|
|
22
|
+
|
|
23
|
+
ET.SubElement(feed, f"{{{ATOM}}}id").text = FEED_ID
|
|
24
|
+
ET.SubElement(feed, f"{{{ATOM}}}title").text = title
|
|
25
|
+
ET.SubElement(feed, f"{{{ATOM}}}updated").text = _stamp(None)
|
|
26
|
+
ET.SubElement(feed, f"{{{ATOM}}}generator").text = "youpdated"
|
|
27
|
+
ET.SubElement(feed, f"{{{ATOM}}}link", {"rel": "self", "href": FEED_ID})
|
|
28
|
+
|
|
29
|
+
for update in result.updates:
|
|
30
|
+
entry = ET.SubElement(feed, f"{{{ATOM}}}entry")
|
|
31
|
+
digest = hashlib.sha256(
|
|
32
|
+
"|".join(update.dedupe_key).encode("utf-8")
|
|
33
|
+
).hexdigest()[:24]
|
|
34
|
+
ET.SubElement(entry, f"{{{ATOM}}}id").text = f"urn:youpdated:{digest}"
|
|
35
|
+
|
|
36
|
+
label = f"[{update.source}] {update.target}"
|
|
37
|
+
ET.SubElement(entry, f"{{{ATOM}}}title").text = f"{label} — {update.title}"
|
|
38
|
+
ET.SubElement(entry, f"{{{ATOM}}}updated").text = _stamp(update.published)
|
|
39
|
+
if update.published:
|
|
40
|
+
ET.SubElement(entry, f"{{{ATOM}}}published").text = _stamp(update.published)
|
|
41
|
+
if update.url:
|
|
42
|
+
ET.SubElement(entry, f"{{{ATOM}}}link", {"rel": "alternate", "href": update.url})
|
|
43
|
+
|
|
44
|
+
author = ET.SubElement(entry, f"{{{ATOM}}}author")
|
|
45
|
+
ET.SubElement(author, f"{{{ATOM}}}name").text = label
|
|
46
|
+
|
|
47
|
+
for tag in update.tags:
|
|
48
|
+
ET.SubElement(entry, f"{{{ATOM}}}category", {"term": tag})
|
|
49
|
+
|
|
50
|
+
summary = update.body or update.title
|
|
51
|
+
if update.version:
|
|
52
|
+
summary = f"{update.version} — {summary}"
|
|
53
|
+
ET.SubElement(entry, f"{{{ATOM}}}summary", {"type": "text"}).text = summary
|
|
54
|
+
|
|
55
|
+
ET.indent(feed, space=" ")
|
|
56
|
+
return '<?xml version="1.0" encoding="utf-8"?>\n' + ET.tostring(
|
|
57
|
+
feed, encoding="unicode"
|
|
58
|
+
)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""default terminal report"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
|
|
8
|
+
from rich.console import Console, Group
|
|
9
|
+
from rich.panel import Panel
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
|
|
12
|
+
from ..models import Target, Update
|
|
13
|
+
from ..runner import RunResult
|
|
14
|
+
|
|
15
|
+
SOURCE_STYLE = {
|
|
16
|
+
"github": "bright_magenta",
|
|
17
|
+
"npm": "bright_red",
|
|
18
|
+
"steam": "bright_blue",
|
|
19
|
+
"itch": "bright_yellow",
|
|
20
|
+
"youtube": "red",
|
|
21
|
+
"browser": "bright_green",
|
|
22
|
+
"feed": "bright_cyan",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def relative_age(when: datetime | None) -> str:
|
|
27
|
+
if when is None:
|
|
28
|
+
return "undated"
|
|
29
|
+
delta = datetime.now(timezone.utc) - when
|
|
30
|
+
seconds = int(delta.total_seconds())
|
|
31
|
+
if seconds < 0:
|
|
32
|
+
return "just now"
|
|
33
|
+
for limit, div, unit in (
|
|
34
|
+
(90, 1, "s"),
|
|
35
|
+
(5400, 60, "m"),
|
|
36
|
+
(172800, 3600, "h"),
|
|
37
|
+
(1209600, 86400, "d"),
|
|
38
|
+
):
|
|
39
|
+
if seconds < limit:
|
|
40
|
+
return f"{seconds // div}{unit} ago"
|
|
41
|
+
return f"{seconds // 604800}w ago"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _labels(targets: list[Target]) -> dict[tuple[str, str], str]:
|
|
45
|
+
return {(t.source, t.key): t.display for t in targets}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def render(result: RunResult, console: Console | None = None, verbose: bool = False) -> None:
|
|
49
|
+
console = console or Console()
|
|
50
|
+
|
|
51
|
+
if result.baseline:
|
|
52
|
+
console.print(
|
|
53
|
+
Panel(
|
|
54
|
+
Text.from_markup(
|
|
55
|
+
f"Baseline recorded for [bold]{len(result.targets)}[/] target(s), "
|
|
56
|
+
f"[bold]{result.total_fetched}[/] existing item(s).\n"
|
|
57
|
+
"Future runs report only what's new. Use [bold]--all[/] to see "
|
|
58
|
+
"everything now.",
|
|
59
|
+
),
|
|
60
|
+
title="first run",
|
|
61
|
+
border_style="cyan",
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
_render_errors(result, console)
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
if not result.updates:
|
|
68
|
+
console.print(
|
|
69
|
+
Text.from_markup(
|
|
70
|
+
f"[dim]No new updates across {len(result.targets)} target(s) "
|
|
71
|
+
f"({result.total_fetched} item(s) checked).[/]"
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
_render_errors(result, console)
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
labels = _labels(result.targets)
|
|
78
|
+
grouped: dict[str, dict[str, list[Update]]] = defaultdict(lambda: defaultdict(list))
|
|
79
|
+
for update in result.updates:
|
|
80
|
+
grouped[update.source][update.target].append(update)
|
|
81
|
+
|
|
82
|
+
for source in sorted(grouped):
|
|
83
|
+
style = SOURCE_STYLE.get(source, "cyan")
|
|
84
|
+
blocks = []
|
|
85
|
+
for target in sorted(grouped[source]):
|
|
86
|
+
label = labels.get((source, target), target)
|
|
87
|
+
heading = Text(label, style="bold")
|
|
88
|
+
if label != target:
|
|
89
|
+
heading.append(f" ({target})", style="dim")
|
|
90
|
+
blocks.append(heading)
|
|
91
|
+
|
|
92
|
+
for update in grouped[source][target]:
|
|
93
|
+
line = Text(" • ")
|
|
94
|
+
# GitHub releases title with tag
|
|
95
|
+
if update.version and update.version != update.title:
|
|
96
|
+
line.append(f"{update.version} ", style="bold green")
|
|
97
|
+
line.append(update.title)
|
|
98
|
+
elif update.version:
|
|
99
|
+
line.append(update.version, style="bold green")
|
|
100
|
+
else:
|
|
101
|
+
line.append(update.title)
|
|
102
|
+
line.append(f" {relative_age(update.published)}", style="dim")
|
|
103
|
+
blocks.append(line)
|
|
104
|
+
|
|
105
|
+
if update.body and verbose:
|
|
106
|
+
body = update.body.replace("\n", " ")
|
|
107
|
+
blocks.append(Text(f" {body}", style="dim italic"))
|
|
108
|
+
if update.url:
|
|
109
|
+
blocks.append(Text(f" {update.url}", style="dim blue"))
|
|
110
|
+
blocks.append(Text(""))
|
|
111
|
+
|
|
112
|
+
if blocks and not blocks[-1].plain:
|
|
113
|
+
blocks.pop()
|
|
114
|
+
|
|
115
|
+
console.print(
|
|
116
|
+
Panel(
|
|
117
|
+
Group(*blocks),
|
|
118
|
+
title=f"[{style}]{source}[/]",
|
|
119
|
+
title_align="left",
|
|
120
|
+
border_style=style,
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
console.print(
|
|
125
|
+
Text.from_markup(
|
|
126
|
+
f"[bold]{len(result.updates)}[/] new update(s) across "
|
|
127
|
+
f"{len(grouped)} source(s)."
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
_render_errors(result, console)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _render_errors(result: RunResult, console: Console) -> None:
|
|
134
|
+
if not result.errors:
|
|
135
|
+
return
|
|
136
|
+
lines = [
|
|
137
|
+
Text.from_markup(f"[yellow]{e.source}[/] {e.target}: {e.message}")
|
|
138
|
+
for e in result.errors
|
|
139
|
+
]
|
|
140
|
+
console.print(
|
|
141
|
+
Panel(Group(*lines), title="[yellow]problems[/]", title_align="left", border_style="yellow")
|
|
142
|
+
)
|
youpdated/runner.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Turns a config into a set of fetches and updates."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from datetime import datetime, timedelta, timezone
|
|
9
|
+
|
|
10
|
+
from .config import Config
|
|
11
|
+
from .http import Client
|
|
12
|
+
from .models import RunError, Target, Update
|
|
13
|
+
from .registry import all_sources
|
|
14
|
+
from .state import State
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class RunResult:
|
|
19
|
+
updates: list[Update] = field(default_factory=list)
|
|
20
|
+
errors: list[RunError] = field(default_factory=list)
|
|
21
|
+
targets: list[Target] = field(default_factory=list)
|
|
22
|
+
baseline: bool = False
|
|
23
|
+
total_fetched: int = 0
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def ok(self) -> bool:
|
|
27
|
+
return not self.errors
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_DURATION_RE = re.compile(r"^(\d+)\s*([smhdw])$", re.IGNORECASE)
|
|
31
|
+
_UNITS = {"s": "seconds", "m": "minutes", "h": "hours", "d": "days", "w": "weeks"}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def parse_since(value: str) -> timedelta:
|
|
35
|
+
"""Parse a window like ``7d``, ``12h``, ``2w``."""
|
|
36
|
+
match = _DURATION_RE.match(value.strip())
|
|
37
|
+
if not match:
|
|
38
|
+
raise ValueError(f"`{value}` is not a duration like 30m, 12h, 7d, 2w")
|
|
39
|
+
return timedelta(**{_UNITS[match.group(2).lower()]: int(match.group(1))})
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def build_targets(config: Config) -> tuple[list[Target], list[RunError]]:
|
|
43
|
+
available = all_sources()
|
|
44
|
+
targets: list[Target] = []
|
|
45
|
+
errors: list[RunError] = []
|
|
46
|
+
|
|
47
|
+
for name, entries in config.sources.items():
|
|
48
|
+
source = available.get(name)
|
|
49
|
+
if source is None:
|
|
50
|
+
errors.append(
|
|
51
|
+
RunError(
|
|
52
|
+
source=name,
|
|
53
|
+
target="-",
|
|
54
|
+
message=f"unknown source `{name}`; known sources: "
|
|
55
|
+
+ ", ".join(sorted(available)),
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
continue
|
|
59
|
+
try:
|
|
60
|
+
targets.extend(source.targets(entries))
|
|
61
|
+
except Exception as exc:
|
|
62
|
+
errors.append(RunError(source=name, target="-", message=str(exc)))
|
|
63
|
+
|
|
64
|
+
return targets, errors
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def run(
|
|
68
|
+
config: Config,
|
|
69
|
+
state: State,
|
|
70
|
+
client: Client,
|
|
71
|
+
*,
|
|
72
|
+
only_sources: list[str] | None = None,
|
|
73
|
+
show_all: bool = False,
|
|
74
|
+
since: timedelta | None = None,
|
|
75
|
+
save: bool = True,
|
|
76
|
+
) -> RunResult:
|
|
77
|
+
targets, errors = build_targets(config)
|
|
78
|
+
if only_sources:
|
|
79
|
+
wanted = set(only_sources)
|
|
80
|
+
targets = [t for t in targets if t.source in wanted]
|
|
81
|
+
errors = [e for e in errors if e.source in wanted]
|
|
82
|
+
|
|
83
|
+
result = RunResult(errors=errors, targets=targets)
|
|
84
|
+
if not targets:
|
|
85
|
+
return result
|
|
86
|
+
|
|
87
|
+
sources = all_sources()
|
|
88
|
+
fetched: list[Update] = []
|
|
89
|
+
|
|
90
|
+
def work(target: Target) -> tuple[Target, list[Update] | Exception]:
|
|
91
|
+
try:
|
|
92
|
+
return target, list(sources[target.source].fetch(target, client))
|
|
93
|
+
except Exception as exc: # collected, non fatal
|
|
94
|
+
return target, exc
|
|
95
|
+
|
|
96
|
+
workers = max(1, min(config.privacy.concurrency, len(targets)))
|
|
97
|
+
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
98
|
+
for target, outcome in pool.map(work, targets):
|
|
99
|
+
if isinstance(outcome, Exception):
|
|
100
|
+
result.errors.append(
|
|
101
|
+
RunError(
|
|
102
|
+
source=target.source,
|
|
103
|
+
target=target.display,
|
|
104
|
+
message=f"{type(outcome).__name__}: {outcome}",
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
else:
|
|
108
|
+
fetched.extend(outcome)
|
|
109
|
+
|
|
110
|
+
result.total_fetched = len(fetched)
|
|
111
|
+
|
|
112
|
+
# The first run would dump all published. Record baseline instead and report changes
|
|
113
|
+
first_run = state.last_run() is None
|
|
114
|
+
if first_run and not show_all:
|
|
115
|
+
result.baseline = True
|
|
116
|
+
if save:
|
|
117
|
+
state.mark_seen(fetched)
|
|
118
|
+
state.set_last_run()
|
|
119
|
+
return result
|
|
120
|
+
|
|
121
|
+
new = fetched if show_all else state.filter_new(fetched)
|
|
122
|
+
|
|
123
|
+
if since is not None:
|
|
124
|
+
cutoff = datetime.now(timezone.utc) - since
|
|
125
|
+
new = [u for u in new if u.published is None or u.published >= cutoff]
|
|
126
|
+
|
|
127
|
+
new.sort(key=lambda u: u.published or datetime.min.replace(tzinfo=timezone.utc), reverse=True)
|
|
128
|
+
result.updates = new
|
|
129
|
+
|
|
130
|
+
if save:
|
|
131
|
+
state.mark_seen(fetched)
|
|
132
|
+
state.set_last_run()
|
|
133
|
+
|
|
134
|
+
return result
|