mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-09-28 08:16:03 +00:00
eb9bd794c273baffc6f4d51ff4fb3df22cfc4c2c
2514
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
eb9bd794c2 | UI - Watchlist - Better alignment and icons | ||
|
|
73da75a52a | UI - Make the 'share watch' optional, reduces visual noise, not really used by non-power users | ||
|
|
706dcb646c | UI - Diff page - tabs should be consistent size with rest of app | ||
|
|
f2e7b88cd3 | UI - Watch list - Tidy up padding between cells | ||
|
|
77fb923de6 |
fix(http): send a single Date header on werkzeug built-in server (#4372)
* fix(http): send a single Date header on werkzeug built-in server Static resources served via werkzeug send_from_directory/send_file get a Date header injected into the WSGI response by make_conditional() (werkzeug/wrappers/response.py:752-757). When the app runs on Werkzeug's built-in server -- the default path started through socketio.run(..., allow_unsafe_werkzeug=True) in changedetectionio/__init__.py:694 and used by the docker entrypoint -- BaseHTTPRequestHandler.send_response() (werkzeug/serving.py:271) emits its own Date header line as well, so the wire response carries two Date headers. RFC 9110 forbids this and nginx rejects the response with "upstream sent duplicate header line" (issue #4299, see also #4101). Fix: a global after_request hook pops the application-side Date copy so only the server's single header reaches the wire. Verified safe on gunicorn too, which also emits its own Date header. Test: new tests/test_duplicate_date_header.py hits the live_server over real HTTP with http.client (the Flask test client talks to the WSGI app directly and never sees the server-added header) and asserts the Date header appears exactly once, on the exact static resources named in the issue. Fails on unfixed code with two identical Date lines; passes with the fix. Fixes #4299 * Apply suggestion from @dgtlmoon * Tidy the #4299 Date header fix and its test flask_app.py: the applied suggestion landed with a 3-space indent and trailing whitespace - the latter was the only W291 in the file, which .ruff.toml selects. test_duplicate_date_header.py: - drop the 10s socket wait loop, pytest-flask's live_server already blocks until the port accepts connections - drop the unused `app` fixture argument (live_server depends on it) - stop hardcoding jquery-3.6.0.min.js: asserting 200 on a vendored filename turns a jQuery bump into a failure in a file about HTTP headers. Any send_from_directory() response exercises the same path, so styles.css alone is enough. Still red before the fix (two identical Date lines) and green after. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: dgtlmoon <leigh@morresi.net> Co-authored-by: dgtlmoon <dgtlmoon@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3d51a04a19 | "Add watch" UI tweaks (#4446) | ||
|
|
5af6bba8c5 |
UI - Giving checkbox operations way more contrast
Build and push containers / metadata (push) Canceled after 0s
Build and push containers / build-push-containers (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Canceled after 0s
ChangeDetection.io Container Build Test / Build linux/amd64 (alpine) (push) Canceled after 0s
ChangeDetection.io Container Build Test / Build linux/arm64 (alpine) (push) Canceled after 0s
ChangeDetection.io Container Build Test / Build linux/amd64 (main) (push) Canceled after 0s
ChangeDetection.io Container Build Test / Build linux/arm/v7 (main) (push) Canceled after 0s
ChangeDetection.io Container Build Test / Build linux/arm/v8 (main) (push) Canceled after 0s
ChangeDetection.io Container Build Test / Build linux/arm64 (main) (push) Canceled after 0s
ChangeDetection.io App Test / lint-code (push) Canceled after 0s
ChangeDetection.io App Test / lint-translations (push) Canceled after 0s
ChangeDetection.io App Test / lint-template-i18n (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-11 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-12 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-13 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-14 (push) Canceled after 0s
|
||
|
|
3c37de6bb7 | General licence cleanups (#4445) | ||
|
|
a5f85da4af |
UI - Keep history diff title and controls visible while scrolling (#4412)
* Keep history diff title and controls visible while scrolling The watch label, version selectors, diff options and tabs move into a sticky #diff-header, and the top menu above it (watch URL, EDIT, theme and GitHub links) becomes sticky too, so the whole header stays on screen while the diff scrolls. The minimap and anchor jumps offset by the measured height of both bars instead of a fixed value. Heights are measured on load and kept current with a ResizeObserver where it is available, falling back to the window resize and hashchange events as the restock graph and queue sparkline already do, plus one deferred measure at load - the tab switch hides #settings and so resizes the header without a window resize, and opening a link already at #screenshot does the same with no hashchange to follow. The app header is stored before the diff header is measured, since the latter's cap is derived from the former. A sticky bar must be opaque to the content scrolling beneath it, but the page's own backdrop is a fixed full-viewport gradient layer over the page colour, so a flat --color-background panel reads as a white band cutting across it. Instead each bar repaints that same backdrop via a shared page-surface-gradient mixin: the three gradient stops pre-composited against --color-background-page with color-mix() at the layer's 0.91 opacity, with background-attachment: fixed so they stay registered with body::after. Children of #diff-header are centred with a flex column, mirroring the align-items: center that section.content applies to the non-sticky siblings, so the controls panel and tabs keep their intrinsic width and standard colours. The top menu is made sticky only under body.difference-page. Upstream already wants this globally (see the @todo in parts/_top_menu.scss) but held off because the bar has no background of its own; scoping it here keeps that decision separate. Sticky makes .header a stacking context, so its mobile drawer is capped at the bar's z-index of 30 - high enough to cover #diff-header, low enough that the action rail's hover flyout and toast notifications still paint over the bar as before. The activity strip is fixed on body in the root stacking context, so it would have painted through the capped drawer; on this page it drops just below the bar rather than raising the bar past the rail. Dropping the 40px section.content padding and tightening the title's own padding closes the gap between the two bars, and #diff-header's half-viewport cap now excludes the top menu so the sticky stack stays within the same budget on short viewports. .app-main's 0.55rem gap goes too on this page: it sits between the two sticky bars, so the diff header would otherwise start 8.8px below the top menu and slide up to meet it over the first 8.8px of scroll. That gap cannot survive sticking - it is outside both bars, so the diff would scroll through it - and the bars are flush at every scroll position instead. Co-authored-by: Engineer <engineer@agents.matrixsi.com> Co-authored-by: Jeff Hedlund <jhedlund@gmail.com> Signed-off-by: Jeff Hedlund <jhedlund@gmail.com> * Condense the history diff header into a compact toolbar The sticky bar the previous commit introduced takes 297px of a 1000px desktop viewport and 422px of a 390x844 phone - at that width its own 50dvh cap is already clipping it, so the shipped page scrolls a header inside itself before the diff has moved at all. Compact it to three rows and that becomes 154px and 228px, without hiding any control. The watch title takes the full width of the bar on its own row above the controls, centred, 0.9rem, one line with an ellipsis. It truncates only when it is wider than the whole viewport - a 184-character title shows in full at 1440px and ellipsizes at 768 and 390 - and the bar keeps exactly the same height either way, which a two-line clamp would not. The full text goes in the title attribute so hover reveals what the ellipsis hides, the same bargain the watch URL above it already makes. The From/To labels shrink to chips rather than hiding. display: none takes a <label> out of the accessibility tree: verified through Chromium's accessibility tree, the selects report "From" and "To" with the chips and "" without them. Sighted users need them too, since two identically formatted datetime selects side by side have only their order to tell them apart, and on a phone they stack so even that stops helping. The chips cost horizontal space and no height at all. Dropping the shipped width: 4rem sizes them to their text, and "From" is wider than "To", so a min-width floor keeps the stacked selects flush. The seven diff options move behind a Filters button. The fieldset stays inline, inside the form, until diff-overview.js swaps it for the popover, so with scripting off the options are exactly as reachable as they are today. The panel is position: fixed, not absolute: #diff-header carries overflow: auto to enforce its 50dvh cap, which clips an absolutely positioned descendant at the bar's bottom edge. The bar is sticky with no transform, filter or contain, so it is not a containing block for fixed and the panel escapes the clip; the script parks it under the button and keeps it inside the viewport, with aria-expanded, Escape, outside-click and a reposition on resize. The keyboard-nav prompt and the two button words are hidden, leaving the arrows; both links carry the full wording as aria-label and title so the accessible name survives the CSS. The tabs' vertical padding halves on this page, 11px off every scroll position, leaving their horizontal padding and so their widths alone. Narrow viewports need one more thing, and font-size alone cannot give it: once the chip takes inline width the select clips its own displayed value, and two snapshots from the same day differ only by the meridiem, so they read identically while closed. #settings, #diff-form, .diff-fieldset and the span are all sized to max-content, which is the select's text, so the container shrinks by exactly as much as the text does - headroom measured 0.0px at every size from 0.9rem to 0.7rem. The chain is pinned to the row first and only then does 0.75rem buy room: +19.3px on the demo pair and +6.3px against the longest realistic English value, which still clips at 0.8rem. All of it hangs off the one 700px breakpoint, and every selector in it carries an ID because the rules it overrides are written with one and a class-only override would lose silently. Co-authored-by: Architect <architect@agents.matrixsi.com> Co-authored-by: Jeff Hedlund <jhedlund@gmail.com> Signed-off-by: Jeff Hedlund <jhedlund@gmail.com> * Rebuild translation catalog for the new diff header strings The compact header adds three msgids - Filters, Previous version and Next version - and the lint-translations job re-runs extract_messages, update_catalog and compile_catalog and fails on any resulting diff, so the catalog has to travel with the strings that created it. It failed on |
||
|
|
b61b83c35d |
Fetch favicon candidates in parallel under one deadline, and stop the two ways it could hang (#4435)
Build and push containers / metadata (push) Canceled after 0s
Build and push containers / build-push-containers (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Canceled after 0s
ChangeDetection.io App Test / lint-code (push) Canceled after 0s
ChangeDetection.io App Test / lint-translations (push) Canceled after 0s
ChangeDetection.io App Test / lint-template-i18n (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-11 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-12 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-13 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-14 (push) Canceled after 0s
Each candidate icon got its own fresh 2s AbortController, so the cost was n x 2s: a site declaring five <link rel="icon"> variants spent 10s+ in here, sequentially, inside the page - holding a browser, a worker and a proxy connection the whole time - to fetch decoration. Capping the total is not enough, and is a trap. Giving up early returns no icon, so nothing is saved, favicon_is_expired() stays true, and the same cost is paid again on the very next check - forever. Measured against a page with five hanging icons, one 404 and one good one: sequential, 2s each : 10.0s, found the icon total cap only : 3.0s, found NOTHING (and repeats every check) parallel + deadline : 3.0s, found the icon So: fetch them all concurrently under one shared 3s AbortController and take the first success in preference order - the array is already sorted largest-first then apple-touch-icon, so this still returns the preferred icon rather than merely the quickest to answer. Two hangs fixed while in here: - clearTimeout() fired before `await resp.blob()`, leaving the body read unguarded. The shared signal now covers it (aborting a signal errors the body stream too), so a slow or never-ending body is bounded like the headers. - the FileReader promise had no reject path and resolved only from onloadend, reading reader.result unguarded. A FileReader failure threw inside the callback and left the promise permanently pending, with the timer already cleared - the favicon fetch then hung forever with nothing to stop it. It now always resolves. Also skips an oversized icon from Content-Length before pulling its body down the wire, where the server declares it. Unchanged: the candidate collection and sort, the data: URI shortcut, the 1MB limit that matches bump_favicon(). Verified no regression on the ordinary paths - a page with one working icon still returns it in 0.0s, and a page with no <link> still falls back to /favicon.ico. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
04b64fa15b |
0.60.6
Build and push containers / metadata (push) Canceled after 0s
Build and push containers / build-push-containers (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Canceled after 0s
ChangeDetection.io App Test / lint-code (push) Canceled after 0s
ChangeDetection.io App Test / lint-translations (push) Canceled after 0s
ChangeDetection.io App Test / lint-template-i18n (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-11 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-12 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-13 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-14 (push) Canceled after 0s
|
||
|
|
7102544310 |
xPath filters - Stop LC_COLLATE deciding what contains() means (#4437) (#4438)
Every watch whose xPath filter used contains() started reporting "Warning, no filters were
found" on pages whose HTML plainly contained the target. 18 unrelated watches broke in the same
hour, browser and plain-requests fetchers alike, and the saved snapshot was perfect every time.
elementpath implements the XPath string functions on top of locale.strxfrm:
def contains(self, a, b): return self.strxfrm(b) in self.strxfrm(a)
Under LC_COLLATE=C, strxfrm() is the identity function and that is an ordinary substring test.
Under a real locale it returns a binary collation key, and a substring of a collation key is not
the collation key of the substring - so contains(), starts-with(), ends-with() and
substring-before/after() return false for EVERY input. Reduced to one line, no document needed:
LC_COLLATE=C contains("xx month xx", "month") -> True
LC_COLLATE=en_US.UTF-8 contains("xx month xx", "month") -> False
Name tests, axes and '=' are untouched, which is exactly why it read as "did the page change
layout?" - //div kept working while //div[contains(.,"month")] returned nothing.
No code change caused this. Generating the image's locales (#4429) made ENV LC_ALL=en_US.UTF-8
satisfiable for the first time; flask_app's setlocale(LC_ALL, ...) had been raising locale.Error
and leaving us in C, and once it succeeded it took LC_COLLATE with it. That is why the bug
survived a bisect to 0.60.2 and why reinstalling the exact pip set from a working install did not
shift it - it could only be found by diffing the two containers. Same image base, same Python
3.11.16, lxml 6.1.3, libxml2 2.14.6, elementpath 5.1.1, same HTML:
good-old 0.60.4 setlocale FAILED: unsupported locale setting 67 matches
bad-new 0.60.5 setlocale en_US.UTF-8 0 matches
Fixed in both places, because either alone leaves a hole:
- flask_app sets LC_CTYPE/LC_NUMERIC/LC_MONETARY/LC_TIME individually instead of LC_ALL. This
block exists to make prices render correctly and it still does - 1234567 is still "1,234,567"
- it just no longer touches collation.
- html_tools.xpath_filter() pins the Unicode codepoint collation per evaluation, so a filter
means the same thing whatever an operator puts in LANG/LC_ALL, and does not depend on a
distant module's locale bookkeeping. forms.py's XPath validation pins it too, so validation
cannot accept an expression that then behaves differently at check time.
Per XPath 3.1 the default collation is codepoint and must not consult LC_COLLATE, so the
underlying behaviour is arguably an elementpath bug; the pin above holds regardless.
Verified end to end inside the failing container: LC_COLLATE=C, LC_NUMERIC=en_US.UTF-8,
thousands separators intact, and the reported filter back from 0 to 1190797 chars of output.
Tested: new unit test covers contains/starts-with/ends-with under a UTF-8 collation and was
checked to fail without the fix; 325 unit tests pass.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
d56ec682fe |
Puppeteer fetcher - Re-navigation cap must not mean "extract right now" (#4439)
BROWSER_CONTENT_READY_MAX_RESETS exists so a page that re-navigates in a loop cannot extend a fetch forever. On hitting that cap the content-ready wait broke straight out into Page.stopLoading and extraction, giving the document we end up on zero settle time - the exact opposite of what the wait is for. Measured against a page that hops every 500ms and then renders via JS 2s after the final load: before: 2.7s, 130 bytes of an intermediate hop, no final document, no JS-rendered content after: 14.1s, final document, JS-rendered content present It also logged "Content-ready wait of 12s elapsed" immediately before extracting, having waited 0s, which is why this reads as a fetcher that ignores the setting. Note 0.60.4 could not do this: its wait was an unconditional `await asyncio.sleep(1 + extra_wait)` after goto(), so every fetch got its settle time no matter how the page behaved. Now the cap stops the wait from being *restarted*, and the delay is spent one final time before extracting. Total stays bounded at (max_resets + 2) * extra_wait, and whatever we extract has had the same settle time every other fetch gets. The stopLoading log line no longer claims a wait that may not have happened. Unrelated to #4437 - found while reading #4426 for that investigation, which turned out to be a locale/collation bug in the filter layer, not a fetcher problem. Tested: new reset-cap probe checked to fail before and pass after; normal (non-re-navigating) path unchanged at 12.5s with content intact; 3 passed browser fetcher suite on pyppeteer, 323 unit tests pass. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
b0783bce90 |
0.60.5
Build and push containers / metadata (push) Canceled after 0s
Build and push containers / build-push-containers (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Canceled after 0s
ChangeDetection.io App Test / lint-code (push) Canceled after 0s
ChangeDetection.io App Test / lint-translations (push) Canceled after 0s
ChangeDetection.io App Test / lint-template-i18n (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-11 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-12 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-13 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-14 (push) Canceled after 0s
|
||
|
|
c9b91e593b | Favicon Setting - Adding a note that disabling this feature can speed up your page fetches/use less resources. | ||
|
|
7a29b5bc73 |
Page fetching - Terminate runaway page script before extracting, or a spinning renderer eats the whole fetch (#4433)
Page.stopLoading stops the network, not script execution. A page whose JavaScript has pegged the renderer's main thread keeps that thread indefinitely, and every CDP call that needs to run script then queues behind it and never returns - page.content, the xPath scraper, the favicon fetcher. The fetch dies at PUPPETEER_MAX_PROCESSING_TIMEOUT_SECONDS having extracted nothing, with a core spinning the entire time. Seen in production on a watch that failed every check for days: the renderer sat at 1.04-1.07 cores for the full 60s budget (sampled every 2s, flat), 33s of which was a single unanswered Runtime.evaluate, and the watch logged "xpath_data length returned empty" every time. Nothing else recovers this state. Runtime.evaluate's own `timeout` parameter bounds an evaluation once it starts, not time spent queued behind the running task - measured, it still hung past 15s. Wrapping the call in asyncio.wait_for is worse than useless: cancelling a pyppeteer request mid-flight leaves the connection unusable, with "Protocol error: Target closed" on everything after it. Runtime.terminateExecution is what releases the thread. Against a page that fires load and then spins forever, through the real fetcher: before: 60.6s, BrowserFetchTimedOut, 0 bytes content, no xpath_data, no screenshot after: 5.6s, no exception, content + payload, xpath_data present, screenshot 8415b and the renderer drops from 1.00 to 0.08 cores. Safe at this point in the fetch: stopLoading has already declared "give me what rendered", and the content-ready wait above has already had its chance to let late JS-rendered content appear. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
943cf9c60a | Favicon on/off - Was not actually applying at fetch time (#4432) | ||
|
|
ba0ef4450e |
i18n: Update zh_Hant_TW translations (#4430)
* i18n: Update zh_Hant_TW translations * Rebuild --------- Co-authored-by: dgtlmoon <dgtlmoon@gmail.com> |
||
|
|
06446fa26b |
Debounce the explicit gc.collect() storm on the watch-check path (#4431)
Build and push containers / metadata (push) Canceled after 0s
Build and push containers / build-push-containers (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Canceled after 0s
ChangeDetection.io App Test / lint-code (push) Canceled after 0s
ChangeDetection.io App Test / lint-translations (push) Canceled after 0s
ChangeDetection.io App Test / lint-template-i18n (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-11 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-12 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-13 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-14 (push) Canceled after 0s
Several places call gc.collect() after a check to keep C-level memory
(pyppeteer buffers, libxml2 documents, PIL, brotli) from accumulating.
Individually each is reasonable; run concurrently by many fetch workers they
become a storm. Every gc.collect() is a full stop-the-world pass that walks the
whole heap holding the GIL, so at FETCH_WORKERS=50 the process spends most of
its time stopped in the collector - which also starves each worker's asyncio
loop, leaving its CDP websocket unread.
Measured on 153 real puppeteer checks of a live site at FETCH_WORKERS=50, with
an `//div` include filter so the lxml document tree is realistic:
collects objects freed gc time checks/sec CPU/check RSS
one per call site 790 2,594,098 91.3s 0.766 1.373s 279.6MB
debounced to 1s 48 2,219,010 7.3s 1.433 0.731s 275.3MB
none at all 0 0 0.0s 1.503 0.685s 287.7MB
Debouncing keeps 86% of the reclamation for 6% of the collections: 1.9x the
throughput, half the CPU per check, gc down from 30.8% to 6.4% of wall time,
and a lower resident plateau than collecting every time. It works because the
collector is process-wide - any worker's collection breaks every other worker's
cycles too, so with many workers the calls are overwhelmingly redundant
duplicates rather than independently necessary.
Removing them entirely is slightly faster still, but it was the only
configuration whose RSS had not plateaued by the end of the run, so it is not
the default. EXPLICIT_GC_MIN_INTERVAL=0 restores the previous behaviour.
Collecting a younger generation was measured and rejected: gen 0 freed 1,136
objects against the full pass's 2,594,098, because objects surviving a 10-30s
fetch have already been promoted out of gen 0.
Two call sites are additionally fixed because they could never reclaim anything:
- Watch.py brotli: brotli.Compressor is not gc-tracked, so the collector cannot
see it - `del` frees it by refcount. Over 60 x 2.2MB compressions, RSS growth
was +0.9MB with neither mechanism, +0.2MB with gc.collect() alone, and +0.0MB
with malloc_trim() alone or with both. malloc_trim is the load-bearing line
and is kept; the collect cost ~31ms of stop-the-world per snapshot save for no
reclamation.
- puppeteer quit(): runs twice per check (run()'s finally, then the worker's
safety net) and nulls self.page/self.browser in its own finally blocks, so the
second call closes nothing and breaks no cycles yet still paid for a full
collection - 88 calls across 51 checks. Now only collects when it actually
closed something.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
16b19a4ab7 |
Dockerfile - actually generate locales, price formatting was falling back to C (#4429)
Build and push containers / metadata (push) Canceled after 0s
Build and push containers / build-push-containers (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Canceled after 0s
ChangeDetection.io Container Build Test / Build linux/amd64 (alpine) (push) Canceled after 0s
ChangeDetection.io Container Build Test / Build linux/arm64 (alpine) (push) Canceled after 0s
ChangeDetection.io Container Build Test / Build linux/amd64 (main) (push) Canceled after 0s
ChangeDetection.io Container Build Test / Build linux/arm/v7 (main) (push) Canceled after 0s
ChangeDetection.io Container Build Test / Build linux/arm/v8 (main) (push) Canceled after 0s
ChangeDetection.io Container Build Test / Build linux/arm64 (main) (push) Canceled after 0s
ChangeDetection.io App Test / lint-code (push) Canceled after 0s
ChangeDetection.io App Test / lint-translations (push) Canceled after 0s
ChangeDetection.io App Test / lint-template-i18n (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-11 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-12 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-13 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-14 (push) Canceled after 0s
The `locales` package was installed "For presenting price amounts correctly in
the restock/price detection overview", but nothing ever ran locale-gen. The
package only ships /etc/locale.gen; it does not build any locale. So published
images had only C, C.utf8 and POSIX available, which made the existing
`ENV LC_ALL=en_US.UTF-8` unsatisfiable:
$ docker run --rm --entrypoint bash ghcr.io/dgtlmoon/changedetection.io:latest \
-c 'locale -a'
C
C.utf8
POSIX
locale.setlocale() in flask_app.py therefore raised, was caught by the existing
`except locale.Error` and logged "Unable to set locale ... is not installed
maybe?", and the process stayed on the C locale. The knock-on effect is that the
format_number_locale / format_int_locale Jinja filters lose their thousands
separators, contradicting format_number_locale's own docstring:
before: format_number_locale(1234567.89) -> '1234567.89'
after: format_number_locale(1234567.89) -> '1,234,567.89'
Those filters render prices in the watchlist overview
(blueprint/watchlist/templates/watch-overview-single-row.html), so every
published release has been showing unseparated price amounts.
How it got this way: LC_ALL arrived on master in
|
||
|
|
48723a09dd |
UI - Search modal - fix Enter dismissing the form instead of searching (#4428)
Build and push containers / metadata (push) Canceled after 0s
Build and push containers / build-push-containers (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Canceled after 0s
ChangeDetection.io App Test / lint-code (push) Canceled after 0s
ChangeDetection.io App Test / lint-translations (push) Canceled after 0s
ChangeDetection.io App Test / lint-template-i18n (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-11 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-12 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-13 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-14 (push) Canceled after 0s
Pressing Enter in the search box closed the modal without running the search; you had to click Search with the mouse. Implicit form submission fires a click at the submit button, and a keyboard-synthesised click carries detail 0 and coordinates of 0,0. The backdrop-click handler only tested the coordinates against the dialog's bounding box, so 0,0 read as "outside" - it closed the dialog and blanked the input while the click was still bubbling. By the time the submit ran, `q` was empty and `required` rejected it, so nothing was searched. Ignore clicks with detail 0 - only a real pointer can hit the backdrop. Verified with Chromium against a local instance: Enter now lands on /?q=<term>, and mouse submit, backdrop click, Escape and Enter-on-empty all still behave. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
718295f30b |
UI - Search modal - keep the search scoped to the tag you were viewing (#4425)
Build and push containers / metadata (push) Canceled after 0s
Build and push containers / build-push-containers (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Canceled after 0s
ChangeDetection.io Container Build Test / Build linux/amd64 (alpine) (push) Canceled after 0s
ChangeDetection.io Container Build Test / Build linux/arm64 (alpine) (push) Canceled after 0s
ChangeDetection.io Container Build Test / Build linux/amd64 (main) (push) Canceled after 0s
ChangeDetection.io Container Build Test / Build linux/arm/v7 (main) (push) Canceled after 0s
ChangeDetection.io Container Build Test / Build linux/arm/v8 (main) (push) Canceled after 0s
ChangeDetection.io Container Build Test / Build linux/arm64 (main) (push) Canceled after 0s
ChangeDetection.io App Test / lint-code (push) Canceled after 0s
ChangeDetection.io App Test / lint-translations (push) Canceled after 0s
ChangeDetection.io App Test / lint-template-i18n (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-11 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-12 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-13 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-14 (push) Canceled after 0s
The modal submitted the active tag as `tags=<uuid>`, but the watchlist filters on `tag` - nothing reads `tags`. So searching from inside a tag view silently searched every watch, despite the label promising "URL or Title in '<tag>'". The test pulls the hidden field straight out of the rendered modal and feeds it back to the watchlist, so the field name can't drift from the arg again. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
7cc62f8321 | Merge branch 'master' of github.com:dgtlmoon/changedetection.io | ||
|
|
cd66f9ed54 |
Search modal native submit (#4427)
* UI - Search - Fix search modal navigating to the host root on sub-path deployments
base_path was referenced by search-modal.js but never defined, so searching
always jumped to the host root instead of the X-Forwarded-Prefix sub-path.
* UI - Search modal - submit natively instead of rebuilding the URL in JS
Alternative to defining a `base_path` JS global: give the search form a
server-rendered `action`, so url_for() supplies the reverse-proxy sub-path the
same way every other link on the page already does, and let the browser submit.
Drops the submit handler and the Enter handler from search-modal.js - Enter in
the input reaches the footer's submit button via implicit submission, which also
runs the `required` validation the synthetic `new Event('submit')` skipped.
The hidden tag field is only rendered when a tag is active, so a plain search no
longer carries an empty value.
Also drops the nginx-job grep for the rendered markup - test_search.py already
covers the sub-path case, and asserting on an exact HTML attribute string from a
shell grep breaks on any unrelated edit to that tag.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: ponstream24 <87808547+ponstream24@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
8d938b5966 |
Browser fetchers - Follow re-navigation, and one content-ready deadline (re-targets #4421 + #4422 at master) (#4426)
* Browser fetchers - Judge a fetch on the document we end up extracting, not the first navigation
The goal is to compare the text of the page the browser lands on, even when the site navigates
again after the first response. Both fetchers were bound to the first navigation, which shows up
as two different bugs:
1. pyppeteer hangs until the hard processing timeout. Its navigation watcher is bound to the
loaderId of the navigation it started, so when the site replaces that document the 'load' it
waits for never arrives for that loaderId. With timeout=0 and setDefaultNavigationTimeout(0)
there is nothing to break the wait, so goto() blocks until
PUPPETEER_MAX_PROCESSING_TIMEOUT_SECONDS (180s) kills the fetch and the watch records an empty
xpath_data - while the browser is sitting on a fully loaded page. Traced on slated.com:
0.24s goto start
0.74s main frame networkIdle loaderId=A0E0E0B2 <- never gets 'load'
2.72s main frame init loaderId=56D2B5EF <- re-navigated to get.slated.com
3.49s main frame load loaderId=56D2B5EF <- fires for the new document
25.2s goto still hanging, frame._loaderId is now 56D2B5EF
Now the navigation races goto() against the main frame firing 'load', bounded by
BROWSER_NAVIGATION_TIMEOUT_SECONDS (default 30), and falls back to the document we can see.
slated.com / getastra.com / addupsolutions.com went from a 180s timeout with no content to
200 with full content in 5-35s.
2. Both fetchers reported the status of the interstitial. A site that gates unseen visitors with
an error status plus a client-side redirect (reported against fotokoch.de: 503 + meta refresh,
then a 200 with the real page) failed the watch even though the content was present, and the
only workaround was ignore_status_codes, which also hides genuine 404s and 500s forever.
The fetchers now keep the latest main-frame document response and judge on that - the refresh
lands during the existing extra_wait, so the 200 wins.
Playwright also waits for a settled load state before extracting, which is what produced
"Execution context was destroyed, most likely because of a navigation" when the refresh collided
with extraction.
The navigation-response tracker is installed once per page and shared between the fetcher and
action_goto_url() rather than each navigation adding its own listener - 'response' fires once per
HTTP response, hundreds of times on a heavy page, so the callbacks are worth not duplicating.
Verified one listener remains after an install plus four navigations.
Selenium is unaffected either way - it hardcodes status_code = 200 because WebDriver cannot see
the HTTP status.
Tested: new test_renavigation.py covers the interstitial case end to end and was checked to fail
without the fix and pass with it, on both fetchers. The test endpoint gates on last-seen time
rather than a hit count, because a counter lets the second check see a clean 200 and the test
then passes without the fix. Full browser suite 11 passed on playwright and on pyppeteer, 494
unit tests pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Puppeteer fetcher - One content-ready deadline instead of a stopLoading watchdog per frame event
Page.stopLoading is what stops a page that would otherwise load forever waiting on a subresource
that never answers, so that we can still screenshot and scrape what rendered. That intent was
right, but it was implemented as a fire-and-forget task armed by every frame event, which measured
on a single fetch of an iframe-heavy page came to:
14 watchdog tasks spawned
11 page-wide Page.stopLoading calls
3 tasks outliving the fetch and firing against a closed page
Page.stopLoading takes no frame or loader argument - it is the Stop button, and it stops the whole
page. Verified directly: one call stopped a pending main frame and a pending iframe in the same
instant. So the other 10 calls were redundant, and because they landed at arbitrary later times
they could stop a *subsequent* navigation we actually wanted - which is the likeliest reason the
same URL fetched in 8s on one run and 35s on the next.
Replaced with a single deadline, awaited inline so nothing can outlive the fetch (there is no
create_task left in this file at all):
navigate (bounded) -> wait the configured delay -> Page.stopLoading -> extract
The delay is measured from when navigation finished, not from when it started. Anchoring it to the
start would quietly rob a slow-loading page of its settle time, and letting JS-rendered content
appear after load is the whole point of the setting. Verified with a server that takes 5s to answer
and renders via JS 2s after load: total 9.6s for a 4s delay, and the late content is captured.
Because a page is never reliably "finished" - many sites navigate as part of their normal design -
the delay restarts when the MAIN frame replaces its document, so a redirect or interstitial gets
the same settle time the first document got. Iframes do not restart it, and it is capped by
BROWSER_CONTENT_READY_MAX_RESETS (default 2).
Only the existing "wait n seconds before extracting text" stays user-facing;
BROWSER_NAVIGATION_TIMEOUT_SECONDS is a safety net with a sane default rather than a second knob
for users to reason about. This matches what other scrapers do: bound the navigation, do not fail
when it times out, settle, then extract.
Timings are also more predictable now - the four reported URLs went from 5-35s of variance to
4.6-7.2s at a 3s delay, all with full content and a 200.
Tested: 11 passed pyppeteer browser suite, 7 passed playwright, 494 unit + llm.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
0d6b254fdb | UI - Really fix #4413 #4419 - Mobile menu fix | ||
|
|
900a77e828 |
Worker id/available fetcher (#4415)
* Worker ID # should be available to the content fetchers * Actually init with the worker ID |
||
|
|
35f0294274 | UI - Really fix #4413 #4419 Counter bubble in minimal sidebar | ||
|
|
de38c6cd4c | Rebuild SCSS/CSS | ||
|
|
b5d768c1d6 |
feat(ui): show count badges in minimal sidebar (#4419)
In minimal sidebar mode, count badges (unread changes, queue size) were hidden until hover. This makes them always visible so users can see at a glance how many items need attention without expanding the rail. Closes #4413 |
||
|
|
6318fc70f4 |
Browser fetchers - Report the real status code when Chrome aborts a bodiless error response (#4420)
* Browser fetchers - Report the real status code when Chrome aborts a bodiless error response Chrome 153+ refuses to commit a navigation when a 4xx/5xx arrives with a zero-length body: page.goto() raises net::ERR_HTTP_RESPONSE_CODE_FAILURE instead of returning the response. The response is received fine, we just never get it as a return value, so the raw net:: string landed in last_error instead of "Error - 404". Verified against two browser images, same HTTP server: Chrome 153 empty-body 404 -> raises ERR_HTTP_RESPONSE_CODE_FAILURE Chrome 153 404 with body -> status=404 Chromium 119 empty-body 404 -> status=404 Chromium 119 404 with body -> status=404 The fix keeps the main-frame response from the 'response' event and hands that back when goto raises, so .status / .all_headers() and the existing non-200 branch (which also captures the screenshot) work unchanged. The latest matching response wins, so a redirect chain still reports its final hop. Any other error re-raises as before, and if no response was captured we re-raise too - the status is never invented, which keeps older browsers on exactly their old path. Two independent navigation sites needed it: - browser_steps.py action_goto_url - covers the playwright fetcher, the live Browser Steps UI, the Goto URL / Goto site steps, and the CloakBrowser plugin which imports it. This is the one that broke CI: content_fetchers/__init__.py forces playwright when a watch has browser steps, so test_non_200_errors_report_browsersteps ran the playwright path in the pyppeteer jobs too. - puppeteer.py - its own goto retry loop, used when FAST_PUPPETEER_CHROME_FETCHER is set and the watch has no browser steps. No test covers that path; verified by driving the fetcher directly. Note pyppeteer exposes isNavigationRequest / frame / mainFrame as properties where playwright uses is_navigation_request() as a method. Mixing them up raises 'bool' object is not callable, which gets swallowed as a renderer page error rather than failing loudly. Checked against the pinned pyppeteer-ng==2.0.0rc16. Selenium is unaffected - it hardcodes status_code = 200 because WebDriver cannot see the HTTP status, so it never reaches the non-200 branch. Tested with the full CI browser set (test_content, test_errorhandling, test_fetch_data, test_custom_js_before_content): 10 passed on each of Chrome 153 + playwright, Chrome 153 + pyppeteer, Chromium 119 + playwright, Chromium 119 + pyppeteer, the last two against a canonical Dockerfile.chromium119 build so Chromium is the only variable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * CI - Make a fail-fast test abort say what was skipped rather than looking like a total failure Fail-fast is kept deliberately - the first failure is nearly always the real problem and it keeps the run short - but nothing said so, which made a single failing assertion read as "every browser test is broken". The playwright and pyppeteer jobs each ran four pytest files as four commands in one `run:` block, which GitHub executes under `bash -e`. tests/visualselector/test_fetch_data.py is the third, so when one 404 assertion failed there, test_custom_js_before_content.py never ran, and the later "Headers and requests" and "Restock detection" steps were skipped as a consequence - three test files silently dropped, reported only as dashes in the job list. run_basic_tests.sh has the same shape: 8 independent pytest groups under `set -e`, so a failure in the first parallel group hides the 7 after it. No behaviour change to when we stop - only to what gets reported: - Each browser test file now runs inside its own ::group:: so the log is navigable, and the failing file is named in a ::error:: annotation that states plainly that the remaining files and steps were SKIPPED, not failed. - run_basic_tests.sh gets an ERR trap saying the same thing, with the line number of the group that aborted. Verified the loop stops on the third file, names it and exits 1, that the all-pass path still exits 0, and that the trap reports the failing line while preserving the exit code. YAML parses and run_basic_tests.sh passes bash -n. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Fix unit test failure - install the navigation-response tracker only on a page that supports events action_goto_url() registered its 'response' listener unconditionally, which broke test_fetch_url_gate.py::TestBrowserStepGotoUrlGate::test_permitted_url_still_navigates: self.page.on("response", _keep_navigation_response) E AttributeError: '_RecordingPage' object has no attribute 'on' The three refusal tests in that class still passed because validate_fetch_url_async() raises before reaching the listener, so only the permitted-URL case (the one that actually navigates) hit it. The listener now lives in track_latest_navigation_response(), which returns None for a page that has no event support instead of raising. That also removes a real inefficiency: registering per navigation meant a page accumulated a listener per goto(), and 'response' fires for every subresource - measured 133 events on getastra.com (54 script, 37 image, 19 fetch, 11 xhr, ...) of which only 2 were navigations. The tracker is installed once per page and shared, verified as one listener remaining after an install plus four navigations. Tested: 494 unit + llm tests pass (was 1 failed / 480 passed), and the full browser set still passes 10/10 on playwright and 10/10 on pyppeteer, so the Chrome 153 "Error - 404" recovery still works through the shared tracker. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
5842e7a158 |
UI - 'Paused' status icon now has red dot instead of yellow #4418
Build and push containers / metadata (push) Canceled after 0s
Build and push containers / build-push-containers (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Canceled after 0s
ChangeDetection.io App Test / lint-code (push) Canceled after 0s
ChangeDetection.io App Test / lint-translations (push) Canceled after 0s
ChangeDetection.io App Test / lint-template-i18n (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-11 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-12 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-13 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-14 (push) Canceled after 0s
|
||
|
|
07d00d0811 |
0.60.4
Build and push containers / metadata (push) Canceled after 0s
Build and push containers / build-push-containers (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Canceled after 0s
ChangeDetection.io App Test / lint-code (push) Canceled after 0s
ChangeDetection.io App Test / lint-translations (push) Canceled after 0s
ChangeDetection.io App Test / lint-template-i18n (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-11 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-12 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-13 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-14 (push) Canceled after 0s
|
||
|
|
e990d9909f |
UI - Watch list - Minor tweak to cell padding
Build and push containers / metadata (push) Canceled after 0s
Build and push containers / build-push-containers (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Canceled after 0s
ChangeDetection.io App Test / lint-code (push) Canceled after 0s
ChangeDetection.io App Test / lint-translations (push) Canceled after 0s
ChangeDetection.io App Test / lint-template-i18n (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-11 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-12 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-13 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-14 (push) Canceled after 0s
|
||
|
|
1e60bafc52 | Rebuild translations | ||
|
|
8354242121 | UI - Watchlist - bring back Restock & Price column to watchlist overview (#4410) | ||
|
|
f71daee2c4 |
i18n: Update zh_Hant_TW translations (#4264)
Co-authored-by: dgtlmoon <dgtlmoon@gmail.com> |
||
|
|
d57043be8b | Merge branch 'master' of github.com:dgtlmoon/changedetection.io | ||
|
|
689bbe412c | UI - Watch list - Making list status icons larger, fixing icon text | ||
|
|
f3c0efac95 | UI - Groups - Fixing button alignment and using better form handling (#4409) | ||
|
|
4cf1bbe7cd | Rebuild translations | ||
|
|
7d45bf104d | UI - Watch stats - Adding a little extra debug to 'Stats' tab | ||
|
|
6b75954061 |
UI - Edit watch - Moving notification error alert to 'error' tab, hiding less used Page Title and Link options under foldout (#4406)
Build and push containers / metadata (push) Canceled after 0s
Build and push containers / build-push-containers (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Canceled after 0s
ChangeDetection.io Container Build Test / Build linux/amd64 (alpine) (push) Canceled after 0s
ChangeDetection.io Container Build Test / Build linux/arm64 (alpine) (push) Canceled after 0s
ChangeDetection.io Container Build Test / Build linux/amd64 (main) (push) Canceled after 0s
ChangeDetection.io Container Build Test / Build linux/arm/v7 (main) (push) Canceled after 0s
ChangeDetection.io Container Build Test / Build linux/arm/v8 (main) (push) Canceled after 0s
ChangeDetection.io Container Build Test / Build linux/arm64 (main) (push) Canceled after 0s
ChangeDetection.io App Test / lint-code (push) Canceled after 0s
ChangeDetection.io App Test / lint-translations (push) Canceled after 0s
ChangeDetection.io App Test / lint-template-i18n (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-11 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-12 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-13 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-14 (push) Canceled after 0s
|
||
|
|
62fd5eed8f | UI - Mobile - Recheck/edit/etc buttons - Centered horizontally | ||
|
|
30fc5d318a | UI - Mobile - Tidy up row padding | ||
|
|
9657ff4bf9 | UI - Watch list - Dont hide buttons on mobile, set light opacity on desktop | ||
|
|
821b538ae6 |
Performance - (watched page lister) open link single eval (#4405)
* Watchlist/Notifications - Resolve 'Link to Open' once per row, don't leak 'DISABLED' into notification tokens Follow-up to #4290. `watch.open_link` was read three times per watch list row (two hrefs plus the new title attribute). Each read re-runs `_resolve_link()`, which for a Jinja2 templated URL builds a fresh ImmutableSandboxedEnvironment - the cost the comment in validate_url.py already warns about. Hoisted to a single `{% set open_link = watch.open_link %}` alongside the other per-row lookups. `watch_open_url` was set from `watch.open_link`, which returns the string 'DISABLED' when the URL fails validation, so the default RSS body template (`RSS_TEMPLATE_HTML_DEFAULT`) rendered `<a href="DISABLED">` for those watches. Now falls back to the raw URL, matching the neighbouring `watch_url` token. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Rebuild template --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c104179ab5 | Rebuild translations | ||
|
|
2f60135cd0 |
feature: Implemented optional override link (#4290)
* Implemented optional link to override feature * Added translation strings (untranslated) |
||
|
|
f6d710ca85 | Apprise notifications - Updating to 1.13.1 (#4404) |