UI - Adding Favicon support to watch overview lister page + FavIcon API (#3196)

armv7-build-fix
dgtlmoon 2025-07-09 15:16:22 +02:00 zatwierdzone przez GitHub
rodzic 4fa2042d12
commit 308f30b2e8
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: B5690EEEBB952194
22 zmienionych plików z 826 dodań i 259 usunięć

Wyświetl plik

@ -5,7 +5,7 @@ from flask_expects_json import expects_json
from changedetectionio import queuedWatchMetaData from changedetectionio import queuedWatchMetaData
from changedetectionio import worker_handler from changedetectionio import worker_handler
from flask_restful import abort, Resource from flask_restful import abort, Resource
from flask import request, make_response from flask import request, make_response, send_from_directory
import validators import validators
from . import auth from . import auth
import copy import copy
@ -191,6 +191,38 @@ class WatchSingleHistory(Resource):
return response return response
class WatchFavicon(Resource):
def __init__(self, **kwargs):
# datastore is a black box dependency
self.datastore = kwargs['datastore']
@auth.check_token
def get(self, uuid):
"""
@api {get} /api/v1/watch/<string:uuid>/favicon Get Favicon for a watch
@apiDescription Requires watch `uuid`
@apiExample {curl} Example usage:
curl http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091/favicon -H"x-api-key:813031b16330fe25e3780cf0325daa45"
@apiName Get latest Favicon
@apiGroup Watch History
@apiSuccess (200) {String} OK
@apiSuccess (404) {String} ERR Not found
"""
watch = self.datastore.data['watching'].get(uuid)
if not watch:
abort(404, message=f"No watch exists with the UUID of {uuid}")
favicon_filename = watch.get_favicon_filename()
if favicon_filename:
import mimetypes
mime, encoding = mimetypes.guess_type(favicon_filename)
response = make_response(send_from_directory(watch.watch_data_dir, favicon_filename))
response.headers['Content-type'] = mime
response.headers['Cache-Control'] = 'max-age=300, must-revalidate' # Cache for 5 minutes, then revalidate
return response
abort(404, message=f'No Favicon available for {uuid}')
class CreateWatch(Resource): class CreateWatch(Resource):
def __init__(self, **kwargs): def __init__(self, **kwargs):

Wyświetl plik

@ -26,7 +26,7 @@ schema_delete_notification_urls = copy.deepcopy(schema_notification_urls)
schema_delete_notification_urls['required'] = ['notification_urls'] schema_delete_notification_urls['required'] = ['notification_urls']
# Import all API resources # Import all API resources
from .Watch import Watch, WatchHistory, WatchSingleHistory, CreateWatch from .Watch import Watch, WatchHistory, WatchSingleHistory, CreateWatch, WatchFavicon
from .Tags import Tags, Tag from .Tags import Tags, Tag
from .Import import Import from .Import import Import
from .SystemInfo import SystemInfo from .SystemInfo import SystemInfo

Wyświetl plik

@ -353,6 +353,12 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore):
except Exception as e: except Exception as e:
pass pass
# Store favicon if necessary
if update_handler.fetcher.favicon_blob and update_handler.fetcher.favicon_blob.get('base64'):
watch.bump_favicon(url=update_handler.fetcher.favicon_blob.get('url'),
favicon_base_64=update_handler.fetcher.favicon_blob.get('base64')
)
datastore.update_watch(uuid=uuid, update_obj={'fetch_time': round(time.time() - fetch_start_time, 3), datastore.update_watch(uuid=uuid, update_obj={'fetch_time': round(time.time() - fetch_start_time, 3),
'check_count': count}) 'check_count': count})

Wyświetl plik

