Compare commits

..

6 Commits

Author SHA1 Message Date
dgtlmoon 5d4ebff235 little for apprise type urls that are converted ot post:// 2023-11-30 13:15:39 +01:00
dgtlmoon 74d4c580cf Merge branch 'fixing-post-headers' into api-import 2023-11-30 12:16:32 +01:00
dgtlmoon b899579ca8 properly handle user/pass auth and clean URL without token 2023-11-30 12:15:22 +01:00
dgtlmoon 1f7f1e2bfa Fixing support for headers in custom post, posts etc notifications 2023-11-30 11:34:19 +01:00
dgtlmoon 0df773a12c Fixing support for headers in custom post, posts etc notifications 2023-11-30 11:33:36 +01:00
dgtlmoon 6a5566e771 Add import by list 2023-11-30 10:14:33 +01:00
26 changed files with 1959 additions and 1985 deletions
+1 -45
View File
@@ -98,58 +98,14 @@ jobs:
- name: Test changedetection.io container starts+runs basically without error
run: |
docker run --name test-changedetectionio -p 5556:5000 -d test-changedetectionio
docker run -p 5556:5000 -d test-changedetectionio
sleep 3
# Should return 0 (no error) when grep finds it
curl -s http://localhost:5556 |grep -q checkbox-uuid
# and IPv6
curl -s -g -6 "http://[::1]:5556"|grep -q checkbox-uuid
docker kill test-changedetectionio
- name: Test changedetection.io SIGTERM and SIGINT signal shutdown
run: |
echo SIGINT Shutdown request test
docker run --name sig-test -d test-changedetectionio
sleep 3
echo ">>> Sending SIGINT to sig-test container"
docker kill --signal=SIGINT sig-test
sleep 3
# invert the check (it should be not 0/not running)
docker ps
# check signal catch(STDOUT) log
docker logs sig-test | grep 'Shutdown: Got Signal - SIGINT' || exit 1
test -z "`docker ps|grep sig-test`"
if [ $? -ne 0 ]
then
echo "Looks like container was running when it shouldnt be"
docker ps
exit 1
fi
# @todo - scan the container log to see the right "graceful shutdown" text exists
docker rm sig-test
echo SIGTERM Shutdown request test
docker run --name sig-test -d test-changedetectionio
sleep 3
echo ">>> Sending SIGTERM to sig-test container"
docker kill --signal=SIGTERM sig-test
sleep 3
# invert the check (it should be not 0/not running)
docker ps
docker logs sig-test | grep 'Shutdown: Got Signal - SIGTERM' || exit 1
test -z "`docker ps|grep sig-test`"
if [ $? -ne 0 ]
then
echo "Looks like container was running when it shouldnt be"
docker ps
exit 1
fi
# @todo - scan the container log to see the right "graceful shutdown" text exists
docker rm sig-test
#export WEBDRIVER_URL=http://localhost:4444/wd/hub
#pytest tests/fetchers/test_content.py
+4 -4
View File
@@ -53,12 +53,12 @@ ENV PYTHONPATH=/usr/local
EXPOSE 5000
# The actual flask app module
# The actual flask app
COPY changedetectionio /app/changedetectionio
# Starting wrapper
# The eventlet server wrapper
COPY changedetection.py /app/changedetection.py
WORKDIR /app
CMD ["python", "./changedetection.py", "-d", "/datastore"]
CMD [ "python", "./changedetection.py" , "-d", "/datastore"]
+1 -2
View File
@@ -11,10 +11,9 @@ _Live your data-life pro-actively._
![changedetection.io](https://github.com/dgtlmoon/changedetection.io/actions/workflows/test-only.yml/badge.svg?branch=master)
[**Get started with website page change monitoring straight away. Don't have time? Try our $8.99/month subscription, use our proxies and support!**](https://changedetection.io) , _half the price of other website change monitoring services!_
[**Don't have time? Let us host it for you! try our $8.99/month subscription - use our proxies and support!**](https://changedetection.io) , _half the price of other website change monitoring services!_
- Chrome browser included.
- Nothing to install, access via browser login after signup.
- Super fast, no registration needed setup.
- Get started watching and receiving website change notifications straight away.
+41 -3
View File
@@ -1,6 +1,44 @@
#!/usr/bin/python3
# Only exists for direct CLI usage
# Entry-point for running from the CLI when not installed via Pip, Pip will handle the console_scripts entry_points's from setup.py
# It's recommended to use `pip3 install changedetection.io` and start with `changedetection.py` instead, it will be linkd to your global path.
# or Docker.
# Read more https://github.com/dgtlmoon/changedetection.io/wiki
import changedetectionio
changedetectionio.main()
from changedetectionio import changedetection
import multiprocessing
import sys
import os
def sigchld_handler(_signo, _stack_frame):
import sys
print('Shutdown: Got SIGCHLD')
# https://stackoverflow.com/questions/40453496/python-multiprocessing-capturing-signals-to-restart-child-processes-or-shut-do
pid, status = os.waitpid(-1, os.WNOHANG | os.WUNTRACED | os.WCONTINUED)
print('Sub-process: pid %d status %d' % (pid, status))
if status != 0:
sys.exit(1)
raise SystemExit
if __name__ == '__main__':
#signal.signal(signal.SIGCHLD, sigchld_handler)
# The only way I could find to get Flask to shutdown, is to wrap it and then rely on the subsystem issuing SIGTERM/SIGKILL
parse_process = multiprocessing.Process(target=changedetection.main)
parse_process.daemon = True
parse_process.start()
import time
try:
while True:
time.sleep(1)
if not parse_process.is_alive():
# Process died/crashed for some reason, exit with error set
sys.exit(1)
except KeyboardInterrupt:
#parse_process.terminate() not needed, because this process will issue it to the sub-process anyway
print ("Exited - CTRL+C")
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -304,7 +304,7 @@ class Import(Resource):
@auth.check_token
def post(self):
"""
@api {post} /api/v1/import Import a list of watched URLs
@api {post} /api/v1/import - Import a list of watched URLs
@apiDescription Accepts a line-feed separated list of URLs to import, additionally with ?tag_uuids=(tag id), ?tag=(name), ?proxy={key}, ?dedupe=true (default true) one URL per line.
@apiExample {curl} Example usage:
curl http://localhost:5000/api/v1/import --data-binary @list-of-sites.txt -H"x-api-key:8a111a21bc2f8f1dd9b9353bbd46049a"
@@ -351,6 +351,7 @@ class Import(Resource):
return added
class SystemInfo(Resource):
def __init__(self, **kwargs):
# datastore is a black box dependency
@@ -25,9 +25,10 @@ from distutils.util import strtobool
from flask import Blueprint, request, make_response
import logging
import os
import re
from changedetectionio.store import ChangeDetectionStore
from changedetectionio.flask_app import login_optionally_required
from changedetectionio import login_optionally_required
browsersteps_sessions = {}
io_interface_context = None
+1 -1
View File
@@ -1,6 +1,6 @@
from flask import Blueprint, request, make_response, render_template, flash, url_for, redirect
from changedetectionio.store import ChangeDetectionStore
from changedetectionio.flask_app import login_optionally_required
from changedetectionio import login_optionally_required
def construct_blueprint(datastore: ChangeDetectionStore):
+153
View File
@@ -0,0 +1,153 @@
#!/usr/bin/python3
# Launch as a eventlet.wsgi server instance.
from distutils.util import strtobool
from json.decoder import JSONDecodeError
import eventlet
import eventlet.wsgi
import getopt
import os
import signal
import socket
import sys
from . import store, changedetection_app, content_fetcher
from . import __version__
# Only global so we can access it in the signal handler
app = None
datastore = None
def sigterm_handler(_signo, _stack_frame):
global app
global datastore
# app.config.exit.set()
print('Shutdown: Got SIGTERM, DB saved to disk')
datastore.sync_to_json()
# raise SystemExit
def main():
global datastore
global app
datastore_path = None
do_cleanup = False
host = ''
ipv6_enabled = False
port = os.environ.get('PORT') or 5000
ssl_mode = False
# On Windows, create and use a default path.
if os.name == 'nt':
datastore_path = os.path.expandvars(r'%APPDATA%\changedetection.io')
os.makedirs(datastore_path, exist_ok=True)
else:
# Must be absolute so that send_from_directory doesnt try to make it relative to backend/
datastore_path = os.path.join(os.getcwd(), "../datastore")
try:
opts, args = getopt.getopt(sys.argv[1:], "6Ccsd:h:p:", "port")
except getopt.GetoptError:
print('backend.py -s SSL enable -h [host] -p [port] -d [datastore path]')
sys.exit(2)
create_datastore_dir = False
for opt, arg in opts:
if opt == '-s':
ssl_mode = True
if opt == '-h':
host = arg
if opt == '-p':
port = int(arg)
if opt == '-d':
datastore_path = arg
if opt == '-6':
print ("Enabling IPv6 listen support")
ipv6_enabled = True
# Cleanup (remove text files that arent in the index)
if opt == '-c':
do_cleanup = True
# Create the datadir if it doesnt exist
if opt == '-C':
create_datastore_dir = True
# isnt there some @thingy to attach to each route to tell it, that this route needs a datastore
app_config = {'datastore_path': datastore_path}
if not os.path.isdir(app_config['datastore_path']):
if create_datastore_dir:
os.mkdir(app_config['datastore_path'])
else:
print(
"ERROR: Directory path for the datastore '{}' does not exist, cannot start, please make sure the directory exists or specify a directory with the -d option.\n"
"Or use the -C parameter to create the directory.".format(app_config['datastore_path']), file=sys.stderr)
sys.exit(2)
try:
datastore = store.ChangeDetectionStore(datastore_path=app_config['datastore_path'], version_tag=__version__)
except JSONDecodeError as e:
# Dont' start if the JSON DB looks corrupt
print ("ERROR: JSON DB or Proxy List JSON at '{}' appears to be corrupt, aborting".format(app_config['datastore_path']))
print(str(e))
return
app = changedetection_app(app_config, datastore)
signal.signal(signal.SIGTERM, sigterm_handler)
# Go into cleanup mode
if do_cleanup:
datastore.remove_unused_snapshots()
app.config['datastore_path'] = datastore_path
@app.context_processor
def inject_version():
return dict(right_sticky="v{}".format(datastore.data['version_tag']),
new_version_available=app.config['NEW_VERSION_AVAILABLE'],
has_password=datastore.data['settings']['application']['password'] != False
)
# Monitored websites will not receive a Referer header when a user clicks on an outgoing link.
# @Note: Incompatible with password login (and maybe other features) for now, submit a PR!
@app.after_request
def hide_referrer(response):
if strtobool(os.getenv("HIDE_REFERER", 'false')):
response.headers["Referrer-Policy"] = "no-referrer"
return response
# Proxy sub-directory support
# Set environment var USE_X_SETTINGS=1 on this script
# And then in your proxy_pass settings
#
# proxy_set_header Host "localhost";
# proxy_set_header X-Forwarded-Prefix /app;
if os.getenv('USE_X_SETTINGS'):
print ("USE_X_SETTINGS is ENABLED\n")
from werkzeug.middleware.proxy_fix import ProxyFix
app.wsgi_app = ProxyFix(app.wsgi_app, x_prefix=1, x_host=1)
s_type = socket.AF_INET6 if ipv6_enabled else socket.AF_INET
if ssl_mode:
# @todo finalise SSL config, but this should get you in the right direction if you need it.
eventlet.wsgi.server(eventlet.wrap_ssl(eventlet.listen((host, port), s_type),
certfile='cert.pem',
keyfile='privkey.pem',
server_side=True), app)
else:
eventlet.wsgi.server(eventlet.listen((host, int(port)), s_type), app)
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -90,6 +90,7 @@ def apprise_custom_api_call_wrapper(body, title, notify_type, *args, **kwargs):
if not k.strip('+-') in results['qsd+'].keys():
params[URLBase.unquote(k)] = URLBase.unquote(v)
# Determine Authentication
auth = ''
if results.get('user') and results.get('password'):
@@ -105,10 +106,10 @@ def apprise_custom_api_call_wrapper(body, title, notify_type, *args, **kwargs):
pass
r(results.get('url'),
auth=auth,
data=body,
headers=headers,
params=params
data=body,
params=params,
auth=auth
)
-5
View File
@@ -52,11 +52,6 @@ class difference_detection_processor():
prefer_fetch_backend = 'base_html_playwright'
browser_connection_url = connection[0].get('browser_connection_url')
# PDF should be html_requests because playwright will serve it up (so far) in a embedded page
# @todo https://github.com/dgtlmoon/changedetection.io/issues/2019
# @todo needs test to or a fix
if self.watch.is_pdf:
prefer_fetch_backend = "html_requests"
# Grab the right kind of 'fetcher', (playwright, requests, etc)
if hasattr(content_fetcher, prefer_fetch_backend):
+1 -5
View File
@@ -28,8 +28,6 @@ function isItemInStock() {
'nicht auf lager',
'nicht lieferbar',
'nicht zur verfügung',
'niet leverbaar',
'niet beschikbaar',
'no disponible temporalmente',
'no longer in stock',
'no tickets available',
@@ -42,11 +40,9 @@ function isItemInStock() {
'out-of-stock',
'produkt niedostępny',
'sold out',
'sold-out',
'temporarily out of stock',
'temporarily unavailable',
'tickets unavailable',
'tijdelijk uitverkocht',
'unavailable tickets',
'we do not currently have an estimate of when this product will be back in stock.',
'zur zeit nicht an lager',
@@ -115,4 +111,4 @@ function isItemInStock() {
}
// returns the element text that makes it think it's out of stock
return isItemInStock();
return isItemInStock();
+7 -14
View File
@@ -2,28 +2,21 @@ $(document).ready(function () {
// Lazy Hide/Show elements mechanism
$('[data-visible-for]').hide();
function show_related_elem(e) {
var n = $(e).attr('name') + "=" + $(e).val();
$(':radio').on('keyup keypress blur change click', function (e) {
$('[data-visible-for]').hide();
$('.advanced-options').hide();
var n = $(this).attr('name') + "=" + $(this).val();
if (n === 'fetch_backend=system') {
n = "fetch_backend=" + default_system_fetch_backend;
}
$(`[data-visible-for~="${n}"]`).show();
}
$(':radio').on('keyup keypress blur change click', function (e) {
$(`[data-visible-for]`).hide();
$('.advanced-options').hide();
show_related_elem(this);
});
$(':radio:checked').each(function (e) {
show_related_elem(this);
})
});
$(':radio:checked').change();
// Show advanced
$('.show-advanced').click(function (e) {
$(this).closest('.tab-pane-inner').find('.advanced-options').each(function (e) {
$(this).toggle();
})
$(this).closest('.tab-pane-inner').find('.advanced-options').toggle();
});
});
@@ -1,4 +1,18 @@
$(document).ready(function () {
// Lazy Hide/Show elements mechanism
$('[data-visible-for]').hide();
$(':radio').on('keyup keypress blur change click', function (e){
$('[data-visible-for]').hide();
var n = $(this).attr('name') + "=" + $(this).val();
if (n === 'fetch_backend=system') {
n = "fetch_backend=" + default_system_fetch_backend;
}
$(`[data-visible-for~="${n}"]`).show();
});
$(':radio:checked').change();
$('#notification-setting-reset-to-default').click(function (e) {
$('#notification_title').val('');
$('#notification_body').val('');
@@ -16,7 +16,7 @@
<li><code><a target=_new href="https://github.com/caronc/apprise/wiki/Notify_discord">discord://</a></code> (or <code>https://discord.com/api/webhooks...</code>)) only supports a maximum <strong>2,000 characters</strong> of notification text, including the title.</li>
<li><code><a target=_new href="https://github.com/caronc/apprise/wiki/Notify_telegram">tgram://</a></code> bots can't send messages to other bots, so you should specify chat ID of non-bot user.</li>
<li><code><a target=_new href="https://github.com/caronc/apprise/wiki/Notify_telegram">tgram://</a></code> only supports very limited HTML and can fail when extra tags are sent, <a href="https://core.telegram.org/bots/api#html-style">read more here</a> (or use plaintext/markdown format)</li>
<li><code>gets://</code>, <code>posts://</code>, <code>puts://</code>, <code>deletes://</code> for direct API calls (or omit the "<code>s</code>" for non-SSL ie <code>get://</code>) <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Notification-configuration-notes#postposts">more help here</a></li>
<li><code>gets://</code>, <code>posts://</code>, <code>puts://</code>, <code>deletes://</code> for direct API calls (or omit the "<code>s</code>" for non-SSL ie <code>get://</code>)</li>
<li>Accepts the <code>{{ '{{token}}' }}</code> placeholders listed below</li>
</ul>
</div>
+6 -6
View File
@@ -127,7 +127,7 @@
{% endif %}
<!-- webdriver always -->
<fieldset data-visible-for="fetch_backend=html_webdriver" style="display: none;">
<fieldset data-visible-for="fetch_backend=html_webdriver">
<div class="pure-control-group">
{{ render_field(form.webdriver_delay) }}
<div class="pure-form-message-inline">
@@ -153,15 +153,15 @@
</div>
</fieldset>
<!-- html requests always -->
<fieldset data-visible-for="fetch_backend=html_requests">
<fieldset data-visible-for="fetch_backend=html_requests" style="display: none;">
<div class="pure-control-group">
<a class="pure-button button-secondary button-xsmall show-advanced">Show advanced options</a>
</div>
<div class="advanced-options" style="display: none;">
<div class="pure-control-group" id="request-method">
<div class="pure-control-group advanced-options" id="request-method" style="display: none;">
{{ render_field(form.method) }}
</div>
<div id="request-body">
<div class="advanced-options" id="request-body">
{{ render_field(form.body, rows=5, placeholder="Example
{
\"name\":\"John\",
@@ -187,8 +187,8 @@ User-Agent: wonderbra 1.0") }}
(Not supported by Selenium browser)
</div>
</div>
<fieldset data-visible-for="fetch_backend=html_requests fetch_backend=html_webdriver" >
<div class="pure-control-group inline-radio advanced-options" style="display: none;">
<fieldset data-visible-for="fetch_backend=html_requests fetch_backend=html_webdriver" style="display: none;">
<div class="pure-control-group inline-radio advanced-options" style="display: none;">
{{ render_checkbox_field(form.ignore_status_codes) }}
</div>
</fieldset>
+2 -5
View File
@@ -236,11 +236,8 @@ nav
<span class="pure-form-message-inline">SOCKS5 proxies with authentication are only supported with 'plain requests' fetcher, for other fetchers you should whitelist the IP access instead</span>
</div>
<div class="pure-control-group" id="extra-browsers-setting">
<p>
<span class="pure-form-message-inline"><i>Extra Browsers</i> can be attached to further defeat CAPTCHA's on websites that are particularly hard to scrape.</span><br>
<span class="pure-form-message-inline">Simply paste the connection address into the box, <a href="https://changedetection.io/tutorial/using-bright-datas-scraping-browser-pass-captchas-and-other-protection-when-monitoring">More instructions and examples here</a> </span>
</p>
{{ render_field(form.requests.form.extra_browsers) }}
<span class="pure-form-message-inline"><i>Extra Browsers</i> allow changedetection.io to communicate with a different web-browser.</span><br>
{{ render_field(form.requests.form.extra_browsers) }}
</div>
</div>
<div id="actions">
+15 -10
View File
@@ -13,17 +13,22 @@ global app
def cleanup(datastore_path):
import glob
# Unlink test output files
for g in ["*.txt", "*.json", "*.pdf"]:
files = glob.glob(os.path.join(datastore_path, g))
for f in files:
if 'proxies.json' in f:
# Usually mounted by docker container during test time
continue
if os.path.isfile(f):
os.unlink(f)
files = [
'count.txt',
'endpoint-content.txt'
'headers.txt',
'headers-testtag.txt',
'notification.txt',
'secret.txt',
'url-watches.json',
'output.txt',
]
for file in files:
try:
os.unlink("{}/{}".format(datastore_path, file))
except FileNotFoundError:
pass
@pytest.fixture(scope='session')
def app(request):
-1
View File
@@ -378,4 +378,3 @@ def test_api_import(client, live_server):
# Should see the new tag in the tag/groups list
res = client.get(url_for('tags.tags_overview_page'))
assert b'import-test' in res.data
+6 -18
View File
@@ -281,8 +281,7 @@ def test_notification_custom_endpoint_and_jinja2(client, live_server):
# CUSTOM JSON BODY CHECK for POST://
set_original_response()
# https://github.com/caronc/apprise/wiki/Notify_Custom_JSON#header-manipulation
test_notification_url = url_for('test_notification_endpoint', _external=True).replace('http://', 'post://')+"?xxx={{ watch_url }}&+custom-header=123"
test_notification_url = url_for('test_notification_endpoint', _external=True).replace('http://', 'post://')+"?xxx={{ watch_url }}"
res = client.post(
url_for("settings_page"),
@@ -298,7 +297,10 @@ def test_notification_custom_endpoint_and_jinja2(client, live_server):
follow_redirects=True
)
assert b'Settings updated' in res.data
client.get(
url_for("form_delete", uuid="all"),
follow_redirects=True
)
# Add a watch and trigger a HTTP POST
test_url = url_for('test_endpoint', _external=True)
res = client.post(
@@ -313,9 +315,7 @@ def test_notification_custom_endpoint_and_jinja2(client, live_server):
set_modified_response()
client.get(url_for("form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
time.sleep(2) # plus extra delay for notifications to fire
time.sleep(2)
with open("test-datastore/notification.txt", 'r') as f:
x = f.read()
@@ -328,13 +328,6 @@ def test_notification_custom_endpoint_and_jinja2(client, live_server):
with open("test-datastore/notification-url.txt", 'r') as f:
notification_url = f.read()
assert 'xxx=http' in notification_url
# apprise style headers should be stripped
assert 'custom-header' not in notification_url
with open("test-datastore/notification-headers.txt", 'r') as f:
notification_headers = f.read()
assert 'custom-header: 123' in notification_headers.lower()
# Should always be automatically detected as JSON content type even when we set it as 'Text' (default)
assert os.path.isfile("test-datastore/notification-content-type.txt")
@@ -342,8 +335,3 @@ def test_notification_custom_endpoint_and_jinja2(client, live_server):
assert 'application/json' in f.read()
os.unlink("test-datastore/notification-url.txt")
client.get(
url_for("form_delete", uuid="all"),
follow_redirects=True
)
-3
View File
@@ -205,9 +205,6 @@ def live_server_setup(live_server):
with open("test-datastore/notification-url.txt", "w") as f:
f.write(request.url)
with open("test-datastore/notification-headers.txt", "w") as f:
f.write(str(request.headers))
if request.content_type:
with open("test-datastore/notification-content-type.txt", "w") as f:
f.write(request.content_type)
+1 -1
View File
@@ -90,7 +90,7 @@ services:
#
# Used for fetching pages via Playwright+Chrome where you need Javascript support.
# Note: works well but is deprecated, does not fetch full page screenshots (doesnt work with Visual Selector) and other issues
# Note: works well but is deprecated, doesnt fetch full page screenshots and other issues
# browser-chrome:
# hostname: browser-chrome
# image: selenium/standalone-chrome:4
File diff suppressed because one or more lines are too long
+8 -8
View File
@@ -5,13 +5,13 @@
<meta name="description" content="Manage your changedetection.io watches via API, requires the `x-api-key` header which is found in the settings UI.">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<link href="assets/bootstrap.min.css?v=1701595483622" rel="stylesheet" media="screen">
<link href="assets/prism.css?v=1701595483622" rel="stylesheet" />
<link href="assets/main.css?v=1701595483622" rel="stylesheet" media="screen, print">
<link href="assets/favicon.ico?v=1701595483622" rel="icon" type="image/x-icon">
<link href="assets/apple-touch-icon.png?v=1701595483622" rel="apple-touch-icon" sizes="180x180">
<link href="assets/favicon-32x32.png?v=1701595483622" rel="icon" type="image/png" sizes="32x32">
<link href="assets/favicon-16x16.png?v=1701595483622" rel="icon" type="image/png" sizes="16x16">
<link href="assets/bootstrap.min.css?v=1677105736053" rel="stylesheet" media="screen">
<link href="assets/prism.css?v=1677105736053" rel="stylesheet" />
<link href="assets/main.css?v=1677105736053" rel="stylesheet" media="screen, print">
<link href="assets/favicon.ico?v=1677105736053" rel="icon" type="image/x-icon">
<link href="assets/apple-touch-icon.png?v=1677105736053" rel="apple-touch-icon" sizes="180x180">
<link href="assets/favicon-32x32.png?v=1677105736053" rel="icon" type="image/png" sizes="32x32">
<link href="assets/favicon-16x16.png?v=1677105736053" rel="icon" type="image/png" sizes="16x16">
</head>
<body class="container-fluid">
@@ -928,6 +928,6 @@
</div>
</div>
<script src="assets/main.bundle.js?v=1701595483622"></script>
<script src="assets/main.bundle.js?v=1677105736053"></script>
</body>
</html>
+1 -1
View File
@@ -32,7 +32,7 @@ setup(
long_description_content_type='text/markdown',
keywords='website change monitor for changes notification change detection '
'alerts tracking website tracker change alert website and monitoring',
entry_points={"console_scripts": ["changedetection.io=changedetectionio:main"]},
entry_points={"console_scripts": ["changedetection.io=changedetectionio.changedetection:main"]},
zip_safe=True,
scripts=["changedetection.py"],
author='dgtlmoon',