mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2025-11-24 02:16:10 +00:00
Compare commits
7 Commits
fix-legacy
...
2408-user-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
606e29f7ee | ||
|
|
9e6def43bd | ||
|
|
21f8ff982b | ||
|
|
10e4126eb5 | ||
|
|
de18f0b5d0 | ||
|
|
78ddb3b546 | ||
|
|
e528cf109a |
4
.github/workflows/containers.yml
vendored
4
.github/workflows/containers.yml
vendored
@@ -88,7 +88,7 @@ jobs:
|
|||||||
- name: Build and push :dev
|
- name: Build and push :dev
|
||||||
id: docker_build
|
id: docker_build
|
||||||
if: ${{ github.ref }} == "refs/heads/master"
|
if: ${{ github.ref }} == "refs/heads/master"
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: ./
|
context: ./
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
@@ -106,7 +106,7 @@ jobs:
|
|||||||
- name: Build and push :tag
|
- name: Build and push :tag
|
||||||
id: docker_build_tag_release
|
id: docker_build_tag_release
|
||||||
if: github.event_name == 'release' && startsWith(github.event.release.tag_name, '0.')
|
if: github.event_name == 'release' && startsWith(github.event.release.tag_name, '0.')
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: ./
|
context: ./
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
|
|||||||
4
.github/workflows/test-container-build.yml
vendored
4
.github/workflows/test-container-build.yml
vendored
@@ -51,7 +51,7 @@ jobs:
|
|||||||
# Check we can still build under alpine/musl
|
# Check we can still build under alpine/musl
|
||||||
- name: Test that the docker containers can build (musl via alpine check)
|
- name: Test that the docker containers can build (musl via alpine check)
|
||||||
id: docker_build_musl
|
id: docker_build_musl
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: ./
|
context: ./
|
||||||
file: ./.github/test/Dockerfile-alpine
|
file: ./.github/test/Dockerfile-alpine
|
||||||
@@ -59,7 +59,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Test that the docker containers can build
|
- name: Test that the docker containers can build
|
||||||
id: docker_build
|
id: docker_build
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v5
|
||||||
# https://github.com/docker/build-push-action#customizing
|
# https://github.com/docker/build-push-action#customizing
|
||||||
with:
|
with:
|
||||||
context: ./
|
context: ./
|
||||||
|
|||||||
221
.github/workflows/test-only.yml
vendored
221
.github/workflows/test-only.yml
vendored
@@ -4,10 +4,17 @@ name: ChangeDetection.io App Test
|
|||||||
on: [push, pull_request]
|
on: [push, pull_request]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint-code:
|
test-application:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Mainly just for link/flake8
|
||||||
|
- name: Set up Python 3.11
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
|
||||||
- name: Lint with flake8
|
- name: Lint with flake8
|
||||||
run: |
|
run: |
|
||||||
pip3 install flake8
|
pip3 install flake8
|
||||||
@@ -16,24 +23,202 @@ jobs:
|
|||||||
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
|
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
|
||||||
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
|
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
|
||||||
|
|
||||||
test-application-3-10:
|
- name: Spin up ancillary testable services
|
||||||
needs: lint-code
|
run: |
|
||||||
uses: ./.github/workflows/test-stack-reusable-workflow.yml
|
|
||||||
with:
|
docker network create changedet-network
|
||||||
python-version: '3.10'
|
|
||||||
|
# Selenium
|
||||||
|
docker run --network changedet-network -d --hostname selenium -p 4444:4444 --rm --shm-size="2g" selenium/standalone-chrome:4
|
||||||
|
|
||||||
|
# SocketPuppetBrowser + Extra for custom browser test
|
||||||
|
docker run --network changedet-network -d -e "LOG_LEVEL=TRACE" --cap-add=SYS_ADMIN --name sockpuppetbrowser --hostname sockpuppetbrowser --rm -p 3000:3000 dgtlmoon/sockpuppetbrowser:latest
|
||||||
|
docker run --network changedet-network -d -e "LOG_LEVEL=TRACE" --cap-add=SYS_ADMIN --name sockpuppetbrowser-custom-url --hostname sockpuppetbrowser-custom-url -p 3001:3000 --rm dgtlmoon/sockpuppetbrowser:latest
|
||||||
|
|
||||||
|
- name: Build changedetection.io container for testing
|
||||||
|
run: |
|
||||||
|
# Build a changedetection.io container and start testing inside
|
||||||
|
docker build --build-arg LOGGER_LEVEL=TRACE -t test-changedetectionio .
|
||||||
|
# Debug info
|
||||||
|
docker run test-changedetectionio bash -c 'pip list'
|
||||||
|
|
||||||
|
- name: Spin up ancillary SMTP+Echo message test server
|
||||||
|
run: |
|
||||||
|
# Debug SMTP server/echo message back server
|
||||||
|
docker run --network changedet-network -d -p 11025:11025 -p 11080:11080 --hostname mailserver test-changedetectionio bash -c 'python changedetectionio/tests/smtp/smtp-test-server.py'
|
||||||
|
|
||||||
|
- name: Show docker container state and other debug info
|
||||||
|
run: |
|
||||||
|
set -x
|
||||||
|
echo "Running processes in docker..."
|
||||||
|
docker ps
|
||||||
|
|
||||||
|
- name: Test built container with Pytest (generally as requests/plaintext fetching)
|
||||||
|
run: |
|
||||||
|
# Unit tests
|
||||||
|
echo "run test with unittest"
|
||||||
|
docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_notification_diff'
|
||||||
|
docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_watch_model'
|
||||||
|
docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_jinja2_security'
|
||||||
|
|
||||||
|
# All tests
|
||||||
|
echo "run test with pytest"
|
||||||
|
# The default pytest logger_level is TRACE
|
||||||
|
# To change logger_level for pytest(test/conftest.py),
|
||||||
|
# append the docker option. e.g. '-e LOGGER_LEVEL=DEBUG'
|
||||||
|
docker run --name test-cdio-basic-tests --network changedet-network test-changedetectionio bash -c 'cd changedetectionio && ./run_basic_tests.sh'
|
||||||
|
|
||||||
|
# PLAYWRIGHT/NODE-> CDP
|
||||||
|
- name: Playwright and SocketPuppetBrowser - Specific tests in built container
|
||||||
|
run: |
|
||||||
|
# Playwright via Sockpuppetbrowser fetch
|
||||||
|
# tests/visualselector/test_fetch_data.py will do browser steps
|
||||||
|
docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio bash -c 'cd changedetectionio;pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/fetchers/test_content.py'
|
||||||
|
docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio bash -c 'cd changedetectionio;pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/test_errorhandling.py'
|
||||||
|
docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio bash -c 'cd changedetectionio;pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/visualselector/test_fetch_data.py'
|
||||||
|
docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio bash -c 'cd changedetectionio;pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/fetchers/test_custom_js_before_content.py'
|
||||||
|
|
||||||
|
|
||||||
test-application-3-11:
|
- name: Playwright and SocketPuppetBrowser - Headers and requests
|
||||||
needs: lint-code
|
run: |
|
||||||
uses: ./.github/workflows/test-stack-reusable-workflow.yml
|
# Settings headers playwright tests - Call back in from Sockpuppetbrowser, check headers
|
||||||
with:
|
docker run --name "changedet" --hostname changedet --rm -e "FLASK_SERVER_NAME=changedet" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000?dumpio=true" --network changedet-network test-changedetectionio bash -c 'cd changedetectionio; pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/test_request.py'
|
||||||
python-version: '3.11'
|
|
||||||
skip-pypuppeteer: true
|
|
||||||
|
|
||||||
test-application-3-12:
|
- name: Playwright and SocketPuppetBrowser - Restock detection
|
||||||
needs: lint-code
|
run: |
|
||||||
uses: ./.github/workflows/test-stack-reusable-workflow.yml
|
# restock detection via playwright - added name=changedet here so that playwright and sockpuppetbrowser can connect to it
|
||||||
with:
|
docker run --rm --name "changedet" -e "FLASK_SERVER_NAME=changedet" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network test-changedetectionio bash -c 'cd changedetectionio;pytest --live-server-port=5004 --live-server-host=0.0.0.0 tests/restock/test_restock.py'
|
||||||
python-version: '3.12'
|
|
||||||
skip-pypuppeteer: true
|
|
||||||
|
|
||||||
|
# STRAIGHT TO CDP
|
||||||
|
- name: Pyppeteer and SocketPuppetBrowser - Specific tests in built container
|
||||||
|
run: |
|
||||||
|
# Playwright via Sockpuppetbrowser fetch
|
||||||
|
docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "FAST_PUPPETEER_CHROME_FETCHER=True" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio bash -c 'cd changedetectionio;pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/fetchers/test_content.py'
|
||||||
|
docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "FAST_PUPPETEER_CHROME_FETCHER=True" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio bash -c 'cd changedetectionio;pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/test_errorhandling.py'
|
||||||
|
docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "FAST_PUPPETEER_CHROME_FETCHER=True" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio bash -c 'cd changedetectionio;pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/visualselector/test_fetch_data.py'
|
||||||
|
docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "FAST_PUPPETEER_CHROME_FETCHER=True" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio bash -c 'cd changedetectionio;pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/fetchers/test_custom_js_before_content.py'
|
||||||
|
|
||||||
|
- name: Pyppeteer and SocketPuppetBrowser - Headers and requests checks
|
||||||
|
run: |
|
||||||
|
# Settings headers playwright tests - Call back in from Sockpuppetbrowser, check headers
|
||||||
|
docker run --name "changedet" --hostname changedet --rm -e "FAST_PUPPETEER_CHROME_FETCHER=True" -e "FLASK_SERVER_NAME=changedet" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000?dumpio=true" --network changedet-network test-changedetectionio bash -c 'cd changedetectionio; pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/test_request.py'
|
||||||
|
|
||||||
|
- name: Pyppeteer and SocketPuppetBrowser - Restock detection
|
||||||
|
run: |
|
||||||
|
# restock detection via playwright - added name=changedet here so that playwright and sockpuppetbrowser can connect to it
|
||||||
|
docker run --rm --name "changedet" -e "FLASK_SERVER_NAME=changedet" -e "FAST_PUPPETEER_CHROME_FETCHER=True" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network test-changedetectionio bash -c 'cd changedetectionio;pytest --live-server-port=5004 --live-server-host=0.0.0.0 tests/restock/test_restock.py'
|
||||||
|
|
||||||
|
# SELENIUM
|
||||||
|
- name: Specific tests in built container for Selenium
|
||||||
|
run: |
|
||||||
|
# Selenium fetch
|
||||||
|
docker run --rm -e "WEBDRIVER_URL=http://selenium:4444/wd/hub" --network changedet-network test-changedetectionio bash -c 'cd changedetectionio;pytest tests/fetchers/test_content.py && pytest tests/test_errorhandling.py'
|
||||||
|
|
||||||
|
- name: Specific tests in built container for headers and requests checks with Selenium
|
||||||
|
run: |
|
||||||
|
docker run --name "changedet" --hostname changedet --rm -e "FLASK_SERVER_NAME=changedet" -e "WEBDRIVER_URL=http://selenium:4444/wd/hub" --network changedet-network test-changedetectionio bash -c 'cd changedetectionio; pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/test_request.py'
|
||||||
|
|
||||||
|
# OTHER STUFF
|
||||||
|
- name: Test SMTP notification mime types
|
||||||
|
run: |
|
||||||
|
# SMTP content types - needs the 'Debug SMTP server/echo message back server' container from above
|
||||||
|
docker run --rm --network changedet-network test-changedetectionio bash -c 'cd changedetectionio;pytest tests/smtp/test_notification_smtp.py'
|
||||||
|
|
||||||
|
# @todo Add a test via playwright/puppeteer
|
||||||
|
# squid with auth is tested in run_proxy_tests.sh -> tests/proxy_list/test_select_custom_proxy.py
|
||||||
|
- name: Test proxy squid style interaction
|
||||||
|
run: |
|
||||||
|
cd changedetectionio
|
||||||
|
./run_proxy_tests.sh
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
- name: Test proxy SOCKS5 style interaction
|
||||||
|
run: |
|
||||||
|
cd changedetectionio
|
||||||
|
./run_socks_proxy_tests.sh
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
- name: Test custom browser URL
|
||||||
|
run: |
|
||||||
|
cd changedetectionio
|
||||||
|
./run_custom_browser_url_tests.sh
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
- name: Test changedetection.io container starts+runs basically without error
|
||||||
|
run: |
|
||||||
|
docker run --name test-changedetectionio -p 5556:5000 -d test-changedetectionio
|
||||||
|
sleep 3
|
||||||
|
# Should return 0 (no error) when grep finds it
|
||||||
|
curl --retry-connrefused --retry 6 -s http://localhost:5556 |grep -q checkbox-uuid
|
||||||
|
|
||||||
|
# and IPv6
|
||||||
|
curl --retry-connrefused --retry 6 -s -g -6 "http://[::1]:5556"|grep -q checkbox-uuid
|
||||||
|
|
||||||
|
# Check whether TRACE log is enabled.
|
||||||
|
# Also, check whether TRACE is came from STDERR
|
||||||
|
docker logs test-changedetectionio 2>&1 1>/dev/null | grep 'TRACE log is enabled' || exit 1
|
||||||
|
# Check whether DEBUG is came from STDOUT
|
||||||
|
docker logs test-changedetectionio 2>/dev/null | grep 'DEBUG' || exit 1
|
||||||
|
|
||||||
|
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(STDERR) log. Because of
|
||||||
|
# changedetectionio/__init__.py: logger.add(sys.stderr, level=logger_level)
|
||||||
|
docker logs sig-test 2>&1 | 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
|
||||||
|
# check signal catch(STDERR) log. Because of
|
||||||
|
# changedetectionio/__init__.py: logger.add(sys.stderr, level=logger_level)
|
||||||
|
docker logs sig-test 2>&1 | 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
|
||||||
|
|
||||||
|
- name: Dump container log
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
mkdir output-logs
|
||||||
|
docker logs test-cdio-basic-tests > output-logs/test-cdio-basic-tests-stdout.txt
|
||||||
|
docker logs test-cdio-basic-tests 2> output-logs/test-cdio-basic-tests-stderr.txt
|
||||||
|
|
||||||
|
- name: Store container log
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: test-cdio-basic-tests-output
|
||||||
|
path: output-logs
|
||||||
|
|||||||
239
.github/workflows/test-stack-reusable-workflow.yml
vendored
239
.github/workflows/test-stack-reusable-workflow.yml
vendored
@@ -1,239 +0,0 @@
|
|||||||
name: ChangeDetection.io App Test
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_call:
|
|
||||||
inputs:
|
|
||||||
python-version:
|
|
||||||
description: 'Python version to use'
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
default: '3.10'
|
|
||||||
skip-pypuppeteer:
|
|
||||||
description: 'Skip PyPuppeteer (not supported in 3.11/3.12)'
|
|
||||||
required: false
|
|
||||||
type: boolean
|
|
||||||
default: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test-application:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
env:
|
|
||||||
PYTHON_VERSION: ${{ inputs.python-version }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
# Mainly just for link/flake8
|
|
||||||
- name: Set up Python ${{ env.PYTHON_VERSION }}
|
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: ${{ env.PYTHON_VERSION }}
|
|
||||||
|
|
||||||
- name: Build changedetection.io container for testing under Python ${{ env.PYTHON_VERSION }}
|
|
||||||
run: |
|
|
||||||
echo "---- Building for Python ${{ env.PYTHON_VERSION }} -----"
|
|
||||||
# Build a changedetection.io container and start testing inside
|
|
||||||
docker build --build-arg PYTHON_VERSION=${{ env.PYTHON_VERSION }} --build-arg LOGGER_LEVEL=TRACE -t test-changedetectionio .
|
|
||||||
# Debug info
|
|
||||||
docker run test-changedetectionio bash -c 'pip list'
|
|
||||||
|
|
||||||
- name: We should be Python ${{ env.PYTHON_VERSION }} ...
|
|
||||||
run: |
|
|
||||||
docker run test-changedetectionio bash -c 'python3 --version'
|
|
||||||
|
|
||||||
- name: Spin up ancillary testable services
|
|
||||||
run: |
|
|
||||||
|
|
||||||
docker network create changedet-network
|
|
||||||
|
|
||||||
# Selenium
|
|
||||||
docker run --network changedet-network -d --hostname selenium -p 4444:4444 --rm --shm-size="2g" selenium/standalone-chrome:4
|
|
||||||
|
|
||||||
# SocketPuppetBrowser + Extra for custom browser test
|
|
||||||
docker run --network changedet-network -d -e "LOG_LEVEL=TRACE" --cap-add=SYS_ADMIN --name sockpuppetbrowser --hostname sockpuppetbrowser --rm -p 3000:3000 dgtlmoon/sockpuppetbrowser:latest
|
|
||||||
docker run --network changedet-network -d -e "LOG_LEVEL=TRACE" --cap-add=SYS_ADMIN --name sockpuppetbrowser-custom-url --hostname sockpuppetbrowser-custom-url -p 3001:3000 --rm dgtlmoon/sockpuppetbrowser:latest
|
|
||||||
|
|
||||||
- name: Spin up ancillary SMTP+Echo message test server
|
|
||||||
run: |
|
|
||||||
# Debug SMTP server/echo message back server
|
|
||||||
docker run --network changedet-network -d -p 11025:11025 -p 11080:11080 --hostname mailserver test-changedetectionio bash -c 'pip3 install aiosmtpd && python changedetectionio/tests/smtp/smtp-test-server.py'
|
|
||||||
docker ps
|
|
||||||
|
|
||||||
- name: Show docker container state and other debug info
|
|
||||||
run: |
|
|
||||||
set -x
|
|
||||||
echo "Running processes in docker..."
|
|
||||||
docker ps
|
|
||||||
|
|
||||||
- name: Test built container with Pytest (generally as requests/plaintext fetching)
|
|
||||||
run: |
|
|
||||||
# Unit tests
|
|
||||||
echo "run test with unittest"
|
|
||||||
docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_notification_diff'
|
|
||||||
docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_watch_model'
|
|
||||||
docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_jinja2_security'
|
|
||||||
|
|
||||||
# All tests
|
|
||||||
echo "run test with pytest"
|
|
||||||
# The default pytest logger_level is TRACE
|
|
||||||
# To change logger_level for pytest(test/conftest.py),
|
|
||||||
# append the docker option. e.g. '-e LOGGER_LEVEL=DEBUG'
|
|
||||||
docker run --name test-cdio-basic-tests --network changedet-network test-changedetectionio bash -c 'cd changedetectionio && ./run_basic_tests.sh'
|
|
||||||
|
|
||||||
# PLAYWRIGHT/NODE-> CDP
|
|
||||||
- name: Playwright and SocketPuppetBrowser - Specific tests in built container
|
|
||||||
run: |
|
|
||||||
# Playwright via Sockpuppetbrowser fetch
|
|
||||||
# tests/visualselector/test_fetch_data.py will do browser steps
|
|
||||||
docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio bash -c 'cd changedetectionio;pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/fetchers/test_content.py'
|
|
||||||
docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio bash -c 'cd changedetectionio;pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/test_errorhandling.py'
|
|
||||||
docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio bash -c 'cd changedetectionio;pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/visualselector/test_fetch_data.py'
|
|
||||||
docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio bash -c 'cd changedetectionio;pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/fetchers/test_custom_js_before_content.py'
|
|
||||||
|
|
||||||
|
|
||||||
- name: Playwright and SocketPuppetBrowser - Headers and requests
|
|
||||||
run: |
|
|
||||||
# Settings headers playwright tests - Call back in from Sockpuppetbrowser, check headers
|
|
||||||
docker run --name "changedet" --hostname changedet --rm -e "FLASK_SERVER_NAME=changedet" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000?dumpio=true" --network changedet-network test-changedetectionio bash -c 'cd changedetectionio; pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/test_request.py'
|
|
||||||
|
|
||||||
- name: Playwright and SocketPuppetBrowser - Restock detection
|
|
||||||
run: |
|
|
||||||
# restock detection via playwright - added name=changedet here so that playwright and sockpuppetbrowser can connect to it
|
|
||||||
docker run --rm --name "changedet" -e "FLASK_SERVER_NAME=changedet" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network test-changedetectionio bash -c 'cd changedetectionio;pytest --live-server-port=5004 --live-server-host=0.0.0.0 tests/restock/test_restock.py'
|
|
||||||
|
|
||||||
# STRAIGHT TO CDP
|
|
||||||
- name: Pyppeteer and SocketPuppetBrowser - Specific tests in built container
|
|
||||||
if: ${{ inputs.skip-pypuppeteer == false }}
|
|
||||||
run: |
|
|
||||||
# Playwright via Sockpuppetbrowser fetch
|
|
||||||
docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "FAST_PUPPETEER_CHROME_FETCHER=True" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio bash -c 'cd changedetectionio;pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/fetchers/test_content.py'
|
|
||||||
docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "FAST_PUPPETEER_CHROME_FETCHER=True" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio bash -c 'cd changedetectionio;pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/test_errorhandling.py'
|
|
||||||
docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "FAST_PUPPETEER_CHROME_FETCHER=True" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio bash -c 'cd changedetectionio;pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/visualselector/test_fetch_data.py'
|
|
||||||
docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "FAST_PUPPETEER_CHROME_FETCHER=True" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio bash -c 'cd changedetectionio;pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/fetchers/test_custom_js_before_content.py'
|
|
||||||
|
|
||||||
- name: Pyppeteer and SocketPuppetBrowser - Headers and requests checks
|
|
||||||
if: ${{ inputs.skip-pypuppeteer == false }}
|
|
||||||
run: |
|
|
||||||
# Settings headers playwright tests - Call back in from Sockpuppetbrowser, check headers
|
|
||||||
docker run --name "changedet" --hostname changedet --rm -e "FAST_PUPPETEER_CHROME_FETCHER=True" -e "FLASK_SERVER_NAME=changedet" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000?dumpio=true" --network changedet-network test-changedetectionio bash -c 'cd changedetectionio; pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/test_request.py'
|
|
||||||
|
|
||||||
- name: Pyppeteer and SocketPuppetBrowser - Restock detection
|
|
||||||
if: ${{ inputs.skip-pypuppeteer == false }}
|
|
||||||
run: |
|
|
||||||
# restock detection via playwright - added name=changedet here so that playwright and sockpuppetbrowser can connect to it
|
|
||||||
docker run --rm --name "changedet" -e "FLASK_SERVER_NAME=changedet" -e "FAST_PUPPETEER_CHROME_FETCHER=True" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network test-changedetectionio bash -c 'cd changedetectionio;pytest --live-server-port=5004 --live-server-host=0.0.0.0 tests/restock/test_restock.py'
|
|
||||||
|
|
||||||
# SELENIUM
|
|
||||||
- name: Specific tests in built container for Selenium
|
|
||||||
run: |
|
|
||||||
# Selenium fetch
|
|
||||||
docker run --rm -e "WEBDRIVER_URL=http://selenium:4444/wd/hub" --network changedet-network test-changedetectionio bash -c 'cd changedetectionio;pytest tests/fetchers/test_content.py && pytest tests/test_errorhandling.py'
|
|
||||||
|
|
||||||
- name: Specific tests in built container for headers and requests checks with Selenium
|
|
||||||
run: |
|
|
||||||
docker run --name "changedet" --hostname changedet --rm -e "FLASK_SERVER_NAME=changedet" -e "WEBDRIVER_URL=http://selenium:4444/wd/hub" --network changedet-network test-changedetectionio bash -c 'cd changedetectionio; pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/test_request.py'
|
|
||||||
|
|
||||||
# OTHER STUFF
|
|
||||||
- name: Test SMTP notification mime types
|
|
||||||
run: |
|
|
||||||
# SMTP content types - needs the 'Debug SMTP server/echo message back server' container from above
|
|
||||||
# "mailserver" hostname defined above
|
|
||||||
docker run --rm --network changedet-network test-changedetectionio bash -c 'cd changedetectionio;pytest tests/smtp/test_notification_smtp.py'
|
|
||||||
|
|
||||||
# @todo Add a test via playwright/puppeteer
|
|
||||||
# squid with auth is tested in run_proxy_tests.sh -> tests/proxy_list/test_select_custom_proxy.py
|
|
||||||
- name: Test proxy squid style interaction
|
|
||||||
run: |
|
|
||||||
cd changedetectionio
|
|
||||||
./run_proxy_tests.sh
|
|
||||||
cd ..
|
|
||||||
|
|
||||||
- name: Test proxy SOCKS5 style interaction
|
|
||||||
run: |
|
|
||||||
cd changedetectionio
|
|
||||||
./run_socks_proxy_tests.sh
|
|
||||||
cd ..
|
|
||||||
|
|
||||||
- name: Test custom browser URL
|
|
||||||
run: |
|
|
||||||
cd changedetectionio
|
|
||||||
./run_custom_browser_url_tests.sh
|
|
||||||
cd ..
|
|
||||||
|
|
||||||
- name: Test changedetection.io container starts+runs basically without error
|
|
||||||
run: |
|
|
||||||
docker run --name test-changedetectionio -p 5556:5000 -d test-changedetectionio
|
|
||||||
sleep 3
|
|
||||||
# Should return 0 (no error) when grep finds it
|
|
||||||
curl --retry-connrefused --retry 6 -s http://localhost:5556 |grep -q checkbox-uuid
|
|
||||||
|
|
||||||
# and IPv6
|
|
||||||
curl --retry-connrefused --retry 6 -s -g -6 "http://[::1]:5556"|grep -q checkbox-uuid
|
|
||||||
|
|
||||||
# Check whether TRACE log is enabled.
|
|
||||||
# Also, check whether TRACE is came from STDERR
|
|
||||||
docker logs test-changedetectionio 2>&1 1>/dev/null | grep 'TRACE log is enabled' || exit 1
|
|
||||||
# Check whether DEBUG is came from STDOUT
|
|
||||||
docker logs test-changedetectionio 2>/dev/null | grep 'DEBUG' || exit 1
|
|
||||||
|
|
||||||
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(STDERR) log. Because of
|
|
||||||
# changedetectionio/__init__.py: logger.add(sys.stderr, level=logger_level)
|
|
||||||
docker logs sig-test 2>&1 | 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
|
|
||||||
# check signal catch(STDERR) log. Because of
|
|
||||||
# changedetectionio/__init__.py: logger.add(sys.stderr, level=logger_level)
|
|
||||||
docker logs sig-test 2>&1 | 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
|
|
||||||
|
|
||||||
- name: Dump container log
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
mkdir output-logs
|
|
||||||
docker logs test-cdio-basic-tests > output-logs/test-cdio-basic-tests-stdout-${{ env.PYTHON_VERSION }}.txt
|
|
||||||
docker logs test-cdio-basic-tests 2> output-logs/test-cdio-basic-tests-stderr-${{ env.PYTHON_VERSION }}.txt
|
|
||||||
|
|
||||||
- name: Store container log
|
|
||||||
if: always()
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: test-cdio-basic-tests-output-py${{ env.PYTHON_VERSION }}
|
|
||||||
path: output-logs
|
|
||||||
10
Dockerfile
10
Dockerfile
@@ -2,10 +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
|
||||||
ARG PYTHON_VERSION=3.11
|
|
||||||
|
|
||||||
FROM python:${PYTHON_VERSION}-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
|
||||||
@@ -26,8 +23,7 @@ WORKDIR /install
|
|||||||
|
|
||||||
COPY requirements.txt /requirements.txt
|
COPY requirements.txt /requirements.txt
|
||||||
|
|
||||||
# --extra-index-url https://www.piwheels.org/simple is for cryptography module to be prebuilt (or rustc etc needs to be installed)
|
RUN pip install --target=/dependencies -r /requirements.txt
|
||||||
RUN pip install --extra-index-url https://www.piwheels.org/simple --target=/dependencies -r /requirements.txt
|
|
||||||
|
|
||||||
# Playwright is an alternative to Selenium
|
# Playwright is an alternative to Selenium
|
||||||
# Excluded this package from requirements.txt to prevent arm/v6 and arm/v7 builds from failing
|
# Excluded this package from requirements.txt to prevent arm/v6 and arm/v7 builds from failing
|
||||||
@@ -36,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:${PYTHON_VERSION}-slim-bookworm
|
FROM python:3.10-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 \
|
||||||
|
|||||||
@@ -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.25'
|
__version__ = '0.45.23'
|
||||||
|
|
||||||
from changedetectionio.strtobool import strtobool
|
from changedetectionio.strtobool import strtobool
|
||||||
from json.decoder import JSONDecodeError
|
from json.decoder import JSONDecodeError
|
||||||
|
|||||||
@@ -170,33 +170,23 @@ class WatchSingleHistory(Resource):
|
|||||||
curl http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091/history/1677092977 -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json"
|
curl http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091/history/1677092977 -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json"
|
||||||
@apiName Get single snapshot content
|
@apiName Get single snapshot content
|
||||||
@apiGroup Watch History
|
@apiGroup Watch History
|
||||||
@apiParam {String} [html] Optional Set to =1 to return the last HTML (only stores last 2 snapshots, use `latest` as timestamp)
|
|
||||||
@apiSuccess (200) {String} OK
|
@apiSuccess (200) {String} OK
|
||||||
@apiSuccess (404) {String} ERR Not found
|
@apiSuccess (404) {String} ERR Not found
|
||||||
"""
|
"""
|
||||||
watch = self.datastore.data['watching'].get(uuid)
|
watch = self.datastore.data['watching'].get(uuid)
|
||||||
if not watch:
|
if not watch:
|
||||||
abort(404, message=f"No watch exists with the UUID of {uuid}")
|
abort(404, message='No watch exists with the UUID of {}'.format(uuid))
|
||||||
|
|
||||||
if not len(watch.history):
|
if not len(watch.history):
|
||||||
abort(404, message=f"Watch found but no history exists for the UUID {uuid}")
|
abort(404, message='Watch found but no history exists for the UUID {}'.format(uuid))
|
||||||
|
|
||||||
if timestamp == 'latest':
|
if timestamp == 'latest':
|
||||||
timestamp = list(watch.history.keys())[-1]
|
timestamp = list(watch.history.keys())[-1]
|
||||||
|
|
||||||
if request.args.get('html'):
|
content = watch.get_history_snapshot(timestamp)
|
||||||
content = watch.get_fetched_html(timestamp)
|
|
||||||
if content:
|
|
||||||
response = make_response(content, 200)
|
|
||||||
response.mimetype = "text/html"
|
|
||||||
else:
|
|
||||||
response = make_response("No content found", 404)
|
|
||||||
response.mimetype = "text/plain"
|
|
||||||
else:
|
|
||||||
content = watch.get_history_snapshot(timestamp)
|
|
||||||
response = make_response(content, 200)
|
|
||||||
response.mimetype = "text/plain"
|
|
||||||
|
|
||||||
|
response = make_response(content, 200)
|
||||||
|
response.mimetype = "text/plain"
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -187,10 +187,8 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
|||||||
u = browsersteps_sessions[browsersteps_session_id]['browserstepper'].page.url
|
u = browsersteps_sessions[browsersteps_session_id]['browserstepper'].page.url
|
||||||
if is_last_step and u:
|
if is_last_step and u:
|
||||||
(screenshot, xpath_data) = browsersteps_sessions[browsersteps_session_id]['browserstepper'].request_visualselector_data()
|
(screenshot, xpath_data) = browsersteps_sessions[browsersteps_session_id]['browserstepper'].request_visualselector_data()
|
||||||
watch = datastore.data['watching'].get(uuid)
|
datastore.save_screenshot(watch_uuid=uuid, screenshot=screenshot)
|
||||||
if watch:
|
datastore.save_xpath_data(watch_uuid=uuid, data=xpath_data)
|
||||||
watch.save_screenshot(screenshot=screenshot)
|
|
||||||
watch.save_xpath_data(data=xpath_data)
|
|
||||||
|
|
||||||
# if not this_session.page:
|
# if not this_session.page:
|
||||||
# cleanup_playwright_session()
|
# cleanup_playwright_session()
|
||||||
|
|||||||
@@ -255,9 +255,8 @@ class browsersteps_live_ui(steppable_browser_interface):
|
|||||||
|
|
||||||
def get_current_state(self):
|
def get_current_state(self):
|
||||||
"""Return the screenshot and interactive elements mapping, generally always called after action_()"""
|
"""Return the screenshot and interactive elements mapping, generally always called after action_()"""
|
||||||
import importlib.resources
|
from pkg_resources import resource_string
|
||||||
xpath_element_js = importlib.resources.files("changedetectionio.content_fetchers.res").joinpath('xpath_element_scraper.js').read_text()
|
xpath_element_js = resource_string(__name__, "../../content_fetchers/res/xpath_element_scraper.js").decode('utf-8')
|
||||||
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
self.page.wait_for_timeout(1 * 1000)
|
self.page.wait_for_timeout(1 * 1000)
|
||||||
|
|
||||||
@@ -288,9 +287,11 @@ class browsersteps_live_ui(steppable_browser_interface):
|
|||||||
:param current_include_filters:
|
:param current_include_filters:
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
import importlib.resources
|
|
||||||
self.page.evaluate("var include_filters=''")
|
self.page.evaluate("var include_filters=''")
|
||||||
xpath_element_js = importlib.resources.files("changedetectionio.content_fetchers.res").joinpath('xpath_element_scraper.js').read_text()
|
from pkg_resources import resource_string
|
||||||
|
# The code that scrapes elements and makes a list of elements/size/position to click on in the VisualSelector
|
||||||
|
xpath_element_js = resource_string(__name__, "../../content_fetchers/res/xpath_element_scraper.js").decode('utf-8')
|
||||||
from changedetectionio.content_fetchers import visualselector_xpath_selectors
|
from changedetectionio.content_fetchers import visualselector_xpath_selectors
|
||||||
xpath_element_js = xpath_element_js.replace('%ELEMENTS%', visualselector_xpath_selectors)
|
xpath_element_js = xpath_element_js.replace('%ELEMENTS%', visualselector_xpath_selectors)
|
||||||
xpath_data = self.page.evaluate("async () => {" + xpath_element_js + "}")
|
xpath_data = self.page.evaluate("async () => {" + xpath_element_js + "}")
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ xpath://body/div/span[contains(@class, 'example-class')]",
|
|||||||
<ul>
|
<ul>
|
||||||
<li>JSONPath: Prefix with <code>json:</code>, use <code>json:$</code> to force re-formatting if required, <a href="https://jsonpath.com/" target="new">test your JSONPath here</a>.</li>
|
<li>JSONPath: Prefix with <code>json:</code>, use <code>json:$</code> to force re-formatting if required, <a href="https://jsonpath.com/" target="new">test your JSONPath here</a>.</li>
|
||||||
{% if jq_support %}
|
{% if jq_support %}
|
||||||
<li>jq: Prefix with <code>jq:</code> and <a href="https://jqplay.org/" target="new">test your jq here</a>. Using <a href="https://stedolan.github.io/jq/" target="new">jq</a> allows for complex filtering and processing of JSON data with built-in functions, regex, filtering, and more. See examples and documentation <a href="https://stedolan.github.io/jq/manual/" target="new">here</a>. Prefix <code>jqraw:</code> outputs the results as text instead of a JSON list.</li>
|
<li>jq: Prefix with <code>jq:</code> and <a href="https://jqplay.org/" target="new">test your jq here</a>. Using <a href="https://stedolan.github.io/jq/" target="new">jq</a> allows for complex filtering and processing of JSON data with built-in functions, regex, filtering, and more. See examples and documentation <a href="https://stedolan.github.io/jq/manual/" target="new">here</a>.</li>
|
||||||
{% else %}
|
{% else %}
|
||||||
<li>jq support not installed</li>
|
<li>jq support not installed</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -64,9 +64,10 @@ class Fetcher():
|
|||||||
render_extract_delay = 0
|
render_extract_delay = 0
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
import importlib.resources
|
from pkg_resources import resource_string
|
||||||
self.xpath_element_js = importlib.resources.files("changedetectionio.content_fetchers.res").joinpath('xpath_element_scraper.js').read_text()
|
# The code that scrapes elements and makes a list of elements/size/position to click on in the VisualSelector
|
||||||
self.instock_data_js = importlib.resources.files("changedetectionio.content_fetchers.res").joinpath('stock-not-in-stock.js').read_text()
|
self.xpath_element_js = resource_string(__name__, "res/xpath_element_scraper.js").decode('utf-8')
|
||||||
|
self.instock_data_js = resource_string(__name__, "res/stock-not-in-stock.js").decode('utf-8')
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def get_error(self):
|
def get_error(self):
|
||||||
|
|||||||
@@ -87,12 +87,11 @@ class ScreenshotUnavailable(Exception):
|
|||||||
|
|
||||||
|
|
||||||
class ReplyWithContentButNoText(Exception):
|
class ReplyWithContentButNoText(Exception):
|
||||||
def __init__(self, status_code, url, screenshot=None, has_filters=False, html_content='', xpath_data=None):
|
def __init__(self, status_code, url, screenshot=None, has_filters=False, html_content=''):
|
||||||
# Set this so we can use it in other parts of the app
|
# Set this so we can use it in other parts of the app
|
||||||
self.status_code = status_code
|
self.status_code = status_code
|
||||||
self.url = url
|
self.url = url
|
||||||
self.screenshot = screenshot
|
self.screenshot = screenshot
|
||||||
self.has_filters = has_filters
|
self.has_filters = has_filters
|
||||||
self.html_content = html_content
|
self.html_content = html_content
|
||||||
self.xpath_data = xpath_data
|
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
# resources for browser injection/scraping
|
|
||||||
@@ -30,21 +30,14 @@ function isItemInStock() {
|
|||||||
'dieser artikel ist bald wieder verfügbar',
|
'dieser artikel ist bald wieder verfügbar',
|
||||||
'dostępne wkrótce',
|
'dostępne wkrótce',
|
||||||
'en rupture de stock',
|
'en rupture de stock',
|
||||||
'isn\'t in stock right now',
|
'ist derzeit nicht auf lager',
|
||||||
'isnt in stock right now',
|
|
||||||
'isn’t in stock right now',
|
|
||||||
'item is no longer available',
|
'item is no longer available',
|
||||||
'let me know when it\'s available',
|
'let me know when it\'s available',
|
||||||
'mail me when available',
|
|
||||||
'message if back in stock',
|
'message if back in stock',
|
||||||
'nachricht bei',
|
'nachricht bei',
|
||||||
'nicht auf lager',
|
'nicht auf lager',
|
||||||
'nicht lagernd',
|
|
||||||
'nicht lieferbar',
|
'nicht lieferbar',
|
||||||
'nicht verfügbar',
|
|
||||||
'nicht vorrätig',
|
|
||||||
'nicht zur verfügung',
|
'nicht zur verfügung',
|
||||||
'nie znaleziono produktów',
|
|
||||||
'niet beschikbaar',
|
'niet beschikbaar',
|
||||||
'niet leverbaar',
|
'niet leverbaar',
|
||||||
'niet op voorraad',
|
'niet op voorraad',
|
||||||
@@ -55,7 +48,6 @@ function isItemInStock() {
|
|||||||
'not currently available',
|
'not currently available',
|
||||||
'not in stock',
|
'not in stock',
|
||||||
'notify me when available',
|
'notify me when available',
|
||||||
'notify me',
|
|
||||||
'notify when available',
|
'notify when available',
|
||||||
'não estamos a aceitar encomendas',
|
'não estamos a aceitar encomendas',
|
||||||
'out of stock',
|
'out of stock',
|
||||||
@@ -70,16 +62,12 @@ function isItemInStock() {
|
|||||||
'this item is currently unavailable',
|
'this item is currently unavailable',
|
||||||
'tickets unavailable',
|
'tickets unavailable',
|
||||||
'tijdelijk uitverkocht',
|
'tijdelijk uitverkocht',
|
||||||
'unavailable nearby',
|
|
||||||
'unavailable tickets',
|
'unavailable tickets',
|
||||||
'vergriffen',
|
|
||||||
'vorbestellen',
|
|
||||||
'vorbestellung ist bald möglich',
|
'vorbestellung ist bald möglich',
|
||||||
'we couldn\'t find any products that match',
|
'we couldn\'t find any products that match',
|
||||||
'we do not currently have an estimate of when this product will be back in stock.',
|
'we do not currently have an estimate of when this product will be back in stock.',
|
||||||
'we don\'t know when or if this item will be back in stock.',
|
'we don\'t know when or if this item will be back in stock.',
|
||||||
'we were not able to find a match',
|
'we were not able to find a match',
|
||||||
'when this arrives in stock',
|
|
||||||
'zur zeit nicht an lager',
|
'zur zeit nicht an lager',
|
||||||
'品切れ',
|
'品切れ',
|
||||||
'已售',
|
'已售',
|
||||||
|
|||||||
@@ -182,7 +182,6 @@ visibleElementsArray.forEach(function (element) {
|
|||||||
// Inject the current one set in the include_filters, which may be a CSS rule
|
// Inject the current one set in the include_filters, which may be a CSS rule
|
||||||
// used for displaying the current one in VisualSelector, where its not one we generated.
|
// used for displaying the current one in VisualSelector, where its not one we generated.
|
||||||
if (include_filters.length) {
|
if (include_filters.length) {
|
||||||
let results;
|
|
||||||
// Foreach filter, go and find it on the page and add it to the results so we can visualise it again
|
// Foreach filter, go and find it on the page and add it to the results so we can visualise it again
|
||||||
for (const f of include_filters) {
|
for (const f of include_filters) {
|
||||||
bbox = false;
|
bbox = false;
|
||||||
@@ -198,15 +197,10 @@ if (include_filters.length) {
|
|||||||
if (f.startsWith('/') || f.startsWith('xpath')) {
|
if (f.startsWith('/') || f.startsWith('xpath')) {
|
||||||
var qry_f = f.replace(/xpath(:|\d:)/, '')
|
var qry_f = f.replace(/xpath(:|\d:)/, '')
|
||||||
console.log("[xpath] Scanning for included filter " + qry_f)
|
console.log("[xpath] Scanning for included filter " + qry_f)
|
||||||
let xpathResult = document.evaluate(qry_f, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
|
q = document.evaluate(qry_f, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
|
||||||
results = [];
|
|
||||||
for (let i = 0; i < xpathResult.snapshotLength; i++) {
|
|
||||||
results.push(xpathResult.snapshotItem(i));
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
console.log("[css] Scanning for included filter " + f)
|
console.log("[css] Scanning for included filter " + f)
|
||||||
console.log("[css] Scanning for included filter " + f);
|
q = document.querySelector(f);
|
||||||
results = document.querySelectorAll(f);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Maybe catch DOMException and alert?
|
// Maybe catch DOMException and alert?
|
||||||
@@ -214,45 +208,44 @@ if (include_filters.length) {
|
|||||||
console.log(e);
|
console.log(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (results.length) {
|
if (q) {
|
||||||
|
// Try to resolve //something/text() back to its /something so we can atleast get the bounding box
|
||||||
|
try {
|
||||||
|
if (typeof q.nodeName == 'string' && q.nodeName === '#text') {
|
||||||
|
q = q.parentElement
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e)
|
||||||
|
console.log("xpath_element_scraper: #text resolver")
|
||||||
|
}
|
||||||
|
|
||||||
// Iterate over the results
|
// #1231 - IN the case XPath attribute filter is applied, we will have to traverse up and find the element.
|
||||||
results.forEach(node => {
|
if (typeof q.getBoundingClientRect == 'function') {
|
||||||
// Try to resolve //something/text() back to its /something so we can atleast get the bounding box
|
bbox = q.getBoundingClientRect();
|
||||||
|
console.log("xpath_element_scraper: Got filter element, scroll from top was " + scroll_y)
|
||||||
|
} else {
|
||||||
try {
|
try {
|
||||||
if (typeof node.nodeName == 'string' && node.nodeName === '#text') {
|
// Try and see we can find its ownerElement
|
||||||
node = node.parentElement
|
bbox = q.ownerElement.getBoundingClientRect();
|
||||||
}
|
console.log("xpath_element_scraper: Got filter by ownerElement element, scroll from top was " + scroll_y)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e)
|
console.log(e)
|
||||||
console.log("xpath_element_scraper: #text resolver")
|
console.log("xpath_element_scraper: error looking up q.ownerElement")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// #1231 - IN the case XPath attribute filter is applied, we will have to traverse up and find the element.
|
if (!q) {
|
||||||
if (typeof node.getBoundingClientRect == 'function') {
|
console.log("xpath_element_scraper: filter element " + f + " was not found");
|
||||||
bbox = node.getBoundingClientRect();
|
}
|
||||||
console.log("xpath_element_scraper: Got filter element, scroll from top was " + scroll_y)
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
// Try and see we can find its ownerElement
|
|
||||||
bbox = node.ownerElement.getBoundingClientRect();
|
|
||||||
console.log("xpath_element_scraper: Got filter by ownerElement element, scroll from top was " + scroll_y)
|
|
||||||
} catch (e) {
|
|
||||||
console.log(e)
|
|
||||||
console.log("xpath_element_scraper: error looking up q.ownerElement")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bbox && bbox['width'] > 0 && bbox['height'] > 0) {
|
if (bbox && bbox['width'] > 0 && bbox['height'] > 0) {
|
||||||
size_pos.push({
|
size_pos.push({
|
||||||
xpath: f,
|
xpath: f,
|
||||||
width: parseInt(bbox['width']),
|
width: parseInt(bbox['width']),
|
||||||
height: parseInt(bbox['height']),
|
height: parseInt(bbox['height']),
|
||||||
left: parseInt(bbox['left']),
|
left: parseInt(bbox['left']),
|
||||||
top: parseInt(bbox['top']) + scroll_y,
|
top: parseInt(bbox['top']) + scroll_y
|
||||||
highlight_as_custom_filter: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,97 +1,62 @@
|
|||||||
|
# used for the notifications, the front-end is using a JS library
|
||||||
|
|
||||||
import difflib
|
import difflib
|
||||||
from typing import List, Iterator, Union
|
|
||||||
|
|
||||||
def same_slicer(lst: List[str], start: int, end: int) -> List[str]:
|
|
||||||
"""Return a slice of the list, or a single element if start == end."""
|
|
||||||
return lst[start:end] if start != end else [lst[start]]
|
|
||||||
|
|
||||||
def customSequenceMatcher(
|
def same_slicer(l, a, b):
|
||||||
before: List[str],
|
if a == b:
|
||||||
after: List[str],
|
return [l[a]]
|
||||||
include_equal: bool = False,
|
else:
|
||||||
include_removed: bool = True,
|
return l[a:b]
|
||||||
include_added: bool = True,
|
|
||||||
include_replaced: bool = True,
|
# like .compare but a little different output
|
||||||
include_change_type_prefix: bool = True
|
def customSequenceMatcher(before, after, include_equal=False, include_removed=True, include_added=True, include_replaced=True, include_change_type_prefix=True):
|
||||||
) -> Iterator[List[str]]:
|
cruncher = difflib.SequenceMatcher(isjunk=lambda x: x in " \\t", a=before, b=after)
|
||||||
"""
|
|
||||||
Compare two sequences and yield differences based on specified parameters.
|
# @todo Line-by-line mode instead of buncghed, including `after` that is not in `before` (maybe unset?)
|
||||||
|
|
||||||
Args:
|
|
||||||
before (List[str]): Original sequence
|
|
||||||
after (List[str]): Modified sequence
|
|
||||||
include_equal (bool): Include unchanged parts
|
|
||||||
include_removed (bool): Include removed parts
|
|
||||||
include_added (bool): Include added parts
|
|
||||||
include_replaced (bool): Include replaced parts
|
|
||||||
include_change_type_prefix (bool): Add prefixes to indicate change types
|
|
||||||
|
|
||||||
Yields:
|
|
||||||
List[str]: Differences between sequences
|
|
||||||
"""
|
|
||||||
cruncher = difflib.SequenceMatcher(isjunk=lambda x: x in " \t", a=before, b=after)
|
|
||||||
|
|
||||||
for tag, alo, ahi, blo, bhi in cruncher.get_opcodes():
|
for tag, alo, ahi, blo, bhi in cruncher.get_opcodes():
|
||||||
if include_equal and tag == 'equal':
|
if include_equal and tag == 'equal':
|
||||||
yield before[alo:ahi]
|
g = before[alo:ahi]
|
||||||
|
yield g
|
||||||
elif include_removed and tag == 'delete':
|
elif include_removed and tag == 'delete':
|
||||||
prefix = "(removed) " if include_change_type_prefix else ''
|
row_prefix = "(removed) " if include_change_type_prefix else ''
|
||||||
yield [f"{prefix}{line}" for line in same_slicer(before, alo, ahi)]
|
g = [ row_prefix + i for i in same_slicer(before, alo, ahi)]
|
||||||
|
yield g
|
||||||
elif include_replaced and tag == 'replace':
|
elif include_replaced and tag == 'replace':
|
||||||
prefix_changed = "(changed) " if include_change_type_prefix else ''
|
row_prefix = "(changed) " if include_change_type_prefix else ''
|
||||||
prefix_into = "(into) " if include_change_type_prefix else ''
|
g = [row_prefix + i for i in same_slicer(before, alo, ahi)]
|
||||||
yield [f"{prefix_changed}{line}" for line in same_slicer(before, alo, ahi)] + \
|
row_prefix = "(into) " if include_change_type_prefix else ''
|
||||||
[f"{prefix_into}{line}" for line in same_slicer(after, blo, bhi)]
|
g += [row_prefix + i for i in same_slicer(after, blo, bhi)]
|
||||||
|
yield g
|
||||||
elif include_added and tag == 'insert':
|
elif include_added and tag == 'insert':
|
||||||
prefix = "(added) " if include_change_type_prefix else ''
|
row_prefix = "(added) " if include_change_type_prefix else ''
|
||||||
yield [f"{prefix}{line}" for line in same_slicer(after, blo, bhi)]
|
g = [row_prefix + i for i in same_slicer(after, blo, bhi)]
|
||||||
|
yield g
|
||||||
|
|
||||||
def render_diff(
|
# only_differences - only return info about the differences, no context
|
||||||
previous_version_file_contents: str,
|
# line_feed_sep could be "<br>" or "<li>" or "\n" etc
|
||||||
newest_version_file_contents: str,
|
def render_diff(previous_version_file_contents, newest_version_file_contents, include_equal=False, include_removed=True, include_added=True, include_replaced=True, line_feed_sep="\n", include_change_type_prefix=True, patch_format=False):
|
||||||
include_equal: bool = False,
|
|
||||||
include_removed: bool = True,
|
newest_version_file_contents = [line.rstrip() for line in newest_version_file_contents.splitlines()]
|
||||||
include_added: bool = True,
|
|
||||||
include_replaced: bool = True,
|
if previous_version_file_contents:
|
||||||
line_feed_sep: str = "\n",
|
previous_version_file_contents = [line.rstrip() for line in previous_version_file_contents.splitlines()]
|
||||||
include_change_type_prefix: bool = True,
|
else:
|
||||||
patch_format: bool = False
|
previous_version_file_contents = ""
|
||||||
) -> str:
|
|
||||||
"""
|
|
||||||
Render the difference between two file contents.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
previous_version_file_contents (str): Original file contents
|
|
||||||
newest_version_file_contents (str): Modified file contents
|
|
||||||
include_equal (bool): Include unchanged parts
|
|
||||||
include_removed (bool): Include removed parts
|
|
||||||
include_added (bool): Include added parts
|
|
||||||
include_replaced (bool): Include replaced parts
|
|
||||||
line_feed_sep (str): Separator for lines in output
|
|
||||||
include_change_type_prefix (bool): Add prefixes to indicate change types
|
|
||||||
patch_format (bool): Use patch format for output
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: Rendered difference
|
|
||||||
"""
|
|
||||||
newest_lines = [line.rstrip() for line in newest_version_file_contents.splitlines()]
|
|
||||||
previous_lines = [line.rstrip() for line in previous_version_file_contents.splitlines()] if previous_version_file_contents else []
|
|
||||||
|
|
||||||
if patch_format:
|
if patch_format:
|
||||||
patch = difflib.unified_diff(previous_lines, newest_lines)
|
patch = difflib.unified_diff(previous_version_file_contents, newest_version_file_contents)
|
||||||
return line_feed_sep.join(patch)
|
return line_feed_sep.join(patch)
|
||||||
|
|
||||||
rendered_diff = customSequenceMatcher(
|
rendered_diff = customSequenceMatcher(before=previous_version_file_contents,
|
||||||
before=previous_lines,
|
after=newest_version_file_contents,
|
||||||
after=newest_lines,
|
include_equal=include_equal,
|
||||||
include_equal=include_equal,
|
include_removed=include_removed,
|
||||||
include_removed=include_removed,
|
include_added=include_added,
|
||||||
include_added=include_added,
|
include_replaced=include_replaced,
|
||||||
include_replaced=include_replaced,
|
include_change_type_prefix=include_change_type_prefix)
|
||||||
include_change_type_prefix=include_change_type_prefix
|
|
||||||
)
|
|
||||||
|
|
||||||
def flatten(lst: List[Union[str, List[str]]]) -> str:
|
# Recursively join lists
|
||||||
return line_feed_sep.join(flatten(x) if isinstance(x, list) else x for x in lst)
|
f = lambda L: line_feed_sep.join([f(x) if type(x) is list else x for x in L])
|
||||||
|
p= f(rendered_diff)
|
||||||
return flatten(rendered_diff)
|
return p
|
||||||
|
|||||||
@@ -339,7 +339,7 @@ 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)
|
# @todo tag notification_muted skip also (improve Watch model)
|
||||||
if datastore.data['settings']['application'].get('rss_hide_muted_watches') and watch.get('notification_muted'):
|
if watch.get('notification_muted'):
|
||||||
continue
|
continue
|
||||||
if limit_tag and not limit_tag in watch['tags']:
|
if limit_tag and not limit_tag in watch['tags']:
|
||||||
continue
|
continue
|
||||||
@@ -472,7 +472,7 @@ def changedetection_app(config=None, datastore_o=None):
|
|||||||
# Don't link to hosting when we're on the hosting environment
|
# Don't link to hosting when we're on the hosting environment
|
||||||
active_tag=active_tag,
|
active_tag=active_tag,
|
||||||
active_tag_uuid=active_tag_uuid,
|
active_tag_uuid=active_tag_uuid,
|
||||||
app_rss_token=datastore.data['settings']['application'].get('rss_access_token'),
|
app_rss_token=datastore.data['settings']['application']['rss_access_token'],
|
||||||
datastore=datastore,
|
datastore=datastore,
|
||||||
errored_count=errored_count,
|
errored_count=errored_count,
|
||||||
form=form,
|
form=form,
|
||||||
@@ -679,10 +679,7 @@ def changedetection_app(config=None, datastore_o=None):
|
|||||||
|
|
||||||
if request.method == 'POST' and form.validate():
|
if request.method == 'POST' and form.validate():
|
||||||
|
|
||||||
extra_update_obj = {
|
extra_update_obj = {}
|
||||||
'consecutive_filter_failures': 0,
|
|
||||||
'last_error' : False
|
|
||||||
}
|
|
||||||
|
|
||||||
if request.args.get('unpause_on_save'):
|
if request.args.get('unpause_on_save'):
|
||||||
extra_update_obj['paused'] = False
|
extra_update_obj['paused'] = False
|
||||||
@@ -721,7 +718,7 @@ def changedetection_app(config=None, datastore_o=None):
|
|||||||
datastore.data['watching'][uuid].update(extra_update_obj)
|
datastore.data['watching'][uuid].update(extra_update_obj)
|
||||||
|
|
||||||
if request.args.get('unpause_on_save'):
|
if request.args.get('unpause_on_save'):
|
||||||
flash("Updated watch - unpaused!")
|
flash("Updated watch - unpaused!.")
|
||||||
else:
|
else:
|
||||||
flash("Updated watch.")
|
flash("Updated watch.")
|
||||||
|
|
||||||
|
|||||||
@@ -572,8 +572,6 @@ class globalSettingsApplicationForm(commonSettingsForm):
|
|||||||
removepassword_button = SubmitField('Remove password', render_kw={"class": "pure-button pure-button-primary"})
|
removepassword_button = SubmitField('Remove password', render_kw={"class": "pure-button pure-button-primary"})
|
||||||
render_anchor_tag_content = BooleanField('Render anchor tag content', default=False)
|
render_anchor_tag_content = BooleanField('Render anchor tag content', default=False)
|
||||||
shared_diff_access = BooleanField('Allow access to view diff page when password is enabled', default=False, validators=[validators.Optional()])
|
shared_diff_access = BooleanField('Allow access to view diff page when password is enabled', default=False, validators=[validators.Optional()])
|
||||||
rss_hide_muted_watches = BooleanField('Hide muted watches from RSS feed', default=True,
|
|
||||||
validators=[validators.Optional()])
|
|
||||||
filter_failure_notification_threshold_attempts = IntegerField('Number of times the filter can be missing before sending a notification',
|
filter_failure_notification_threshold_attempts = IntegerField('Number of times the filter can be missing before sending a notification',
|
||||||
render_kw={"style": "width: 5em;"},
|
render_kw={"style": "width: 5em;"},
|
||||||
validators=[validators.NumberRange(min=0,
|
validators=[validators.NumberRange(min=0,
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ from bs4 import BeautifulSoup
|
|||||||
from inscriptis import get_text
|
from inscriptis import get_text
|
||||||
from jsonpath_ng.ext import parse
|
from jsonpath_ng.ext import parse
|
||||||
from typing import List
|
from typing import List
|
||||||
|
from inscriptis.css_profiles import CSS_PROFILES, HtmlElement
|
||||||
|
from inscriptis.html_properties import Display
|
||||||
from inscriptis.model.config import ParserConfig
|
from inscriptis.model.config import ParserConfig
|
||||||
from xml.sax.saxutils import escape as xml_escape
|
from xml.sax.saxutils import escape as xml_escape
|
||||||
import json
|
import json
|
||||||
@@ -194,12 +196,12 @@ def extract_element(find='title', html_content=''):
|
|||||||
|
|
||||||
#
|
#
|
||||||
def _parse_json(json_data, json_filter):
|
def _parse_json(json_data, json_filter):
|
||||||
if json_filter.startswith("json:"):
|
if 'json:' in json_filter:
|
||||||
jsonpath_expression = parse(json_filter.replace('json:', ''))
|
jsonpath_expression = parse(json_filter.replace('json:', ''))
|
||||||
match = jsonpath_expression.find(json_data)
|
match = jsonpath_expression.find(json_data)
|
||||||
return _get_stripped_text_from_json_match(match)
|
return _get_stripped_text_from_json_match(match)
|
||||||
|
|
||||||
if json_filter.startswith("jq:") or json_filter.startswith("jqraw:"):
|
if 'jq:' in json_filter:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import jq
|
import jq
|
||||||
@@ -207,15 +209,10 @@ def _parse_json(json_data, json_filter):
|
|||||||
# `jq` requires full compilation in windows and so isn't generally available
|
# `jq` requires full compilation in windows and so isn't generally available
|
||||||
raise Exception("jq not support not found")
|
raise Exception("jq not support not found")
|
||||||
|
|
||||||
if json_filter.startswith("jq:"):
|
jq_expression = jq.compile(json_filter.replace('jq:', ''))
|
||||||
jq_expression = jq.compile(json_filter.removeprefix("jq:"))
|
match = jq_expression.input(json_data).all()
|
||||||
match = jq_expression.input(json_data).all()
|
|
||||||
return _get_stripped_text_from_json_match(match)
|
|
||||||
|
|
||||||
if json_filter.startswith("jqraw:"):
|
return _get_stripped_text_from_json_match(match)
|
||||||
jq_expression = jq.compile(json_filter.removeprefix("jqraw:"))
|
|
||||||
match = jq_expression.input(json_data).all()
|
|
||||||
return '\n'.join(str(item) for item in match)
|
|
||||||
|
|
||||||
def _get_stripped_text_from_json_match(match):
|
def _get_stripped_text_from_json_match(match):
|
||||||
s = []
|
s = []
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ from changedetectionio.notification import (
|
|||||||
default_notification_title,
|
default_notification_title,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Equal to or greater than this number of FilterNotFoundInResponse exceptions will trigger a filter-not-found notification
|
|
||||||
_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'
|
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'
|
||||||
|
|
||||||
@@ -47,8 +46,6 @@ class model(dict):
|
|||||||
'pager_size': 50,
|
'pager_size': 50,
|
||||||
'password': False,
|
'password': False,
|
||||||
'render_anchor_tag_content': False,
|
'render_anchor_tag_content': False,
|
||||||
'rss_access_token': None,
|
|
||||||
'rss_hide_muted_watches': True,
|
|
||||||
'schema_version' : 0,
|
'schema_version' : 0,
|
||||||
'shared_diff_access': False,
|
'shared_diff_access': False,
|
||||||
'webdriver_delay': None , # Extra delay in seconds before extracting text
|
'webdriver_delay': None , # Extra delay in seconds before extracting text
|
||||||
|
|||||||
@@ -238,8 +238,6 @@ class model(dict):
|
|||||||
|
|
||||||
if len(tmp_history):
|
if len(tmp_history):
|
||||||
self.__newest_history_key = list(tmp_history.keys())[-1]
|
self.__newest_history_key = list(tmp_history.keys())[-1]
|
||||||
else:
|
|
||||||
self.__newest_history_key = None
|
|
||||||
|
|
||||||
self.__history_n = len(tmp_history)
|
self.__history_n = len(tmp_history)
|
||||||
|
|
||||||
@@ -330,10 +328,15 @@ class model(dict):
|
|||||||
def save_history_text(self, contents, timestamp, snapshot_id):
|
def save_history_text(self, contents, timestamp, snapshot_id):
|
||||||
import brotli
|
import brotli
|
||||||
|
|
||||||
logger.trace(f"{self.get('uuid')} - Updating history.txt with timestamp {timestamp}")
|
|
||||||
|
|
||||||
self.ensure_data_dir_exists()
|
self.ensure_data_dir_exists()
|
||||||
|
|
||||||
|
# Small hack so that we sleep just enough to allow 1 second between history snapshots
|
||||||
|
# this is because history.txt indexes/keys snapshots by epoch seconds and we dont want dupe keys
|
||||||
|
if self.__newest_history_key and int(timestamp) == int(self.__newest_history_key):
|
||||||
|
logger.warning(f"Timestamp {timestamp} already exists, waiting 1 seconds so we have a unique key in history.txt")
|
||||||
|
timestamp = str(int(timestamp) + 1)
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
threshold = int(os.getenv('SNAPSHOT_BROTLI_COMPRESSION_THRESHOLD', 1024))
|
threshold = int(os.getenv('SNAPSHOT_BROTLI_COMPRESSION_THRESHOLD', 1024))
|
||||||
skip_brotli = strtobool(os.getenv('DISABLE_BROTLI_TEXT_SNAPSHOT', 'False'))
|
skip_brotli = strtobool(os.getenv('DISABLE_BROTLI_TEXT_SNAPSHOT', 'False'))
|
||||||
|
|
||||||
@@ -525,42 +528,8 @@ class model(dict):
|
|||||||
# None is set
|
# None is set
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def save_error_text(self, contents):
|
|
||||||
self.ensure_data_dir_exists()
|
|
||||||
target_path = os.path.join(self.watch_data_dir, "last-error.txt")
|
|
||||||
with open(target_path, 'w') as f:
|
|
||||||
f.write(contents)
|
|
||||||
|
|
||||||
def save_xpath_data(self, data, as_error=False):
|
def get_last_fetched_before_filters(self):
|
||||||
import json
|
|
||||||
|
|
||||||
if as_error:
|
|
||||||
target_path = os.path.join(self.watch_data_dir, "elements-error.json")
|
|
||||||
else:
|
|
||||||
target_path = os.path.join(self.watch_data_dir, "elements.json")
|
|
||||||
|
|
||||||
self.ensure_data_dir_exists()
|
|
||||||
|
|
||||||
with open(target_path, 'w') as f:
|
|
||||||
f.write(json.dumps(data))
|
|
||||||
f.close()
|
|
||||||
|
|
||||||
# Save as PNG, PNG is larger but better for doing visual diff in the future
|
|
||||||
def save_screenshot(self, screenshot: bytes, as_error=False):
|
|
||||||
|
|
||||||
if as_error:
|
|
||||||
target_path = os.path.join(self.watch_data_dir, "last-error-screenshot.png")
|
|
||||||
else:
|
|
||||||
target_path = os.path.join(self.watch_data_dir, "last-screenshot.png")
|
|
||||||
|
|
||||||
self.ensure_data_dir_exists()
|
|
||||||
|
|
||||||
with open(target_path, 'wb') as f:
|
|
||||||
f.write(screenshot)
|
|
||||||
f.close()
|
|
||||||
|
|
||||||
|
|
||||||
def get_last_fetched_text_before_filters(self):
|
|
||||||
import brotli
|
import brotli
|
||||||
filepath = os.path.join(self.watch_data_dir, 'last-fetched.br')
|
filepath = os.path.join(self.watch_data_dir, 'last-fetched.br')
|
||||||
|
|
||||||
@@ -575,56 +544,12 @@ class model(dict):
|
|||||||
with open(filepath, 'rb') as f:
|
with open(filepath, 'rb') as f:
|
||||||
return(brotli.decompress(f.read()).decode('utf-8'))
|
return(brotli.decompress(f.read()).decode('utf-8'))
|
||||||
|
|
||||||
def save_last_text_fetched_before_filters(self, contents):
|
def save_last_fetched_before_filters(self, contents):
|
||||||
import brotli
|
import brotli
|
||||||
filepath = os.path.join(self.watch_data_dir, 'last-fetched.br')
|
filepath = os.path.join(self.watch_data_dir, 'last-fetched.br')
|
||||||
with open(filepath, 'wb') as f:
|
with open(filepath, 'wb') as f:
|
||||||
f.write(brotli.compress(contents, mode=brotli.MODE_TEXT))
|
f.write(brotli.compress(contents, mode=brotli.MODE_TEXT))
|
||||||
|
|
||||||
def save_last_fetched_html(self, timestamp, contents):
|
|
||||||
import brotli
|
|
||||||
|
|
||||||
self.ensure_data_dir_exists()
|
|
||||||
snapshot_fname = f"{timestamp}.html.br"
|
|
||||||
filepath = os.path.join(self.watch_data_dir, snapshot_fname)
|
|
||||||
|
|
||||||
with open(filepath, 'wb') as f:
|
|
||||||
contents = contents.encode('utf-8') if isinstance(contents, str) else contents
|
|
||||||
try:
|
|
||||||
f.write(brotli.compress(contents))
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"{self.get('uuid')} - Unable to compress snapshot, saving as raw data to {filepath}")
|
|
||||||
logger.warning(e)
|
|
||||||
f.write(contents)
|
|
||||||
|
|
||||||
self._prune_last_fetched_html_snapshots()
|
|
||||||
|
|
||||||
def get_fetched_html(self, timestamp):
|
|
||||||
import brotli
|
|
||||||
|
|
||||||
snapshot_fname = f"{timestamp}.html.br"
|
|
||||||
filepath = os.path.join(self.watch_data_dir, snapshot_fname)
|
|
||||||
if os.path.isfile(filepath):
|
|
||||||
with open(filepath, 'rb') as f:
|
|
||||||
return (brotli.decompress(f.read()).decode('utf-8'))
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _prune_last_fetched_html_snapshots(self):
|
|
||||||
|
|
||||||
dates = list(self.history.keys())
|
|
||||||
dates.reverse()
|
|
||||||
|
|
||||||
for index, timestamp in enumerate(dates):
|
|
||||||
snapshot_fname = f"{timestamp}.html.br"
|
|
||||||
filepath = os.path.join(self.watch_data_dir, snapshot_fname)
|
|
||||||
|
|
||||||
# Keep only the first 2
|
|
||||||
if index > 1 and os.path.isfile(filepath):
|
|
||||||
os.remove(filepath)
|
|
||||||
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def get_browsersteps_available_screenshots(self):
|
def get_browsersteps_available_screenshots(self):
|
||||||
"For knowing which screenshots are available to show the user in BrowserSteps UI"
|
"For knowing which screenshots are available to show the user in BrowserSteps UI"
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
from abc import abstractmethod
|
from abc import abstractmethod
|
||||||
from changedetectionio.strtobool import strtobool
|
from changedetectionio.strtobool import strtobool
|
||||||
from changedetectionio.model import Watch
|
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -139,7 +138,7 @@ class difference_detection_processor():
|
|||||||
# After init, call run_changedetection() which will do the actual change-detection
|
# After init, call run_changedetection() which will do the actual change-detection
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def run_changedetection(self, watch: Watch, skip_when_checksum_same=True):
|
def run_changedetection(self, uuid, skip_when_checksum_same=True):
|
||||||
update_obj = {'last_notification_error': False, 'last_error': False}
|
update_obj = {'last_notification_error': False, 'last_error': False}
|
||||||
some_data = 'xxxxx'
|
some_data = 'xxxxx'
|
||||||
update_obj["previous_md5"] = hashlib.md5(some_data.encode('utf-8')).hexdigest()
|
update_obj["previous_md5"] = hashlib.md5(some_data.encode('utf-8')).hexdigest()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
|
||||||
from . import difference_detection_processor
|
from . import difference_detection_processor
|
||||||
|
from copy import deepcopy
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
import hashlib
|
import hashlib
|
||||||
import urllib3
|
import urllib3
|
||||||
@@ -19,7 +20,10 @@ class perform_site_check(difference_detection_processor):
|
|||||||
screenshot = None
|
screenshot = None
|
||||||
xpath_data = None
|
xpath_data = None
|
||||||
|
|
||||||
def run_changedetection(self, watch, skip_when_checksum_same=True):
|
def run_changedetection(self, uuid, skip_when_checksum_same=True):
|
||||||
|
|
||||||
|
# DeepCopy so we can be sure we don't accidently change anything by reference
|
||||||
|
watch = deepcopy(self.datastore.data['watching'].get(uuid))
|
||||||
|
|
||||||
if not watch:
|
if not watch:
|
||||||
raise Exception("Watch no longer exists.")
|
raise Exception("Watch no longer exists.")
|
||||||
@@ -40,13 +44,13 @@ class perform_site_check(difference_detection_processor):
|
|||||||
fetched_md5 = hashlib.md5(self.fetcher.instock_data.encode('utf-8')).hexdigest()
|
fetched_md5 = hashlib.md5(self.fetcher.instock_data.encode('utf-8')).hexdigest()
|
||||||
# 'Possibly in stock' comes from stock-not-in-stock.js when no string found above the fold.
|
# 'Possibly in stock' comes from stock-not-in-stock.js when no string found above the fold.
|
||||||
update_obj["in_stock"] = True if self.fetcher.instock_data == 'Possibly in stock' else False
|
update_obj["in_stock"] = True if self.fetcher.instock_data == 'Possibly in stock' else False
|
||||||
logger.debug(f"Watch UUID {watch.get('uuid')} restock check returned '{self.fetcher.instock_data}' from JS scraper.")
|
logger.debug(f"Watch UUID {uuid} restock check returned '{self.fetcher.instock_data}' from JS scraper.")
|
||||||
else:
|
else:
|
||||||
raise UnableToExtractRestockData(status_code=self.fetcher.status_code)
|
raise UnableToExtractRestockData(status_code=self.fetcher.status_code)
|
||||||
|
|
||||||
# The main thing that all this at the moment comes down to :)
|
# The main thing that all this at the moment comes down to :)
|
||||||
changed_detected = False
|
changed_detected = False
|
||||||
logger.debug(f"Watch UUID {watch.get('uuid')} restock check - Previous MD5: {watch.get('previous_md5')}, Fetched MD5 {fetched_md5}")
|
logger.debug(f"Watch UUID {uuid} restock check - Previous MD5: {watch.get('previous_md5')}, Fetched MD5 {fetched_md5}")
|
||||||
|
|
||||||
if watch.get('previous_md5') and watch.get('previous_md5') != fetched_md5:
|
if watch.get('previous_md5') and watch.get('previous_md5') != fetched_md5:
|
||||||
# Yes if we only care about it going to instock, AND we are in stock
|
# Yes if we only care about it going to instock, AND we are in stock
|
||||||
|
|||||||
@@ -10,18 +10,18 @@ from . import difference_detection_processor
|
|||||||
from ..html_tools import PERL_STYLE_REGEX, cdata_in_document_to_text
|
from ..html_tools import PERL_STYLE_REGEX, cdata_in_document_to_text
|
||||||
from changedetectionio import html_tools, content_fetchers
|
from changedetectionio import html_tools, content_fetchers
|
||||||
from changedetectionio.blueprint.price_data_follower import PRICE_DATA_TRACK_ACCEPT, PRICE_DATA_TRACK_REJECT
|
from changedetectionio.blueprint.price_data_follower import PRICE_DATA_TRACK_ACCEPT, PRICE_DATA_TRACK_REJECT
|
||||||
|
import changedetectionio.content_fetchers
|
||||||
|
from copy import deepcopy
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||||
|
|
||||||
name = 'Webpage Text/HTML, JSON and PDF changes'
|
name = 'Webpage Text/HTML, JSON and PDF changes'
|
||||||
description = 'Detects all text changes where possible'
|
description = 'Detects all text changes where possible'
|
||||||
json_filter_prefixes = ['json:', 'jq:', 'jqraw:']
|
json_filter_prefixes = ['json:', 'jq:']
|
||||||
|
|
||||||
class FilterNotFoundInResponse(ValueError):
|
class FilterNotFoundInResponse(ValueError):
|
||||||
def __init__(self, msg, screenshot=None, xpath_data=None):
|
def __init__(self, msg):
|
||||||
self.screenshot = screenshot
|
|
||||||
self.xpath_data = xpath_data
|
|
||||||
ValueError.__init__(self, msg)
|
ValueError.__init__(self, msg)
|
||||||
|
|
||||||
|
|
||||||
@@ -34,12 +34,14 @@ class PDFToHTMLToolNotFound(ValueError):
|
|||||||
# (set_proxy_from_list)
|
# (set_proxy_from_list)
|
||||||
class perform_site_check(difference_detection_processor):
|
class perform_site_check(difference_detection_processor):
|
||||||
|
|
||||||
def run_changedetection(self, watch, skip_when_checksum_same=True):
|
def run_changedetection(self, uuid, skip_when_checksum_same=True):
|
||||||
changed_detected = False
|
changed_detected = False
|
||||||
html_content = ""
|
html_content = ""
|
||||||
screenshot = False # as bytes
|
screenshot = False # as bytes
|
||||||
stripped_text_from_html = ""
|
stripped_text_from_html = ""
|
||||||
|
|
||||||
|
# DeepCopy so we can be sure we don't accidently change anything by reference
|
||||||
|
watch = deepcopy(self.datastore.data['watching'].get(uuid))
|
||||||
if not watch:
|
if not watch:
|
||||||
raise Exception("Watch no longer exists.")
|
raise Exception("Watch no longer exists.")
|
||||||
|
|
||||||
@@ -114,12 +116,12 @@ class perform_site_check(difference_detection_processor):
|
|||||||
# Better would be if Watch.model could access the global data also
|
# Better would be if Watch.model could access the global data also
|
||||||
# and then use getattr https://docs.python.org/3/reference/datamodel.html#object.__getitem__
|
# and then use getattr https://docs.python.org/3/reference/datamodel.html#object.__getitem__
|
||||||
# https://realpython.com/inherit-python-dict/ instead of doing it procedurely
|
# https://realpython.com/inherit-python-dict/ instead of doing it procedurely
|
||||||
include_filters_from_tags = self.datastore.get_tag_overrides_for_watch(uuid=watch.get('uuid'), attr='include_filters')
|
include_filters_from_tags = self.datastore.get_tag_overrides_for_watch(uuid=uuid, attr='include_filters')
|
||||||
|
|
||||||
# 1845 - remove duplicated filters in both group and watch include filter
|
# 1845 - remove duplicated filters in both group and watch include filter
|
||||||
include_filters_rule = list(dict.fromkeys(watch.get('include_filters', []) + include_filters_from_tags))
|
include_filters_rule = list(dict.fromkeys(watch.get('include_filters', []) + include_filters_from_tags))
|
||||||
|
|
||||||
subtractive_selectors = [*self.datastore.get_tag_overrides_for_watch(uuid=watch.get('uuid'), attr='subtractive_selectors'),
|
subtractive_selectors = [*self.datastore.get_tag_overrides_for_watch(uuid=uuid, attr='subtractive_selectors'),
|
||||||
*watch.get("subtractive_selectors", []),
|
*watch.get("subtractive_selectors", []),
|
||||||
*self.datastore.data["settings"]["application"].get("global_subtractive_selectors", [])
|
*self.datastore.data["settings"]["application"].get("global_subtractive_selectors", [])
|
||||||
]
|
]
|
||||||
@@ -186,7 +188,7 @@ class perform_site_check(difference_detection_processor):
|
|||||||
append_pretty_line_formatting=not watch.is_source_type_url)
|
append_pretty_line_formatting=not watch.is_source_type_url)
|
||||||
|
|
||||||
if not html_content.strip():
|
if not html_content.strip():
|
||||||
raise FilterNotFoundInResponse(msg=include_filters_rule, screenshot=self.fetcher.screenshot, xpath_data=self.fetcher.xpath_data)
|
raise FilterNotFoundInResponse(include_filters_rule)
|
||||||
|
|
||||||
if has_subtractive_selectors:
|
if has_subtractive_selectors:
|
||||||
html_content = html_tools.element_removal(subtractive_selectors, html_content)
|
html_content = html_tools.element_removal(subtractive_selectors, html_content)
|
||||||
@@ -220,7 +222,7 @@ class perform_site_check(difference_detection_processor):
|
|||||||
from .. import diff
|
from .. import diff
|
||||||
# needs to not include (added) etc or it may get used twice
|
# needs to not include (added) etc or it may get used twice
|
||||||
# Replace the processed text with the preferred result
|
# Replace the processed text with the preferred result
|
||||||
rendered_diff = diff.render_diff(previous_version_file_contents=watch.get_last_fetched_text_before_filters(),
|
rendered_diff = diff.render_diff(previous_version_file_contents=watch.get_last_fetched_before_filters(),
|
||||||
newest_version_file_contents=stripped_text_from_html,
|
newest_version_file_contents=stripped_text_from_html,
|
||||||
include_equal=False, # not the same lines
|
include_equal=False, # not the same lines
|
||||||
include_added=watch.get('filter_text_added', True),
|
include_added=watch.get('filter_text_added', True),
|
||||||
@@ -229,7 +231,7 @@ class perform_site_check(difference_detection_processor):
|
|||||||
line_feed_sep="\n",
|
line_feed_sep="\n",
|
||||||
include_change_type_prefix=False)
|
include_change_type_prefix=False)
|
||||||
|
|
||||||
watch.save_last_text_fetched_before_filters(text_content_before_ignored_filter)
|
watch.save_last_fetched_before_filters(text_content_before_ignored_filter)
|
||||||
|
|
||||||
if not rendered_diff and stripped_text_from_html:
|
if not rendered_diff and stripped_text_from_html:
|
||||||
# We had some content, but no differences were found
|
# We had some content, but no differences were found
|
||||||
@@ -244,10 +246,9 @@ class perform_site_check(difference_detection_processor):
|
|||||||
if not is_json and not empty_pages_are_a_change and len(stripped_text_from_html.strip()) == 0:
|
if not is_json and not empty_pages_are_a_change and len(stripped_text_from_html.strip()) == 0:
|
||||||
raise content_fetchers.exceptions.ReplyWithContentButNoText(url=url,
|
raise content_fetchers.exceptions.ReplyWithContentButNoText(url=url,
|
||||||
status_code=self.fetcher.get_last_status_code(),
|
status_code=self.fetcher.get_last_status_code(),
|
||||||
screenshot=self.fetcher.screenshot,
|
screenshot=screenshot,
|
||||||
has_filters=has_filter_rule,
|
has_filters=has_filter_rule,
|
||||||
html_content=html_content,
|
html_content=html_content
|
||||||
xpath_data=self.fetcher.xpath_data
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# We rely on the actual text in the html output.. many sites have random script vars etc,
|
# We rely on the actual text in the html output.. many sites have random script vars etc,
|
||||||
@@ -343,17 +344,17 @@ class perform_site_check(difference_detection_processor):
|
|||||||
if not watch['title'] or not len(watch['title']):
|
if not watch['title'] or not len(watch['title']):
|
||||||
update_obj['title'] = html_tools.extract_element(find='title', html_content=self.fetcher.content)
|
update_obj['title'] = html_tools.extract_element(find='title', html_content=self.fetcher.content)
|
||||||
|
|
||||||
logger.debug(f"Watch UUID {watch.get('uuid')} content check - Previous MD5: {watch.get('previous_md5')}, Fetched MD5 {fetched_md5}")
|
logger.debug(f"Watch UUID {uuid} content check - Previous MD5: {watch.get('previous_md5')}, Fetched MD5 {fetched_md5}")
|
||||||
|
|
||||||
if changed_detected:
|
if changed_detected:
|
||||||
if watch.get('check_unique_lines', False):
|
if watch.get('check_unique_lines', False):
|
||||||
has_unique_lines = watch.lines_contain_something_unique_compared_to_history(lines=stripped_text_from_html.splitlines())
|
has_unique_lines = watch.lines_contain_something_unique_compared_to_history(lines=stripped_text_from_html.splitlines())
|
||||||
# One or more lines? unsure?
|
# One or more lines? unsure?
|
||||||
if not has_unique_lines:
|
if not has_unique_lines:
|
||||||
logger.debug(f"check_unique_lines: UUID {watch.get('uuid')} didnt have anything new setting change_detected=False")
|
logger.debug(f"check_unique_lines: UUID {uuid} didnt have anything new setting change_detected=False")
|
||||||
changed_detected = False
|
changed_detected = False
|
||||||
else:
|
else:
|
||||||
logger.debug(f"check_unique_lines: UUID {watch.get('uuid')} had unique content")
|
logger.debug(f"check_unique_lines: UUID {uuid} had unique content")
|
||||||
|
|
||||||
# Always record the new checksum
|
# Always record the new checksum
|
||||||
update_obj["previous_md5"] = fetched_md5
|
update_obj["previous_md5"] = fetched_md5
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
$(document).ready(function () {
|
$(document).ready(function () {
|
||||||
|
|
||||||
|
// duplicate
|
||||||
|
var csrftoken = $('input[name=csrf_token]').val();
|
||||||
|
$.ajaxSetup({
|
||||||
|
beforeSend: function (xhr, settings) {
|
||||||
|
if (!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type) && !this.crossDomain) {
|
||||||
|
xhr.setRequestHeader("X-CSRFToken", csrftoken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
var browsersteps_session_id;
|
var browsersteps_session_id;
|
||||||
var browser_interface_seconds_remaining = 0;
|
var browser_interface_seconds_remaining = 0;
|
||||||
var apply_buttons_disabled = false;
|
var apply_buttons_disabled = false;
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
$(document).ready(function () {
|
|
||||||
$.ajaxSetup({
|
|
||||||
beforeSend: function (xhr, settings) {
|
|
||||||
if (!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type) && !this.crossDomain) {
|
|
||||||
xhr.setRequestHeader("X-CSRFToken", csrftoken)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
@@ -1,4 +1,13 @@
|
|||||||
$(document).ready(function () {
|
$(document).ready(function () {
|
||||||
|
var csrftoken = $('input[name=csrf_token]').val();
|
||||||
|
$.ajaxSetup({
|
||||||
|
beforeSend: function (xhr, settings) {
|
||||||
|
if (!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type) && !this.crossDomain) {
|
||||||
|
xhr.setRequestHeader("X-CSRFToken", csrftoken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
$('.needs-localtime').each(function () {
|
$('.needs-localtime').each(function () {
|
||||||
for (var option of this.options) {
|
for (var option of this.options) {
|
||||||
var dateObject = new Date(option.value * 1000);
|
var dateObject = new Date(option.value * 1000);
|
||||||
@@ -39,12 +48,6 @@ $(document).ready(function () {
|
|||||||
$("#highlightSnippet").remove();
|
$("#highlightSnippet").remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listen for Escape key press
|
|
||||||
window.addEventListener('keydown', function (e) {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
clean();
|
|
||||||
}
|
|
||||||
}, false);
|
|
||||||
|
|
||||||
function dragTextHandler(event) {
|
function dragTextHandler(event) {
|
||||||
console.log('mouseupped');
|
console.log('mouseupped');
|
||||||
|
|||||||
@@ -13,6 +13,16 @@ $(document).ready(function() {
|
|||||||
$('#send-test-notification').click(function (e) {
|
$('#send-test-notification').click(function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
// this can be global
|
||||||
|
var csrftoken = $('input[name=csrf_token]').val();
|
||||||
|
$.ajaxSetup({
|
||||||
|
beforeSend: function(xhr, settings) {
|
||||||
|
if (!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type) && !this.crossDomain) {
|
||||||
|
xhr.setRequestHeader("X-CSRFToken", csrftoken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
notification_body: $('#notification_body').val(),
|
notification_body: $('#notification_body').val(),
|
||||||
notification_format: $('#notification_format').val(),
|
notification_format: $('#notification_format').val(),
|
||||||
|
|||||||
@@ -2,258 +2,250 @@
|
|||||||
// All rights reserved.
|
// All rights reserved.
|
||||||
// yes - this is really a hack, if you are a front-ender and want to help, please get in touch!
|
// yes - this is really a hack, if you are a front-ender and want to help, please get in touch!
|
||||||
|
|
||||||
let runInClearMode = false;
|
$(document).ready(function () {
|
||||||
|
|
||||||
$(document).ready(() => {
|
var current_selected_i;
|
||||||
let currentSelections = [];
|
var state_clicked = false;
|
||||||
let currentSelection = null;
|
|
||||||
let appendToList = false;
|
|
||||||
let c, xctx, ctx;
|
|
||||||
let xScale = 1, yScale = 1;
|
|
||||||
let selectorImage, selectorImageRect, selectorData;
|
|
||||||
|
|
||||||
|
var c;
|
||||||
|
|
||||||
// Global jQuery selectors with "Elem" appended
|
// greyed out fill context
|
||||||
const $selectorCanvasElem = $('#selector-canvas');
|
var xctx;
|
||||||
const $includeFiltersElem = $("#include_filters");
|
// redline highlight context
|
||||||
const $selectorBackgroundElem = $("img#selector-background");
|
var ctx;
|
||||||
const $selectorCurrentXpathElem = $("#selector-current-xpath span");
|
|
||||||
const $fetchingUpdateNoticeElem = $('.fetching-update-notice');
|
|
||||||
const $selectorWrapperElem = $("#selector-wrapper");
|
|
||||||
|
|
||||||
// Color constants
|
var current_default_xpath = [];
|
||||||
const FILL_STYLE_HIGHLIGHT = 'rgba(205,0,0,0.35)';
|
var x_scale = 1;
|
||||||
const FILL_STYLE_GREYED_OUT = 'rgba(205,205,205,0.95)';
|
var y_scale = 1;
|
||||||
const STROKE_STYLE_HIGHLIGHT = 'rgba(255,0,0, 0.9)';
|
var selector_image;
|
||||||
const FILL_STYLE_REDLINE = 'rgba(255,0,0, 0.1)';
|
var selector_image_rect;
|
||||||
const STROKE_STYLE_REDLINE = 'rgba(225,0,0,0.9)';
|
var selector_data;
|
||||||
|
|
||||||
$('#visualselector-tab').click(() => {
|
$('#visualselector-tab').click(function () {
|
||||||
$selectorBackgroundElem.off('load');
|
$("img#selector-background").off('load');
|
||||||
currentSelections = [];
|
state_clicked = false;
|
||||||
bootstrapVisualSelector();
|
current_selected_i = false;
|
||||||
|
bootstrap_visualselector();
|
||||||
});
|
});
|
||||||
|
|
||||||
function clearReset() {
|
$(document).on('keydown', function (event) {
|
||||||
ctx.clearRect(0, 0, c.width, c.height);
|
if ($("img#selector-background").is(":visible")) {
|
||||||
|
if (event.key == "Escape") {
|
||||||
if ($includeFiltersElem.val().length) {
|
state_clicked = false;
|
||||||
alert("Existing filters under the 'Filters & Triggers' tab were cleared.");
|
ctx.clearRect(0, 0, c.width, c.height);
|
||||||
}
|
|
||||||
$includeFiltersElem.val('');
|
|
||||||
|
|
||||||
currentSelections = [];
|
|
||||||
|
|
||||||
// Means we ignore the xpaths from the scraper marked as sel.highlight_as_custom_filter (it matched a previous selector)
|
|
||||||
runInClearMode = true;
|
|
||||||
|
|
||||||
highlightCurrentSelected();
|
|
||||||
}
|
|
||||||
|
|
||||||
function splitToList(v) {
|
|
||||||
return v.split('\n').map(line => line.trim()).filter(line => line.length > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortScrapedElementsBySize() {
|
|
||||||
// Sort the currentSelections array by area (width * height) in descending order
|
|
||||||
selectorData['size_pos'].sort((a, b) => {
|
|
||||||
const areaA = a.width * a.height;
|
|
||||||
const areaB = b.width * b.height;
|
|
||||||
return areaB - areaA;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
$(document).on('keydown keyup', (event) => {
|
|
||||||
if (event.code === 'ShiftLeft' || event.code === 'ShiftRight') {
|
|
||||||
appendToList = event.type === 'keydown';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.type === 'keydown') {
|
|
||||||
if ($selectorBackgroundElem.is(":visible") && event.key === "Escape") {
|
|
||||||
clearReset();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$('#clear-selector').on('click', () => {
|
// For when the page loads
|
||||||
clearReset();
|
if (!window.location.hash || window.location.hash != '#visualselector') {
|
||||||
});
|
$("img#selector-background").attr('src', '');
|
||||||
// So if they start switching between visualSelector and manual filters, stop it from rendering old filters
|
|
||||||
$('li.tab a').on('click', () => {
|
|
||||||
runInClearMode = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!window.location.hash || window.location.hash !== '#visualselector') {
|
|
||||||
$selectorBackgroundElem.attr('src', '');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
bootstrapVisualSelector();
|
// Handle clearing button/link
|
||||||
|
$('#clear-selector').on('click', function (event) {
|
||||||
|
if (!state_clicked) {
|
||||||
|
alert('Oops, Nothing selected!');
|
||||||
|
}
|
||||||
|
state_clicked = false;
|
||||||
|
ctx.clearRect(0, 0, c.width, c.height);
|
||||||
|
xctx.clearRect(0, 0, c.width, c.height);
|
||||||
|
$("#include_filters").val('');
|
||||||
|
});
|
||||||
|
|
||||||
function bootstrapVisualSelector() {
|
|
||||||
$selectorBackgroundElem
|
bootstrap_visualselector();
|
||||||
.on("error", () => {
|
|
||||||
$fetchingUpdateNoticeElem.html("<strong>Ooops!</strong> The VisualSelector tool needs at least one fetched page, please unpause the watch and/or wait for the watch to complete fetching and then reload this page.")
|
|
||||||
.css('color', '#bb0000');
|
function bootstrap_visualselector() {
|
||||||
$('#selector-current-xpath, #clear-selector').hide();
|
if (1) {
|
||||||
})
|
// bootstrap it, this will trigger everything else
|
||||||
.on('load', () => {
|
$("img#selector-background").on("error", function () {
|
||||||
|
$('.fetching-update-notice').html("<strong>Ooops!</strong> The VisualSelector tool needs atleast one fetched page, please unpause the watch and/or wait for the watch to complete fetching and then reload this page.");
|
||||||
|
$('.fetching-update-notice').css('color','#bb0000');
|
||||||
|
$('#selector-current-xpath').hide();
|
||||||
|
$('#clear-selector').hide();
|
||||||
|
}).bind('load', function () {
|
||||||
console.log("Loaded background...");
|
console.log("Loaded background...");
|
||||||
c = document.getElementById("selector-canvas");
|
c = document.getElementById("selector-canvas");
|
||||||
|
// greyed out fill context
|
||||||
xctx = c.getContext("2d");
|
xctx = c.getContext("2d");
|
||||||
|
// redline highlight context
|
||||||
ctx = c.getContext("2d");
|
ctx = c.getContext("2d");
|
||||||
fetchData();
|
if ($("#include_filters").val().trim().length) {
|
||||||
$selectorCanvasElem.off("mousemove mousedown");
|
current_default_xpath = $("#include_filters").val().split(/\r?\n/g);
|
||||||
})
|
} else {
|
||||||
.attr("src", screenshot_url);
|
current_default_xpath = [];
|
||||||
|
}
|
||||||
let s = `${$selectorBackgroundElem.attr('src')}?${new Date().getTime()}`;
|
fetch_data();
|
||||||
$selectorBackgroundElem.attr('src', s);
|
$('#selector-canvas').off("mousemove mousedown");
|
||||||
}
|
// screenshot_url defined in the edit.html template
|
||||||
|
}).attr("src", screenshot_url);
|
||||||
function alertIfFilterNotFound() {
|
|
||||||
let existingFilters = splitToList($includeFiltersElem.val());
|
|
||||||
let sizePosXpaths = selectorData['size_pos'].map(sel => sel.xpath);
|
|
||||||
|
|
||||||
for (let filter of existingFilters) {
|
|
||||||
if (!sizePosXpaths.includes(filter)) {
|
|
||||||
alert(`One or more of your existing filters was not found and will be removed when a new filter is selected.`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// Tell visualSelector that the image should update
|
||||||
|
var s = $("img#selector-background").attr('src') + "?" + new Date().getTime();
|
||||||
|
$("img#selector-background").attr('src', s)
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchData() {
|
// This is fired once the img src is loaded in bootstrap_visualselector()
|
||||||
$fetchingUpdateNoticeElem.html("Fetching element data..");
|
function fetch_data() {
|
||||||
|
// Image is ready
|
||||||
|
$('.fetching-update-notice').html("Fetching element data..");
|
||||||
|
|
||||||
$.ajax({
|
$.ajax({
|
||||||
url: watch_visual_selector_data_url,
|
url: watch_visual_selector_data_url,
|
||||||
context: document.body
|
context: document.body
|
||||||
}).done((data) => {
|
}).done(function (data) {
|
||||||
$fetchingUpdateNoticeElem.html("Rendering..");
|
$('.fetching-update-notice').html("Rendering..");
|
||||||
selectorData = data;
|
selector_data = data;
|
||||||
sortScrapedElementsBySize();
|
console.log("Reported browser width from backend: " + data['browser_width']);
|
||||||
console.log(`Reported browser width from backend: ${data['browser_width']}`);
|
state_clicked = false;
|
||||||
|
set_scale();
|
||||||
// Little sanity check for the user, alert them if something missing
|
reflow_selector();
|
||||||
alertIfFilterNotFound();
|
$('.fetching-update-notice').fadeOut();
|
||||||
|
|
||||||
setScale();
|
|
||||||
reflowSelector();
|
|
||||||
$fetchingUpdateNoticeElem.fadeOut();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateFiltersText() {
|
|
||||||
// Assuming currentSelections is already defined and contains the selections
|
|
||||||
let uniqueSelections = new Set(currentSelections.map(sel => (sel[0] === '/' ? `xpath:${sel.xpath}` : sel.xpath)));
|
|
||||||
|
|
||||||
if (currentSelections.length > 0) {
|
function set_scale() {
|
||||||
// Convert the Set back to an array and join with newline characters
|
|
||||||
let textboxFilterText = Array.from(uniqueSelections).join("\n");
|
|
||||||
$includeFiltersElem.val(textboxFilterText);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setScale() {
|
// some things to check if the scaling doesnt work
|
||||||
$selectorWrapperElem.show();
|
// - that the widths/sizes really are about the actual screen size cat elements.json |grep -o width......|sort|uniq
|
||||||
selectorImage = $selectorBackgroundElem[0];
|
$("#selector-wrapper").show();
|
||||||
selectorImageRect = selectorImage.getBoundingClientRect();
|
selector_image = $("img#selector-background")[0];
|
||||||
|
selector_image_rect = selector_image.getBoundingClientRect();
|
||||||
|
|
||||||
$selectorCanvasElem.attr({
|
// make the canvas the same size as the image
|
||||||
'height': selectorImageRect.height,
|
$('#selector-canvas').attr('height', selector_image_rect.height);
|
||||||
'width': selectorImageRect.width
|
$('#selector-canvas').attr('width', selector_image_rect.width);
|
||||||
});
|
$('#selector-wrapper').attr('width', selector_image_rect.width);
|
||||||
$selectorWrapperElem.attr('width', selectorImageRect.width);
|
x_scale = selector_image_rect.width / selector_data['browser_width'];
|
||||||
$('#visual-selector-heading').css('max-width', selectorImageRect.width + "px")
|
y_scale = selector_image_rect.height / selector_image.naturalHeight;
|
||||||
|
ctx.strokeStyle = 'rgba(255,0,0, 0.9)';
|
||||||
xScale = selectorImageRect.width / selectorImage.naturalWidth;
|
ctx.fillStyle = 'rgba(255,0,0, 0.1)';
|
||||||
yScale = selectorImageRect.height / selectorImage.naturalHeight;
|
|
||||||
|
|
||||||
ctx.strokeStyle = STROKE_STYLE_HIGHLIGHT;
|
|
||||||
ctx.fillStyle = FILL_STYLE_REDLINE;
|
|
||||||
ctx.lineWidth = 3;
|
ctx.lineWidth = 3;
|
||||||
console.log("Scaling set x: " + xScale + " by y:" + yScale);
|
console.log("scaling set x: " + x_scale + " by y:" + y_scale);
|
||||||
$("#selector-current-xpath").css('max-width', selectorImageRect.width);
|
$("#selector-current-xpath").css('max-width', selector_image_rect.width);
|
||||||
}
|
}
|
||||||
|
|
||||||
function reflowSelector() {
|
function reflow_selector() {
|
||||||
$(window).resize(() => {
|
$(window).resize(function () {
|
||||||
setScale();
|
set_scale();
|
||||||
highlightCurrentSelected();
|
highlight_current_selected_i();
|
||||||
});
|
});
|
||||||
|
var selector_currnt_xpath_text = $("#selector-current-xpath span");
|
||||||
|
|
||||||
setScale();
|
set_scale();
|
||||||
|
|
||||||
console.log(selectorData['size_pos'].length + " selectors found");
|
console.log(selector_data['size_pos'].length + " selectors found");
|
||||||
|
|
||||||
let existingFilters = splitToList($includeFiltersElem.val());
|
// highlight the default one if we can find it in the xPath list
|
||||||
|
// or the xpath matches the default one
|
||||||
selectorData['size_pos'].forEach(sel => {
|
found = false;
|
||||||
if ((!runInClearMode && sel.highlight_as_custom_filter) || existingFilters.includes(sel.xpath)) {
|
if (current_default_xpath.length) {
|
||||||
console.log("highlighting " + c);
|
// Find the first one that matches
|
||||||
currentSelections.push(sel);
|
// @todo In the future paint all that match
|
||||||
|
for (const c of current_default_xpath) {
|
||||||
|
for (var i = selector_data['size_pos'].length; i !== 0; i--) {
|
||||||
|
if (selector_data['size_pos'][i - 1].xpath.trim() === c.trim()) {
|
||||||
|
console.log("highlighting " + c);
|
||||||
|
current_selected_i = i - 1;
|
||||||
|
highlight_current_selected_i();
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (found) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
if (!found) {
|
||||||
|
alert("Unfortunately your existing CSS/xPath Filter was no longer found!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
highlightCurrentSelected();
|
$('#selector-canvas').bind('mousemove', function (e) {
|
||||||
updateFiltersText();
|
if (state_clicked) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.clearRect(0, 0, c.width, c.height);
|
||||||
|
current_selected_i = null;
|
||||||
|
|
||||||
$selectorCanvasElem.bind('mousemove', handleMouseMove.debounce(5));
|
// Add in offset
|
||||||
$selectorCanvasElem.bind('mousedown', handleMouseDown.debounce(5));
|
if ((typeof e.offsetX === "undefined" || typeof e.offsetY === "undefined") || (e.offsetX === 0 && e.offsetY === 0)) {
|
||||||
$selectorCanvasElem.bind('mouseleave', highlightCurrentSelected.debounce(5));
|
var targetOffset = $(e.target).offset();
|
||||||
|
|
||||||
function handleMouseMove(e) {
|
|
||||||
if (!e.offsetX && !e.offsetY) {
|
|
||||||
const targetOffset = $(e.target).offset();
|
|
||||||
e.offsetX = e.pageX - targetOffset.left;
|
e.offsetX = e.pageX - targetOffset.left;
|
||||||
e.offsetY = e.pageY - targetOffset.top;
|
e.offsetY = e.pageY - targetOffset.top;
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.fillStyle = FILL_STYLE_HIGHLIGHT;
|
// Reverse order - the most specific one should be deeper/"laster"
|
||||||
|
// Basically, find the most 'deepest'
|
||||||
|
var found = 0;
|
||||||
|
ctx.fillStyle = 'rgba(205,0,0,0.35)';
|
||||||
|
// Will be sorted by smallest width*height first
|
||||||
|
for (var i = 0; i <= selector_data['size_pos'].length; i++) {
|
||||||
|
// draw all of them? let them choose somehow?
|
||||||
|
var sel = selector_data['size_pos'][i];
|
||||||
|
// If we are in a bounding-box
|
||||||
|
if (e.offsetY > sel.top * y_scale && e.offsetY < sel.top * y_scale + sel.height * y_scale
|
||||||
|
&&
|
||||||
|
e.offsetX > sel.left * y_scale && e.offsetX < sel.left * y_scale + sel.width * y_scale
|
||||||
|
|
||||||
selectorData['size_pos'].forEach(sel => {
|
) {
|
||||||
if (e.offsetY > sel.top * yScale && e.offsetY < sel.top * yScale + sel.height * yScale &&
|
|
||||||
e.offsetX > sel.left * yScale && e.offsetX < sel.left * yScale + sel.width * yScale) {
|
// FOUND ONE
|
||||||
setCurrentSelectedText(sel.xpath);
|
set_current_selected_text(sel.xpath);
|
||||||
drawHighlight(sel);
|
ctx.strokeRect(sel.left * x_scale, sel.top * y_scale, sel.width * x_scale, sel.height * y_scale);
|
||||||
currentSelections.push(sel);
|
ctx.fillRect(sel.left * x_scale, sel.top * y_scale, sel.width * x_scale, sel.height * y_scale);
|
||||||
currentSelection = sel;
|
|
||||||
highlightCurrentSelected();
|
// no need to keep digging
|
||||||
currentSelections.pop();
|
// @todo or, O to go out/up, I to go in
|
||||||
|
// or double click to go up/out the selector?
|
||||||
|
current_selected_i = i;
|
||||||
|
found += 1;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
|
}.debounce(5));
|
||||||
|
|
||||||
|
function set_current_selected_text(s) {
|
||||||
|
selector_currnt_xpath_text[0].innerHTML = s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlight_current_selected_i() {
|
||||||
|
if (state_clicked) {
|
||||||
|
state_clicked = false;
|
||||||
|
xctx.clearRect(0, 0, c.width, c.height);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sel = selector_data['size_pos'][current_selected_i];
|
||||||
|
if (sel[0] == '/') {
|
||||||
|
// @todo - not sure just checking / is right
|
||||||
|
$("#include_filters").val('xpath:' + sel.xpath);
|
||||||
|
} else {
|
||||||
|
$("#include_filters").val(sel.xpath);
|
||||||
|
}
|
||||||
|
xctx.fillStyle = 'rgba(205,205,205,0.95)';
|
||||||
|
xctx.strokeStyle = 'rgba(225,0,0,0.9)';
|
||||||
|
xctx.lineWidth = 3;
|
||||||
|
xctx.fillRect(0, 0, c.width, c.height);
|
||||||
|
// Clear out what only should be seen (make a clear/clean spot)
|
||||||
|
xctx.clearRect(sel.left * x_scale, sel.top * y_scale, sel.width * x_scale, sel.height * y_scale);
|
||||||
|
xctx.strokeRect(sel.left * x_scale, sel.top * y_scale, sel.width * x_scale, sel.height * y_scale);
|
||||||
|
state_clicked = true;
|
||||||
|
set_current_selected_text(sel.xpath);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function setCurrentSelectedText(s) {
|
$('#selector-canvas').bind('mousedown', function (e) {
|
||||||
$selectorCurrentXpathElem[0].innerHTML = s;
|
highlight_current_selected_i();
|
||||||
}
|
|
||||||
|
|
||||||
function drawHighlight(sel) {
|
|
||||||
ctx.strokeRect(sel.left * xScale, sel.top * yScale, sel.width * xScale, sel.height * yScale);
|
|
||||||
ctx.fillRect(sel.left * xScale, sel.top * yScale, sel.width * xScale, sel.height * yScale);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleMouseDown() {
|
|
||||||
// If we are in 'appendToList' mode, grow the list, if not, just 1
|
|
||||||
currentSelections = appendToList ? [...currentSelections, currentSelection] : [currentSelection];
|
|
||||||
highlightCurrentSelected();
|
|
||||||
updateFiltersText();
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
function highlightCurrentSelected() {
|
|
||||||
xctx.fillStyle = FILL_STYLE_GREYED_OUT;
|
|
||||||
xctx.strokeStyle = STROKE_STYLE_REDLINE;
|
|
||||||
xctx.lineWidth = 3;
|
|
||||||
xctx.clearRect(0, 0, c.width, c.height);
|
|
||||||
|
|
||||||
currentSelections.forEach(sel => {
|
|
||||||
//xctx.clearRect(sel.left * xScale, sel.top * yScale, sel.width * xScale, sel.height * yScale);
|
|
||||||
xctx.strokeRect(sel.left * xScale, sel.top * yScale, sel.width * xScale, sel.height * yScale);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
|
|
||||||
#selector-wrapper {
|
#selector-wrapper {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
max-height: 70vh;
|
max-height: 70vh;
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@@ -671,25 +671,14 @@ footer {
|
|||||||
and also iPads specifically.
|
and also iPads specifically.
|
||||||
*/
|
*/
|
||||||
.watch-table {
|
.watch-table {
|
||||||
/* make headings work on mobile */
|
|
||||||
thead {
|
|
||||||
display: block;
|
|
||||||
tr {
|
|
||||||
th {
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.empty-cell {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Force table to not be like tables anymore */
|
/* Force table to not be like tables anymore */
|
||||||
tbody {
|
thead,
|
||||||
td,
|
tbody,
|
||||||
tr {
|
th,
|
||||||
display: block;
|
td,
|
||||||
}
|
tr {
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.last-checked {
|
.last-checked {
|
||||||
@@ -713,6 +702,13 @@ footer {
|
|||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hide table headers (but not display: none;, for accessibility) */
|
||||||
|
thead tr {
|
||||||
|
position: absolute;
|
||||||
|
top: -9999px;
|
||||||
|
left: -9999px;
|
||||||
|
}
|
||||||
|
|
||||||
.pure-table td,
|
.pure-table td,
|
||||||
.pure-table th {
|
.pure-table th {
|
||||||
border: none;
|
border: none;
|
||||||
@@ -757,7 +753,6 @@ footer {
|
|||||||
thead {
|
thead {
|
||||||
background-color: var(--color-background-table-thead);
|
background-color: var(--color-background-table-thead);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
border-bottom: 1px solid var(--color-background-table-thead);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
td,
|
td,
|
||||||
@@ -1026,11 +1021,6 @@ ul {
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
margin-bottom: 1em;
|
margin-bottom: 1em;
|
||||||
display: none;
|
display: none;
|
||||||
button {
|
|
||||||
/* some space if they wrap the page */
|
|
||||||
margin-bottom: 3px;
|
|
||||||
margin-top: 3px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox-uuid {
|
.checkbox-uuid {
|
||||||
|
|||||||
@@ -863,17 +863,14 @@ footer {
|
|||||||
and also iPads specifically.
|
and also iPads specifically.
|
||||||
*/
|
*/
|
||||||
.watch-table {
|
.watch-table {
|
||||||
/* make headings work on mobile */
|
|
||||||
/* Force table to not be like tables anymore */
|
/* Force table to not be like tables anymore */
|
||||||
/* Force table to not be like tables anymore */ }
|
/* Force table to not be like tables anymore */
|
||||||
.watch-table thead {
|
/* Hide table headers (but not display: none;, for accessibility) */ }
|
||||||
display: block; }
|
.watch-table thead,
|
||||||
.watch-table thead tr th {
|
.watch-table tbody,
|
||||||
display: inline-block; }
|
.watch-table th,
|
||||||
.watch-table thead .empty-cell {
|
.watch-table td,
|
||||||
display: none; }
|
.watch-table tr {
|
||||||
.watch-table tbody td,
|
|
||||||
.watch-table tbody tr {
|
|
||||||
display: block; }
|
display: block; }
|
||||||
.watch-table .last-checked > span {
|
.watch-table .last-checked > span {
|
||||||
vertical-align: middle; }
|
vertical-align: middle; }
|
||||||
@@ -885,6 +882,10 @@ footer {
|
|||||||
content: "Last Changed "; }
|
content: "Last Changed "; }
|
||||||
.watch-table td.inline {
|
.watch-table td.inline {
|
||||||
display: inline-block; }
|
display: inline-block; }
|
||||||
|
.watch-table thead tr {
|
||||||
|
position: absolute;
|
||||||
|
top: -9999px;
|
||||||
|
left: -9999px; }
|
||||||
.watch-table .pure-table td,
|
.watch-table .pure-table td,
|
||||||
.watch-table .pure-table th {
|
.watch-table .pure-table th {
|
||||||
border: none; }
|
border: none; }
|
||||||
@@ -911,8 +912,7 @@ footer {
|
|||||||
border-color: var(--color-border-table-cell); }
|
border-color: var(--color-border-table-cell); }
|
||||||
.pure-table thead {
|
.pure-table thead {
|
||||||
background-color: var(--color-background-table-thead);
|
background-color: var(--color-background-table-thead);
|
||||||
color: var(--color-text);
|
color: var(--color-text); }
|
||||||
border-bottom: 1px solid var(--color-background-table-thead); }
|
|
||||||
.pure-table td,
|
.pure-table td,
|
||||||
.pure-table th {
|
.pure-table th {
|
||||||
border-left-color: var(--color-border-table-cell); }
|
border-left-color: var(--color-border-table-cell); }
|
||||||
@@ -1065,7 +1065,6 @@ ul {
|
|||||||
|
|
||||||
#selector-wrapper {
|
#selector-wrapper {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
text-align: center;
|
|
||||||
max-height: 70vh;
|
max-height: 70vh;
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
position: relative; }
|
position: relative; }
|
||||||
@@ -1128,10 +1127,6 @@ ul {
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
margin-bottom: 1em;
|
margin-bottom: 1em;
|
||||||
display: none; }
|
display: none; }
|
||||||
#checkbox-operations button {
|
|
||||||
/* some space if they wrap the page */
|
|
||||||
margin-bottom: 3px;
|
|
||||||
margin-top: 3px; }
|
|
||||||
|
|
||||||
.checkbox-uuid > * {
|
.checkbox-uuid > * {
|
||||||
vertical-align: middle; }
|
vertical-align: middle; }
|
||||||
|
|||||||
@@ -124,12 +124,12 @@ class ChangeDetectionStore:
|
|||||||
self.__data['app_guid'] = str(uuid_builder.uuid4())
|
self.__data['app_guid'] = str(uuid_builder.uuid4())
|
||||||
|
|
||||||
# Generate the URL access token for RSS feeds
|
# Generate the URL access token for RSS feeds
|
||||||
if not self.__data['settings']['application'].get('rss_access_token'):
|
if not 'rss_access_token' in self.__data['settings']['application']:
|
||||||
secret = secrets.token_hex(16)
|
secret = secrets.token_hex(16)
|
||||||
self.__data['settings']['application']['rss_access_token'] = secret
|
self.__data['settings']['application']['rss_access_token'] = secret
|
||||||
|
|
||||||
# Generate the API access token
|
# Generate the API access token
|
||||||
if not self.__data['settings']['application'].get('api_access_token'):
|
if not 'api_access_token' in self.__data['settings']['application']:
|
||||||
secret = secrets.token_hex(16)
|
secret = secrets.token_hex(16)
|
||||||
self.__data['settings']['application']['api_access_token'] = secret
|
self.__data['settings']['application']['api_access_token'] = secret
|
||||||
|
|
||||||
@@ -163,6 +163,7 @@ class ChangeDetectionStore:
|
|||||||
del (update_obj[dict_key])
|
del (update_obj[dict_key])
|
||||||
|
|
||||||
self.__data['watching'][uuid].update(update_obj)
|
self.__data['watching'][uuid].update(update_obj)
|
||||||
|
|
||||||
self.needs_write = True
|
self.needs_write = True
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -242,14 +243,6 @@ class ChangeDetectionStore:
|
|||||||
def clear_watch_history(self, uuid):
|
def clear_watch_history(self, uuid):
|
||||||
import pathlib
|
import pathlib
|
||||||
|
|
||||||
# JSON Data, Screenshots, Textfiles (history index and snapshots), HTML in the future etc
|
|
||||||
for item in pathlib.Path(os.path.join(self.datastore_path, uuid)).rglob("*.*"):
|
|
||||||
unlink(item)
|
|
||||||
|
|
||||||
# Force the attr to recalculate
|
|
||||||
bump = self.__data['watching'][uuid].history
|
|
||||||
|
|
||||||
# Do this last because it will trigger a recheck due to last_checked being zero
|
|
||||||
self.__data['watching'][uuid].update({
|
self.__data['watching'][uuid].update({
|
||||||
'browser_steps_last_error_step' : None,
|
'browser_steps_last_error_step' : None,
|
||||||
'check_count': 0,
|
'check_count': 0,
|
||||||
@@ -266,6 +259,13 @@ class ChangeDetectionStore:
|
|||||||
'track_ldjson_price_data': None,
|
'track_ldjson_price_data': None,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# JSON Data, Screenshots, Textfiles (history index and snapshots), HTML in the future etc
|
||||||
|
for item in pathlib.Path(os.path.join(self.datastore_path, uuid)).rglob("*.*"):
|
||||||
|
unlink(item)
|
||||||
|
|
||||||
|
# Force the attr to recalculate
|
||||||
|
bump = self.__data['watching'][uuid].history
|
||||||
|
|
||||||
self.needs_write_urgent = True
|
self.needs_write_urgent = True
|
||||||
|
|
||||||
def add_watch(self, url, tag='', extras=None, tag_uuids=None, write_to_disk_now=True):
|
def add_watch(self, url, tag='', extras=None, tag_uuids=None, write_to_disk_now=True):
|
||||||
@@ -376,6 +376,46 @@ class ChangeDetectionStore:
|
|||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# Save as PNG, PNG is larger but better for doing visual diff in the future
|
||||||
|
def save_screenshot(self, watch_uuid, screenshot: bytes, as_error=False):
|
||||||
|
if not self.data['watching'].get(watch_uuid):
|
||||||
|
return
|
||||||
|
|
||||||
|
if as_error:
|
||||||
|
target_path = os.path.join(self.datastore_path, watch_uuid, "last-error-screenshot.png")
|
||||||
|
else:
|
||||||
|
target_path = os.path.join(self.datastore_path, watch_uuid, "last-screenshot.png")
|
||||||
|
|
||||||
|
self.data['watching'][watch_uuid].ensure_data_dir_exists()
|
||||||
|
|
||||||
|
with open(target_path, 'wb') as f:
|
||||||
|
f.write(screenshot)
|
||||||
|
f.close()
|
||||||
|
|
||||||
|
|
||||||
|
def save_error_text(self, watch_uuid, contents):
|
||||||
|
if not self.data['watching'].get(watch_uuid):
|
||||||
|
return
|
||||||
|
|
||||||
|
self.data['watching'][watch_uuid].ensure_data_dir_exists()
|
||||||
|
target_path = os.path.join(self.datastore_path, watch_uuid, "last-error.txt")
|
||||||
|
with open(target_path, 'w') as f:
|
||||||
|
f.write(contents)
|
||||||
|
|
||||||
|
def save_xpath_data(self, watch_uuid, data, as_error=False):
|
||||||
|
|
||||||
|
if not self.data['watching'].get(watch_uuid):
|
||||||
|
return
|
||||||
|
if as_error:
|
||||||
|
target_path = os.path.join(self.datastore_path, watch_uuid, "elements-error.json")
|
||||||
|
else:
|
||||||
|
target_path = os.path.join(self.datastore_path, watch_uuid, "elements.json")
|
||||||
|
self.data['watching'][watch_uuid].ensure_data_dir_exists()
|
||||||
|
with open(target_path, 'w') as f:
|
||||||
|
f.write(json.dumps(data))
|
||||||
|
f.close()
|
||||||
|
|
||||||
|
|
||||||
def sync_to_json(self):
|
def sync_to_json(self):
|
||||||
logger.info("Saving JSON..")
|
logger.info("Saving JSON..")
|
||||||
try:
|
try:
|
||||||
@@ -844,8 +884,3 @@ class ChangeDetectionStore:
|
|||||||
# Something custom here
|
# Something custom here
|
||||||
self.__data["watching"][uuid]['time_between_check_use_default'] = False
|
self.__data["watching"][uuid]['time_between_check_use_default'] = False
|
||||||
|
|
||||||
# Correctly set datatype for older installs where 'tag' was string and update_12 did not catch it
|
|
||||||
def update_16(self):
|
|
||||||
for uuid, watch in self.data['watching'].items():
|
|
||||||
if isinstance(watch.get('tags'), str):
|
|
||||||
self.data['watching'][uuid]['tags'] = []
|
|
||||||
|
|||||||
@@ -26,11 +26,7 @@
|
|||||||
<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">
|
||||||
<script>
|
|
||||||
const csrftoken="{{ csrf_token() }}";
|
|
||||||
</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='jquery-3.6.0.min.js')}}"></script>
|
||||||
<script src="{{url_for('static_content', group='js', filename='csrf.js')}}" defer></script>
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -292,7 +292,7 @@ xpath://body/div/span[contains(@class, 'example-class')]",
|
|||||||
<ul>
|
<ul>
|
||||||
<li>JSONPath: Prefix with <code>json:</code>, use <code>json:$</code> to force re-formatting if required, <a href="https://jsonpath.com/" target="new">test your JSONPath here</a>.</li>
|
<li>JSONPath: Prefix with <code>json:</code>, use <code>json:$</code> to force re-formatting if required, <a href="https://jsonpath.com/" target="new">test your JSONPath here</a>.</li>
|
||||||
{% if jq_support %}
|
{% if jq_support %}
|
||||||
<li>jq: Prefix with <code>jq:</code> and <a href="https://jqplay.org/" target="new">test your jq here</a>. Using <a href="https://stedolan.github.io/jq/" target="new">jq</a> allows for complex filtering and processing of JSON data with built-in functions, regex, filtering, and more. See examples and documentation <a href="https://stedolan.github.io/jq/manual/" target="new">here</a>. Prefix <code>jqraw:</code> outputs the results as text instead of a JSON list.</li>
|
<li>jq: Prefix with <code>jq:</code> and <a href="https://jqplay.org/" target="new">test your jq here</a>. Using <a href="https://stedolan.github.io/jq/" target="new">jq</a> allows for complex filtering and processing of JSON data with built-in functions, regex, filtering, and more. See examples and documentation <a href="https://stedolan.github.io/jq/manual/" target="new">here</a>.</li>
|
||||||
{% else %}
|
{% else %}
|
||||||
<li>jq support not installed</li>
|
<li>jq support not installed</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -432,8 +432,9 @@ Unavailable") }}
|
|||||||
<fieldset>
|
<fieldset>
|
||||||
<div class="pure-control-group">
|
<div class="pure-control-group">
|
||||||
{% if visualselector_enabled %}
|
{% if visualselector_enabled %}
|
||||||
<span class="pure-form-message-inline" id="visual-selector-heading">
|
<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. It automatically fills-in the filters in the "CSS/JSONPath/JQ/XPath Filters" box of the <a href="#filters-and-triggers">Filters & Triggers</a> tab. Use <strong>Shift+Click</strong> to select multiple items.
|
The Visual Selector tool lets you select the <i>text</i> elements that will be used for the change detection ‐ 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">
|
||||||
|
|||||||
@@ -62,9 +62,6 @@
|
|||||||
<span class="pure-form-message-inline">Allow access to view watch diff page when password is enabled (Good for sharing the diff page)
|
<span class="pure-form-message-inline">Allow access to view watch diff page when password is enabled (Good for sharing the diff page)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="pure-control-group">
|
|
||||||
{{ render_checkbox_field(form.application.form.rss_hide_muted_watches) }}
|
|
||||||
</div>
|
|
||||||
<div class="pure-control-group">
|
<div class="pure-control-group">
|
||||||
{{ render_field(form.application.form.pager_size) }}
|
{{ render_field(form.application.form.pager_size) }}
|
||||||
<span class="pure-form-message-inline">Number of items per page in the watch overview list, 0 to disable.</span>
|
<span class="pure-form-message-inline">Number of items per page in the watch overview list, 0 to disable.</span>
|
||||||
|
|||||||
@@ -68,11 +68,11 @@
|
|||||||
{% 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_uuid)}}"># <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 class="empty-cell"></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_uuid)}}">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_uuid)}}">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_uuid)}}">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 class="empty-cell"></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|||||||
@@ -1,51 +1,42 @@
|
|||||||
#!/usr/bin/python3
|
#!/usr/bin/python3
|
||||||
import asyncio
|
import smtpd
|
||||||
from aiosmtpd.controller import Controller
|
import asyncore
|
||||||
from aiosmtpd.smtp import SMTP
|
|
||||||
|
|
||||||
# Accept a SMTP message and offer a way to retrieve the last message via TCP Socket
|
# Accept a SMTP message and offer a way to retrieve the last message via TCP Socket
|
||||||
|
|
||||||
last_received_message = b"Nothing"
|
last_received_message = b"Nothing"
|
||||||
|
|
||||||
|
|
||||||
class CustomSMTPHandler:
|
class CustomSMTPServer(smtpd.SMTPServer):
|
||||||
async def handle_DATA(self, server, session, envelope):
|
|
||||||
|
def process_message(self, peer, mailfrom, rcpttos, data, **kwargs):
|
||||||
global last_received_message
|
global last_received_message
|
||||||
last_received_message = envelope.content
|
last_received_message = data
|
||||||
print('Receiving message from:', session.peer)
|
print('Receiving message from:', peer)
|
||||||
print('Message addressed from:', envelope.mail_from)
|
print('Message addressed from:', mailfrom)
|
||||||
print('Message addressed to :', envelope.rcpt_tos)
|
print('Message addressed to :', rcpttos)
|
||||||
print('Message length :', len(envelope.content))
|
print('Message length :', len(data))
|
||||||
print(envelope.content.decode('utf8'))
|
print(data.decode('utf8'))
|
||||||
return '250 Message accepted for delivery'
|
return
|
||||||
|
|
||||||
|
|
||||||
class EchoServerProtocol(asyncio.Protocol):
|
# Just print out the last message received on plain TCP socket server
|
||||||
def connection_made(self, transport):
|
class EchoServer(asyncore.dispatcher):
|
||||||
|
|
||||||
|
def __init__(self, host, port):
|
||||||
|
asyncore.dispatcher.__init__(self)
|
||||||
|
self.create_socket()
|
||||||
|
self.set_reuse_addr()
|
||||||
|
self.bind((host, port))
|
||||||
|
self.listen(5)
|
||||||
|
|
||||||
|
def handle_accepted(self, sock, addr):
|
||||||
global last_received_message
|
global last_received_message
|
||||||
self.transport = transport
|
print('Incoming connection from %s' % repr(addr))
|
||||||
peername = transport.get_extra_info('peername')
|
sock.send(last_received_message)
|
||||||
print('Incoming connection from {}'.format(peername))
|
|
||||||
self.transport.write(last_received_message)
|
|
||||||
|
|
||||||
last_received_message = b''
|
last_received_message = b''
|
||||||
self.transport.close()
|
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
server = CustomSMTPServer(('0.0.0.0', 11025), None) # SMTP mail goes here
|
||||||
# Start the SMTP server
|
server2 = EchoServer('0.0.0.0', 11080) # Echo back last message received
|
||||||
controller = Controller(CustomSMTPHandler(), hostname='0.0.0.0', port=11025)
|
asyncore.loop()
|
||||||
controller.start()
|
|
||||||
|
|
||||||
# Start the TCP Echo server
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
server = await loop.create_server(
|
|
||||||
lambda: EchoServerProtocol(),
|
|
||||||
'0.0.0.0', 11080
|
|
||||||
)
|
|
||||||
async with server:
|
|
||||||
await server.serve_forever()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(main())
|
|
||||||
|
|||||||
@@ -32,8 +32,6 @@ def get_last_message_from_smtp_server():
|
|||||||
client_socket.connect((smtp_test_server, port)) # connect to the server
|
client_socket.connect((smtp_test_server, port)) # connect to the server
|
||||||
|
|
||||||
data = client_socket.recv(50024).decode() # receive response
|
data = client_socket.recv(50024).decode() # receive response
|
||||||
logging.info("get_last_message_from_smtp_server..")
|
|
||||||
logging.info(data)
|
|
||||||
client_socket.close() # close the connection
|
client_socket.close() # close the connection
|
||||||
return data
|
return data
|
||||||
|
|
||||||
@@ -85,7 +83,7 @@ def test_check_notification_email_formats_default_HTML(client, live_server):
|
|||||||
|
|
||||||
# The email should have two bodies, and the text/html part should be <br>
|
# The email should have two bodies, and the text/html part should be <br>
|
||||||
assert 'Content-Type: text/plain' in msg
|
assert 'Content-Type: text/plain' in msg
|
||||||
assert '(added) So let\'s see what happens.\r\n' in msg # The plaintext part with \r\n
|
assert '(added) So let\'s see what happens.\n' in msg # The plaintext part with \n
|
||||||
assert 'Content-Type: text/html' in msg
|
assert 'Content-Type: text/html' in msg
|
||||||
assert '(added) So let\'s see what happens.<br>' in msg # the html part
|
assert '(added) So let\'s see what happens.<br>' in msg # the html part
|
||||||
res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True)
|
res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True)
|
||||||
@@ -152,7 +150,7 @@ def test_check_notification_email_formats_default_Text_override_HTML(client, liv
|
|||||||
# The email should not have two bodies, should be TEXT only
|
# The email should not have two bodies, should be TEXT only
|
||||||
|
|
||||||
assert 'Content-Type: text/plain' in msg
|
assert 'Content-Type: text/plain' in msg
|
||||||
assert '(added) So let\'s see what happens.\r\n' in msg # The plaintext part with \r\n
|
assert '(added) So let\'s see what happens.\n' in msg # The plaintext part with \n
|
||||||
|
|
||||||
set_original_response()
|
set_original_response()
|
||||||
# Now override as HTML format
|
# Now override as HTML format
|
||||||
@@ -173,7 +171,7 @@ def test_check_notification_email_formats_default_Text_override_HTML(client, liv
|
|||||||
|
|
||||||
# The email should have two bodies, and the text/html part should be <br>
|
# The email should have two bodies, and the text/html part should be <br>
|
||||||
assert 'Content-Type: text/plain' in msg
|
assert 'Content-Type: text/plain' in msg
|
||||||
assert '(removed) So let\'s see what happens.\r\n' in msg # The plaintext part with \n
|
assert '(removed) So let\'s see what happens.\n' in msg # The plaintext part with \n
|
||||||
assert 'Content-Type: text/html' in msg
|
assert 'Content-Type: text/html' in msg
|
||||||
assert '(removed) So let\'s see what happens.<br>' in msg # the html part
|
assert '(removed) So let\'s see what happens.<br>' in msg # the html part
|
||||||
|
|
||||||
|
|||||||
@@ -149,15 +149,6 @@ def test_api_simple(client, live_server):
|
|||||||
headers={'x-api-key': api_key},
|
headers={'x-api-key': api_key},
|
||||||
)
|
)
|
||||||
assert b'which has this one new line' in res.data
|
assert b'which has this one new line' in res.data
|
||||||
assert b'<div id' not in res.data
|
|
||||||
|
|
||||||
# Fetch the HTML of the latest one
|
|
||||||
res = client.get(
|
|
||||||
url_for("watchsinglehistory", uuid=watch_uuid, timestamp='latest')+"?html=1",
|
|
||||||
headers={'x-api-key': api_key},
|
|
||||||
)
|
|
||||||
assert b'which has this one new line' in res.data
|
|
||||||
assert b'<div id' in res.data
|
|
||||||
|
|
||||||
# Fetch the whole watch
|
# Fetch the whole watch
|
||||||
res = client.get(
|
res = client.get(
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
|
|
||||||
import time
|
import time
|
||||||
from flask import url_for
|
from flask import url_for
|
||||||
from .util import live_server_setup, wait_for_all_checks
|
from . util import live_server_setup
|
||||||
|
|
||||||
|
|
||||||
def test_basic_auth(client, live_server):
|
def test_basic_auth(client, live_server):
|
||||||
|
|
||||||
live_server_setup(live_server)
|
live_server_setup(live_server)
|
||||||
|
# Give the endpoint time to spin up
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
# Add our URL to the import page
|
# Add our URL to the import page
|
||||||
test_url = url_for('test_basicauth_method', _external=True).replace("//","//myuser:mypass@")
|
test_url = url_for('test_basicauth_method', _external=True).replace("//","//myuser:mypass@")
|
||||||
@@ -18,8 +19,8 @@ def test_basic_auth(client, live_server):
|
|||||||
follow_redirects=True
|
follow_redirects=True
|
||||||
)
|
)
|
||||||
assert b"1 Imported" in res.data
|
assert b"1 Imported" in res.data
|
||||||
wait_for_all_checks(client)
|
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
|
|
||||||
# Check form validation
|
# Check form validation
|
||||||
res = client.post(
|
res = client.post(
|
||||||
url_for("edit_page", uuid="first"),
|
url_for("edit_page", uuid="first"),
|
||||||
@@ -28,7 +29,7 @@ def test_basic_auth(client, live_server):
|
|||||||
)
|
)
|
||||||
assert b"Updated watch." in res.data
|
assert b"Updated watch." in res.data
|
||||||
|
|
||||||
wait_for_all_checks(client)
|
time.sleep(1)
|
||||||
res = client.get(
|
res = client.get(
|
||||||
url_for("preview_page", uuid="first"),
|
url_for("preview_page", uuid="first"),
|
||||||
follow_redirects=True
|
follow_redirects=True
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ def test_check_ldjson_price_autodetect(client, live_server):
|
|||||||
|
|
||||||
# Accept it
|
# Accept it
|
||||||
uuid = extract_UUID_from_client(client)
|
uuid = extract_UUID_from_client(client)
|
||||||
time.sleep(1)
|
|
||||||
client.get(url_for('price_data_follower.accept', uuid=uuid, follow_redirects=True))
|
client.get(url_for('price_data_follower.accept', uuid=uuid, follow_redirects=True))
|
||||||
wait_for_all_checks(client)
|
wait_for_all_checks(client)
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
import time
|
import time
|
||||||
from flask import url_for
|
from flask import url_for
|
||||||
from urllib.request import urlopen
|
from urllib.request import urlopen
|
||||||
from .util import set_original_response, set_modified_response, live_server_setup, wait_for_all_checks, extract_rss_token_from_UI, \
|
from .util import set_original_response, set_modified_response, live_server_setup, wait_for_all_checks, extract_rss_token_from_UI
|
||||||
extract_UUID_from_client
|
|
||||||
|
|
||||||
sleep_time_for_fetch_thread = 3
|
sleep_time_for_fetch_thread = 3
|
||||||
|
|
||||||
@@ -63,6 +62,9 @@ def test_check_basic_change_detection_functionality(client, live_server):
|
|||||||
# Make a change
|
# Make a change
|
||||||
set_modified_response()
|
set_modified_response()
|
||||||
|
|
||||||
|
res = urlopen(url_for('test_endpoint', _external=True))
|
||||||
|
assert b'which has this one new line' in res.read()
|
||||||
|
|
||||||
# Force recheck
|
# Force recheck
|
||||||
res = client.get(url_for("form_watch_checknow"), follow_redirects=True)
|
res = client.get(url_for("form_watch_checknow"), follow_redirects=True)
|
||||||
assert b'1 watches queued for rechecking.' in res.data
|
assert b'1 watches queued for rechecking.' in res.data
|
||||||
@@ -142,14 +144,6 @@ def test_check_basic_change_detection_functionality(client, live_server):
|
|||||||
assert b'Mark all viewed' not in res.data
|
assert b'Mark all viewed' not in res.data
|
||||||
assert b'unviewed' not in res.data
|
assert b'unviewed' not in res.data
|
||||||
|
|
||||||
# #2458 "clear history" should make the Watch object update its status correctly when the first snapshot lands again
|
|
||||||
uuid = extract_UUID_from_client(client)
|
|
||||||
client.get(url_for("clear_watch_history", uuid=uuid))
|
|
||||||
client.get(url_for("form_watch_checknow"), follow_redirects=True)
|
|
||||||
wait_for_all_checks(client)
|
|
||||||
res = client.get(url_for("index"))
|
|
||||||
assert b'preview/' in res.data
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Cleanup everything
|
# Cleanup everything
|
||||||
res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True)
|
res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
import time
|
import time
|
||||||
from flask import url_for
|
from flask import url_for
|
||||||
from .util import live_server_setup, wait_for_all_checks
|
from .util import live_server_setup
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
@@ -27,6 +27,9 @@ def set_html_response():
|
|||||||
def test_check_encoding_detection(client, live_server):
|
def test_check_encoding_detection(client, live_server):
|
||||||
set_html_response()
|
set_html_response()
|
||||||
|
|
||||||
|
# Give the endpoint time to spin up
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
# Add our URL to the import page
|
# Add our URL to the import page
|
||||||
test_url = url_for('test_endpoint', content_type="text/html", _external=True)
|
test_url = url_for('test_endpoint', content_type="text/html", _external=True)
|
||||||
client.post(
|
client.post(
|
||||||
@@ -36,7 +39,7 @@ def test_check_encoding_detection(client, live_server):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Give the thread time to pick it up
|
# Give the thread time to pick it up
|
||||||
wait_for_all_checks(client)
|
time.sleep(2)
|
||||||
|
|
||||||
res = client.get(
|
res = client.get(
|
||||||
url_for("preview_page", uuid="first"),
|
url_for("preview_page", uuid="first"),
|
||||||
@@ -53,6 +56,9 @@ def test_check_encoding_detection(client, live_server):
|
|||||||
def test_check_encoding_detection_missing_content_type_header(client, live_server):
|
def test_check_encoding_detection_missing_content_type_header(client, live_server):
|
||||||
set_html_response()
|
set_html_response()
|
||||||
|
|
||||||
|
# Give the endpoint time to spin up
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
# Add our URL to the import page
|
# Add our URL to the import page
|
||||||
test_url = url_for('test_endpoint', _external=True)
|
test_url = url_for('test_endpoint', _external=True)
|
||||||
client.post(
|
client.post(
|
||||||
@@ -61,7 +67,8 @@ def test_check_encoding_detection_missing_content_type_header(client, live_serve
|
|||||||
follow_redirects=True
|
follow_redirects=True
|
||||||
)
|
)
|
||||||
|
|
||||||
wait_for_all_checks(client)
|
# Give the thread time to pick it up
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
res = client.get(
|
res = client.get(
|
||||||
url_for("preview_page", uuid="first"),
|
url_for("preview_page", uuid="first"),
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ def test_check_extract_text_from_diff(client, live_server):
|
|||||||
# Load in 5 different numbers/changes
|
# Load in 5 different numbers/changes
|
||||||
last_date=""
|
last_date=""
|
||||||
for n in range(5):
|
for n in range(5):
|
||||||
time.sleep(1)
|
|
||||||
# Give the thread time to pick it up
|
# Give the thread time to pick it up
|
||||||
print("Bumping snapshot and checking.. ", n)
|
print("Bumping snapshot and checking.. ", n)
|
||||||
last_date = str(time.time())
|
last_date = str(time.time())
|
||||||
|
|||||||
@@ -21,11 +21,10 @@ def set_response_with_filter():
|
|||||||
f.write(test_return_data)
|
f.write(test_return_data)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def run_filter_test(client, live_server, content_filter):
|
def run_filter_test(client, content_filter):
|
||||||
|
|
||||||
# Response WITHOUT the filter ID element
|
|
||||||
set_original_response()
|
|
||||||
|
|
||||||
|
# Give the endpoint time to spin up
|
||||||
|
time.sleep(1)
|
||||||
# cleanup for the next
|
# cleanup for the next
|
||||||
client.get(
|
client.get(
|
||||||
url_for("form_delete", uuid="all"),
|
url_for("form_delete", uuid="all"),
|
||||||
@@ -80,7 +79,6 @@ def run_filter_test(client, live_server, content_filter):
|
|||||||
"include_filters": content_filter,
|
"include_filters": content_filter,
|
||||||
"fetch_backend": "html_requests"})
|
"fetch_backend": "html_requests"})
|
||||||
|
|
||||||
# A POST here will also reset the filter failure counter (filter_failure_notification_threshold_attempts)
|
|
||||||
res = client.post(
|
res = client.post(
|
||||||
url_for("edit_page", uuid="first"),
|
url_for("edit_page", uuid="first"),
|
||||||
data=notification_form_data,
|
data=notification_form_data,
|
||||||
@@ -93,21 +91,20 @@ def run_filter_test(client, live_server, content_filter):
|
|||||||
# Now the notification should not exist, because we didnt reach the threshold
|
# Now the notification should not exist, because we didnt reach the threshold
|
||||||
assert not os.path.isfile("test-datastore/notification.txt")
|
assert not os.path.isfile("test-datastore/notification.txt")
|
||||||
|
|
||||||
# recheck it up to just before the threshold, including the fact that in the previous POST it would have rechecked (and incremented)
|
# -2 because we would have checked twice above (on adding and on edit)
|
||||||
for i in range(0, App._FILTER_FAILURE_THRESHOLD_ATTEMPTS_DEFAULT-2):
|
for i in range(0, App._FILTER_FAILURE_THRESHOLD_ATTEMPTS_DEFAULT-2):
|
||||||
client.get(url_for("form_watch_checknow"), follow_redirects=True)
|
res = client.get(url_for("form_watch_checknow"), follow_redirects=True)
|
||||||
wait_for_all_checks(client)
|
wait_for_all_checks(client)
|
||||||
time.sleep(2) # delay for apprise to fire
|
assert not os.path.isfile("test-datastore/notification.txt"), f"test-datastore/notification.txt should not exist - Attempt {i}"
|
||||||
assert not os.path.isfile("test-datastore/notification.txt"), f"test-datastore/notification.txt should not exist - Attempt {i} when threshold is {App._FILTER_FAILURE_THRESHOLD_ATTEMPTS_DEFAULT}"
|
|
||||||
|
|
||||||
# We should see something in the frontend
|
# We should see something in the frontend
|
||||||
res = client.get(url_for("index"))
|
|
||||||
assert b'Warning, no filters were found' in res.data
|
assert b'Warning, no filters were found' in res.data
|
||||||
|
|
||||||
# One more check should trigger the _FILTER_FAILURE_THRESHOLD_ATTEMPTS_DEFAULT threshold
|
# One more check should trigger it (see -2 above)
|
||||||
|
client.get(url_for("form_watch_checknow"), follow_redirects=True)
|
||||||
|
wait_for_all_checks(client)
|
||||||
client.get(url_for("form_watch_checknow"), follow_redirects=True)
|
client.get(url_for("form_watch_checknow"), follow_redirects=True)
|
||||||
wait_for_all_checks(client)
|
wait_for_all_checks(client)
|
||||||
time.sleep(2) # delay for apprise to fire
|
|
||||||
# Now it should exist and contain our "filter not found" alert
|
# Now it should exist and contain our "filter not found" alert
|
||||||
assert os.path.isfile("test-datastore/notification.txt")
|
assert os.path.isfile("test-datastore/notification.txt")
|
||||||
|
|
||||||
@@ -152,9 +149,13 @@ def test_setup(live_server):
|
|||||||
live_server_setup(live_server)
|
live_server_setup(live_server)
|
||||||
|
|
||||||
def test_check_include_filters_failure_notification(client, live_server):
|
def test_check_include_filters_failure_notification(client, live_server):
|
||||||
run_filter_test(client, live_server,'#nope-doesnt-exist')
|
set_original_response()
|
||||||
|
wait_for_all_checks(client)
|
||||||
|
run_filter_test(client, '#nope-doesnt-exist')
|
||||||
|
|
||||||
def test_check_xpath_filter_failure_notification(client, live_server):
|
def test_check_xpath_filter_failure_notification(client, live_server):
|
||||||
run_filter_test(client, live_server, '//*[@id="nope-doesnt-exist"]')
|
set_original_response()
|
||||||
|
time.sleep(1)
|
||||||
|
run_filter_test(client, '//*[@id="nope-doesnt-exist"]')
|
||||||
|
|
||||||
# Test that notification is never sent
|
# Test that notification is never sent
|
||||||
|
|||||||
@@ -5,13 +5,15 @@ import os
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from flask import url_for
|
from flask import url_for
|
||||||
from .util import live_server_setup, wait_for_all_checks
|
from .util import live_server_setup
|
||||||
from urllib.parse import urlparse, parse_qs
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
|
||||||
def test_consistent_history(client, live_server):
|
def test_consistent_history(client, live_server):
|
||||||
live_server_setup(live_server)
|
live_server_setup(live_server)
|
||||||
|
|
||||||
r = range(1, 30)
|
# Give the endpoint time to spin up
|
||||||
|
time.sleep(1)
|
||||||
|
r = range(1, 50)
|
||||||
|
|
||||||
for one in r:
|
for one in r:
|
||||||
test_url = url_for('test_endpoint', content_type="text/html", content=str(one), _external=True)
|
test_url = url_for('test_endpoint', content_type="text/html", content=str(one), _external=True)
|
||||||
@@ -23,8 +25,15 @@ def test_consistent_history(client, live_server):
|
|||||||
|
|
||||||
assert b"1 Imported" in res.data
|
assert b"1 Imported" in res.data
|
||||||
|
|
||||||
wait_for_all_checks(client)
|
time.sleep(3)
|
||||||
|
while True:
|
||||||
|
res = client.get(url_for("index"))
|
||||||
|
logging.debug("Waiting for 'Checking now' to go away..")
|
||||||
|
if b'Checking now' not in res.data:
|
||||||
|
break
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
time.sleep(3)
|
||||||
# Essentially just triggers the DB write/update
|
# Essentially just triggers the DB write/update
|
||||||
res = client.post(
|
res = client.post(
|
||||||
url_for("settings_page"),
|
url_for("settings_page"),
|
||||||
@@ -35,9 +44,8 @@ def test_consistent_history(client, live_server):
|
|||||||
)
|
)
|
||||||
assert b"Settings updated." in res.data
|
assert b"Settings updated." in res.data
|
||||||
|
|
||||||
|
# Give it time to write it out
|
||||||
time.sleep(2)
|
time.sleep(3)
|
||||||
|
|
||||||
json_db_file = os.path.join(live_server.app.config['DATASTORE'].datastore_path, 'url-watches.json')
|
json_db_file = os.path.join(live_server.app.config['DATASTORE'].datastore_path, 'url-watches.json')
|
||||||
|
|
||||||
json_obj = None
|
json_obj = None
|
||||||
@@ -50,7 +58,7 @@ def test_consistent_history(client, live_server):
|
|||||||
# each one should have a history.txt containing just one line
|
# each one should have a history.txt containing just one line
|
||||||
for w in json_obj['watching'].keys():
|
for w in json_obj['watching'].keys():
|
||||||
history_txt_index_file = os.path.join(live_server.app.config['DATASTORE'].datastore_path, w, 'history.txt')
|
history_txt_index_file = os.path.join(live_server.app.config['DATASTORE'].datastore_path, w, 'history.txt')
|
||||||
assert os.path.isfile(history_txt_index_file), f"History.txt should exist where I expect it at {history_txt_index_file}"
|
assert os.path.isfile(history_txt_index_file), "History.txt should exist where I expect it - {}".format(history_txt_index_file)
|
||||||
|
|
||||||
# Same like in model.Watch
|
# Same like in model.Watch
|
||||||
with open(history_txt_index_file, "r") as f:
|
with open(history_txt_index_file, "r") as f:
|
||||||
@@ -62,15 +70,15 @@ def test_consistent_history(client, live_server):
|
|||||||
w))
|
w))
|
||||||
# Find the snapshot one
|
# Find the snapshot one
|
||||||
for fname in files_in_watch_dir:
|
for fname in files_in_watch_dir:
|
||||||
if fname != 'history.txt' and 'html' not in fname:
|
if fname != 'history.txt':
|
||||||
# contents should match what we requested as content returned from the test url
|
# contents should match what we requested as content returned from the test url
|
||||||
with open(os.path.join(live_server.app.config['DATASTORE'].datastore_path, w, fname), 'r') as snapshot_f:
|
with open(os.path.join(live_server.app.config['DATASTORE'].datastore_path, w, fname), 'r') as snapshot_f:
|
||||||
contents = snapshot_f.read()
|
contents = snapshot_f.read()
|
||||||
watch_url = json_obj['watching'][w]['url']
|
watch_url = json_obj['watching'][w]['url']
|
||||||
u = urlparse(watch_url)
|
u = urlparse(watch_url)
|
||||||
q = parse_qs(u[4])
|
q = parse_qs(u[4])
|
||||||
assert q['content'][0] == contents.strip(), f"Snapshot file {fname} should contain {q['content'][0]}"
|
assert q['content'][0] == contents.strip(), "Snapshot file {} should contain {}".format(fname, q['content'][0])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
assert len(files_in_watch_dir) == 3, "Should be just three files in the dir, html.br snapshot, history.txt and the extracted text snapshot"
|
assert len(files_in_watch_dir) == 2, "Should be just two files in the dir, history.txt and the snapshot"
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ def test_highlight_ignore(client, live_server):
|
|||||||
)
|
)
|
||||||
|
|
||||||
res = client.get(url_for("edit_page", uuid=uuid))
|
res = client.get(url_for("edit_page", uuid=uuid))
|
||||||
|
|
||||||
# should be a regex now
|
# should be a regex now
|
||||||
assert b'/oh\ yeah\ \d+/' in res.data
|
assert b'/oh\ yeah\ \d+/' in res.data
|
||||||
|
|
||||||
@@ -54,7 +55,3 @@ def test_highlight_ignore(client, live_server):
|
|||||||
# And it should register in the preview page
|
# And it should register in the preview page
|
||||||
res = client.get(url_for("preview_page", uuid=uuid))
|
res = client.get(url_for("preview_page", uuid=uuid))
|
||||||
assert b'<div class="ignored">oh yeah 456' in res.data
|
assert b'<div class="ignored">oh yeah 456' in res.data
|
||||||
|
|
||||||
# Should be in base.html
|
|
||||||
assert b'csrftoken' in res.data
|
|
||||||
|
|
||||||
|
|||||||
@@ -41,26 +41,19 @@ and it can also be repeated
|
|||||||
from .. import html_tools
|
from .. import html_tools
|
||||||
|
|
||||||
# See that we can find the second <script> one, which is not broken, and matches our filter
|
# See that we can find the second <script> one, which is not broken, and matches our filter
|
||||||
text = html_tools.extract_json_as_string(content, "json:$.offers.priceCurrency")
|
text = html_tools.extract_json_as_string(content, "json:$.offers.price")
|
||||||
assert text == '"AUD"'
|
assert text == "23.5"
|
||||||
|
|
||||||
text = html_tools.extract_json_as_string('{"id":5}', "json:$.id")
|
|
||||||
assert text == "5"
|
|
||||||
|
|
||||||
# also check for jq
|
# also check for jq
|
||||||
if jq_support:
|
if jq_support:
|
||||||
text = html_tools.extract_json_as_string(content, "jq:.offers.priceCurrency")
|
text = html_tools.extract_json_as_string(content, "jq:.offers.price")
|
||||||
assert text == '"AUD"'
|
assert text == "23.5"
|
||||||
|
|
||||||
text = html_tools.extract_json_as_string('{"id":5}', "jq:.id")
|
text = html_tools.extract_json_as_string('{"id":5}', "jq:.id")
|
||||||
assert text == "5"
|
assert text == "5"
|
||||||
|
|
||||||
text = html_tools.extract_json_as_string(content, "jqraw:.offers.priceCurrency")
|
text = html_tools.extract_json_as_string('{"id":5}', "json:$.id")
|
||||||
assert text == "AUD"
|
assert text == "5"
|
||||||
|
|
||||||
text = html_tools.extract_json_as_string('{"id":5}', "jqraw:.id")
|
|
||||||
assert text == "5"
|
|
||||||
|
|
||||||
|
|
||||||
# When nothing at all is found, it should throw JSONNOTFound
|
# When nothing at all is found, it should throw JSONNOTFound
|
||||||
# Which is caught and shown to the user in the watch-overview table
|
# Which is caught and shown to the user in the watch-overview table
|
||||||
@@ -71,9 +64,6 @@ and it can also be repeated
|
|||||||
with pytest.raises(html_tools.JSONNotFound) as e_info:
|
with pytest.raises(html_tools.JSONNotFound) as e_info:
|
||||||
html_tools.extract_json_as_string('COMPLETE GIBBERISH, NO JSON!', "jq:.id")
|
html_tools.extract_json_as_string('COMPLETE GIBBERISH, NO JSON!', "jq:.id")
|
||||||
|
|
||||||
with pytest.raises(html_tools.JSONNotFound) as e_info:
|
|
||||||
html_tools.extract_json_as_string('COMPLETE GIBBERISH, NO JSON!', "jqraw:.id")
|
|
||||||
|
|
||||||
|
|
||||||
def test_unittest_inline_extract_body():
|
def test_unittest_inline_extract_body():
|
||||||
content = """
|
content = """
|
||||||
@@ -301,10 +291,6 @@ def test_check_jq_filter(client, live_server):
|
|||||||
if jq_support:
|
if jq_support:
|
||||||
check_json_filter('jq:.boss.name', client, live_server)
|
check_json_filter('jq:.boss.name', client, live_server)
|
||||||
|
|
||||||
def test_check_jqraw_filter(client, live_server):
|
|
||||||
if jq_support:
|
|
||||||
check_json_filter('jqraw:.boss.name', client, live_server)
|
|
||||||
|
|
||||||
def check_json_filter_bool_val(json_filter, client, live_server):
|
def check_json_filter_bool_val(json_filter, client, live_server):
|
||||||
set_original_response()
|
set_original_response()
|
||||||
|
|
||||||
@@ -359,10 +345,6 @@ def test_check_jq_filter_bool_val(client, live_server):
|
|||||||
if jq_support:
|
if jq_support:
|
||||||
check_json_filter_bool_val("jq:.available", client, live_server)
|
check_json_filter_bool_val("jq:.available", client, live_server)
|
||||||
|
|
||||||
def test_check_jqraw_filter_bool_val(client, live_server):
|
|
||||||
if jq_support:
|
|
||||||
check_json_filter_bool_val("jq:.available", client, live_server)
|
|
||||||
|
|
||||||
# Re #265 - Extended JSON selector test
|
# Re #265 - Extended JSON selector test
|
||||||
# Stuff to consider here
|
# Stuff to consider here
|
||||||
# - Selector should be allowed to return empty when it doesnt match (people might wait for some condition)
|
# - Selector should be allowed to return empty when it doesnt match (people might wait for some condition)
|
||||||
@@ -509,8 +491,4 @@ def test_check_jsonpath_ext_filter(client, live_server):
|
|||||||
|
|
||||||
def test_check_jq_ext_filter(client, live_server):
|
def test_check_jq_ext_filter(client, live_server):
|
||||||
if jq_support:
|
if jq_support:
|
||||||
check_json_ext_filter('jq:.[] | select(.status | contains("Sold"))', client, live_server)
|
check_json_ext_filter('jq:.[] | select(.status | contains("Sold"))', client, live_server)
|
||||||
|
|
||||||
def test_check_jqraw_ext_filter(client, live_server):
|
|
||||||
if jq_support:
|
|
||||||
check_json_ext_filter('jq:.[] | select(.status | contains("Sold"))', client, live_server)
|
|
||||||
@@ -69,7 +69,6 @@ def test_rss_and_token(client, live_server):
|
|||||||
|
|
||||||
wait_for_all_checks(client)
|
wait_for_all_checks(client)
|
||||||
set_modified_response()
|
set_modified_response()
|
||||||
time.sleep(1)
|
|
||||||
client.get(url_for("form_watch_checknow"), follow_redirects=True)
|
client.get(url_for("form_watch_checknow"), follow_redirects=True)
|
||||||
wait_for_all_checks(client)
|
wait_for_all_checks(client)
|
||||||
|
|
||||||
@@ -88,7 +87,7 @@ def test_rss_and_token(client, live_server):
|
|||||||
assert b"Access denied, bad token" not in res.data
|
assert b"Access denied, bad token" not in res.data
|
||||||
assert b"Random content" in res.data
|
assert b"Random content" in res.data
|
||||||
|
|
||||||
client.get(url_for("form_delete", uuid="all"), follow_redirects=True)
|
res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True)
|
||||||
|
|
||||||
def test_basic_cdata_rss_markup(client, live_server):
|
def test_basic_cdata_rss_markup(client, live_server):
|
||||||
#live_server_setup(live_server)
|
#live_server_setup(live_server)
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
from . import content_fetchers
|
|
||||||
from .processors.restock_diff import UnableToExtractRestockData
|
|
||||||
from .processors.text_json_diff import FilterNotFoundInResponse
|
|
||||||
from changedetectionio import html_tools
|
|
||||||
from copy import deepcopy
|
|
||||||
import os
|
import os
|
||||||
import queue
|
|
||||||
import threading
|
import threading
|
||||||
|
import queue
|
||||||
import time
|
import time
|
||||||
|
from . import content_fetchers
|
||||||
|
from changedetectionio import html_tools
|
||||||
|
from .processors.text_json_diff import FilterNotFoundInResponse
|
||||||
|
from .processors.restock_diff import UnableToExtractRestockData
|
||||||
|
|
||||||
# A single update worker
|
# A single update worker
|
||||||
#
|
#
|
||||||
@@ -246,18 +245,14 @@ class update_worker(threading.Thread):
|
|||||||
contents = b''
|
contents = b''
|
||||||
process_changedetection_results = True
|
process_changedetection_results = True
|
||||||
update_obj = {}
|
update_obj = {}
|
||||||
|
logger.info(f"Processing watch UUID {uuid} "
|
||||||
# Clear last errors (move to preflight func?)
|
f"Priority {queued_item_data.priority} "
|
||||||
self.datastore.data['watching'][uuid]['browser_steps_last_error_step'] = None
|
f"URL {self.datastore.data['watching'][uuid]['url']}")
|
||||||
|
|
||||||
watch = self.datastore.data['watching'].get(uuid)
|
|
||||||
|
|
||||||
logger.info(f"Processing watch UUID {uuid} Priority {queued_item_data.priority} URL {watch['url']}")
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Processor is what we are using for detecting the "Change"
|
# Processor is what we are using for detecting the "Change"
|
||||||
processor = watch.get('processor', 'text_json_diff')
|
processor = self.datastore.data['watching'][uuid].get('processor', 'text_json_diff')
|
||||||
# if system...
|
# if system...
|
||||||
|
|
||||||
# Abort processing when the content was the same as the last fetch
|
# Abort processing when the content was the same as the last fetch
|
||||||
@@ -277,12 +272,14 @@ class update_worker(threading.Thread):
|
|||||||
watch_uuid=uuid
|
watch_uuid=uuid
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Clear last errors (move to preflight func?)
|
||||||
|
self.datastore.data['watching'][uuid]['browser_steps_last_error_step'] = None
|
||||||
|
|
||||||
update_handler.call_browser()
|
update_handler.call_browser()
|
||||||
|
|
||||||
changed_detected, update_obj, contents = update_handler.run_changedetection(
|
changed_detected, update_obj, contents = update_handler.run_changedetection(uuid,
|
||||||
watch=watch,
|
skip_when_checksum_same=skip_when_same_checksum,
|
||||||
skip_when_checksum_same=skip_when_same_checksum,
|
)
|
||||||
)
|
|
||||||
|
|
||||||
# Re #342
|
# Re #342
|
||||||
# In Python 3, all strings are sequences of Unicode characters. There is a bytes type that holds raw bytes.
|
# In Python 3, all strings are sequences of Unicode characters. There is a bytes type that holds raw bytes.
|
||||||
@@ -312,11 +309,7 @@ class update_worker(threading.Thread):
|
|||||||
})
|
})
|
||||||
|
|
||||||
if e.screenshot:
|
if e.screenshot:
|
||||||
watch.save_screenshot(screenshot=e.screenshot, as_error=True)
|
self.datastore.save_screenshot(watch_uuid=uuid, screenshot=e.screenshot)
|
||||||
|
|
||||||
if e.xpath_data:
|
|
||||||
watch.save_xpath_data(data=e.xpath_data)
|
|
||||||
|
|
||||||
process_changedetection_results = False
|
process_changedetection_results = False
|
||||||
|
|
||||||
except content_fetchers.exceptions.Non200ErrorCodeReceived as e:
|
except content_fetchers.exceptions.Non200ErrorCodeReceived as e:
|
||||||
@@ -332,11 +325,11 @@ class update_worker(threading.Thread):
|
|||||||
err_text = "Error - Request returned a HTTP error code {}".format(str(e.status_code))
|
err_text = "Error - Request returned a HTTP error code {}".format(str(e.status_code))
|
||||||
|
|
||||||
if e.screenshot:
|
if e.screenshot:
|
||||||
watch.save_screenshot(screenshot=e.screenshot, as_error=True)
|
self.datastore.save_screenshot(watch_uuid=uuid, screenshot=e.screenshot, as_error=True)
|
||||||
if e.xpath_data:
|
if e.xpath_data:
|
||||||
watch.save_xpath_data(data=e.xpath_data, as_error=True)
|
self.datastore.save_xpath_data(watch_uuid=uuid, data=e.xpath_data, as_error=True)
|
||||||
if e.page_text:
|
if e.page_text:
|
||||||
watch.save_error_text(contents=e.page_text)
|
self.datastore.save_error_text(watch_uuid=uuid, contents=e.page_text)
|
||||||
|
|
||||||
self.datastore.update_watch(uuid=uuid, update_obj={'last_error': err_text})
|
self.datastore.update_watch(uuid=uuid, update_obj={'last_error': err_text})
|
||||||
process_changedetection_results = False
|
process_changedetection_results = False
|
||||||
@@ -348,23 +341,16 @@ class update_worker(threading.Thread):
|
|||||||
err_text = "Warning, no filters were found, no change detection ran - Did the page change layout? update your Visual Filter if necessary."
|
err_text = "Warning, no filters were found, no change detection ran - Did the page change layout? update your Visual Filter if necessary."
|
||||||
self.datastore.update_watch(uuid=uuid, update_obj={'last_error': err_text})
|
self.datastore.update_watch(uuid=uuid, update_obj={'last_error': err_text})
|
||||||
|
|
||||||
# Filter wasnt found, but we should still update the visual selector so that they can have a chance to set it up again
|
|
||||||
if e.screenshot:
|
|
||||||
watch.save_screenshot(screenshot=e.screenshot)
|
|
||||||
|
|
||||||
if e.xpath_data:
|
|
||||||
watch.save_xpath_data(data=e.xpath_data)
|
|
||||||
|
|
||||||
# Only when enabled, send the notification
|
# Only when enabled, send the notification
|
||||||
if watch.get('filter_failure_notification_send', False):
|
if self.datastore.data['watching'][uuid].get('filter_failure_notification_send', False):
|
||||||
c = watch.get('consecutive_filter_failures', 5)
|
c = self.datastore.data['watching'][uuid].get('consecutive_filter_failures', 5)
|
||||||
c += 1
|
c += 1
|
||||||
# Send notification if we reached the threshold?
|
# Send notification if we reached the threshold?
|
||||||
threshold = self.datastore.data['settings']['application'].get('filter_failure_notification_threshold_attempts',
|
threshold = self.datastore.data['settings']['application'].get('filter_failure_notification_threshold_attempts',
|
||||||
0)
|
0)
|
||||||
logger.warning(f"Filter for {uuid} not found, consecutive_filter_failures: {c}")
|
logger.error(f"Filter for {uuid} not found, consecutive_filter_failures: {c}")
|
||||||
if threshold > 0 and c >= threshold:
|
if threshold > 0 and c >= threshold:
|
||||||
if not watch.get('notification_muted'):
|
if not self.datastore.data['watching'][uuid].get('notification_muted'):
|
||||||
self.send_filter_failure_notification(uuid)
|
self.send_filter_failure_notification(uuid)
|
||||||
c = 0
|
c = 0
|
||||||
|
|
||||||
@@ -376,6 +362,7 @@ class update_worker(threading.Thread):
|
|||||||
# Yes fine, so nothing todo, don't continue to process.
|
# Yes fine, so nothing todo, don't continue to process.
|
||||||
process_changedetection_results = False
|
process_changedetection_results = False
|
||||||
changed_detected = False
|
changed_detected = False
|
||||||
|
self.datastore.update_watch(uuid=uuid, update_obj={'last_error': False})
|
||||||
except content_fetchers.exceptions.BrowserConnectError as e:
|
except content_fetchers.exceptions.BrowserConnectError as e:
|
||||||
self.datastore.update_watch(uuid=uuid,
|
self.datastore.update_watch(uuid=uuid,
|
||||||
update_obj={'last_error': e.msg
|
update_obj={'last_error': e.msg
|
||||||
@@ -414,15 +401,15 @@ class update_worker(threading.Thread):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
if watch.get('filter_failure_notification_send', False):
|
if self.datastore.data['watching'][uuid].get('filter_failure_notification_send', False):
|
||||||
c = watch.get('consecutive_filter_failures', 5)
|
c = self.datastore.data['watching'][uuid].get('consecutive_filter_failures', 5)
|
||||||
c += 1
|
c += 1
|
||||||
# Send notification if we reached the threshold?
|
# Send notification if we reached the threshold?
|
||||||
threshold = self.datastore.data['settings']['application'].get('filter_failure_notification_threshold_attempts',
|
threshold = self.datastore.data['settings']['application'].get('filter_failure_notification_threshold_attempts',
|
||||||
0)
|
0)
|
||||||
logger.error(f"Step for {uuid} not found, consecutive_filter_failures: {c}")
|
logger.error(f"Step for {uuid} not found, consecutive_filter_failures: {c}")
|
||||||
if threshold > 0 and c >= threshold:
|
if threshold > 0 and c >= threshold:
|
||||||
if not watch.get('notification_muted'):
|
if not self.datastore.data['watching'][uuid].get('notification_muted'):
|
||||||
self.send_step_failure_notification(watch_uuid=uuid, step_n=e.step_n)
|
self.send_step_failure_notification(watch_uuid=uuid, step_n=e.step_n)
|
||||||
c = 0
|
c = 0
|
||||||
|
|
||||||
@@ -444,7 +431,7 @@ class update_worker(threading.Thread):
|
|||||||
except content_fetchers.exceptions.JSActionExceptions as e:
|
except content_fetchers.exceptions.JSActionExceptions as e:
|
||||||
err_text = "Error running JS Actions - Page request - "+e.message
|
err_text = "Error running JS Actions - Page request - "+e.message
|
||||||
if e.screenshot:
|
if e.screenshot:
|
||||||
watch.save_screenshot(screenshot=e.screenshot, as_error=True)
|
self.datastore.save_screenshot(watch_uuid=uuid, screenshot=e.screenshot, as_error=True)
|
||||||
self.datastore.update_watch(uuid=uuid, update_obj={'last_error': err_text,
|
self.datastore.update_watch(uuid=uuid, update_obj={'last_error': err_text,
|
||||||
'last_check_status': e.status_code})
|
'last_check_status': e.status_code})
|
||||||
process_changedetection_results = False
|
process_changedetection_results = False
|
||||||
@@ -454,7 +441,7 @@ class update_worker(threading.Thread):
|
|||||||
err_text = "{} - {}".format(err_text, e.message)
|
err_text = "{} - {}".format(err_text, e.message)
|
||||||
|
|
||||||
if e.screenshot:
|
if e.screenshot:
|
||||||
watch.save_screenshot(screenshot=e.screenshot, as_error=True)
|
self.datastore.save_screenshot(watch_uuid=uuid, screenshot=e.screenshot, as_error=True)
|
||||||
|
|
||||||
self.datastore.update_watch(uuid=uuid, update_obj={'last_error': err_text,
|
self.datastore.update_watch(uuid=uuid, update_obj={'last_error': err_text,
|
||||||
'last_check_status': e.status_code,
|
'last_check_status': e.status_code,
|
||||||
@@ -478,6 +465,8 @@ class update_worker(threading.Thread):
|
|||||||
self.datastore.update_watch(uuid=uuid, update_obj={'last_error': "Exception: " + str(e)})
|
self.datastore.update_watch(uuid=uuid, update_obj={'last_error': "Exception: " + str(e)})
|
||||||
# Other serious error
|
# Other serious error
|
||||||
process_changedetection_results = False
|
process_changedetection_results = False
|
||||||
|
# import traceback
|
||||||
|
# print(traceback.format_exc())
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Crash protection, the watch entry could have been removed by this point (during a slow chrome fetch etc)
|
# Crash protection, the watch entry could have been removed by this point (during a slow chrome fetch etc)
|
||||||
@@ -485,7 +474,7 @@ class update_worker(threading.Thread):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Mark that we never had any failures
|
# Mark that we never had any failures
|
||||||
if not watch.get('ignore_status_codes'):
|
if not self.datastore.data['watching'][uuid].get('ignore_status_codes'):
|
||||||
update_obj['consecutive_filter_failures'] = 0
|
update_obj['consecutive_filter_failures'] = 0
|
||||||
|
|
||||||
# Everything ran OK, clean off any previous error
|
# Everything ran OK, clean off any previous error
|
||||||
@@ -493,48 +482,25 @@ class update_worker(threading.Thread):
|
|||||||
|
|
||||||
self.cleanup_error_artifacts(uuid)
|
self.cleanup_error_artifacts(uuid)
|
||||||
|
|
||||||
if not self.datastore.data['watching'].get(uuid):
|
|
||||||
continue
|
|
||||||
#
|
#
|
||||||
# Different exceptions mean that we may or may not want to bump the snapshot, trigger notifications etc
|
# Different exceptions mean that we may or may not want to bump the snapshot, trigger notifications etc
|
||||||
if process_changedetection_results:
|
if process_changedetection_results:
|
||||||
# Always save the screenshot if it's available
|
|
||||||
|
|
||||||
if update_handler.screenshot:
|
|
||||||
watch.save_screenshot(screenshot=update_handler.screenshot)
|
|
||||||
|
|
||||||
if update_handler.xpath_data:
|
|
||||||
watch.save_xpath_data(data=update_handler.xpath_data)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
watch = self.datastore.data['watching'].get(uuid)
|
||||||
self.datastore.update_watch(uuid=uuid, update_obj=update_obj)
|
self.datastore.update_watch(uuid=uuid, update_obj=update_obj)
|
||||||
|
|
||||||
# Also save the snapshot on the first time checked
|
# Also save the snapshot on the first time checked
|
||||||
if changed_detected or not watch.get('last_checked'):
|
if changed_detected or not watch['last_checked']:
|
||||||
timestamp = round(time.time())
|
|
||||||
|
|
||||||
# Small hack so that we sleep just enough to allow 1 second between history snapshots
|
|
||||||
# this is because history.txt indexes/keys snapshots by epoch seconds and we dont want dupe keys
|
|
||||||
|
|
||||||
if watch.newest_history_key and int(timestamp) == int(watch.newest_history_key):
|
|
||||||
logger.warning(
|
|
||||||
f"Timestamp {timestamp} already exists, waiting 1 seconds so we have a unique key in history.txt")
|
|
||||||
timestamp = str(int(timestamp) + 1)
|
|
||||||
time.sleep(1)
|
|
||||||
|
|
||||||
watch.save_history_text(contents=contents,
|
watch.save_history_text(contents=contents,
|
||||||
timestamp=timestamp,
|
timestamp=str(round(time.time())),
|
||||||
snapshot_id=update_obj.get('previous_md5', 'none'))
|
snapshot_id=update_obj.get('previous_md5', 'none'))
|
||||||
|
|
||||||
if update_handler.fetcher.content:
|
|
||||||
watch.save_last_fetched_html(contents=update_handler.fetcher.content, timestamp=timestamp)
|
|
||||||
|
|
||||||
# A change was detected
|
# A change was detected
|
||||||
if changed_detected:
|
if changed_detected:
|
||||||
# Notifications should only trigger on the second time (first time, we gather the initial snapshot)
|
# Notifications should only trigger on the second time (first time, we gather the initial snapshot)
|
||||||
if watch.history_n >= 2:
|
if watch.history_n >= 2:
|
||||||
logger.info(f"Change detected in UUID {uuid} - {watch['url']}")
|
logger.info(f"Change detected in UUID {uuid} - {watch['url']}")
|
||||||
if not watch.get('notification_muted'):
|
if not self.datastore.data['watching'][uuid].get('notification_muted'):
|
||||||
self.send_content_changed_notification(watch_uuid=uuid)
|
self.send_content_changed_notification(watch_uuid=uuid)
|
||||||
else:
|
else:
|
||||||
logger.info(f"Change triggered in UUID {uuid} due to first history saving (no notifications sent) - {watch['url']}")
|
logger.info(f"Change triggered in UUID {uuid} due to first history saving (no notifications sent) - {watch['url']}")
|
||||||
@@ -545,23 +511,29 @@ class update_worker(threading.Thread):
|
|||||||
logger.critical(str(e))
|
logger.critical(str(e))
|
||||||
self.datastore.update_watch(uuid=uuid, update_obj={'last_error': str(e)})
|
self.datastore.update_watch(uuid=uuid, update_obj={'last_error': str(e)})
|
||||||
|
|
||||||
|
if self.datastore.data['watching'].get(uuid):
|
||||||
|
# Always record that we atleast tried
|
||||||
|
count = self.datastore.data['watching'][uuid].get('check_count', 0) + 1
|
||||||
|
|
||||||
# Always record that we atleast tried
|
# Record the 'server' header reply, can be used for actions in the future like cloudflare/akamai workarounds
|
||||||
count = watch.get('check_count', 0) + 1
|
try:
|
||||||
|
server_header = update_handler.fetcher.headers.get('server', '').strip().lower()[:255]
|
||||||
|
self.datastore.update_watch(uuid=uuid,
|
||||||
|
update_obj={'remote_server_reply': server_header}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
pass
|
||||||
|
|
||||||
# Record the 'server' header reply, can be used for actions in the future like cloudflare/akamai workarounds
|
self.datastore.update_watch(uuid=uuid, update_obj={'fetch_time': round(time.time() - now, 3),
|
||||||
try:
|
'last_checked': round(time.time()),
|
||||||
server_header = update_handler.fetcher.headers.get('server', '').strip().lower()[:255]
|
'check_count': count
|
||||||
self.datastore.update_watch(uuid=uuid,
|
})
|
||||||
update_obj={'remote_server_reply': server_header}
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
pass
|
|
||||||
|
|
||||||
self.datastore.update_watch(uuid=uuid, update_obj={'fetch_time': round(time.time() - now, 3),
|
# Always save the screenshot if it's available
|
||||||
'last_checked': round(time.time()),
|
if update_handler.screenshot:
|
||||||
'check_count': count
|
self.datastore.save_screenshot(watch_uuid=uuid, screenshot=update_handler.screenshot)
|
||||||
})
|
if update_handler.xpath_data:
|
||||||
|
self.datastore.save_xpath_data(watch_uuid=uuid, data=update_handler.xpath_data)
|
||||||
|
|
||||||
|
|
||||||
self.current_uuid = None # Done
|
self.current_uuid = None # Done
|
||||||
|
|||||||
@@ -68,10 +68,9 @@ services:
|
|||||||
|
|
||||||
# If WEBDRIVER or PLAYWRIGHT are enabled, changedetection container depends on that
|
# If WEBDRIVER or PLAYWRIGHT are enabled, changedetection container depends on that
|
||||||
# and must wait before starting (substitute "browser-chrome" with "playwright-chrome" if last one is used)
|
# and must wait before starting (substitute "browser-chrome" with "playwright-chrome" if last one is used)
|
||||||
# depends_on:
|
# depends_on:
|
||||||
# playwright-chrome:
|
# browser-chrome:
|
||||||
# condition: service_started
|
# condition: service_started
|
||||||
|
|
||||||
|
|
||||||
# Used for fetching pages via Playwright+Chrome where you need Javascript support.
|
# Used for fetching pages via Playwright+Chrome where you need Javascript support.
|
||||||
# RECOMMENDED FOR FETCHING PAGES WITH CHROME
|
# RECOMMENDED FOR FETCHING PAGES WITH CHROME
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Used by Pyppeteer
|
# Used by Pyppeteer
|
||||||
pyee
|
pyee
|
||||||
|
|
||||||
eventlet>=0.36.1 # fixes SSL error on Python 3.12
|
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)
|
||||||
@@ -23,13 +23,15 @@ validators~=0.21
|
|||||||
brotli~=1.0
|
brotli~=1.0
|
||||||
requests[socks]
|
requests[socks]
|
||||||
|
|
||||||
urllib3==1.26.19
|
urllib3==1.26.18
|
||||||
chardet>2.3.0
|
chardet>2.3.0
|
||||||
|
|
||||||
wtforms~=3.0
|
wtforms~=3.0
|
||||||
jsonpath-ng~=1.5.3
|
jsonpath-ng~=1.5.3
|
||||||
|
|
||||||
dnspython==2.6.1 # related to eventlet fixes
|
# Pinned: module 'eventlet.green.select' has no attribute 'epoll'
|
||||||
|
# https://github.com/eventlet/eventlet/issues/805#issuecomment-1640463482
|
||||||
|
dnspython==2.3.0 # related to eventlet fixes
|
||||||
|
|
||||||
# jq not available on Windows so must be installed manually
|
# jq not available on Windows so must be installed manually
|
||||||
|
|
||||||
@@ -41,8 +43,10 @@ apprise~=1.8.0
|
|||||||
# use v1.x due to https://github.com/eclipse/paho.mqtt.python/issues/814
|
# use v1.x due to https://github.com/eclipse/paho.mqtt.python/issues/814
|
||||||
paho-mqtt>=1.6.1,<2.0.0
|
paho-mqtt>=1.6.1,<2.0.0
|
||||||
|
|
||||||
# Requires extra wheel for rPi
|
# This mainly affects some ARM builds, which unlike the other builds ignores "ARG CRYPTOGRAPHY_DONT_BUILD_RUST=1"
|
||||||
cryptography~=42.0.8
|
# so without this pinning, the newer versions on ARM will forcefully try to build rust, which results in "rust compiler not found"
|
||||||
|
# (introduced once apprise became a dep)
|
||||||
|
cryptography~=3.4
|
||||||
|
|
||||||
# Used for CSS filtering
|
# Used for CSS filtering
|
||||||
beautifulsoup4
|
beautifulsoup4
|
||||||
@@ -82,5 +86,3 @@ pytest-flask ~=1.2
|
|||||||
jsonschema==4.17.3
|
jsonschema==4.17.3
|
||||||
|
|
||||||
loguru
|
loguru
|
||||||
# Needed for > 3.10, https://github.com/microsoft/playwright-python/issues/2096
|
|
||||||
greenlet >= 3.0.3
|
|
||||||
|
|||||||
Reference in New Issue
Block a user