Compare commits

...

22 Commits

Author SHA1 Message Date
dgtlmoon
989e75aba8 Merge branch 'master' into python312 2024-06-04 11:27:47 +02:00
dependabot[bot]
8041d00e75 Code - Bump eventlet from 0.33.3 to 0.35.2 (#2305) 2024-06-03 17:25:14 +02:00
dgtlmoon
6a0e14cfce UI - Mobile CSS/layout fix wrapping on empty list text #2393 2024-05-30 14:26:51 +02:00
dgtlmoon
be91c5425c UI - Preview single snapshot - Date and button fixes (#2389) 2024-05-27 12:51:01 +02:00
dgtlmoon
778680d517 Build - PIL/pillow package not used, probably shouldnt be installed/required (#2382) 2024-05-27 10:17:19 +02:00
dgtlmoon
3aacba3281 Merge branch 'master' into python312 2024-05-22 10:22:21 +02:00
dgtlmoon
7e8aa7e3ff 0.45.23 2024-05-22 00:01:50 +02:00
Alexander Sulfrian
d77f913aa0 RSS - Only insert feed header if app_rss_token is set (should be only shown in index/overview page) (#2381) 2024-05-21 23:47:35 +02:00
dgtlmoon
e72187221b Merge branch 'master' into python312 2024-05-21 17:20:52 +02:00
dgtlmoon
59cefe58e7 Fetcher - Using pyppeteerstealth with puppeteer fetcher (#2203) 2024-05-21 17:03:50 +02:00
dgtlmoon
cfc689e046 Fix overflowing text 2024-05-21 12:13:23 +02:00
Alexander Sulfrian
7b04b52e45 RSS and tags/groups - Fixes use active_tag_uuid, fixes broken RSS link in page html (#2379) 2024-05-20 15:49:12 +02:00
dgtlmoon
f49eb4567f Ability to set default User-Agent for either fetching types directly in the UI (#2375) 2024-05-20 15:11:15 +02:00
dgtlmoon
a8959be348 Testing - Fixing JSON test 2024-05-20 14:14:40 +02:00
dgtlmoon
05bf3c9a5c UI - Mobile - quick watch form element fixes 2024-05-20 13:43:59 +02:00
dgtlmoon
4293639f51 UI - CSS - Remove gradient border, it did not add much to the design #2377 2024-05-20 13:41:23 +02:00
dgtlmoon
f0ed4f64e8 RSS - Muted watches should not show in RSS feed (#2374 #2304) 2024-05-17 17:47:35 +02:00
dgtlmoon
add2c658b4 Notifications - Fixing truncated notifications when tgram:// or discord:// is used with other notification methods (#2372 #2299) 2024-05-17 09:20:26 +02:00
dgtlmoon
bd84f6c41d Merge branch 'master' into python312 2024-05-15 12:37:32 +02:00
dgtlmoon
98d57abb9f packing our own strtobool 2024-04-03 13:42:43 +02:00
dgtlmoon
8e6bb8d728 0.34.1 - fixes python 3.12 "AttributeError: module 'ssl' has no attribute 'wrap_socket'" 2024-03-31 22:39:27 +02:00
dgtlmoon
16593faa6e 3.12 2024-03-31 22:31:53 +02:00
27 changed files with 158 additions and 77 deletions

View File

@@ -40,10 +40,10 @@ jobs:
if: ${{ github.event.workflow_run.conclusion == 'success' }} || ${{ github.event.release.tag_name }} != '' if: ${{ github.event.workflow_run.conclusion == 'success' }} || ${{ github.event.release.tag_name }} != ''
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Python 3.11 - name: Set up Python 3.12
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: 3.11 python-version: 3.12
- name: Install dependencies - name: Install dependencies
run: | run: |

View File

@@ -11,7 +11,7 @@ jobs:
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: "3.11" python-version: "3.12"
- name: Install pypa/build - name: Install pypa/build
run: >- run: >-
python3 -m python3 -m
@@ -38,10 +38,10 @@ jobs:
with: with:
name: python-package-distributions name: python-package-distributions
path: dist/ path: dist/
- name: Set up Python 3.11 - name: Set up Python 3.12
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: '3.11' python-version: '3.12'
- name: Test that the basic pip built package runs without error - name: Test that the basic pip built package runs without error
run: | run: |
set -ex set -ex

View File

@@ -27,10 +27,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Python 3.11 - name: Set up Python 3.12
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: 3.11 python-version: 3.12
# Just test that the build works, some libraries won't compile on ARM/rPi etc # Just test that the build works, some libraries won't compile on ARM/rPi etc
- name: Set up QEMU - name: Set up QEMU

View File

@@ -10,10 +10,10 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
# Mainly just for link/flake8 # Mainly just for link/flake8
- name: Set up Python 3.11 - name: Set up Python 3.12
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: '3.11' python-version: '3.12'
- name: Lint with flake8 - name: Lint with flake8
run: | run: |

View File

@@ -2,7 +2,7 @@
# @NOTE! I would love to move to 3.11 but it breaks the async handler in changedetectionio/content_fetchers/puppeteer.py # @NOTE! I would love to move to 3.11 but it breaks the async handler in changedetectionio/content_fetchers/puppeteer.py
# If you know how to fix it, please do! and test it for both 3.10 and 3.11 # If you know how to fix it, please do! and test it for both 3.10 and 3.11
FROM python:3.10-slim-bookworm as builder FROM python:3.12-slim-bookworm as builder
# See `cryptography` pin comment in requirements.txt # See `cryptography` pin comment in requirements.txt
ARG CRYPTOGRAPHY_DONT_BUILD_RUST=1 ARG CRYPTOGRAPHY_DONT_BUILD_RUST=1
@@ -32,7 +32,7 @@ RUN pip install --target=/dependencies playwright~=1.41.2 \
|| echo "WARN: Failed to install Playwright. The application can still run, but the Playwright option will be disabled." || echo "WARN: Failed to install Playwright. The application can still run, but the Playwright option will be disabled."
# Final image stage # Final image stage
FROM python:3.10-slim-bookworm FROM python:3.12-slim-bookworm
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
libxslt1.1 \ libxslt1.1 \

View File

@@ -2,7 +2,7 @@
# Read more https://github.com/dgtlmoon/changedetection.io/wiki # Read more https://github.com/dgtlmoon/changedetection.io/wiki
__version__ = '0.45.22' __version__ = '0.45.23'
from changedetectionio.strtobool import strtobool from changedetectionio.strtobool import strtobool
from json.decoder import JSONDecodeError from json.decoder import JSONDecodeError

View File

@@ -9,7 +9,6 @@ from loguru import logger
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, BrowserConnectError from changedetectionio.content_fetchers.exceptions import PageUnloadable, Non200ErrorCodeReceived, EmptyReply, BrowserFetchTimedOut, BrowserConnectError
class fetcher(Fetcher): class fetcher(Fetcher):
fetcher_description = "Puppeteer/direct {}/Javascript".format( fetcher_description = "Puppeteer/direct {}/Javascript".format(
os.getenv("PLAYWRIGHT_BROWSER_TYPE", 'chromium').capitalize() os.getenv("PLAYWRIGHT_BROWSER_TYPE", 'chromium').capitalize()
@@ -93,15 +92,39 @@ class fetcher(Fetcher):
ignoreHTTPSErrors=True ignoreHTTPSErrors=True
) )
except websockets.exceptions.InvalidStatusCode as e: except websockets.exceptions.InvalidStatusCode as e:
raise BrowserConnectError(msg=f"Error while trying to connect the browser, Code {e.status_code} (check your access)") raise BrowserConnectError(msg=f"Error while trying to connect the browser, Code {e.status_code} (check your access, whitelist IP, password etc)")
except websockets.exceptions.InvalidURI: except websockets.exceptions.InvalidURI:
raise BrowserConnectError(msg=f"Error connecting to the browser, check your browser connection address (should be ws:// or wss://") raise BrowserConnectError(msg=f"Error connecting to the browser, check your browser connection address (should be ws:// or wss://")
except Exception as e: except Exception as e:
raise BrowserConnectError(msg=f"Error connecting to the browser {str(e)}") raise BrowserConnectError(msg=f"Error connecting to the browser {str(e)}")
else:
self.page = await browser.newPage()
await self.page.setUserAgent(manage_user_agent(headers=request_headers, current_ua=await self.page.evaluate('navigator.userAgent'))) # Better is to launch chrome with the URL as arg
# non-headless - newPage() will launch an extra tab/window, .browser should already contain 1 page/tab
# headless - ask a new page
self.page = (pages := await browser.pages) and len(pages) or await browser.newPage()
try:
from pyppeteerstealth import inject_evasions_into_page
except ImportError:
logger.debug("pyppeteerstealth module not available, skipping")
pass
else:
# I tried hooking events via self.page.on(Events.Page.DOMContentLoaded, inject_evasions_requiring_obj_to_page)
# But I could never get it to fire reliably, so we just inject it straight after
await inject_evasions_into_page(self.page)
# This user agent is similar to what was used when tweaking the evasions in inject_evasions_into_page(..)
user_agent = None
if request_headers:
user_agent = next((value for key, value in request_headers.items() if key.lower().strip() == 'user-agent'), None)
if user_agent:
await self.page.setUserAgent(user_agent)
# Remove it so it's not sent again with headers after
[request_headers.pop(key) for key in list(request_headers) if key.lower().strip() == 'user-agent'.lower().strip()]
if not user_agent:
# Attempt to strip 'HeadlessChrome' etc
await self.page.setUserAgent(manage_user_agent(headers=request_headers, current_ua=await self.page.evaluate('navigator.userAgent')))
await self.page.setBypassCSP(True) await self.page.setBypassCSP(True)
if request_headers: if request_headers:

View File

@@ -30,11 +30,6 @@ class fetcher(Fetcher):
if self.browser_steps_get_valid_steps(): if self.browser_steps_get_valid_steps():
raise BrowserStepsInUnsupportedFetcher(url=url) raise BrowserStepsInUnsupportedFetcher(url=url)
# Make requests use a more modern looking user-agent
if not {k.lower(): v for k, v in request_headers.items()}.get('user-agent', None):
request_headers['User-Agent'] = os.getenv("DEFAULT_SETTINGS_HEADERS_USERAGENT",
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.66 Safari/537.36')
proxies = {} proxies = {}
# Allows override the proxy on a per-request basis # Allows override the proxy on a per-request basis

View File

@@ -124,10 +124,10 @@ def _jinja2_filter_datetime(watch_obj, format="%Y-%m-%d %H:%M:%S"):
@app.template_filter('format_timestamp_timeago') @app.template_filter('format_timestamp_timeago')
def _jinja2_filter_datetimestamp(timestamp, format="%Y-%m-%d %H:%M:%S"): def _jinja2_filter_datetimestamp(timestamp, format="%Y-%m-%d %H:%M:%S"):
if timestamp == False: if not timestamp:
return 'Not yet' return 'Not yet'
return timeago.format(timestamp, time.time()) return timeago.format(int(timestamp), time.time())
@app.template_filter('pagination_slice') @app.template_filter('pagination_slice')
@@ -338,8 +338,11 @@ def changedetection_app(config=None, datastore_o=None):
# @todo needs a .itemsWithTag() or something - then we can use that in Jinaj2 and throw this away # @todo needs a .itemsWithTag() or something - then we can use that in Jinaj2 and throw this away
for uuid, watch in datastore.data['watching'].items(): for uuid, watch in datastore.data['watching'].items():
# @todo tag notification_muted skip also (improve Watch model)
if watch.get('notification_muted'):
continue
if limit_tag and not limit_tag in watch['tags']: if limit_tag and not limit_tag in watch['tags']:
continue continue
watch['uuid'] = uuid watch['uuid'] = uuid
sorted_watches.append(watch) sorted_watches.append(watch)
@@ -768,7 +771,7 @@ def changedetection_app(config=None, datastore_o=None):
jq_support=jq_support, jq_support=jq_support,
playwright_enabled=os.getenv('PLAYWRIGHT_DRIVER_URL', False), playwright_enabled=os.getenv('PLAYWRIGHT_DRIVER_URL', False),
settings_application=datastore.data['settings']['application'], settings_application=datastore.data['settings']['application'],
using_global_webdriver_wait=default['webdriver_delay'] is None, using_global_webdriver_wait=not default['webdriver_delay'],
uuid=uuid, uuid=uuid,
visualselector_enabled=visualselector_enabled, visualselector_enabled=visualselector_enabled,
watch=watch watch=watch

View File

@@ -526,6 +526,10 @@ class SingleExtraBrowser(Form):
browser_connection_url = StringField('Browser connection URL', [validators.Optional()], render_kw={"placeholder": "wss://brightdata... wss://oxylabs etc", "size":50}) browser_connection_url = StringField('Browser connection URL', [validators.Optional()], render_kw={"placeholder": "wss://brightdata... wss://oxylabs etc", "size":50})
# @todo do the validation here instead # @todo do the validation here instead
class DefaultUAInputForm(Form):
html_requests = StringField('Plaintext requests', validators=[validators.Optional()], render_kw={"placeholder": "<default>"})
if os.getenv("PLAYWRIGHT_DRIVER_URL") or os.getenv("WEBDRIVER_URL"):
html_webdriver = StringField('Chrome requests', validators=[validators.Optional()], render_kw={"placeholder": "<default>"})
# datastore.data['settings']['requests'].. # datastore.data['settings']['requests']..
class globalSettingsRequestForm(Form): class globalSettingsRequestForm(Form):
@@ -537,6 +541,8 @@ class globalSettingsRequestForm(Form):
extra_proxies = FieldList(FormField(SingleExtraProxy), min_entries=5) extra_proxies = FieldList(FormField(SingleExtraProxy), min_entries=5)
extra_browsers = FieldList(FormField(SingleExtraBrowser), min_entries=5) extra_browsers = FieldList(FormField(SingleExtraBrowser), min_entries=5)
default_ua = FormField(DefaultUAInputForm, label="Default User-Agent overrides")
def validate_extra_proxies(self, extra_validators=None): def validate_extra_proxies(self, extra_validators=None):
for e in self.data['extra_proxies']: for e in self.data['extra_proxies']:
if e.get('proxy_name') or e.get('proxy_url'): if e.get('proxy_name') or e.get('proxy_url'):

View File

@@ -6,6 +6,7 @@ from changedetectionio.notification import (
) )
_FILTER_FAILURE_THRESHOLD_ATTEMPTS_DEFAULT = 6 _FILTER_FAILURE_THRESHOLD_ATTEMPTS_DEFAULT = 6
DEFAULT_SETTINGS_HEADERS_USERAGENT='Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.66 Safari/537.36'
class model(dict): class model(dict):
base_config = { base_config = {
@@ -22,6 +23,10 @@ class model(dict):
'time_between_check': {'weeks': None, 'days': None, 'hours': 3, 'minutes': None, 'seconds': None}, 'time_between_check': {'weeks': None, 'days': None, 'hours': 3, 'minutes': None, 'seconds': None},
'timeout': int(getenv("DEFAULT_SETTINGS_REQUESTS_TIMEOUT", "45")), # Default 45 seconds 'timeout': int(getenv("DEFAULT_SETTINGS_REQUESTS_TIMEOUT", "45")), # Default 45 seconds
'workers': int(getenv("DEFAULT_SETTINGS_REQUESTS_WORKERS", "10")), # Number of threads, lower is better for slow connections 'workers': int(getenv("DEFAULT_SETTINGS_REQUESTS_WORKERS", "10")), # Number of threads, lower is better for slow connections
'default_ua': {
'html_requests': getenv("DEFAULT_SETTINGS_HEADERS_USERAGENT", DEFAULT_SETTINGS_HEADERS_USERAGENT),
'html_webdriver': None,
}
}, },
'application': { 'application': {
# Custom notification content # Custom notification content

View File

@@ -1,6 +1,5 @@
from changedetectionio.strtobool import strtobool from changedetectionio.strtobool import strtobool
from changedetectionio.safe_jinja import render as jinja_render from changedetectionio.safe_jinja import render as jinja_render
import os import os
import re import re
import time import time

View File

@@ -122,10 +122,6 @@ def process_notification(n_object, datastore):
# Insert variables into the notification content # Insert variables into the notification content
notification_parameters = create_notification_parameters(n_object, datastore) notification_parameters = create_notification_parameters(n_object, datastore)
# Get the notification body from datastore
n_body = jinja_render(template_str=n_object.get('notification_body', ''), **notification_parameters)
n_title = jinja_render(template_str=n_object.get('notification_title', ''), **notification_parameters)
n_format = valid_notification_formats.get( n_format = valid_notification_formats.get(
n_object.get('notification_format', default_notification_format), n_object.get('notification_format', default_notification_format),
valid_notification_formats[default_notification_format], valid_notification_formats[default_notification_format],
@@ -151,6 +147,11 @@ def process_notification(n_object, datastore):
with apprise.LogCapture(level=apprise.logging.DEBUG) as logs: with apprise.LogCapture(level=apprise.logging.DEBUG) as logs:
for url in n_object['notification_urls']: for url in n_object['notification_urls']:
# Get the notification body from datastore
n_body = jinja_render(template_str=n_object.get('notification_body', ''), **notification_parameters)
n_title = jinja_render(template_str=n_object.get('notification_title', ''), **notification_parameters)
url = url.strip() url = url.strip()
if not url: if not url:
logger.warning(f"Process Notification: skipping empty notification URL.") logger.warning(f"Process Notification: skipping empty notification URL.")

View File

@@ -97,6 +97,10 @@ class difference_detection_processor():
request_headers.update(self.datastore.get_all_base_headers()) request_headers.update(self.datastore.get_all_base_headers())
request_headers.update(self.datastore.get_all_headers_in_textfile_for_watch(uuid=self.watch.get('uuid'))) request_headers.update(self.datastore.get_all_headers_in_textfile_for_watch(uuid=self.watch.get('uuid')))
ua = self.datastore.data['settings']['requests'].get('default_ua')
if ua and ua.get(prefer_fetch_backend):
request_headers.update({'User-Agent': ua.get(prefer_fetch_backend)})
# https://github.com/psf/requests/issues/4525 # https://github.com/psf/requests/issues/4525
# Requests doesnt yet support brotli encoding, so don't put 'br' here, be totally sure that the user cannot # Requests doesnt yet support brotli encoding, so don't put 'br' here, be totally sure that the user cannot
# do this by accident. # do this by accident.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -40,9 +40,13 @@ if (selectElement) {
if (selectedOption) { if (selectedOption) {
if (selectedOption.previousElementSibling) { if (selectedOption.previousElementSibling) {
document.getElementById('btn-previous').href = "?version=" + selectedOption.previousElementSibling.value; document.getElementById('btn-previous').href = "?version=" + selectedOption.previousElementSibling.value;
} else {
document.getElementById('btn-previous').remove()
} }
if (selectedOption.nextElementSibling) { if (selectedOption.nextElementSibling) {
document.getElementById('btn-next').href = "?version=" + selectedOption.nextElementSibling.value; document.getElementById('btn-next').href = "?version=" + selectedOption.nextElementSibling.value;
} else {
document.getElementById('btn-next').remove()
} }
} }

View File

@@ -243,7 +243,6 @@ body::after {
body::before { body::before {
// background-image set in base.html so it works with reverse proxies etc // background-image set in base.html so it works with reverse proxies etc
content: ""; content: "";
background-size: cover
} }
body:after, body:after,
@@ -1083,6 +1082,9 @@ ul {
li { li {
list-style: none; list-style: none;
font-size: 0.8rem; font-size: 0.8rem;
> * {
display: inline-block;
}
} }
} }

View File

@@ -574,8 +574,7 @@ body::after {
opacity: 0.91; } opacity: 0.91; }
body::before { body::before {
content: ""; content: ""; }
background-size: cover; }
body:after, body:after,
body:before { body:before {
@@ -1173,6 +1172,8 @@ ul {
#quick-watch-processor-type ul li { #quick-watch-processor-type ul li {
list-style: none; list-style: none;
font-size: 0.8rem; } font-size: 0.8rem; }
#quick-watch-processor-type ul li > * {
display: inline-block; }
.restock-label { .restock-label {
padding: 3px; padding: 3px;

View File

@@ -554,7 +554,6 @@ class ChangeDetectionStore:
return os.path.isfile(filepath) return os.path.isfile(filepath)
def get_all_base_headers(self): def get_all_base_headers(self):
from .model.App import parse_headers_from_text_file
headers = {} headers = {}
# Global app settings # Global app settings
headers.update(self.data['settings'].get('headers', {})) headers.update(self.data['settings'].get('headers', {}))

View File

@@ -6,7 +6,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" > <meta name="viewport" content="width=device-width, initial-scale=1.0" >
<meta name="description" content="Self hosted website change detection." > <meta name="description" content="Self hosted website change detection." >
<title>Change Detection{{extra_title}}</title> <title>Change Detection{{extra_title}}</title>
<link rel="alternate" type="application/rss+xml" title="Changedetection.io » Feed{% if active_tag %}- {{active_tag}}{% endif %}" href="{{ url_for('rss', tag=active_tag , token=app_rss_token)}}" > {% if app_rss_token %}
<link rel="alternate" type="application/rss+xml" title="Changedetection.io » Feed{% if active_tag_uuid %}- {{active_tag.title}}{% endif %}" href="{{ url_for('rss', tag=active_tag_uuid , token=app_rss_token)}}" >
{% endif %}
<link rel="stylesheet" href="{{url_for('static_content', group='styles', filename='pure-min.css')}}" > <link rel="stylesheet" href="{{url_for('static_content', group='styles', filename='pure-min.css')}}" >
<link rel="stylesheet" href="{{url_for('static_content', group='styles', filename='styles.css')}}?v={{ get_css_version() }}" > <link rel="stylesheet" href="{{url_for('static_content', group='styles', filename='styles.css')}}?v={{ get_css_version() }}" >
{% if extra_stylesheets %} {% if extra_stylesheets %}
@@ -24,12 +26,6 @@
<meta name="msapplication-TileColor" content="#da532c"> <meta name="msapplication-TileColor" content="#da532c">
<meta name="msapplication-config" content="favicons/browserconfig.xml"> <meta name="msapplication-config" content="favicons/browserconfig.xml">
<meta name="theme-color" content="#ffffff"> <meta name="theme-color" content="#ffffff">
<style>
body::before {
background-image: url({{url_for('static_content', group='images', filename='gradient-border.png') }});
}
</style>
<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>
</head> </head>
@@ -89,8 +85,8 @@
<li class="pure-menu-item pure-form" id="search-menu-item"> <li class="pure-menu-item pure-form" id="search-menu-item">
<!-- We use GET here so it offers people a chance to set bookmarks etc --> <!-- We use GET here so it offers people a chance to set bookmarks etc -->
<form name="searchForm" action="" method="GET"> <form name="searchForm" action="" method="GET">
<input id="search-q" class="" name="q" placeholder="URL or Title {% if active_tag %}in '{{ active_tag }}'{% endif %}" required="" type="text" value=""> <input id="search-q" class="" name="q" placeholder="URL or Title {% if active_tag_uuid %}in '{{ active_tag.title }}'{% endif %}" required="" type="text" value="">
<input name="tags" type="hidden" value="{% if active_tag %}{{active_tag}}{% endif %}"> <input name="tags" type="hidden" value="{% if active_tag_uuid %}{{active_tag_uuid}}{% endif %}">
<button class="toggle-button " id="toggle-search" type="button" title="Search, or Use Alt+S Key" > <button class="toggle-button " id="toggle-search" type="button" title="Search, or Use Alt+S Key" >
{% include "svgs/search-icon.svg" %} {% include "svgs/search-icon.svg" %}
</button> </button>

View File

@@ -433,7 +433,8 @@ Unavailable") }}
<div class="pure-control-group"> <div class="pure-control-group">
{% if visualselector_enabled %} {% if visualselector_enabled %}
<span class="pure-form-message-inline"> <span class="pure-form-message-inline">
The Visual Selector tool lets you select the <i>text</i> elements that will be used for the change detection &dash; after the <i>Browser Steps</i> has completed, this tool is a helper to manage filters in the "CSS/JSONPath/JQ/XPath Filters" box of the <a href="#filters-and-triggers">Filters & Triggers</a> tab. The Visual Selector tool lets you select the <i>text</i> elements that will be used for the change detection &dash; after the <i>Browser Steps</i> has completed.<br>
This tool is a helper to manage filters in the "CSS/JSONPath/JQ/XPath Filters" box of the <a href="#filters-and-triggers">Filters & Triggers</a> tab.
</span> </span>
<div id="selector-header"> <div id="selector-header">

View File

@@ -66,7 +66,7 @@
</div> </div>
<div class="tab-pane-inner" id="text"> <div class="tab-pane-inner" id="text">
<div class="snapshot-age">{{ watch.snapshot_text_ctime|format_timestamp_timeago }}</div> <div class="snapshot-age">{{ current_version|format_timestamp_timeago }}</div>
<span class="ignored">Grey lines are ignored</span> <span class="triggered">Blue lines are triggers</span> <span class="ignored">Grey lines are ignored</span> <span class="triggered">Blue lines are triggers</span>
<span class="tip"><strong>Pro-tip</strong>: Highlight text to add to ignore filters</span> <span class="tip"><strong>Pro-tip</strong>: Highlight text to add to ignore filters</span>

View File

@@ -108,8 +108,6 @@
<p>Use the <strong>Basic</strong> method (default) where your watched sites don't need Javascript to render.</p> <p>Use the <strong>Basic</strong> method (default) where your watched sites don't need Javascript to render.</p>
<p>The <strong>Chrome/Javascript</strong> method requires a network connection to a running WebDriver+Chrome server, set by the ENV var 'WEBDRIVER_URL'. </p> <p>The <strong>Chrome/Javascript</strong> method requires a network connection to a running WebDriver+Chrome server, set by the ENV var 'WEBDRIVER_URL'. </p>
</span> </span>
<br>
Tip: <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Proxy-configuration#brightdata-proxy-support">Connect using Bright Data and Oxylabs Proxies, find out more here.</a>
</div> </div>
<fieldset class="pure-group" id="webdriver-override-options" data-visible-for="application-fetch_backend=html_webdriver"> <fieldset class="pure-group" id="webdriver-override-options" data-visible-for="application-fetch_backend=html_webdriver">
<div class="pure-form-message-inline"> <div class="pure-form-message-inline">
@@ -121,6 +119,18 @@
{{ render_field(form.application.form.webdriver_delay) }} {{ render_field(form.application.form.webdriver_delay) }}
</div> </div>
</fieldset> </fieldset>
<div class="pure-control-group inline-radio">
{{ render_field(form.requests.form.default_ua) }}
<span class="pure-form-message-inline">
Applied to all requests.<br><br>
Note: Simply changing the User-Agent often does not defeat anti-robot technologies, it's important to consider <a href="https://changedetection.io/tutorial/what-are-main-types-anti-robot-mechanisms">all of the ways that the browser is detected</a>.
</span>
</div>
<div class="pure-control-group">
<br>
Tip: <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Proxy-configuration#brightdata-proxy-support">Connect using Bright Data and Oxylabs Proxies, find out more here.</a>
</div>
</div> </div>
<div class="tab-pane-inner" id="filters"> <div class="tab-pane-inner" id="filters">
@@ -190,7 +200,7 @@ nav
<a id="chrome-extension-link" <a id="chrome-extension-link"
title="Try our new Chrome Extension!" title="Try our new Chrome Extension!"
href="https://chromewebstore.google.com/detail/changedetectionio-website/kefcfmgmlhmankjmnbijimhofdjekbop"> href="https://chromewebstore.google.com/detail/changedetectionio-website/kefcfmgmlhmankjmnbijimhofdjekbop">
<img src="{{ url_for('static_content', group='images', filename='Google-Chrome-icon.png') }}"> <img src="{{ url_for('static_content', group='images', filename='Google-Chrome-icon.png') }}" alt="Chrome">
Chrome Webstore Chrome Webstore
</a> </a>
</p> </p>

View File

@@ -13,7 +13,7 @@
<div id="watch-add-wrapper-zone"> <div id="watch-add-wrapper-zone">
{{ render_nolabel_field(form.url, placeholder="https://...", required=true) }} {{ render_nolabel_field(form.url, placeholder="https://...", required=true) }}
{{ render_nolabel_field(form.tags, value=active_tag.title if active_tag else '', placeholder="watch label / tag") }} {{ render_nolabel_field(form.tags, value=active_tag.title if active_tag_uuid else '', placeholder="watch label / tag") }}
{{ render_nolabel_field(form.watch_submit_button, title="Watch this URL!" ) }} {{ render_nolabel_field(form.watch_submit_button, title="Watch this URL!" ) }}
{{ render_nolabel_field(form.edit_and_watch_submit_button, title="Edit first then Watch") }} {{ render_nolabel_field(form.edit_and_watch_submit_button, title="Edit first then Watch") }}
</div> </div>
@@ -46,7 +46,7 @@
{% endif %} {% endif %}
{% if search_q %}<div id="search-result-info">Searching "<strong><i>{{search_q}}</i></strong>"</div>{% endif %} {% if search_q %}<div id="search-result-info">Searching "<strong><i>{{search_q}}</i></strong>"</div>{% endif %}
<div> <div>
<a href="{{url_for('index')}}" class="pure-button button-tag {{'active' if not active_tag }}">All</a> <a href="{{url_for('index')}}" class="pure-button button-tag {{'active' if not active_tag_uuid }}">All</a>
<!-- tag list --> <!-- tag list -->
{% for uuid, tag in tags %} {% for uuid, tag in tags %}
@@ -67,18 +67,18 @@
<tr> <tr>
{% set link_order = "desc" if sort_order == 'asc' else "asc" %} {% set link_order = "desc" if sort_order == 'asc' else "asc" %}
{% set arrow_span = "" %} {% set arrow_span = "" %}
<th><input style="vertical-align: middle" type="checkbox" id="check-all" > <a class="{{ 'active '+link_order if sort_attribute == 'date_created' else 'inactive' }}" href="{{url_for('index', sort='date_created', order=link_order, tag=active_tag)}}"># <span class='arrow {{link_order}}'></span></a></th> <th><input style="vertical-align: middle" type="checkbox" id="check-all" > <a class="{{ 'active '+link_order if sort_attribute == 'date_created' else 'inactive' }}" href="{{url_for('index', sort='date_created', order=link_order, tag=active_tag_uuid)}}"># <span class='arrow {{link_order}}'></span></a></th>
<th></th> <th></th>
<th><a class="{{ 'active '+link_order if sort_attribute == 'label' else 'inactive' }}" href="{{url_for('index', sort='label', order=link_order, tag=active_tag)}}">Website <span class='arrow {{link_order}}'></span></a></th> <th><a class="{{ 'active '+link_order if sort_attribute == 'label' else 'inactive' }}" href="{{url_for('index', sort='label', order=link_order, tag=active_tag_uuid)}}">Website <span class='arrow {{link_order}}'></span></a></th>
<th><a class="{{ 'active '+link_order if sort_attribute == 'last_checked' else 'inactive' }}" href="{{url_for('index', sort='last_checked', order=link_order, tag=active_tag)}}">Last Checked <span class='arrow {{link_order}}'></span></a></th> <th><a class="{{ 'active '+link_order if sort_attribute == 'last_checked' else 'inactive' }}" href="{{url_for('index', sort='last_checked', order=link_order, tag=active_tag_uuid)}}">Last Checked <span class='arrow {{link_order}}'></span></a></th>
<th><a class="{{ 'active '+link_order if sort_attribute == 'last_changed' else 'inactive' }}" href="{{url_for('index', sort='last_changed', order=link_order, tag=active_tag)}}">Last Changed <span class='arrow {{link_order}}'></span></a></th> <th><a class="{{ 'active '+link_order if sort_attribute == 'last_changed' else 'inactive' }}" href="{{url_for('index', sort='last_changed', order=link_order, tag=active_tag_uuid)}}">Last Changed <span class='arrow {{link_order}}'></span></a></th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% if not watches|length %} {% if not watches|length %}
<tr> <tr>
<td colspan="6">No website watches configured, please add a URL in the box above, or <a href="{{ url_for('import_page')}}" >import a list</a>.</td> <td colspan="6" style="text-wrap: wrap;">No website watches configured, please add a URL in the box above, or <a href="{{ url_for('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) %}
@@ -95,11 +95,11 @@
<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" ><input name="uuids" type="checkbox" value="{{ watch.uuid}} " > <span>{{ loop.index+pagination.skip }}</span></td>
<td class="inline watch-controls"> <td class="inline watch-controls">
{% if not watch.paused %} {% if not watch.paused %}
<a class="state-off" href="{{url_for('index', op='pause', uuid=watch.uuid, tag=active_tag)}}"><img src="{{url_for('static_content', group='images', filename='pause.svg')}}" alt="Pause checks" title="Pause checks" class="icon icon-pause" ></a> <a class="state-off" href="{{url_for('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>
{% else %} {% else %}
<a class="state-on" href="{{url_for('index', op='pause', uuid=watch.uuid, tag=active_tag)}}"><img src="{{url_for('static_content', group='images', filename='play.svg')}}" alt="UnPause checks" title="UnPause checks" class="icon icon-unpause" ></a> <a class="state-on" href="{{url_for('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>
{% endif %} {% endif %}
<a class="link-mute state-{{'on' if watch.notification_muted else 'off'}}" href="{{url_for('index', op='mute', uuid=watch.uuid, tag=active_tag)}}"><img src="{{url_for('static_content', group='images', filename='bell-off.svg')}}" alt="Mute notifications" title="Mute notifications" class="icon icon-mute" ></a> <a class="link-mute state-{{'on' if watch.notification_muted else 'off'}}" href="{{url_for('index', op='mute', uuid=watch.uuid, tag=active_tag_uuid)}}"><img src="{{url_for('static_content', group='images', filename='bell-off.svg')}}" alt="Mute notifications" title="Mute notifications" class="icon icon-mute" ></a>
</td> </td>
<td class="title-col inline">{{watch.title if watch.title is not none and watch.title|length > 0 else watch.url}} <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:','') }}"></a> <a class="external" target="_blank" rel="noopener" href="{{ watch.link.replace('source:','') }}"></a>
@@ -204,7 +204,7 @@
all {% if active_tag_uuid %} in "{{active_tag.title}}"{%endif%}</a> all {% if active_tag_uuid %} in "{{active_tag.title}}"{%endif%}</a>
</li> </li>
<li> <li>
<a href="{{ url_for('rss', tag=active_tag , token=app_rss_token)}}"><img alt="RSS Feed" id="feed-icon" src="{{url_for('static_content', group='images', filename='Generic_Feed-icon.svg')}}" height="15"></a> <a href="{{ url_for('rss', tag=active_tag_uuid, token=app_rss_token)}}"><img alt="RSS Feed" id="feed-icon" src="{{url_for('static_content', group='images', filename='Generic_Feed-icon.svg')}}" height="15"></a>
</li> </li>
</ul> </ul>
{{ pagination.links }} {{ pagination.links }}

View File

@@ -479,8 +479,9 @@ def test_correct_header_detect(client, live_server):
url_for("preview_page", uuid="first"), url_for("preview_page", uuid="first"),
follow_redirects=True follow_redirects=True
) )
assert b'&#34;world&#34;:' in res.data
assert res.data.count(b'{') >= 2 assert b'&#34;hello&#34;: 123,' in res.data
assert b'&#34;world&#34;: 123</div>' in res.data
res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True)
assert b'Deleted' in res.data assert b'Deleted' in res.data

View File

@@ -256,12 +256,40 @@ def test_method_in_request(client, live_server):
def test_headers_textfile_in_request(client, live_server): def test_headers_textfile_in_request(client, live_server):
#live_server_setup(live_server) #live_server_setup(live_server)
# Add our URL to the import page # Add our URL to the import page
webdriver_ua = "Hello fancy webdriver UA 1.0"
requests_ua = "Hello basic requests UA 1.1"
test_url = url_for('test_headers', _external=True) test_url = url_for('test_headers', _external=True)
if os.getenv('PLAYWRIGHT_DRIVER_URL'): if os.getenv('PLAYWRIGHT_DRIVER_URL'):
# Because its no longer calling back to localhost but from the browser container, set in test-only.yml # Because its no longer calling back to localhost but from the browser container, set in test-only.yml
test_url = test_url.replace('localhost', 'cdio') test_url = test_url.replace('localhost', 'cdio')
print ("TEST URL IS ",test_url) form_data = {
"application-fetch_backend": "html_requests",
"application-minutes_between_check": 180,
"requests-default_ua-html_requests": requests_ua
}
if os.getenv('PLAYWRIGHT_DRIVER_URL'):
form_data["requests-default_ua-html_webdriver"] = webdriver_ua
res = client.post(
url_for("settings_page"),
data=form_data,
follow_redirects=True
)
assert b'Settings updated' in res.data
res = client.get(url_for("settings_page"))
# Only when some kind of real browser is setup
if os.getenv('PLAYWRIGHT_DRIVER_URL'):
assert b'requests-default_ua-html_webdriver' in res.data
# Field should always be there
assert b"requests-default_ua-html_requests" in res.data
# Add the test URL twice, we will check # Add the test URL twice, we will check
res = client.post( res = client.post(
url_for("import_page"), url_for("import_page"),
@@ -272,15 +300,14 @@ def test_headers_textfile_in_request(client, live_server):
wait_for_all_checks(client) wait_for_all_checks(client)
# Add some headers to a request # Add some headers to a request
res = client.post( res = client.post(
url_for("edit_page", uuid="first"), url_for("edit_page", uuid="first"),
data={ data={
"url": test_url, "url": test_url,
"tags": "testtag", "tags": "testtag",
"fetch_backend": 'html_webdriver' if os.getenv('PLAYWRIGHT_DRIVER_URL') else 'html_requests', "fetch_backend": 'html_webdriver' if os.getenv('PLAYWRIGHT_DRIVER_URL') else 'html_requests',
"headers": "xxx:ooo\ncool:yeah\r\n"}, "headers": "xxx:ooo\ncool:yeah\r\n"},
follow_redirects=True follow_redirects=True
) )
assert b"Updated watch." in res.data assert b"Updated watch." in res.data
@@ -292,7 +319,7 @@ def test_headers_textfile_in_request(client, live_server):
with open('test-datastore/headers.txt', 'w') as f: with open('test-datastore/headers.txt', 'w') as f:
f.write("global-header: nice\r\nnext-global-header: nice") f.write("global-header: nice\r\nnext-global-header: nice")
with open('test-datastore/'+extract_UUID_from_client(client)+'/headers.txt', 'w') as f: with open('test-datastore/' + extract_UUID_from_client(client) + '/headers.txt', 'w') as f:
f.write("watch-header: nice") f.write("watch-header: nice")
client.get(url_for("form_watch_checknow"), follow_redirects=True) client.get(url_for("form_watch_checknow"), follow_redirects=True)
@@ -306,7 +333,7 @@ def test_headers_textfile_in_request(client, live_server):
# Not needed anymore # Not needed anymore
os.unlink('test-datastore/headers.txt') os.unlink('test-datastore/headers.txt')
os.unlink('test-datastore/headers-testtag.txt') os.unlink('test-datastore/headers-testtag.txt')
os.unlink('test-datastore/'+extract_UUID_from_client(client)+'/headers.txt') os.unlink('test-datastore/' + extract_UUID_from_client(client) + '/headers.txt')
# The service should echo back the request verb # The service should echo back the request verb
res = client.get( res = client.get(
url_for("preview_page", uuid="first"), url_for("preview_page", uuid="first"),
@@ -319,7 +346,12 @@ def test_headers_textfile_in_request(client, live_server):
assert b"Watch-Header:nice" in res.data assert b"Watch-Header:nice" in res.data
assert b"Tag-Header:test" in res.data assert b"Tag-Header:test" in res.data
# Check the custom UA from system settings page made it through
if os.getenv('PLAYWRIGHT_DRIVER_URL'):
assert "User-Agent:".encode('utf-8') + webdriver_ua.encode('utf-8') in res.data
else:
assert "User-Agent:".encode('utf-8') + requests_ua.encode('utf-8') in res.data
#unlink headers.txt on start/stop # unlink headers.txt on start/stop
res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True)
assert b'Deleted' in res.data assert b'Deleted' in res.data

View File

@@ -1,7 +1,8 @@
# Used by Pyppeteer # Used by Pyppeteer
pyee pyee
# eventlet 0.33.3 was related to dnspython fixes
eventlet==0.33.3 # related to dnspython fixes # 0.34.1 - fixes python 3.12 "AttributeError: module 'ssl' has no attribute 'wrap_socket'"
eventlet==0.35.2 # related to dnspython fixes
feedgen~=0.9 feedgen~=0.9
flask-compress flask-compress
# 0.6.3 included compatibility fix for werkzeug 3.x (2.x had deprecation of url handlers) # 0.6.3 included compatibility fix for werkzeug 3.x (2.x had deprecation of url handlers)
@@ -73,12 +74,10 @@ openpyxl
jq~=1.3; python_version >= "3.8" and sys_platform == "darwin" jq~=1.3; python_version >= "3.8" and sys_platform == "darwin"
jq~=1.3; python_version >= "3.8" and sys_platform == "linux" jq~=1.3; python_version >= "3.8" and sys_platform == "linux"
# Any current modern version, required so far for screenshot PNG->JPEG conversion but will be used more in the future
pillow
# playwright is installed at Dockerfile build time because it's not available on all platforms # playwright is installed at Dockerfile build time because it's not available on all platforms
# experimental release
pyppeteer-ng==2.0.0rc5 pyppeteer-ng==2.0.0rc5
pyppeteerstealth>=0.0.4
# Include pytest, so if theres a support issue we can ask them to run these tests on their setup # Include pytest, so if theres a support issue we can ask them to run these tests on their setup
pytest ~=7.2 pytest ~=7.2