@ -4,6 +4,7 @@
<script src="{{url_for('static_content', group='js', filename='jquery-3.6.0.min.js')}}"></script> <script src="{{url_for('static_content', group='js', filename='jquery-3.6.0.min.js')}}"></script>
<script src="{{url_for('static_content', group='js', filename='watch-overview.js')}}" defer></script> <script src="{{url_for('static_content', group='js', filename='watch-overview.js')}}" defer></script>
<script>let nowtimeserver={{ now_time_server }};</script> <script>let nowtimeserver={{ now_time_server }};</script>
<script>let favicon_baseURL={{ url_for('static_content', group='favicon', filename="PLACEHOLDER")}}; </script>
<script> <script>
// Initialize Feather icons after the page loads // Initialize Feather icons after the page loads
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
@ -109,6 +110,7 @@ document.addEventListener('DOMContentLoaded', function() {
<td colspan="{{ cols_required }}" style="text-wrap: wrap;">No website watches configured, please add a URL in the box above, or <a href="{{ url_for('imports.import_page')}}" >import a list</a>.</td> <td colspan="{{ cols_required }}" style="text-wrap: wrap;">No website watches configured, please add a URL in the box above, or <a href="{{ url_for('imports.import_page')}}" >import a list</a>.</td>
</tr> </tr>
{%- endif -%} {%- endif -%}
{%- for watch in (watches|sort(attribute=sort_attribute, reverse=sort_order == 'asc'))|pagination_slice(skip=pagination.skip) -%} {%- for watch in (watches|sort(attribute=sort_attribute, reverse=sort_order == 'asc'))|pagination_slice(skip=pagination.skip) -%}
{%- set checking_now = is_checking_now(watch) -%} {%- set checking_now = is_checking_now(watch) -%}
{%- set history_n = watch.history_n -%} {%- set history_n = watch.history_n -%}
@ -120,38 +122,36 @@ document.addEventListener('DOMContentLoaded', function() {
'paused' if watch.paused is defined and watch.paused != False else '', 'paused' if watch.paused is defined and watch.paused != False else '',
'unviewed' if watch.has_unviewed else '', 'unviewed' if watch.has_unviewed else '',
'has-restock-info' if watch.has_restock_info else 'no-restock-info', 'has-restock-info' if watch.has_restock_info else 'no-restock-info',
'has-favicon' if watch.get_favicon_filename() else '',
'in-stock' if watch.has_restock_info and watch['restock']['in_stock'] else '', 'in-stock' if watch.has_restock_info and watch['restock']['in_stock'] else '',
'not-in-stock' if watch.has_restock_info and not watch['restock']['in_stock'] else '', 'not-in-stock' if watch.has_restock_info and not watch['restock']['in_stock'] else '',
'queued' if watch.uuid in queued_uuids else '', 'queued' if watch.uuid in queued_uuids else '',
'checking-now' if checking_now else '', 'checking-now' if checking_now else '',
'notification_muted' if watch.notification_muted else '', 'notification_muted' if watch.notification_muted else '',
'single-history' if history_n == 1 else '', 'single-history' if history_n == 1 else '',
'multiple-history' if history_n >= 2 else '' 'multiple-history' if history_n >= 2 else '',
] -%} ] -%}
<tr id="{{ watch.uuid }}" data-watch-uuid="{{ watch.uuid }}" class="{{ row_classes | reject('equalto', '') | join(' ') }}"> <tr id="{{ watch.uuid }}" data-watch-uuid="{{ watch.uuid }}" class="{{ row_classes | reject('equalto', '') | join(' ') }}">
<td class="inline checkbox-uuid" ><input name="uuids" type="checkbox" value="{{ watch.uuid}} " > <span>{{ loop.index+pagination.skip }}</span></td> <td class="inline checkbox-uuid" ><div><input name="uuids" type="checkbox" value="{{ watch.uuid}} " > <span class="counter-i">{{ loop.index+pagination.skip }}</span></div></td>
<td class="inline watch-controls"> <td class="inline watch-controls">
<div>
<a class="ajax-op state-off pause-toggle" data-op="pause" href="{{url_for('watchlist.index', op='pause', uuid=watch.uuid, tag=active_tag_uuid)}}"><img src="{{url_for('static_content', group='images', filename='pause.svg')}}" alt="Pause checks" title="Pause checks" class="icon icon-pause" ></a> <a class="ajax-op state-off pause-toggle" data-op="pause" href="{{url_for('watchlist.index', op='pause', uuid=watch.uuid, tag=active_tag_uuid)}}"><img src="{{url_for('static_content', group='images', filename='pause.svg')}}" alt="Pause checks" title="Pause checks" class="icon icon-pause" ></a>
<a class="ajax-op state-on pause-toggle" data-op="pause" style="display: none" href="{{url_for('watchlist.index', op='pause', uuid=watch.uuid, tag=active_tag_uuid)}}"><img src="{{url_for('static_content', group='images', filename='play.svg')}}" alt="UnPause checks" title="UnPause checks" class="icon icon-unpause" ></a> <a class="ajax-op state-on pause-toggle" data-op="pause" style="display: none" href="{{url_for('watchlist.index', op='pause', uuid=watch.uuid, tag=active_tag_uuid)}}"><img src="{{url_for('static_content', group='images', filename='play.svg')}}" alt="UnPause checks" title="UnPause checks" class="icon icon-unpause" ></a>
<a class="ajax-op state-off mute-toggle" data-op="mute" href="{{url_for('watchlist.index', op='mute', uuid=watch.uuid, tag=active_tag_uuid)}}"><img src="{{url_for('static_content', group='images', filename='bell-off.svg')}}" alt="Mute notification" title="Mute notification" class="icon icon-mute" ></a> <a class="ajax-op state-off mute-toggle" data-op="mute" href="{{url_for('watchlist.index', op='mute', uuid=watch.uuid, tag=active_tag_uuid)}}"><img src="{{url_for('static_content', group='images', filename='bell-off.svg')}}" alt="Mute notification" title="Mute notification" class="icon icon-mute" ></a>
<a class="ajax-op state-on mute-toggle" data-op="mute" style="display: none" href="{{url_for('watchlist.index', op='mute', uuid=watch.uuid, tag=active_tag_uuid)}}"><img src="{{url_for('static_content', group='images', filename='bell-off.svg')}}" alt="UnMute notification" title="UnMute notification" class="icon icon-mute" ></a> <a class="ajax-op state-on mute-toggle" data-op="mute" style="display: none" href="{{url_for('watchlist.index', op='mute', uuid=watch.uuid, tag=active_tag_uuid)}}"><img src="{{url_for('static_content', group='images', filename='bell-off.svg')}}" alt="UnMute notification" title="UnMute notification" class="icon icon-mute" ></a>
</div>
</td> </td>
<td class="title-col inline">{{watch.title if watch.title is not none and watch.title|length > 0 else watch.url}}
<a class="external" target="_blank" rel="noopener" href="{{ watch.link.replace('source:','') }}">&nbsp;</a>
<a class="link-spread" href="{{url_for('ui.form_share_put_watch', uuid=watch.uuid)}}"><img src="{{url_for('static_content', group='images', filename='spread.svg')}}" class="status-icon icon icon-spread" title="Create a link to share watch config with others" ></a>
{%- if watch.get_fetch_backend == "html_webdriver"
or ( watch.get_fetch_backend == "system" and system_default_fetcher == 'html_webdriver' )
or "extra_browser_" in watch.get_fetch_backend
-%}
<img class="status-icon" src="{{url_for('static_content', group='images', filename='google-chrome-icon.png')}}" alt="Using a Chrome browser" title="Using a Chrome browser" >
{%- endif -%}
{%- if watch.is_pdf -%}<img class="status-icon" src="{{url_for('static_content', group='images', filename='pdf-icon.svg')}}" alt="Converting PDF to text" >{%- endif -%}
{%- if watch.has_browser_steps -%}<img class="status-icon status-browsersteps" src="{{url_for('static_content', group='images', filename='steps.svg')}}" alt="Browser Steps is enabled" >{%- endif -%}
<td class="title-col inline">
<div class="flex-wrapper">
<div>
<img style="display: none;" class="favicon" src="{{url_for('static_content', group='favicon', filename=watch.uuid)}}">
</div>
<div>
<span class="watch-title">
{{watch.title if watch.title is not none and watch.title|length > 0 else watch.url}}&nbsp;<a class="external" target="_blank" rel="noopener" href="{{ watch.link.replace('source:','') }}">&nbsp;</a>
</span>
<div class="error-text" style="display:none;">{{ watch.compile_error_texts(has_proxies=datastore.proxy_list) }}</div> <div class="error-text" style="display:none;">{{ watch.compile_error_texts(has_proxies=datastore.proxy_list) }}</div>
{%- if watch['processor'] == 'text_json_diff' -%} {%- if watch['processor'] == 'text_json_diff' -%}
{%- if watch['has_ldjson_price_data'] and not watch['track_ldjson_price_data'] -%} {%- if watch['has_ldjson_price_data'] and not watch['track_ldjson_price_data'] -%}
<div class="ldjson-price-track-offer">Switch to Restock & Price watch mode? <a href="{{url_for('price_data_follower.accept', uuid=watch.uuid)}}" class="pure-button button-xsmall">Yes</a> <a href="{{url_for('price_data_follower.reject', uuid=watch.uuid)}}" class="">No</a></div> <div class="ldjson-price-track-offer">Switch to Restock & Price watch mode? <a href="{{url_for('price_data_follower.accept', uuid=watch.uuid)}}" class="pure-button button-xsmall">Yes</a> <a href="{{url_for('price_data_follower.reject', uuid=watch.uuid)}}" class="">No</a></div>
@ -163,6 +163,20 @@ document.addEventListener('DOMContentLoaded', function() {
{%- for watch_tag_uuid, watch_tag in datastore.get_all_tags_for_watch(watch['uuid']).items() -%} {%- for watch_tag_uuid, watch_tag in datastore.get_all_tags_for_watch(watch['uuid']).items() -%}
<span class="watch-tag-list">{{ watch_tag.title }}</span> <span class="watch-tag-list">{{ watch_tag.title }}</span>
{%- endfor -%} {%- endfor -%}
</div>
<div class="status-icons">
<a class="link-spread" href="{{url_for('ui.form_share_put_watch', uuid=watch.uuid)}}"><img src="{{url_for('static_content', group='images', filename='spread.svg')}}" class="status-icon icon icon-spread" title="Create a link to share watch config with others" ></a>
{%- if watch.get_fetch_backend == "html_webdriver"
or ( watch.get_fetch_backend == "system" and system_default_fetcher == 'html_webdriver' )
or "extra_browser_" in watch.get_fetch_backend
-%}
<img class="status-icon" src="{{url_for('static_content', group='images', filename='google-chrome-icon.png')}}" alt="Using a Chrome browser" title="Using a Chrome browser" >
{%- endif -%}
{%- if watch.is_pdf -%}<img class="status-icon" src="{{url_for('static_content', group='images', filename='pdf-icon.svg')}}" alt="Converting PDF to text" >{%- endif -%}
{%- if watch.has_browser_steps -%}<img class="status-icon status-browsersteps" src="{{url_for('static_content', group='images', filename='steps.svg')}}" alt="Browser Steps is enabled" >{%- endif -%}
</div>
</div>
</td> </td>
{%- if any_has_restock_price_processor -%} {%- if any_has_restock_price_processor -%}
<td class="restock-and-price"> <td class="restock-and-price">
@ -199,13 +213,15 @@ document.addEventListener('DOMContentLoaded', function() {
Not yet Not yet
{%- endif -%} {%- endif -%}
</td> </td>
<td> <td class="buttons">
<div>
{%- set target_attr = ' target="' ~ watch.uuid ~ '"' if datastore.data['settings']['application']['ui'].get('open_diff_in_new_tab') else '' -%} {%- set target_attr = ' target="' ~ watch.uuid ~ '"' if datastore.data['settings']['application']['ui'].get('open_diff_in_new_tab') else '' -%}
<a href="" class="already-in-queue-button recheck pure-button pure-button-primary" style="display: none;" disabled="disabled">Queued</a> <a href="" class="already-in-queue-button recheck pure-button pure-button-primary" style="display: none;" disabled="disabled">Queued</a>
<a href="{{ url_for('ui.form_watch_checknow', uuid=watch.uuid, tag=request.args.get('tag')) }}" data-op='recheck' class="ajax-op recheck pure-button pure-button-primary">Recheck</a> <a href="{{ url_for('ui.form_watch_checknow', uuid=watch.uuid, tag=request.args.get('tag')) }}" data-op='recheck' class="ajax-op recheck pure-button pure-button-primary">Recheck</a>
<a href="{{ url_for('ui.ui_edit.edit_page', uuid=watch.uuid, tag=active_tag_uuid)}}#general" class="pure-button pure-button-primary">Edit</a> <a href="{{ url_for('ui.ui_edit.edit_page', uuid=watch.uuid, tag=active_tag_uuid)}}#general" class="pure-button pure-button-primary">Edit</a>
<a href="{{ url_for('ui.ui_views.diff_history_page', uuid=watch.uuid)}}" {{target_attr}} class="pure-button pure-button-primary history-link" style="display: none;">History</a> <a href="{{ url_for('ui.ui_views.diff_history_page', uuid=watch.uuid)}}" {{target_attr}} class="pure-button pure-button-primary history-link" style="display: none;">History</a>
<a href="{{ url_for('ui.ui_views.preview_page', uuid=watch.uuid)}}" {{target_attr}} class="pure-button pure-button-primary preview-link" style="display: none;">Preview</a> <a href="{{ url_for('ui.ui_views.preview_page', uuid=watch.uuid)}}" {{target_attr}} class="pure-button pure-button-primary preview-link" style="display: none;">Preview</a>
</div>
</td> </td>
</tr> </tr>
{%- endfor -%} {%- endfor -%}

Wyświetl plik

@ -1,5 +1,3 @@
from flask import Blueprint
from json_logic.builtins import BUILTINS from json_logic.builtins import BUILTINS
from .exceptions import EmptyConditionRuleRowNotUsable from .exceptions import EmptyConditionRuleRowNotUsable

Wyświetl plik

@ -28,6 +28,7 @@ from changedetectionio.content_fetchers.requests import fetcher as html_requests
import importlib.resources import importlib.resources
XPATH_ELEMENT_JS = importlib.resources.files("changedetectionio.content_fetchers.res").joinpath('xpath_element_scraper.js').read_text(encoding='utf-8') XPATH_ELEMENT_JS = importlib.resources.files("changedetectionio.content_fetchers.res").joinpath('xpath_element_scraper.js').read_text(encoding='utf-8')
INSTOCK_DATA_JS = importlib.resources.files("changedetectionio.content_fetchers.res").joinpath('stock-not-in-stock.js').read_text(encoding='utf-8') INSTOCK_DATA_JS = importlib.resources.files("changedetectionio.content_fetchers.res").joinpath('stock-not-in-stock.js').read_text(encoding='utf-8')
FAVICON_FETCHER_JS = importlib.resources.files("changedetectionio.content_fetchers.res").joinpath('favicon-fetcher.js').read_text(encoding='utf-8')
def available_fetchers(): def available_fetchers():

Wyświetl plik

@ -48,6 +48,7 @@ class Fetcher():
error = None error = None
fetcher_description = "No description" fetcher_description = "No description"
headers = {} headers = {}
favicon_blob = None
instock_data = None instock_data = None
instock_data_js = "" instock_data_js = ""
status_code = None status_code = None

Wyświetl plik

@ -5,7 +5,7 @@ from urllib.parse import urlparse
from loguru import logger from loguru import logger
from changedetectionio.content_fetchers import SCREENSHOT_MAX_HEIGHT_DEFAULT, visualselector_xpath_selectors, \ from changedetectionio.content_fetchers import SCREENSHOT_MAX_HEIGHT_DEFAULT, visualselector_xpath_selectors, \
SCREENSHOT_SIZE_STITCH_THRESHOLD, SCREENSHOT_MAX_TOTAL_HEIGHT, XPATH_ELEMENT_JS, INSTOCK_DATA_JS SCREENSHOT_SIZE_STITCH_THRESHOLD, SCREENSHOT_MAX_TOTAL_HEIGHT, XPATH_ELEMENT_JS, INSTOCK_DATA_JS, FAVICON_FETCHER_JS
from changedetectionio.content_fetchers.base import Fetcher, manage_user_agent from changedetectionio.content_fetchers.base import Fetcher, manage_user_agent
from changedetectionio.content_fetchers.exceptions import PageUnloadable, Non200ErrorCodeReceived, EmptyReply, ScreenshotUnavailable from changedetectionio.content_fetchers.exceptions import PageUnloadable, Non200ErrorCodeReceived, EmptyReply, ScreenshotUnavailable
@ -234,6 +234,12 @@ class fetcher(Fetcher):
await browser.close() await browser.close()
raise PageUnloadable(url=url, status_code=None, message=str(e)) raise PageUnloadable(url=url, status_code=None, message=str(e))
try:
self.favicon_blob = await self.page.evaluate(FAVICON_FETCHER_JS)
await self.page.request_gc()
except Exception as e:
logger.error(f"Error fetching FavIcon info {str(e)}, continuing.")
if self.status_code != 200 and not ignore_status_codes: if self.status_code != 200 and not ignore_status_codes:
screenshot = await capture_full_page_async(self.page) screenshot = await capture_full_page_async(self.page)
raise Non200ErrorCodeReceived(url=url, status_code=self.status_code, screenshot=screenshot) raise Non200ErrorCodeReceived(url=url, status_code=self.status_code, screenshot=screenshot)
@ -274,6 +280,7 @@ class fetcher(Fetcher):
await self.page.request_gc() await self.page.request_gc()
logger.debug(f"Scrape xPath element data in browser done in {time.time() - now:.2f}s") logger.debug(f"Scrape xPath element data in browser done in {time.time() - now:.2f}s")
# Bug 3 in Playwright screenshot handling # Bug 3 in Playwright screenshot handling
# Some bug where it gives the wrong screenshot size, but making a request with the clip set first seems to solve it # Some bug where it gives the wrong screenshot size, but making a request with the clip set first seems to solve it
# JPEG is better here because the screenshots can be very very large # JPEG is better here because the screenshots can be very very large

Wyświetl plik

@ -8,7 +8,7 @@ from loguru import logger
from changedetectionio.content_fetchers import SCREENSHOT_MAX_HEIGHT_DEFAULT, visualselector_xpath_selectors, \ from changedetectionio.content_fetchers import SCREENSHOT_MAX_HEIGHT_DEFAULT, visualselector_xpath_selectors, \
SCREENSHOT_SIZE_STITCH_THRESHOLD, SCREENSHOT_DEFAULT_QUALITY, XPATH_ELEMENT_JS, INSTOCK_DATA_JS, \ SCREENSHOT_SIZE_STITCH_THRESHOLD, SCREENSHOT_DEFAULT_QUALITY, XPATH_ELEMENT_JS, INSTOCK_DATA_JS, \
SCREENSHOT_MAX_TOTAL_HEIGHT SCREENSHOT_MAX_TOTAL_HEIGHT, FAVICON_FETCHER_JS
from changedetectionio.content_fetchers.base import Fetcher, manage_user_agent from changedetectionio.content_fetchers.base import Fetcher, manage_user_agent
from changedetectionio.content_fetchers.exceptions import PageUnloadable, Non200ErrorCodeReceived, EmptyReply, BrowserFetchTimedOut, \ from changedetectionio.content_fetchers.exceptions import PageUnloadable, Non200ErrorCodeReceived, EmptyReply, BrowserFetchTimedOut, \
BrowserConnectError BrowserConnectError
@ -179,10 +179,8 @@ class fetcher(Fetcher):
except Exception as e: except Exception as e:
raise BrowserConnectError(msg=f"Error connecting to the browser - Exception '{str(e)}'") raise BrowserConnectError(msg=f"Error connecting to the browser - Exception '{str(e)}'")
# Better is to launch chrome with the URL as arg # more reliable is to just request a new page
# non-headless - newPage() will launch an extra tab/window, .browser should already contain 1 page/tab self.page = await browser.newPage()
# headless - ask a new page
self.page = (pages := await browser.pages) and len(pages) or await browser.newPage()
if '--window-size' in self.browser_connection_url: if '--window-size' in self.browser_connection_url:
# Be sure the viewport is always the window-size, this is often not the same thing # Be sure the viewport is always the window-size, this is often not the same thing
@ -292,6 +290,11 @@ class fetcher(Fetcher):
await browser.close() await browser.close()
raise PageUnloadable(url=url, status_code=None, message=str(e)) raise PageUnloadable(url=url, status_code=None, message=str(e))
try:
self.favicon_blob = await self.page.evaluate(FAVICON_FETCHER_JS)
except Exception as e:
logger.error(f"Error fetching FavIcon info {str(e)}, continuing.")
if self.status_code != 200 and not ignore_status_codes: if self.status_code != 200 and not ignore_status_codes:
screenshot = await capture_full_page(page=self.page) screenshot = await capture_full_page(page=self.page)

Wyświetl plik

@ -0,0 +1,70 @@
async () => {
const links = Array.from(document.querySelectorAll('link[rel~="apple-touch-icon"], link[rel~="icon"]'));
const icons = links.map(link => {
const sizesStr = link.getAttribute('sizes');
let size = 0;
if (sizesStr) {
const [w] = sizesStr.split('x').map(Number);
if (!isNaN(w)) size = w;
} else {
size = 16;
}
return {
size,
rel: link.getAttribute('rel'),
href: link.href
};
});
if (icons.length === 0) return null;
icons.sort((a, b) => {
const isAppleA = /apple-touch-icon/.test(a.rel);
const isAppleB = /apple-touch-icon/.test(b.rel);
if (isAppleA && !isAppleB) return -1;
if (!isAppleA && isAppleB) return 1;
return b.size - a.size;
});
// Set a timeout value in ms
const timeoutMs = 2000;
for (const icon of icons) {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
const resp = await fetch(icon.href, {
signal: controller.signal,
redirect: 'follow'
});
clearTimeout(timeout);
if (!resp.ok) {
// skip 404, 500, etc.
continue;
}
const blob = await resp.blob();
// Convert to base64
const reader = new FileReader();
return await new Promise(resolve => {
reader.onloadend = () => {
resolve({
url: icon.href,
base64: reader.result.split(",")[1]
});
};
reader.readAsDataURL(blob);
});
} catch (e) {
// fetch error, timeout, or abort
continue;
}
}
return null;
}

Wyświetl plik

@ -19,12 +19,10 @@ from flask import (
Flask, Flask,
abort, abort,
flash, flash,
make_response,
redirect, redirect,
render_template, render_template,
request, request,
send_from_directory, send_from_directory,
session,
url_for, url_for,
) )
from flask_compress import Compress as FlaskCompress from flask_compress import Compress as FlaskCompress
@ -40,7 +38,7 @@ from loguru import logger
from changedetectionio import __version__ from changedetectionio import __version__
from changedetectionio import queuedWatchMetaData from changedetectionio import queuedWatchMetaData
from changedetectionio.api import Watch, WatchHistory, WatchSingleHistory, CreateWatch, Import, SystemInfo, Tag, Tags, Notifications from changedetectionio.api import Watch, WatchHistory, WatchSingleHistory, CreateWatch, Import, SystemInfo, Tag, Tags, Notifications, WatchFavicon
from changedetectionio.api.Search import Search from changedetectionio.api.Search import Search
from .time_handler import is_within_schedule from .time_handler import is_within_schedule
@ -307,7 +305,9 @@ def changedetection_app(config=None, datastore_o=None):
watch_api.add_resource(WatchSingleHistory, watch_api.add_resource(WatchSingleHistory,
'/api/v1/watch/<string:uuid>/history/<string:timestamp>', '/api/v1/watch/<string:uuid>/history/<string:timestamp>',
resource_class_kwargs={'datastore': datastore, 'update_q': update_q}) resource_class_kwargs={'datastore': datastore, 'update_q': update_q})
watch_api.add_resource(WatchFavicon,
'/api/v1/watch/<string:uuid>/favicon',
resource_class_kwargs={'datastore': datastore})
watch_api.add_resource(WatchHistory, watch_api.add_resource(WatchHistory,
'/api/v1/watch/<string:uuid>/history', '/api/v1/watch/<string:uuid>/history',
resource_class_kwargs={'datastore': datastore}) resource_class_kwargs={'datastore': datastore})
@ -427,6 +427,23 @@ def changedetection_app(config=None, datastore_o=None):
except FileNotFoundError: except FileNotFoundError:
abort(404) abort(404)
if group == 'favicon':
# Could be sensitive, follow password requirements
if datastore.data['settings']['application']['password'] and not flask_login.current_user.is_authenticated:
abort(403)
# Get the watch object
watch = datastore.data['watching'].get(filename)
if not watch:
abort(404)
favicon_filename = watch.get_favicon_filename()
if favicon_filename:
import mimetypes
mime, encoding = mimetypes.guess_type(favicon_filename)
response = make_response(send_from_directory(watch.watch_data_dir, favicon_filename))
response.headers['Content-type'] = mime
response.headers['Cache-Control'] = 'max-age=300, must-revalidate' # Cache for 5 minutes, then revalidate
return response
if group == 'visual_selector_data': if group == 'visual_selector_data':
# Could be sensitive, follow password requirements # Could be sensitive, follow password requirements

Wyświetl plik

@ -62,7 +62,7 @@ class model(dict):
'timezone': None, # Default IANA timezone name 'timezone': None, # Default IANA timezone name
'ui': { 'ui': {
'open_diff_in_new_tab': True, 'open_diff_in_new_tab': True,
'socket_io_enabled': True 'socket_io_enabled': True,
}, },
} }
} }

