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
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""GitHub releases, tags, and commits.
|
|
2
|
+
|
|
3
|
+
``.atom`` endpoints don't need credentials and no rate-limit (default here)
|
|
4
|
+
``GITHUB_TOKEN`` in the environment switches to the REST API, which has better release info
|
|
5
|
+
|
|
6
|
+
COMMONLY RATE LIMITED!
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from typing import Any, ClassVar, Iterable
|
|
15
|
+
|
|
16
|
+
from ..http import Client
|
|
17
|
+
from ..models import Target, Update
|
|
18
|
+
from ..registry import register
|
|
19
|
+
from .base import ConfigEntryError, entry_fields, require
|
|
20
|
+
from .feed import html_to_text, parse_feed
|
|
21
|
+
|
|
22
|
+
REPO_RE = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$")
|
|
23
|
+
VALID_WATCH = ("releases", "tags", "commits")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _normalize_repo(value: Any, source: str) -> str:
|
|
27
|
+
text = str(value).strip().rstrip("/")
|
|
28
|
+
# Accept a full URL as well as owner/repo.
|
|
29
|
+
match = re.search(r"github\.com/([A-Za-z0-9._-]+/[A-Za-z0-9._-]+)", text)
|
|
30
|
+
if match:
|
|
31
|
+
text = match.group(1)
|
|
32
|
+
if not REPO_RE.match(text):
|
|
33
|
+
raise ConfigEntryError(
|
|
34
|
+
f"sources.{source}: `{value}` is not an owner/repo or a github.com URL"
|
|
35
|
+
)
|
|
36
|
+
return text
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@register
|
|
40
|
+
class GitHubSource:
|
|
41
|
+
name: ClassVar[str] = "github"
|
|
42
|
+
summary: ClassVar[str] = "Releases, tags, and commits for a repository"
|
|
43
|
+
|
|
44
|
+
def targets(self, entries: list[Any]) -> list[Target]:
|
|
45
|
+
targets = []
|
|
46
|
+
for entry in entries:
|
|
47
|
+
fields = entry_fields(entry, "repo", self.name)
|
|
48
|
+
repo = _normalize_repo(require(fields, "repo", self.name), self.name)
|
|
49
|
+
|
|
50
|
+
watch = fields.get("watch") or ["releases"]
|
|
51
|
+
if isinstance(watch, str):
|
|
52
|
+
watch = [watch]
|
|
53
|
+
unknown = [w for w in watch if w not in VALID_WATCH]
|
|
54
|
+
if unknown:
|
|
55
|
+
raise ConfigEntryError(
|
|
56
|
+
f"sources.{self.name}: unknown watch {unknown} for {repo}; "
|
|
57
|
+
f"valid options are {list(VALID_WATCH)}"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
targets.append(
|
|
61
|
+
Target(
|
|
62
|
+
source=self.name,
|
|
63
|
+
key=repo,
|
|
64
|
+
label=fields.get("name"),
|
|
65
|
+
params={"watch": list(watch), "branch": fields.get("branch")},
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
return targets
|
|
69
|
+
|
|
70
|
+
def fetch(self, target: Target, client: Client) -> Iterable[Update]:
|
|
71
|
+
watch = target.params.get("watch") or ["releases"]
|
|
72
|
+
token = os.environ.get("GITHUB_TOKEN")
|
|
73
|
+
updates: list[Update] = []
|
|
74
|
+
|
|
75
|
+
for kind in watch:
|
|
76
|
+
if kind == "releases" and token:
|
|
77
|
+
updates.extend(self._releases_via_api(target, client, token))
|
|
78
|
+
elif kind == "releases":
|
|
79
|
+
updates.extend(self._atom(target, client, "releases.atom", "release"))
|
|
80
|
+
elif kind == "tags":
|
|
81
|
+
updates.extend(self._atom(target, client, "tags.atom", "tag"))
|
|
82
|
+
elif kind == "commits":
|
|
83
|
+
branch = target.params.get("branch")
|
|
84
|
+
path = f"commits/{branch}.atom" if branch else "commits.atom"
|
|
85
|
+
updates.extend(self._atom(target, client, path, "commit"))
|
|
86
|
+
|
|
87
|
+
return updates
|
|
88
|
+
|
|
89
|
+
# anon path
|
|
90
|
+
|
|
91
|
+
def _atom(self, target: Target, client: Client, path: str, tag: str) -> list[Update]:
|
|
92
|
+
url = f"https://github.com/{target.key}/{path}"
|
|
93
|
+
fetched = client.get(url, conditional=True)
|
|
94
|
+
if fetched is None: # 304 Not Modified, or test run
|
|
95
|
+
return []
|
|
96
|
+
return parse_feed(
|
|
97
|
+
fetched.content,
|
|
98
|
+
source=self.name,
|
|
99
|
+
target=target.key,
|
|
100
|
+
version_of=_version_from_entry if tag != "commit" else None,
|
|
101
|
+
tags=(tag,),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# authed path
|
|
105
|
+
|
|
106
|
+
def _releases_via_api(self, target: Target, client: Client, token: str) -> list[Update]:
|
|
107
|
+
url = f"https://api.github.com/repos/{target.key}/releases?per_page=20"
|
|
108
|
+
fetched = client.get(
|
|
109
|
+
url,
|
|
110
|
+
conditional=True,
|
|
111
|
+
headers={
|
|
112
|
+
"Authorization": f"Bearer {token}",
|
|
113
|
+
"Accept": "application/vnd.github+json",
|
|
114
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
115
|
+
},
|
|
116
|
+
)
|
|
117
|
+
if fetched is None:
|
|
118
|
+
return []
|
|
119
|
+
|
|
120
|
+
updates = []
|
|
121
|
+
for release in fetched.json():
|
|
122
|
+
if release.get("draft"):
|
|
123
|
+
continue
|
|
124
|
+
published = release.get("published_at") or release.get("created_at")
|
|
125
|
+
updates.append(
|
|
126
|
+
Update(
|
|
127
|
+
source=self.name,
|
|
128
|
+
target=target.key,
|
|
129
|
+
uid=f"release:{release['id']}",
|
|
130
|
+
title=(release.get("name") or release.get("tag_name") or "").strip()
|
|
131
|
+
or "(untitled)",
|
|
132
|
+
url=release.get("html_url", ""),
|
|
133
|
+
published=_parse_iso(published),
|
|
134
|
+
version=release.get("tag_name"),
|
|
135
|
+
body=html_to_text(release.get("body")),
|
|
136
|
+
tags=("release",) + (("prerelease",) if release.get("prerelease") else ()),
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
return updates
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _version_from_entry(entry: Any) -> str | None:
|
|
143
|
+
"""Recover the tag from an atom entry id like ``tag:github.com,...:Repository/1/v3.13.0``."""
|
|
144
|
+
for candidate in (entry.get("id", ""), entry.get("link", "")):
|
|
145
|
+
match = re.search(r"[/:]([^/:]*\d[^/:]*)$", str(candidate))
|
|
146
|
+
if match:
|
|
147
|
+
return match.group(1)
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _parse_iso(value: str | None) -> datetime | None:
|
|
152
|
+
if not value:
|
|
153
|
+
return None
|
|
154
|
+
try:
|
|
155
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
156
|
+
except ValueError:
|
|
157
|
+
return None
|
|
158
|
+
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""itch.io devlogs and builds.
|
|
2
|
+
|
|
3
|
+
Two watched per game:
|
|
4
|
+
- devlogs (``<game-url>/devlog.rss``): itch answers 404 when a game doesn't have one, which is normal and not an error
|
|
5
|
+
- releases: many games ship new builds without a devlog, so 404 above moves here. The game page has "Updated" timestamps
|
|
6
|
+
and the list of downloadable files; they fingerprint the current build, and a change means a new release.
|
|
7
|
+
|
|
8
|
+
Both are on by default. ``watch: [devlog]`` or ``watch: [releases]`` to choose
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import re
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from typing import Any, ClassVar, Iterable
|
|
17
|
+
|
|
18
|
+
from ..http import Client
|
|
19
|
+
from ..models import Target, Update
|
|
20
|
+
from ..registry import register
|
|
21
|
+
from .base import ConfigEntryError, entry_fields, require
|
|
22
|
+
from .feed import parse_feed
|
|
23
|
+
|
|
24
|
+
GAME_URL_RE = re.compile(
|
|
25
|
+
r"^(?:https?://)?(?P<user>[A-Za-z0-9-]+)\.itch\.io/(?P<game>[A-Za-z0-9._-]+)/?$"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# info panel: <tr><td>Updated</td><td><abbr title="17 August 2026 @ 10:34 UTC">…
|
|
29
|
+
INFO_ROW_RE = re.compile(r"<td>([^<]{1,30})</td><td>(.{0,400}?)</td>", re.S)
|
|
30
|
+
ABBR_TITLE_RE = re.compile(r'<abbr title="([^"]+)"')
|
|
31
|
+
UPLOAD_NAME_RE = re.compile(r'<strong title="([^"]*)" class="name">')
|
|
32
|
+
UPLOAD_SIZE_RE = re.compile(r'class="file_size"><span>([^<]*)</span>')
|
|
33
|
+
ITCH_DATE_RE = re.compile(
|
|
34
|
+
r"(\d{1,2})\s+([A-Za-z]+)\s+(\d{4})(?:\s*@\s*(\d{1,2}):(\d{2}))?", re.I
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
MONTHS = {
|
|
38
|
+
m.lower(): i
|
|
39
|
+
for i, m in enumerate(
|
|
40
|
+
[
|
|
41
|
+
"January", "February", "March", "April", "May", "June",
|
|
42
|
+
"July", "August", "September", "October", "November", "December",
|
|
43
|
+
],
|
|
44
|
+
start=1,
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
VALID_WATCH = ("devlog", "releases")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@register
|
|
52
|
+
class ItchSource:
|
|
53
|
+
name: ClassVar[str] = "itch"
|
|
54
|
+
summary: ClassVar[str] = "Devlog posts and new builds for an itch.io game"
|
|
55
|
+
|
|
56
|
+
def targets(self, entries: list[Any]) -> list[Target]:
|
|
57
|
+
targets = []
|
|
58
|
+
for entry in entries:
|
|
59
|
+
fields = entry_fields(entry, "url", self.name)
|
|
60
|
+
|
|
61
|
+
if fields.get("user") and fields.get("game"):
|
|
62
|
+
user, game = str(fields["user"]).strip(), str(fields["game"]).strip()
|
|
63
|
+
else:
|
|
64
|
+
raw = str(require(fields, "url", self.name)).strip()
|
|
65
|
+
match = GAME_URL_RE.match(raw)
|
|
66
|
+
if not match:
|
|
67
|
+
raise ConfigEntryError(
|
|
68
|
+
f"sources.{self.name}: `{raw}` is not an itch.io game URL "
|
|
69
|
+
"(expected https://user.itch.io/game)"
|
|
70
|
+
)
|
|
71
|
+
user, game = match.group("user"), match.group("game")
|
|
72
|
+
|
|
73
|
+
watch = fields.get("watch") or list(VALID_WATCH)
|
|
74
|
+
if isinstance(watch, str):
|
|
75
|
+
watch = [watch]
|
|
76
|
+
unknown = [w for w in watch if w not in VALID_WATCH]
|
|
77
|
+
if unknown:
|
|
78
|
+
raise ConfigEntryError(
|
|
79
|
+
f"sources.{self.name}: unknown watch {unknown} for {user}/{game}; "
|
|
80
|
+
f"valid options are {list(VALID_WATCH)}"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
targets.append(
|
|
84
|
+
Target(
|
|
85
|
+
source=self.name,
|
|
86
|
+
key=f"{user}/{game}",
|
|
87
|
+
label=fields.get("name") or game.replace("-", " "),
|
|
88
|
+
params={
|
|
89
|
+
"page": f"https://{user}.itch.io/{game}",
|
|
90
|
+
"watch": list(watch),
|
|
91
|
+
},
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
return targets
|
|
95
|
+
|
|
96
|
+
def fetch(self, target: Target, client: Client) -> Iterable[Update]:
|
|
97
|
+
watch = target.params.get("watch") or list(VALID_WATCH)
|
|
98
|
+
updates: list[Update] = []
|
|
99
|
+
if "devlog" in watch:
|
|
100
|
+
updates.extend(self._devlog(target, client))
|
|
101
|
+
if "releases" in watch:
|
|
102
|
+
updates.extend(self._release(target, client))
|
|
103
|
+
return updates
|
|
104
|
+
|
|
105
|
+
# devlog feed
|
|
106
|
+
|
|
107
|
+
def _devlog(self, target: Target, client: Client) -> list[Update]:
|
|
108
|
+
url = f"{target.params['page']}/devlog.rss"
|
|
109
|
+
fetched = client.get(url, conditional=True, soft_statuses=(404,))
|
|
110
|
+
if fetched is None:
|
|
111
|
+
return []
|
|
112
|
+
if fetched.status == 404:
|
|
113
|
+
# No devlog has ever been posted for this game
|
|
114
|
+
return []
|
|
115
|
+
|
|
116
|
+
return parse_feed(
|
|
117
|
+
fetched.content,
|
|
118
|
+
source=self.name,
|
|
119
|
+
target=target.key,
|
|
120
|
+
tags=("devlog",),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# build fingerprint from the game page
|
|
124
|
+
|
|
125
|
+
def _release(self, target: Target, client: Client) -> list[Update]:
|
|
126
|
+
page = target.params["page"]
|
|
127
|
+
fetched = client.get(page, conditional=True, soft_statuses=(404,))
|
|
128
|
+
if fetched is None or fetched.status == 404:
|
|
129
|
+
return []
|
|
130
|
+
|
|
131
|
+
html = fetched.text
|
|
132
|
+
info = self._info_rows(html)
|
|
133
|
+
uploads = self._uploads(html)
|
|
134
|
+
updated = _parse_itch_date(info.get("Updated") or info.get("Published"))
|
|
135
|
+
version = info.get("Version")
|
|
136
|
+
|
|
137
|
+
# Nothing observable on page
|
|
138
|
+
if not uploads and updated is None and not version:
|
|
139
|
+
return []
|
|
140
|
+
|
|
141
|
+
# Any change to the build produces a different uid
|
|
142
|
+
fingerprint = "|".join(
|
|
143
|
+
[
|
|
144
|
+
updated.isoformat() if updated else "",
|
|
145
|
+
version or "",
|
|
146
|
+
*(f"{name}:{size}" for name, size in uploads),
|
|
147
|
+
]
|
|
148
|
+
)
|
|
149
|
+
digest = hashlib.sha256(fingerprint.encode("utf-8")).hexdigest()[:16]
|
|
150
|
+
|
|
151
|
+
if version:
|
|
152
|
+
title = f"New build — {version}"
|
|
153
|
+
elif uploads:
|
|
154
|
+
title = f"New build — {len(uploads)} file(s)"
|
|
155
|
+
else:
|
|
156
|
+
title = "Game page updated"
|
|
157
|
+
|
|
158
|
+
body = ", ".join(f"{name} ({size})" for name, size in uploads) or None
|
|
159
|
+
|
|
160
|
+
return [
|
|
161
|
+
Update(
|
|
162
|
+
source=self.name,
|
|
163
|
+
target=target.key,
|
|
164
|
+
uid=f"build:{digest}",
|
|
165
|
+
title=title,
|
|
166
|
+
url=page,
|
|
167
|
+
published=updated,
|
|
168
|
+
version=version,
|
|
169
|
+
body=body,
|
|
170
|
+
tags=("release",),
|
|
171
|
+
)
|
|
172
|
+
]
|
|
173
|
+
|
|
174
|
+
def _info_rows(self, html: str) -> dict[str, str]:
|
|
175
|
+
"""Pull "More information" table into a plain dict.
|
|
176
|
+
|
|
177
|
+
Values keep their timestamp when supplied. (visible text is relative ("20 hours ago") fyi)
|
|
178
|
+
"""
|
|
179
|
+
rows: dict[str, str] = {}
|
|
180
|
+
for key, cell in INFO_ROW_RE.findall(html):
|
|
181
|
+
key = key.strip()
|
|
182
|
+
if key in rows:
|
|
183
|
+
continue
|
|
184
|
+
abbr = ABBR_TITLE_RE.search(cell)
|
|
185
|
+
value = abbr.group(1) if abbr else re.sub(r"<[^>]+>", "", cell).strip()
|
|
186
|
+
if value:
|
|
187
|
+
rows[key] = value
|
|
188
|
+
return rows
|
|
189
|
+
|
|
190
|
+
def _uploads(self, html: str) -> list[tuple[str, str]]:
|
|
191
|
+
names = UPLOAD_NAME_RE.findall(html)
|
|
192
|
+
sizes = UPLOAD_SIZE_RE.findall(html)
|
|
193
|
+
# Sizes can be missing for some entries
|
|
194
|
+
return [(n, sizes[i] if i < len(sizes) else "") for i, n in enumerate(names)]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _parse_itch_date(value: str | None) -> datetime | None:
|
|
198
|
+
"""Parse itch's "17 August 2026 @ 10:34 UTC" (time optional)."""
|
|
199
|
+
if not value:
|
|
200
|
+
return None
|
|
201
|
+
match = ITCH_DATE_RE.search(value)
|
|
202
|
+
if not match:
|
|
203
|
+
return None
|
|
204
|
+
day, month_name, year, hour, minute = match.groups()
|
|
205
|
+
month = MONTHS.get(month_name.lower())
|
|
206
|
+
if month is None:
|
|
207
|
+
return None
|
|
208
|
+
return datetime(
|
|
209
|
+
int(year), month, int(day), int(hour or 0), int(minute or 0), tzinfo=timezone.utc
|
|
210
|
+
)
|
youpdated/sources/npm.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""npm package releases from the public registry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Any, ClassVar, Iterable
|
|
7
|
+
from urllib.parse import quote
|
|
8
|
+
|
|
9
|
+
from ..http import Client
|
|
10
|
+
from ..models import Target, Update
|
|
11
|
+
from ..registry import register
|
|
12
|
+
from .base import ConfigEntryError, entry_fields, require
|
|
13
|
+
|
|
14
|
+
# keys alongside real versions in the registry `time` map
|
|
15
|
+
_NON_VERSION_KEYS = {"created", "modified"}
|
|
16
|
+
MAX_VERSIONS = 10
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@register
|
|
20
|
+
class NpmSource:
|
|
21
|
+
name: ClassVar[str] = "npm"
|
|
22
|
+
summary: ClassVar[str] = "New published versions of a package"
|
|
23
|
+
|
|
24
|
+
def targets(self, entries: list[Any]) -> list[Target]:
|
|
25
|
+
targets = []
|
|
26
|
+
for entry in entries:
|
|
27
|
+
fields = entry_fields(entry, "package", self.name)
|
|
28
|
+
package = str(require(fields, "package", self.name)).strip()
|
|
29
|
+
if not package or package.startswith("."):
|
|
30
|
+
raise ConfigEntryError(f"sources.{self.name}: `{package}` is not a package name")
|
|
31
|
+
targets.append(
|
|
32
|
+
Target(
|
|
33
|
+
source=self.name,
|
|
34
|
+
key=package,
|
|
35
|
+
label=fields.get("name"),
|
|
36
|
+
params={"tag": fields.get("tag")},
|
|
37
|
+
)
|
|
38
|
+
)
|
|
39
|
+
return targets
|
|
40
|
+
|
|
41
|
+
def fetch(self, target: Target, client: Client) -> Iterable[Update]:
|
|
42
|
+
# name keeps @ but slash escaped
|
|
43
|
+
url = f"https://registry.npmjs.org/{quote(target.key, safe='@')}"
|
|
44
|
+
fetched = client.get(url, conditional=True)
|
|
45
|
+
if fetched is None:
|
|
46
|
+
return []
|
|
47
|
+
|
|
48
|
+
doc = fetched.json()
|
|
49
|
+
latest = (doc.get("dist-tags") or {}).get("latest")
|
|
50
|
+
only_tag = target.params.get("tag")
|
|
51
|
+
if only_tag:
|
|
52
|
+
wanted = (doc.get("dist-tags") or {}).get(only_tag)
|
|
53
|
+
versions = [wanted] if wanted else []
|
|
54
|
+
else:
|
|
55
|
+
versions = [v for v in (doc.get("time") or {}) if v not in _NON_VERSION_KEYS]
|
|
56
|
+
|
|
57
|
+
times = doc.get("time") or {}
|
|
58
|
+
dated = [(v, _parse_iso(times.get(v))) for v in versions if v]
|
|
59
|
+
dated.sort(key=lambda pair: pair[1] or datetime.min.replace(tzinfo=timezone.utc), reverse=True)
|
|
60
|
+
|
|
61
|
+
updates = []
|
|
62
|
+
for version, published in dated[:MAX_VERSIONS]:
|
|
63
|
+
meta = (doc.get("versions") or {}).get(version) or {}
|
|
64
|
+
tags = ("release",) if version == latest else ()
|
|
65
|
+
updates.append(
|
|
66
|
+
Update(
|
|
67
|
+
source=self.name,
|
|
68
|
+
target=target.key,
|
|
69
|
+
uid=f"version:{version}",
|
|
70
|
+
title=f"{target.key} {version}",
|
|
71
|
+
url=f"https://www.npmjs.com/package/{target.key}/v/{version}",
|
|
72
|
+
published=published,
|
|
73
|
+
version=version,
|
|
74
|
+
body=meta.get("description") or doc.get("description"),
|
|
75
|
+
tags=tags + (("latest",) if version == latest else ()),
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
return updates
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _parse_iso(value: str | None) -> datetime | None:
|
|
82
|
+
if not value:
|
|
83
|
+
return None
|
|
84
|
+
try:
|
|
85
|
+
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
|
86
|
+
except ValueError:
|
|
87
|
+
return None
|
|
88
|
+
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Steam patch notes and news for a game.
|
|
2
|
+
|
|
3
|
+
Config can be an appid; the store name is resolved and cached locally so reports stay readable
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from typing import Any, ClassVar, Iterable
|
|
10
|
+
|
|
11
|
+
from ..http import Client, FetchError
|
|
12
|
+
from ..models import Target, Update
|
|
13
|
+
from ..registry import register
|
|
14
|
+
from .base import ConfigEntryError, entry_fields, require
|
|
15
|
+
from .feed import parse_feed
|
|
16
|
+
|
|
17
|
+
NAME_CACHE = "steam_app_names"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@register
|
|
21
|
+
class SteamSource:
|
|
22
|
+
name: ClassVar[str] = "steam"
|
|
23
|
+
summary: ClassVar[str] = "Patch notes and news for Steam games"
|
|
24
|
+
|
|
25
|
+
def targets(self, entries: list[Any]) -> list[Target]:
|
|
26
|
+
targets = []
|
|
27
|
+
for entry in entries:
|
|
28
|
+
fields = entry_fields(entry, "appid", self.name)
|
|
29
|
+
raw = str(require(fields, "appid", self.name)).strip()
|
|
30
|
+
# Accept store URL and id
|
|
31
|
+
match = re.search(r"(?:app/)?(\d+)", raw)
|
|
32
|
+
if not match:
|
|
33
|
+
raise ConfigEntryError(
|
|
34
|
+
f"sources.{self.name}: `{raw}` is not an appid or a store URL"
|
|
35
|
+
)
|
|
36
|
+
appid = match.group(1)
|
|
37
|
+
targets.append(
|
|
38
|
+
Target(
|
|
39
|
+
source=self.name,
|
|
40
|
+
key=appid,
|
|
41
|
+
label=fields.get("name"),
|
|
42
|
+
params={},
|
|
43
|
+
)
|
|
44
|
+
)
|
|
45
|
+
return targets
|
|
46
|
+
|
|
47
|
+
def fetch(self, target: Target, client: Client) -> Iterable[Update]:
|
|
48
|
+
if not target.label:
|
|
49
|
+
target.label = self._app_name(target.key, client)
|
|
50
|
+
|
|
51
|
+
url = (
|
|
52
|
+
f"https://store.steampowered.com/feeds/news/app/{target.key}/"
|
|
53
|
+
"?cc=US&l=english"
|
|
54
|
+
)
|
|
55
|
+
fetched = client.get(url, conditional=True)
|
|
56
|
+
if fetched is None:
|
|
57
|
+
return []
|
|
58
|
+
|
|
59
|
+
return parse_feed(
|
|
60
|
+
fetched.content,
|
|
61
|
+
source=self.name,
|
|
62
|
+
target=target.key,
|
|
63
|
+
tags=("news",),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def _app_name(self, appid: str, client: Client) -> str | None:
|
|
67
|
+
"""Resolve store name once then cache"""
|
|
68
|
+
state = client.state
|
|
69
|
+
if state is not None:
|
|
70
|
+
cached = state.cache_get(NAME_CACHE, appid)
|
|
71
|
+
if cached:
|
|
72
|
+
return cached
|
|
73
|
+
try:
|
|
74
|
+
fetched = client.get(
|
|
75
|
+
f"https://store.steampowered.com/api/appdetails?appids={appid}&l=english"
|
|
76
|
+
)
|
|
77
|
+
except FetchError:
|
|
78
|
+
return None
|
|
79
|
+
if fetched is None:
|
|
80
|
+
return None
|
|
81
|
+
try:
|
|
82
|
+
payload = fetched.json().get(appid) or {}
|
|
83
|
+
name = (payload.get("data") or {}).get("name")
|
|
84
|
+
except (ValueError, AttributeError):
|
|
85
|
+
return None
|
|
86
|
+
if name and state is not None:
|
|
87
|
+
state.cache_set(NAME_CACHE, appid, name)
|
|
88
|
+
return name
|