Compare commits

...

14 Commits

Author SHA1 Message Date
dgtlmoon
e110b3ee93 0.45.20 2024-04-18 11:55:46 +02:00
dgtlmoon
3ae9bfa6f9 Bug fix - further work on lxml filter extract (#2313 #2312 #2317) 2024-04-18 11:53:45 +02:00
dgtlmoon
6f3c3b7dfb 0.45.19 2024-04-17 20:01:35 +02:00
dgtlmoon
74707909f1 Bug fix for newer lxml module - module 'lxml.etree' has no attribute '_ElementStringResult' - reimplement _ElementStringResult (#2313 #2312) 2024-04-17 19:55:45 +02:00
dgtlmoon
d4dac23ba1 0.45.18 2024-04-16 18:50:14 +02:00
dgtlmoon
f9954f93f3 UI - Adding UI notice if watch has group options set (#2311 #2307) 2024-04-16 18:48:51 +02:00
dgtlmoon
1a43b112dc dependabot - automatically follow apprise 2024-04-15 11:17:50 +02:00
dgtlmoon
db59bf73e1 "Send Test Notification" - In "Group" settings form it should not fallback to the system wide notifications when sending a test if nothing is set. 2024-04-03 17:10:13 +02:00
dgtlmoon
8aac7bccbe "Send Test Notification" - Now provides better feedback and works with the actual values in system settings form 2024-04-03 16:52:42 +02:00
dgtlmoon
9449c59fbb Code - Getting ready for newer python versions - packing our own strtobool (#2291) 2024-04-03 16:17:15 +02:00
dgtlmoon
21f4ba2208 UI - BrowserSteps - Show step screenshot/pic should use absolute URL #2243 2024-04-03 16:15:33 +02:00
dgtlmoon
daef1cd036 UI - Remove unique check for URLs entered on the "quick watch add" form ( #2286 #2292 ) 2024-04-03 16:08:33 +02:00
dgtlmoon
56b365df40 UI - Improvements to tag/groups page, show number of watches under each group, link group name to list (#2290) 2024-04-03 16:01:24 +02:00
dgtlmoon
8e5bf91965 "Send Test Notification" button from watch form edit should respect global settings and tag/group settings ( #2289, #2263 ) 2024-04-03 15:18:21 +02:00
22 changed files with 174 additions and 45 deletions

View File

@@ -4,6 +4,10 @@ updates:
directory: / directory: /
schedule: schedule:
interval: "weekly" interval: "weekly"
"caronc/apprise":
versioning-strategy: "increase"
schedule:
interval: "daily"
groups: groups:
all: all:
patterns: patterns:

View File

@@ -2,9 +2,9 @@
# Read more https://github.com/dgtlmoon/changedetection.io/wiki # Read more https://github.com/dgtlmoon/changedetection.io/wiki
__version__ = '0.45.17' __version__ = '0.45.20'
from distutils.util import strtobool from changedetectionio.strtobool import strtobool
from json.decoder import JSONDecodeError from json.decoder import JSONDecodeError
import os import os
#os.environ['EVENTLET_NO_GREENDNS'] = 'yes' #os.environ['EVENTLET_NO_GREENDNS'] = 'yes'

View File

@@ -1,5 +1,5 @@
import os import os
from distutils.util import strtobool from changedetectionio.strtobool import strtobool
from flask_expects_json import expects_json from flask_expects_json import expects_json
from changedetectionio import queuedWatchMetaData from changedetectionio import queuedWatchMetaData

View File

@@ -12,7 +12,7 @@
# #
# #
from distutils.util import strtobool from changedetectionio.strtobool import strtobool
from flask import Blueprint, request, make_response from flask import Blueprint, request, make_response
import os import os

View File

@@ -1,5 +1,5 @@
from distutils.util import strtobool from changedetectionio.strtobool import strtobool
from flask import Blueprint, flash, redirect, url_for from flask import Blueprint, flash, redirect, url_for
from flask_login import login_required from flask_login import login_required
from changedetectionio.store import ChangeDetectionStore from changedetectionio.store import ChangeDetectionStore

View File

@@ -12,9 +12,15 @@ def construct_blueprint(datastore: ChangeDetectionStore):
from .form import SingleTag from .form import SingleTag
add_form = SingleTag(request.form) add_form = SingleTag(request.form)
sorted_tags = sorted(datastore.data['settings']['application'].get('tags').items(), key=lambda x: x[1]['title']) sorted_tags = sorted(datastore.data['settings']['application'].get('tags').items(), key=lambda x: x[1]['title'])
from collections import Counter
tag_count = Counter(tag for watch in datastore.data['watching'].values() if watch.get('tags') for tag in watch['tags'])
output = render_template("groups-overview.html", output = render_template("groups-overview.html",
form=add_form,
available_tags=sorted_tags, available_tags=sorted_tags,
form=add_form,
tag_count=tag_count
) )
return output return output

View File

@@ -3,7 +3,7 @@
{% from '_helpers.jinja' import render_field, render_checkbox_field, render_button %} {% from '_helpers.jinja' import render_field, render_checkbox_field, render_button %}
{% from '_common_fields.jinja' import render_common_settings_form %} {% from '_common_fields.jinja' import render_common_settings_form %}
<script> <script>
const notification_base_url="{{url_for('ajax_callback_send_notification_test', watch_uuid=uuid)}}"; const notification_base_url="{{url_for('ajax_callback_send_notification_test', mode="group-settings")}}";
</script> </script>
<script src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script> <script src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script>

View File

@@ -27,6 +27,7 @@
<thead> <thead>
<tr> <tr>
<th></th> <th></th>
<th># Watches</th>
<th>Tag / Label name</th> <th>Tag / Label name</th>
<th></th> <th></th>
</tr> </tr>
@@ -45,7 +46,8 @@
<td class="watch-controls"> <td class="watch-controls">
<a class="link-mute state-{{'on' if tag.notification_muted else 'off'}}" href="{{url_for('tags.mute', uuid=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> <a class="link-mute state-{{'on' if tag.notification_muted else 'off'}}" href="{{url_for('tags.mute', uuid=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">{{tag.title}}</td> <td>{{ "{:,}".format(tag_count[uuid]) if uuid in tag_count else 0 }}</td>
<td class="title-col inline"> <a href="{{url_for('index', tag=uuid) }}">{{ tag.title }}</a></td>
<td> <td>
<a class="pure-button pure-button-primary" href="{{ url_for('tags.form_tag_edit', uuid=uuid) }}">Edit</a>&nbsp; <a class="pure-button pure-button-primary" href="{{ url_for('tags.form_tag_edit', uuid=uuid) }}">Edit</a>&nbsp;
<a class="pure-button pure-button-primary" href="{{ url_for('tags.delete', uuid=uuid) }}" title="Deletes and removes tag">Delete</a> <a class="pure-button pure-button-primary" href="{{ url_for('tags.delete', uuid=uuid) }}" title="Deletes and removes tag">Delete</a>

View File

@@ -1,5 +1,5 @@
import sys import sys
from distutils.util import strtobool from changedetectionio.strtobool import strtobool
from loguru import logger from loguru import logger
from changedetectionio.content_fetchers.exceptions import BrowserStepsStepException from changedetectionio.content_fetchers.exceptions import BrowserStepsStepException
import os import os

View File

@@ -6,7 +6,7 @@ import queue
import threading import threading
import time import time
from copy import deepcopy from copy import deepcopy
from distutils.util import strtobool from changedetectionio.strtobool import strtobool
from functools import wraps from functools import wraps
from threading import Event from threading import Event
@@ -516,21 +516,38 @@ def changedetection_app(config=None, datastore_o=None):
watch = datastore.data['watching'].get(watch_uuid) if watch_uuid else None watch = datastore.data['watching'].get(watch_uuid) if watch_uuid else None
# validate URLS notification_urls = request.form['notification_urls'].strip().splitlines()
if not len(request.form['notification_urls'].strip()):
return make_response({'error': 'No Notification URLs set'}, 400)
for server_url in request.form['notification_urls'].splitlines(): if not notification_urls:
if len(server_url.strip()): logger.debug("Test notification - Trying by group/tag in the edit form if available")
if not apobj.add(server_url): # On an edit page, we should also fire off to the tags if they have notifications
message = '{} is not a valid AppRise URL.'.format(server_url) if request.form.get('tags') and request.form['tags'].strip():
return make_response({'error': message}, 400) for k in request.form['tags'].split(','):
tag = datastore.tag_exists_by_name(k.strip())
notification_urls = tag.get('notifications_urls') if tag and tag.get('notifications_urls') else None
is_global_settings_form = request.args.get('mode', '') == 'global-settings'
is_group_settings_form = request.args.get('mode', '') == 'group-settings'
if not notification_urls and not is_global_settings_form and not is_group_settings_form:
# In the global settings, use only what is typed currently in the text box
logger.debug("Test notification - Trying by global system settings notifications")
if datastore.data['settings']['application'].get('notification_urls'):
notification_urls = datastore.data['settings']['application']['notification_urls']
if not notification_urls:
return 'No Notification URLs set/found'
for n_url in notification_urls:
if len(n_url.strip()):
if not apobj.add(n_url):
return f'Error - {n_url} is not a valid AppRise URL.'
try: try:
# use the same as when it is triggered, but then override it with the form test values # use the same as when it is triggered, but then override it with the form test values
n_object = { n_object = {
'watch_url': request.form['window_url'], 'watch_url': request.form['window_url'],
'notification_urls': request.form['notification_urls'].splitlines() 'notification_urls': notification_urls
} }
# Only use if present, if not set in n_object it should use the default system value # Only use if present, if not set in n_object it should use the default system value
@@ -549,7 +566,7 @@ def changedetection_app(config=None, datastore_o=None):
except Exception as e: except Exception as e:
return make_response({'error': str(e)}, 400) return make_response({'error': str(e)}, 400)
return 'OK' return 'OK - Sent test notifications'
@app.route("/clear_history/<string:uuid>", methods=['GET']) @app.route("/clear_history/<string:uuid>", methods=['GET'])
@@ -586,6 +603,12 @@ def changedetection_app(config=None, datastore_o=None):
output = render_template("clear_all_history.html") output = render_template("clear_all_history.html")
return output return output
def _watch_has_tag_options_set(watch):
"""This should be fixed better so that Tag is some proper Model, a tag is just a Watch also"""
for tag_uuid, tag in datastore.data['settings']['application'].get('tags', {}).items():
if tag_uuid in watch.get('tags', []) and (tag.get('include_filters') or tag.get('subtractive_selectors')):
return True
@app.route("/edit/<string:uuid>", methods=['GET', 'POST']) @app.route("/edit/<string:uuid>", methods=['GET', 'POST'])
@login_optionally_required @login_optionally_required
# https://stackoverflow.com/questions/42984453/wtforms-populate-form-with-data-if-data-exists # https://stackoverflow.com/questions/42984453/wtforms-populate-form-with-data-if-data-exists
@@ -756,6 +779,7 @@ def changedetection_app(config=None, datastore_o=None):
has_default_notification_urls=True if len(datastore.data['settings']['application']['notification_urls']) else False, has_default_notification_urls=True if len(datastore.data['settings']['application']['notification_urls']) else False,
has_empty_checktime=using_default_check_time, has_empty_checktime=using_default_check_time,
has_extra_headers_file=len(datastore.get_all_headers_in_textfile_for_watch(uuid=uuid)) > 0, has_extra_headers_file=len(datastore.get_all_headers_in_textfile_for_watch(uuid=uuid)) > 0,
has_special_tag_options=_watch_has_tag_options_set(watch=watch),
is_html_webdriver=is_html_webdriver, is_html_webdriver=is_html_webdriver,
jq_support=jq_support, jq_support=jq_support,
playwright_enabled=os.getenv('PLAYWRIGHT_DRIVER_URL', False), playwright_enabled=os.getenv('PLAYWRIGHT_DRIVER_URL', False),
@@ -1279,9 +1303,8 @@ def changedetection_app(config=None, datastore_o=None):
url = request.form.get('url').strip() url = request.form.get('url').strip()
if datastore.url_exists(url): if datastore.url_exists(url):
flash('The URL {} already exists'.format(url), "error") flash(f'Warning, URL {url} already exists', "notice")
return redirect(url_for('index'))
add_paused = request.form.get('edit_and_watch_submit_button') != None add_paused = request.form.get('edit_and_watch_submit_button') != None
processor = request.form.get('processor', 'text_json_diff') processor = request.form.get('processor', 'text_json_diff')
new_uuid = datastore.add_watch(url=url, tag=request.form.get('tags').strip(), extras={'paused': add_paused, 'processor': processor}) new_uuid = datastore.add_watch(url=url, tag=request.form.get('tags').strip(), extras={'paused': add_paused, 'processor': processor})

View File

@@ -1,6 +1,6 @@
import os import os
import re import re
from distutils.util import strtobool from changedetectionio.strtobool import strtobool
from wtforms import ( from wtforms import (
BooleanField, BooleanField,

View File

@@ -169,14 +169,14 @@ def xpath1_filter(xpath_filter, html_content, append_pretty_line_formatting=Fals
# And where the matched result doesn't include something that will cause Inscriptis to add a newline # And where the matched result doesn't include something that will cause Inscriptis to add a newline
# (This way each 'match' reliably has a new-line in the diff) # (This way each 'match' reliably has a new-line in the diff)
# Divs are converted to 4 whitespaces by inscriptis # Divs are converted to 4 whitespaces by inscriptis
if append_pretty_line_formatting and len(html_block) and (not hasattr( element, 'tag' ) or not element.tag in (['br', 'hr', 'div', 'p'])): if append_pretty_line_formatting and len(html_block) and (not hasattr(element, 'tag') or not element.tag in (['br', 'hr', 'div', 'p'])):
html_block += TEXT_FILTER_LIST_LINE_SUFFIX html_block += TEXT_FILTER_LIST_LINE_SUFFIX
if type(element) == etree._ElementStringResult: # Some kind of text, UTF-8 or other
html_block += str(element) if isinstance(element, (str, bytes)):
elif type(element) == etree._ElementUnicodeResult: html_block += element
html_block += str(element)
else: else:
# Return the HTML which will get parsed as text
html_block += etree.tostring(element, pretty_print=True).decode('utf-8') html_block += etree.tostring(element, pretty_print=True).decode('utf-8')
return html_block return html_block

View File

@@ -1,4 +1,4 @@
from distutils.util import strtobool from changedetectionio.strtobool import strtobool
import os import os
import re import re
import time import time
@@ -362,6 +362,7 @@ class model(dict):
# @todo bump static cache of the last timestamp so we dont need to examine the file to set a proper ''viewed'' status # @todo bump static cache of the last timestamp so we dont need to examine the file to set a proper ''viewed'' status
return snapshot_fname return snapshot_fname
@property
@property @property
def has_empty_checktime(self): def has_empty_checktime(self):
# using all() + dictionary comprehension # using all() + dictionary comprehension

View File

@@ -3,7 +3,7 @@ import os
import hashlib import hashlib
import re import re
from copy import deepcopy from copy import deepcopy
from distutils.util import strtobool from changedetectionio.strtobool import strtobool
from loguru import logger from loguru import logger
class difference_detection_processor(): class difference_detection_processor():

View File

@@ -28,15 +28,11 @@ $(document).ready(function() {
notification_format: $('#notification_format').val(), notification_format: $('#notification_format').val(),
notification_title: $('#notification_title').val(), notification_title: $('#notification_title').val(),
notification_urls: $('.notification-urls').val(), notification_urls: $('.notification-urls').val(),
tags: $('#tags').val(),
window_url: window.location.href, window_url: window.location.href,
} }
if (!data['notification_urls'].length) {
alert("Notification URL list is empty, cannot send test.")
return;
}
$.ajax({ $.ajax({
type: "POST", type: "POST",
url: notification_base_url, url: notification_base_url,
@@ -49,7 +45,7 @@ $(document).ready(function() {
} }
}).done(function(data){ }).done(function(data){
console.log(data); console.log(data);
alert('Sent'); alert(data);
}).fail(function(data){ }).fail(function(data){
console.log(data); console.log(data);
alert('There was an error communicating with the server.'); alert('There was an error communicating with the server.');

View File

@@ -1,4 +1,4 @@
from distutils.util import strtobool from changedetectionio.strtobool import strtobool
from flask import ( from flask import (
flash flash
@@ -657,7 +657,10 @@ class ChangeDetectionStore:
return res return res
def tag_exists_by_name(self, tag_name): def tag_exists_by_name(self, tag_name):
return any(v.get('title', '').lower() == tag_name.lower() for k, v in self.__data['settings']['application']['tags'].items()) # Check if any tag dictionary has a 'title' attribute matching the provided tag_name
tags = self.__data['settings']['application']['tags'].values()
return next((v for v in tags if v.get('title', '').lower() == tag_name.lower()),
None)
def get_updates_available(self): def get_updates_available(self):
import inspect import inspect

View File

@@ -0,0 +1,23 @@
# Because strtobool was removed in python 3.12 distutils
_MAP = {
'y': True,
'yes': True,
't': True,
'true': True,
'on': True,
'1': True,
'n': False,
'no': False,
'f': False,
'false': False,
'off': False,
'0': False
}
def strtobool(value):
try:
return _MAP[str(value).lower()]
except KeyError:
raise ValueError('"{}" is not a valid bool value'.format(value))

View File

@@ -7,7 +7,8 @@
<script> <script>
const browser_steps_available_screenshots=JSON.parse('{{ watch.get_browsersteps_available_screenshots|tojson }}'); const browser_steps_available_screenshots=JSON.parse('{{ watch.get_browsersteps_available_screenshots|tojson }}');
const browser_steps_config=JSON.parse('{{ browser_steps_config|tojson }}'); const browser_steps_config=JSON.parse('{{ browser_steps_config|tojson }}');
const browser_steps_fetch_screenshot_image_url="{{url_for('browser_steps.browser_steps_fetch_screenshot_image', uuid=uuid)}}"; <!-- Should be _external so that firefox and others load it more reliably -->
const browser_steps_fetch_screenshot_image_url="{{url_for('browser_steps.browser_steps_fetch_screenshot_image', uuid=uuid, _external=True)}}";
const browser_steps_last_error_step={{ watch.browser_steps_last_error_step|tojson }}; const browser_steps_last_error_step={{ watch.browser_steps_last_error_step|tojson }};
const browser_steps_start_url="{{url_for('browser_steps.browsersteps_start_session', uuid=uuid)}}"; const browser_steps_start_url="{{url_for('browser_steps.browsersteps_start_session', uuid=uuid)}}";
const browser_steps_sync_url="{{url_for('browser_steps.browsersteps_ui_update', uuid=uuid)}}"; const browser_steps_sync_url="{{url_for('browser_steps.browsersteps_ui_update', uuid=uuid)}}";
@@ -31,6 +32,7 @@
<script src="{{url_for('static_content', group='js', filename='browser-steps.js')}}" defer></script> <script src="{{url_for('static_content', group='js', filename='browser-steps.js')}}" defer></script>
{% endif %} {% endif %}
{% set has_tag_filters_extra="WARNING: Watch has tag/groups set with special filters\n" if has_special_tag_options else '' %}
<script src="{{url_for('static_content', group='js', filename='recheck-proxy.js')}}" defer></script> <script src="{{url_for('static_content', group='js', filename='recheck-proxy.js')}}" defer></script>
<div class="edit-form monospaced-textarea"> <div class="edit-form monospaced-textarea">
@@ -280,7 +282,7 @@ User-Agent: wonderbra 1.0") }}
<div class="pure-control-group"> <div class="pure-control-group">
{% set field = render_field(form.include_filters, {% set field = render_field(form.include_filters,
rows=5, rows=5,
placeholder="#example placeholder=has_tag_filters_extra+"#example
xpath://body/div/span[contains(@class, 'example-class')]", xpath://body/div/span[contains(@class, 'example-class')]",
class="m-d") class="m-d")
%} %}
@@ -316,7 +318,7 @@ xpath://body/div/span[contains(@class, 'example-class')]",
</span> </span>
</div> </div>
<fieldset class="pure-control-group"> <fieldset class="pure-control-group">
{{ render_field(form.subtractive_selectors, rows=5, placeholder="header {{ render_field(form.subtractive_selectors, rows=5, placeholder=has_tag_filters_extra+"header
footer footer
nav nav
.stockticker") }} .stockticker") }}

View File

@@ -4,7 +4,7 @@
{% from '_helpers.jinja' import render_field, render_checkbox_field, render_button %} {% from '_helpers.jinja' import render_field, render_checkbox_field, render_button %}
{% from '_common_fields.jinja' import render_common_settings_form %} {% from '_common_fields.jinja' import render_common_settings_form %}
<script> <script>
const notification_base_url="{{url_for('ajax_callback_send_notification_test', watch_uuid=uuid)}}"; const notification_base_url="{{url_for('ajax_callback_send_notification_test', mode="global-settings")}}";
{% if emailprefix %} {% if emailprefix %}
const email_notification_prefix=JSON.parse('{{emailprefix|tojson}}'); const email_notification_prefix=JSON.parse('{{emailprefix|tojson}}');
{% endif %} {% endif %}

View File

@@ -100,6 +100,12 @@ def test_setup_group_tag(client, live_server):
assert b'Should be only this' in res.data assert b'Should be only this' in res.data
assert b'And never this' not in res.data assert b'And never this' not in res.data
res = client.get(
url_for("edit_page", uuid="first"),
follow_redirects=True
)
# 2307 the UI notice should appear in the placeholder
assert b'WARNING: Watch has tag/groups set with special filters' in res.data
# RSS Group tag filter # RSS Group tag filter
# An extra one that should be excluded # An extra one that should be excluded

View File

@@ -1,4 +1,4 @@
#!/usr/bin/python3 # -*- coding: utf-8 -*-
import time import time
from flask import url_for from flask import url_for
@@ -255,6 +255,69 @@ def test_xpath23_prefix_validation(client, live_server):
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
def test_xpath1_lxml(client, live_server):
#live_server_setup(live_server)
d = '''<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:taxo="http://purl.org/rss/1.0/modules/taxonomy/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:dc="http://purl.org/dc/elements/1.1/" version="2.0">
<channel>
<title>rpilocator.com</title>
<link>https://rpilocator.com</link>
<description>Find Raspberry Pi Computers in Stock</description>
<lastBuildDate>Thu, 19 May 2022 23:27:30 GMT</lastBuildDate>
<image>
<url>https://rpilocator.com/favicon.png</url>
<title>rpilocator.com</title>
<link>https://rpilocator.com/</link>
<width>32</width>
<height>32</height>
</image>
<item>
<title>Stock Alert (UK): RPi CM4</title>
<foo>something else unrelated</foo>
</item>
<item>
<title>Stock Alert (UK): Big monitorěěěě</title>
<foo>something else unrelated</foo>
</item>
</channel>
</rss>'''.encode('utf-8')
with open("test-datastore/endpoint-content.txt", "wb") as f:
f.write(d)
test_url = url_for('test_endpoint', _external=True)
res = client.post(
url_for("import_page"),
data={"urls": test_url},
follow_redirects=True
)
assert b"1 Imported" in res.data
wait_for_all_checks(client)
res = client.post(
url_for("edit_page", uuid="first"),
data={"include_filters": "xpath1://title/text()", "url": test_url, "tags": "", "headers": "",
'fetch_backend': "html_requests"},
follow_redirects=True
)
##### #2312
wait_for_all_checks(client)
res = client.get(url_for("index"))
assert b'_ElementStringResult' not in res.data # tested with 5.1.1 when it was removed and 5.1.0
assert b'Exception' not in res.data
res = client.get(
url_for("preview_page", uuid="first"),
follow_redirects=True
)
assert b"rpilocator.com" in res.data # in selector
assert "Stock Alert (UK): Big monitorěěěě".encode('utf-8') in res.data # not in selector
#####
def test_xpath1_validation(client, live_server): def test_xpath1_validation(client, live_server):
# Add our URL to the import page # Add our URL to the import page

View File

@@ -52,7 +52,7 @@ cryptography~=3.4
beautifulsoup4 beautifulsoup4
# XPath filtering, lxml is required by bs4 anyway, but put it here to be safe. # XPath filtering, lxml is required by bs4 anyway, but put it here to be safe.
lxml lxml >=4.8.0,<6
# XPath 2.0-3.1 support - 4.2.0 broke something? # XPath 2.0-3.1 support - 4.2.0 broke something?
elementpath==4.1.5 elementpath==4.1.5