Wyświetl plik

@ -103,6 +103,13 @@ class model(watch_base):
return 'DISABLED' return 'DISABLED'
return ready_url return ready_url
@property
def domain_only_from_link(self):
from urllib.parse import urlparse
parsed = urlparse(self.link)
domain = parsed.hostname
return domain
def clear_watch(self): def clear_watch(self):
import pathlib import pathlib
@ -413,6 +420,127 @@ class model(watch_base):
# False is not an option for AppRise, must be type None # False is not an option for AppRise, must be type None
return None return None
def bump_favicon(self, url, favicon_base_64: str) -> None:
from urllib.parse import urlparse
import base64
import binascii
decoded = None
if url:
try:
parsed = urlparse(url)
filename = os.path.basename(parsed.path)
(base, extension) = filename.lower().strip().rsplit('.', 1)
except ValueError:
logger.error(f"UUID: {self.get('uuid')} Cant work out file extension from '{url}'")
return None
else:
# Assume favicon.ico
base = "favicon"
extension = "ico"
fname = os.path.join(self.watch_data_dir, f"favicon.{extension}")
try:
# validate=True makes sure the string only contains valid base64 chars
decoded = base64.b64decode(favicon_base_64, validate=True)
except (binascii.Error, ValueError) as e:
logger.warning(f"UUID: {self.get('uuid')} FavIcon save data (Base64) corrupt? {str(e)}")
else:
if decoded:
try:
with open(fname, 'wb') as f:
f.write(decoded)
except Exception as e:
logger.warning(f"UUID: {self.get('uuid')} error saving FavIcon to {fname} - {str(e)}")
# @todo - Store some checksum and only write when its different
logger.debug(f"UUID: {self.get('uuid')} updated favicon to at {fname}")
def get_favicon_filename(self) -> str | None:
"""
Find any favicon.* file in the current working directory
and return the contents of the newest one.
Returns:
bytes: Contents of the newest favicon file, or None if not found.
"""
import glob
# Search for all favicon.* files
files = glob.glob(os.path.join(self.watch_data_dir, "favicon.*"))
if not files:
return None
# Find the newest by modification time
newest_file = max(files, key=os.path.getmtime)
return os.path.basename(newest_file)
def get_screenshot_as_thumbnail(self, max_age=3200):
"""Return path to a square thumbnail of the most recent screenshot.
Creates a 150x150 pixel thumbnail from the top portion of the screenshot.
Args:
max_age: Maximum age in seconds before recreating thumbnail
Returns:
Path to thumbnail or None if no screenshot exists
"""
import os
import time
thumbnail_path = os.path.join(self.watch_data_dir, "thumbnail.jpeg")
top_trim = 500 # Pixels from top of screenshot to use
screenshot_path = self.get_screenshot()
if not screenshot_path:
return None
# Reuse thumbnail if it's fresh and screenshot hasn't changed
if os.path.isfile(thumbnail_path):
thumbnail_mtime = os.path.getmtime(thumbnail_path)
screenshot_mtime = os.path.getmtime(screenshot_path)
if screenshot_mtime <= thumbnail_mtime and time.time() - thumbnail_mtime < max_age:
return thumbnail_path
try:
from PIL import Image
with Image.open(screenshot_path) as img:
# Crop top portion first (full width, top_trim height)
top_crop_height = min(top_trim, img.height)
img = img.crop((0, 0, img.width, top_crop_height))
# Create a smaller intermediate image (to reduce memory usage)
aspect = img.width / img.height
interim_width = min(top_trim, img.width)
interim_height = int(interim_width / aspect) if aspect > 0 else top_trim
img = img.resize((interim_width, interim_height), Image.NEAREST)
# Convert to RGB if needed
if img.mode != 'RGB':
img = img.convert('RGB')
# Crop to square from top center
square_size = min(img.width, img.height)
left = (img.width - square_size) // 2
img = img.crop((left, 0, left + square_size, square_size))
# Final resize to exact thumbnail size with better filter
img = img.resize((350, 350), Image.BILINEAR)
# Save with optimized settings
img.save(thumbnail_path, "JPEG", quality=75, optimize=True)
return thumbnail_path
except Exception as e:
logger.error(f"Error creating thumbnail for {self.get('uuid')}: {str(e)}")
return None
def __get_file_ctime(self, filename): def __get_file_ctime(self, filename):
fname = os.path.join(self.watch_data_dir, filename) fname = os.path.join(self.watch_data_dir, filename)
if os.path.isfile(fname): if os.path.isfile(fname):

