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/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,5 @@
1
+ """Output renderers"""
2
+
3
+ from . import json_out, rss_out, terminal # noqa: F401
4
+
5
+ __all__ = ["json_out", "rss_out", "terminal"]
@@ -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