Wyświetl plik

@ -111,7 +111,6 @@ class SignalHandler:
except Exception as e: except Exception as e:
logger.error(f"Socket.IO error in handle_notification_event: {str(e)}") logger.error(f"Socket.IO error in handle_notification_event: {str(e)}")
def polling_emit_running_or_queued_watches_threaded(self): def polling_emit_running_or_queued_watches_threaded(self):
"""Threading version of polling for Windows compatibility""" """Threading version of polling for Windows compatibility"""
import time import time
@ -208,20 +207,20 @@ def handle_watch_update(socketio, **kwargs):
watch_data = { watch_data = {
'checking_now': True if watch.get('uuid') in running_uuids else False, 'checking_now': True if watch.get('uuid') in running_uuids else False,
'error_text': error_texts,
'event_timestamp': time.time(),
'fetch_time': watch.get('fetch_time'), 'fetch_time': watch.get('fetch_time'),
'has_error': True if error_texts else False, 'has_error': True if error_texts else False,
'last_changed': watch.get('last_changed'), 'has_favicon': True if watch.get_favicon_filename() else False,
'last_checked': watch.get('last_checked'),
'error_text': error_texts,
'history_n': watch.history_n, 'history_n': watch.history_n,
'last_checked_text': _jinja2_filter_datetime(watch),
'last_changed_text': timeago.format(int(watch.last_changed), time.time()) if watch.history_n >= 2 and int(watch.last_changed) > 0 else 'Not yet', 'last_changed_text': timeago.format(int(watch.last_changed), time.time()) if watch.history_n >= 2 and int(watch.last_changed) > 0 else 'Not yet',
'queued': True if watch.get('uuid') in queue_list else False, 'last_checked': watch.get('last_checked'),
'paused': True if watch.get('paused') else False, 'last_checked_text': _jinja2_filter_datetime(watch),
'notification_muted': True if watch.get('notification_muted') else False, 'notification_muted': True if watch.get('notification_muted') else False,
'paused': True if watch.get('paused') else False,
'queued': True if watch.get('uuid') in queue_list else False,
'unviewed': watch.has_unviewed, 'unviewed': watch.has_unviewed,
'uuid': watch.get('uuid'), 'uuid': watch.get('uuid'),
'event_timestamp': time.time()
} }
errored_count = 0 errored_count = 0
@ -315,7 +314,6 @@ def init_socketio(app, datastore):
emit_flash=False emit_flash=False
) )
@socketio.on('connect') @socketio.on('connect')
def handle_connect(): def handle_connect():
"""Handle client connection""" """Handle client connection"""

Wyświetl plik

@ -159,6 +159,7 @@
// Return the current request in case it's needed // Return the current request in case it's needed
return requests[namespace]; return requests[namespace];
}; };
})(jQuery); })(jQuery);

Wyświetl plik

@ -126,15 +126,19 @@ $(document).ready(function () {
$($watchRow).toggleClass('queued', watch.queued); $($watchRow).toggleClass('queued', watch.queued);
$($watchRow).toggleClass('unviewed', watch.unviewed); $($watchRow).toggleClass('unviewed', watch.unviewed);
$($watchRow).toggleClass('has-error', watch.has_error); $($watchRow).toggleClass('has-error', watch.has_error);
$($watchRow).toggleClass('has-favicon', watch.has_favicon);
$($watchRow).toggleClass('notification_muted', watch.notification_muted); $($watchRow).toggleClass('notification_muted', watch.notification_muted);
$($watchRow).toggleClass('paused', watch.paused); $($watchRow).toggleClass('paused', watch.paused);
$($watchRow).toggleClass('single-history', watch.history_n === 1); $($watchRow).toggleClass('single-history', watch.history_n === 1);
$($watchRow).toggleClass('multiple-history', watch.history_n >= 2); $($watchRow).toggleClass('multiple-history', watch.history_n >= 2);
$('td.title-col .error-text', $watchRow).html(watch.error_text) $('td.title-col .error-text', $watchRow).html(watch.error_text)
if (watch.has_favicon) {
// Because the event could be emitted from a process that is outside the app context, url_for() might not work.
// Lets use url_for at template generation time to give us a PLACEHOLDER instead
$('img.favicon', $watchRow).attr('src', favicon_baseURL.replace('/PLACEHOLDER', `/${watch.uuid}?cache=${watch.event_timestamp}`));
}
$('td.last-changed', $watchRow).text(watch.last_changed_text) $('td.last-changed', $watchRow).text(watch.last_changed_text)
$('td.last-checked .innertext', $watchRow).text(watch.last_checked_text) $('td.last-checked .innertext', $watchRow).text(watch.last_checked_text)
$('td.last-checked', $watchRow).data('timestamp', watch.last_checked).data('fetchduration', watch.fetch_time); $('td.last-checked', $watchRow).data('timestamp', watch.last_checked).data('fetchduration', watch.fetch_time);
$('td.last-checked', $watchRow).data('eta_complete', watch.last_checked + watch.fetch_time); $('td.last-checked', $watchRow).data('eta_complete', watch.last_checked + watch.fetch_time);

Wyświetl plik

@ -0,0 +1,87 @@
.watch-table {
td,
th {
vertical-align: middle;
}
tr.has-favicon {
img.favicon {
display: inline-block !important;
}
&.unviewed {
img.favicon {
opacity: 1.0 !important;
}
}
}
.status-icons {
white-space: nowrap;
display: flex;
align-items: center; /* Vertical centering */
gap: 4px; /* Space between image and text */
> * {
vertical-align: middle;
}
}
}
.title-col {
/* Optional, for spacing */
padding: 10px;
}
.title-wrapper {
display: flex;
align-items: center; /* Vertical centering */
gap: 10px; /* Space between image and text */
}
/* Make sure .title-col-inner doesn't collapse or misalign */
.title-col-inner {
display: inline-block;
vertical-align: middle;
}
/* favicon styling */
.watch-table {
img.favicon {
vertical-align: middle;
max-width: 25px;
max-height: 25px;
height: 25px;
padding-right: 4px;
}
tr.has-favicon {
td.inline.title-col {
.flex-wrapper {
display: flex;
align-items: center;
gap: 4px;
}
}
}
// Reserved for future use
/* &.thumbnail-type-screenshot {
tr.has-favicon {
td.inline.title-col {
img.thumbnail {
background-color: #fff; !* fallback bg for SVGs without bg *!
border-radius: 4px; !* subtle rounded corners *!
border: 1px solid #ddd; !* light border for contrast *!
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); !* soft shadow *!
filter: contrast(1.05) saturate(1.1) drop-shadow(0 0 0.5px rgba(0, 0, 0, 0.2));
object-fit: cover; !* crop/fill if needed *!
opacity: 0.8;
max-width: 30px;
max-height: 30px;
height: 30px;
}
}
}
}*/
}

Wyświetl plik

@ -0,0 +1,172 @@
$grid-col-checkbox: 20px;
$grid-col-watch: 100px;
$grid-gap: 0.5rem;
@media (max-width: 767px) {
/*
Max width before this PARTICULAR table gets nasty
This query will take effect for any screen smaller than 760px
and also iPads specifically.
*/
.watch-table {
/* make headings work on mobile */
thead {
display: block;
tr {
th {
display: inline-block;
// Hide the "Last" text for smaller screens
@media (max-width: 768px) {
.hide-on-mobile {
display: none;
}
}
}
}
.empty-cell {
display: none;
}
}
.last-checked {
> span {
vertical-align: middle;
}
}
.last-checked::before {
color: var(--color-text);
content: "Last Checked ";
}
.last-changed::before {
color: var(--color-text);
content: "Last Changed ";
}
/* Force table to not be like tables anymore */
td.inline {
display: inline-block;
}
.pure-table td,
.pure-table th {
border: none;
}
td {
/* Behave like a "row" */
border: none;
border-bottom: 1px solid var(--color-border-watch-table-cell);
vertical-align: middle;
&:before {
/* Top/left values mimic padding */
top: 6px;
left: 6px;
width: 45%;
padding-right: 10px;
white-space: nowrap;
}
}
&.pure-table-striped {
tr {
background-color: var(--color-table-background);
}
tr:nth-child(2n-1) {
background-color: var(--color-table-stripe);
}
tr:nth-child(2n-1) td {
background-color: inherit;
}
}
}
}
@media (max-width: 767px) {
.watch-table {
tbody {
tr {
padding-bottom: 10px;
padding-top: 10px;
display: grid;
grid-template-columns: $grid-col-checkbox 1fr $grid-col-watch;
grid-template-rows: auto auto auto auto;
gap: $grid-gap;
.counter-i {
display: none;
}
td.checkbox-uuid {
display: grid;
place-items: center;
}
td.inline {
/* display: block !important;;*/
}
> td {
border-bottom: none;
}
> td.title-col {
grid-column: 1 / -1;
grid-row: 1;
.watch-title {
font-size: 0.92rem;
}
.link-spread {
display: none;
}
}
> td.last-checked {
grid-column: 1 / -1;
grid-row: 2;
}
> td.last-changed {
grid-column: 1 / -1;
grid-row: 3;
}
> td.checkbox-uuid {
grid-column: 1;
grid-row: 4;
}
> td.buttons {
grid-column: 2;
grid-row: 4;
display: flex;
align-items: center;
justify-content: flex-start;
}
> td.watch-controls {
grid-column: 3;
grid-row: 4;
display: grid;
place-items: center;
a img {
padding: 10px;
}
}
}
}
}
.pure-table td {
padding: 5px !important;
}
}

Wyświetl plik

@ -7,6 +7,7 @@
&.unviewed { &.unviewed {
font-weight: bold; font-weight: bold;
} }
color: var(--color-watch-table-row-text); color: var(--color-watch-table-row-text);
} }
@ -109,6 +110,7 @@
tr.has-error { tr.has-error {
color: var(--color-watch-table-error); color: var(--color-watch-table-error);
.error-text { .error-text {
display: block !important; display: block !important;
} }
@ -119,6 +121,7 @@
display: inline-block !important; display: inline-block !important;
} }
} }
tr.multiple-history { tr.multiple-history {
a.history-link { a.history-link {
display: inline-block !important; display: inline-block !important;
@ -126,5 +129,3 @@
} }
} }

Wyświetl plik

@ -14,10 +14,13 @@
@import "parts/_love"; @import "parts/_love";
@import "parts/preview_text_filter"; @import "parts/preview_text_filter";
@import "parts/_watch_table"; @import "parts/_watch_table";
@import "parts/_watch_table-mobile";
@import "parts/_edit"; @import "parts/_edit";
@import "parts/_conditions_table"; @import "parts/_conditions_table";
@import "parts/_lister_extra";
@import "parts/_socket"; @import "parts/_socket";
body { body {
color: var(--color-text); color: var(--color-text);
background: var(--color-background-page); background: var(--color-background-page);
@ -694,114 +697,6 @@ footer {
width: 100%; width: 100%;
} }
/*
Max width before this PARTICULAR table gets nasty
This query will take effect for any screen smaller than 760px
and also iPads specifically.
*/
.watch-table {
/* make headings work on mobile */
thead {
display: block;
tr {
th {
display: inline-block;
// Hide the "Last" text for smaller screens
@media (max-width: 768px) {
.hide-on-mobile {
display: none;
}
}
}
}
.empty-cell {
display: none;
}
}
/* Force table to not be like tables anymore */
tbody {
td,
tr {
display: block;
}
}
tbody {
tr {
display: flex;
flex-wrap: wrap;
// The third child of each row will take up the remaining space
// This is useful for the URL column, which should expand to fill the remaining space
:nth-child(3) {
flex-grow: 1;
}
// The last three children (from the end) of each row will take up the full width
// This is useful for the "Last Checked", "Last Changed", and the action buttons columns, which should each take up the full width
:nth-last-child(-n+3) {
flex-basis: 100%;
}
}
}
.last-checked {
>span {
vertical-align: middle;
}
}
.last-checked::before {
color: var(--color-last-checked);
content: "Last Checked ";
}
.last-changed::before {
color: var(--color-last-checked);
content: "Last Changed ";
}
/* Force table to not be like tables anymore */
td.inline {
display: inline-block;
}
.pure-table td,
.pure-table th {
border: none;
}
td {
/* Behave like a "row" */
border: none;
border-bottom: 1px solid var(--color-border-watch-table-cell);
vertical-align: middle;
&:before {
/* Top/left values mimic padding */
top: 6px;
left: 6px;
width: 45%;
padding-right: 10px;
white-space: nowrap;
}
}
&.pure-table-striped {
tr {
background-color: var(--color-table-background);
}
tr:nth-child(2n-1) {
background-color: var(--color-table-stripe);
}
tr:nth-child(2n-1) td {
background-color: inherit;
}
}
}
} }
.pure-table { .pure-table {

Wyświetl plik

@ -585,6 +585,107 @@ body.preview-text-enabled {
.watch-table tr.multiple-history a.history-link { .watch-table tr.multiple-history a.history-link {
display: inline-block !important; } display: inline-block !important; }
@media (max-width: 767px) {
/*
Max width before this PARTICULAR table gets nasty
This query will take effect for any screen smaller than 760px
and also iPads specifically.
*/
.watch-table {
/* make headings work on mobile */
/* Force table to not be like tables anymore */ }
.watch-table thead {
display: block; }
.watch-table thead tr th {
display: inline-block; } }
@media (max-width: 767px) and (max-width: 768px) {
.watch-table thead tr th .hide-on-mobile {
display: none; } }
@media (max-width: 767px) {
.watch-table thead .empty-cell {
display: none; }
.watch-table .last-checked > span {
vertical-align: middle; }
.watch-table .last-checked::before {
color: var(--color-text);
content: "Last Checked "; }
.watch-table .last-changed::before {
color: var(--color-text);
content: "Last Changed "; }
.watch-table td.inline {
display: inline-block; }
.watch-table .pure-table td,
.watch-table .pure-table th {
border: none; }
.watch-table td {
/* Behave like a "row" */
border: none;
border-bottom: 1px solid var(--color-border-watch-table-cell);
vertical-align: middle; }
.watch-table td:before {
/* Top/left values mimic padding */
top: 6px;
left: 6px;
width: 45%;
padding-right: 10px;
white-space: nowrap; }
.watch-table.pure-table-striped tr {
background-color: var(--color-table-background); }
.watch-table.pure-table-striped tr:nth-child(2n-1) {
background-color: var(--color-table-stripe); }
.watch-table.pure-table-striped tr:nth-child(2n-1) td {
background-color: inherit; } }
@media (max-width: 767px) {
.watch-table tbody tr {
padding-bottom: 10px;
padding-top: 10px;
display: grid;
grid-template-columns: 20px 1fr 100px;
grid-template-rows: auto auto auto auto;
gap: 0.5rem; }
.watch-table tbody tr .counter-i {
display: none; }
.watch-table tbody tr td.checkbox-uuid {
display: grid;
place-items: center; }
.watch-table tbody tr td.inline {
/* display: block !important;;*/ }
.watch-table tbody tr > td {
border-bottom: none; }
.watch-table tbody tr > td.title-col {
grid-column: 1 / -1;
grid-row: 1; }
.watch-table tbody tr > td.title-col .watch-title {
font-size: 0.92rem; }
.watch-table tbody tr > td.title-col .link-spread {
display: none; }
.watch-table tbody tr > td.last-checked {
grid-column: 1 / -1;
grid-row: 2; }
.watch-table tbody tr > td.last-changed {
grid-column: 1 / -1;
grid-row: 3; }
.watch-table tbody tr > td.checkbox-uuid {
grid-column: 1;
grid-row: 4; }
.watch-table tbody tr > td.buttons {
grid-column: 2;
grid-row: 4;
display: flex;
align-items: center;
justify-content: flex-start; }
.watch-table tbody tr > td.watch-controls {
grid-column: 3;
grid-row: 4;
display: grid;
place-items: center; }
.watch-table tbody tr > td.watch-controls a img {
padding: 10px; }
.pure-table td {
padding: 5px !important; } }
ul#conditions_match_logic { ul#conditions_match_logic {
list-style: none; } list-style: none; }
ul#conditions_match_logic input, ul#conditions_match_logic label, ul#conditions_match_logic li { ul#conditions_match_logic input, ul#conditions_match_logic label, ul#conditions_match_logic li {
@ -685,6 +786,73 @@ ul#conditions_match_logic {
.fieldlist_formfields .addRuleRow:hover, .fieldlist_formfields .removeRuleRow:hover, .fieldlist_formfields .verifyRuleRow:hover { .fieldlist_formfields .addRuleRow:hover, .fieldlist_formfields .removeRuleRow:hover, .fieldlist_formfields .verifyRuleRow:hover {
background-color: #999; } background-color: #999; }
.watch-table td,
.watch-table th {
vertical-align: middle; }
.watch-table tr.has-favicon img.favicon {
display: inline-block !important; }
.watch-table tr.has-favicon.unviewed img.favicon {
opacity: 1.0 !important; }
.watch-table .status-icons {
white-space: nowrap;
display: flex;
align-items: center;
/* Vertical centering */
gap: 4px;
/* Space between image and text */ }
.watch-table .status-icons > * {
vertical-align: middle; }
.title-col {
/* Optional, for spacing */
padding: 10px; }
.title-wrapper {
display: flex;
align-items: center;
/* Vertical centering */
gap: 10px;
/* Space between image and text */ }
/* Make sure .title-col-inner doesn't collapse or misalign */
.title-col-inner {
display: inline-block;
vertical-align: middle; }
/* favicon styling */
.watch-table {
/* &.thumbnail-type-screenshot {
tr.has-favicon {
td.inline.title-col {
img.thumbnail {
background-color: #fff; !* fallback bg for SVGs without bg *!
border-radius: 4px; !* subtle rounded corners *!
border: 1px solid #ddd; !* light border for contrast *!
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); !* soft shadow *!
filter: contrast(1.05) saturate(1.1) drop-shadow(0 0 0.5px rgba(0, 0, 0, 0.2));
object-fit: cover; !* crop/fill if needed *!
opacity: 0.8;
max-width: 30px;
max-height: 30px;
height: 30px;
}
}
}
}*/ }
.watch-table img.favicon {
vertical-align: middle;
max-width: 25px;
max-height: 25px;
height: 25px;
padding-right: 4px; }
.watch-table tr.has-favicon td.inline.title-col .flex-wrapper {
display: flex;
align-items: center;
gap: 4px; }
body.checking-now #checking-now-fixed-tab { body.checking-now #checking-now-fixed-tab {
display: block !important; } display: block !important; }
@ -1177,68 +1345,7 @@ footer {
border-radius: 0px; border-radius: 0px;
margin-right: 0px; } margin-right: 0px; }
input[type='text'] { input[type='text'] {
width: 100%; } width: 100%; } }
/*
Max width before this PARTICULAR table gets nasty
This query will take effect for any screen smaller than 760px
and also iPads specifically.
*/
.watch-table {
/* make headings work on mobile */
/* Force table to not be like tables anymore */
/* Force table to not be like tables anymore */ }
.watch-table thead {
display: block; }
.watch-table thead tr th {
display: inline-block; } }
@media only screen and (max-width: 760px) and (max-width: 768px), (min-device-width: 768px) and (max-device-width: 800px) and (max-width: 768px) {
.watch-table thead tr th .hide-on-mobile {
display: none; } }
@media only screen and (max-width: 760px), (min-device-width: 768px) and (max-device-width: 800px) {
.watch-table thead .empty-cell {
display: none; }
.watch-table tbody td,
.watch-table tbody tr {
display: block; }
.watch-table tbody tr {
display: flex;
flex-wrap: wrap; }
.watch-table tbody tr :nth-child(3) {
flex-grow: 1; }
.watch-table tbody tr :nth-last-child(-n+3) {
flex-basis: 100%; }
.watch-table .last-checked > span {
vertical-align: middle; }
.watch-table .last-checked::before {
color: var(--color-last-checked);
content: "Last Checked "; }
.watch-table .last-changed::before {
color: var(--color-last-checked);
content: "Last Changed "; }
.watch-table td.inline {
display: inline-block; }
.watch-table .pure-table td,
.watch-table .pure-table th {
border: none; }
.watch-table td {
/* Behave like a "row" */
border: none;
border-bottom: 1px solid var(--color-border-watch-table-cell);
vertical-align: middle; }
.watch-table td:before {
/* Top/left values mimic padding */
top: 6px;
left: 6px;
width: 45%;
padding-right: 10px;
white-space: nowrap; }
.watch-table.pure-table-striped tr {
background-color: var(--color-table-background); }
.watch-table.pure-table-striped tr:nth-child(2n-1) {
background-color: var(--color-table-stripe); }
.watch-table.pure-table-striped tr:nth-child(2n-1) td {
background-color: inherit; } }
.pure-table { .pure-table {
border-color: var(--color-border-table-cell); } border-color: var(--color-border-table-cell); }

Wyświetl plik

@ -2,9 +2,11 @@
import time import time
from flask import url_for from flask import url_for
import os
from ..util import live_server_setup, wait_for_all_checks from ..util import live_server_setup, wait_for_all_checks
import logging import logging
# Requires playwright to be installed # Requires playwright to be installed
def test_fetch_webdriver_content(client, live_server, measure_memory_usage): def test_fetch_webdriver_content(client, live_server, measure_memory_usage):
# live_server_setup(live_server) # Setup on conftest per function # live_server_setup(live_server) # Setup on conftest per function
@ -30,11 +32,32 @@ def test_fetch_webdriver_content(client, live_server, measure_memory_usage):
assert b"1 Imported" in res.data assert b"1 Imported" in res.data
wait_for_all_checks(client) wait_for_all_checks(client)
res = client.get( res = client.get(
url_for("ui.ui_views.preview_page", uuid="first"), url_for("ui.ui_views.preview_page", uuid="first"),
follow_redirects=True follow_redirects=True
) )
logging.getLogger().info("Looking for correct fetched HTML (text) from server") logging.getLogger().info("Looking for correct fetched HTML (text) from server")
assert b'cool it works' in res.data assert b'cool it works' in res.data
# Favicon scraper check, favicon only so far is fetched when in browser mode (not requests mode)
if os.getenv("PLAYWRIGHT_DRIVER_URL"):
uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))
res = client.get(
url_for("watchlist.index"),
)
# The UI can access it here
assert f'src="/static/favicon/{uuid}'.encode('utf8') in res.data
# Attempt to fetch it, make sure that works
res = client.get(url_for('static_content', group='favicon', filename=uuid))
assert res.status_code == 200
assert len(res.data) > 10
# Check the API also returns it
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
res = client.get(
url_for("watchfavicon", uuid=uuid),
headers={'x-api-key': api_key}
)
assert res.status_code == 200
assert len(res.data) > 10