mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-04-24 03:47:58 +00:00
Compare commits
2 Commits
0.29
...
diff-strea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14d88c249e | ||
|
|
0fa443c3f2 |
@@ -1,2 +0,0 @@
|
|||||||
.git
|
|
||||||
.github
|
|
||||||
9
.github/FUNDING.yml
vendored
9
.github/FUNDING.yml
vendored
@@ -1,3 +1,12 @@
|
|||||||
# These are supported funding model platforms
|
# These are supported funding model platforms
|
||||||
|
|
||||||
github: dgtlmoon
|
github: dgtlmoon
|
||||||
|
patreon: # Replace with a single Patreon username
|
||||||
|
open_collective: # Replace with a single Open Collective username
|
||||||
|
ko_fi: # Replace with a single Ko-fi username
|
||||||
|
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||||
|
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||||
|
liberapay: # Replace with a single Liberapay username
|
||||||
|
issuehunt: # Replace with a single IssueHunt username
|
||||||
|
otechie: # Replace with a single Otechie username
|
||||||
|
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||||
|
|||||||
67
.github/workflows/codeql-analysis.yml
vendored
67
.github/workflows/codeql-analysis.yml
vendored
@@ -1,67 +0,0 @@
|
|||||||
# For most projects, this workflow file will not need changing; you simply need
|
|
||||||
# to commit it to your repository.
|
|
||||||
#
|
|
||||||
# You may wish to alter this file to override the set of languages analyzed,
|
|
||||||
# or to provide custom queries or build logic.
|
|
||||||
#
|
|
||||||
# ******** NOTE ********
|
|
||||||
# We have attempted to detect the languages in your repository. Please check
|
|
||||||
# the `language` matrix defined below to confirm you have the correct set of
|
|
||||||
# supported CodeQL languages.
|
|
||||||
#
|
|
||||||
name: "CodeQL"
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ master ]
|
|
||||||
pull_request:
|
|
||||||
# The branches below must be a subset of the branches above
|
|
||||||
branches: [ master ]
|
|
||||||
schedule:
|
|
||||||
- cron: '27 9 * * 4'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
analyze:
|
|
||||||
name: Analyze
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
language: [ 'javascript', 'python' ]
|
|
||||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
|
|
||||||
# Learn more:
|
|
||||||
# https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v2
|
|
||||||
|
|
||||||
# Initializes the CodeQL tools for scanning.
|
|
||||||
- name: Initialize CodeQL
|
|
||||||
uses: github/codeql-action/init@v1
|
|
||||||
with:
|
|
||||||
languages: ${{ matrix.language }}
|
|
||||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
|
||||||
# By default, queries listed here will override any specified in a config file.
|
|
||||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
|
||||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
|
||||||
|
|
||||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
|
||||||
# If this step fails, then you should remove it and run the build manually (see below)
|
|
||||||
- name: Autobuild
|
|
||||||
uses: github/codeql-action/autobuild@v1
|
|
||||||
|
|
||||||
# ℹ️ Command-line programs to run using the OS shell.
|
|
||||||
# 📚 https://git.io/JvXDl
|
|
||||||
|
|
||||||
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
|
|
||||||
# and modify them (or add more) to build your code if your project
|
|
||||||
# uses a compiled language
|
|
||||||
|
|
||||||
#- run: |
|
|
||||||
# make bootstrap
|
|
||||||
# make release
|
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
|
||||||
uses: github/codeql-action/analyze@v1
|
|
||||||
77
.github/workflows/image.yml
vendored
77
.github/workflows/image.yml
vendored
@@ -1,77 +0,0 @@
|
|||||||
name: Test, build and push to Docker Hub
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ master ]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
|
|
||||||
- uses: actions/checkout@v2
|
|
||||||
- name: Set up Python 3.9
|
|
||||||
uses: actions/setup-python@v2
|
|
||||||
with:
|
|
||||||
python-version: 3.9
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
pip install flake8 pytest
|
|
||||||
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
|
|
||||||
|
|
||||||
- name: Lint with flake8
|
|
||||||
run: |
|
|
||||||
# stop the build if there are Python syntax errors or undefined names
|
|
||||||
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
|
|
||||||
# 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
|
|
||||||
- name: Test with pytest
|
|
||||||
run: |
|
|
||||||
cd backend; pytest
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v1
|
|
||||||
with:
|
|
||||||
image: tonistiigi/binfmt:latest
|
|
||||||
platforms: all
|
|
||||||
- name: Login to Docker Hub
|
|
||||||
uses: docker/login-action@v1
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
id: buildx
|
|
||||||
uses: docker/setup-buildx-action@v1
|
|
||||||
with:
|
|
||||||
install: true
|
|
||||||
version: latest
|
|
||||||
driver-opts: image=moby/buildkit:master
|
|
||||||
- name: Build and push
|
|
||||||
id: docker_build
|
|
||||||
uses: docker/build-push-action@v2
|
|
||||||
with:
|
|
||||||
context: ./
|
|
||||||
file: ./Dockerfile
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
${{ secrets.DOCKER_HUB_USERNAME }}/changedetection.io:latest
|
|
||||||
# ${{ secrets.DOCKER_HUB_USERNAME }}:/changedetection.io:${{ env.RELEASE_VERSION }}
|
|
||||||
platforms: linux/amd64,linux/arm64,linux/arm/v6,linux/arm/v7
|
|
||||||
cache-from: type=local,src=/tmp/.buildx-cache
|
|
||||||
cache-to: type=local,dest=/tmp/.buildx-cache
|
|
||||||
|
|
||||||
- name: Image digest
|
|
||||||
run: echo ${{ steps.docker_build.outputs.digest }}
|
|
||||||
|
|
||||||
- name: Cache Docker layers
|
|
||||||
uses: actions/cache@v2
|
|
||||||
with:
|
|
||||||
path: /tmp/.buildx-cache
|
|
||||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-buildx-
|
|
||||||
|
|
||||||
|
|
||||||
33
.github/workflows/python-app.yml
vendored
Normal file
33
.github/workflows/python-app.yml
vendored
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# This workflow will install Python dependencies, run tests and lint with a single version of Python
|
||||||
|
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
|
||||||
|
|
||||||
|
name: changedetection.io
|
||||||
|
|
||||||
|
|
||||||
|
on: [push, pull_request]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- name: Set up Python 3.9
|
||||||
|
uses: actions/setup-python@v2
|
||||||
|
with:
|
||||||
|
python-version: 3.9
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install flake8 pytest
|
||||||
|
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
|
||||||
|
- name: Lint with flake8
|
||||||
|
run: |
|
||||||
|
# stop the build if there are Python syntax errors or undefined names
|
||||||
|
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
|
||||||
|
# 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
|
||||||
|
- name: Test with pytest
|
||||||
|
run: |
|
||||||
|
cd backend; pytest
|
||||||
35
.github/workflows/test-only.yml
vendored
35
.github/workflows/test-only.yml
vendored
@@ -1,35 +0,0 @@
|
|||||||
name: Test only
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- /refs/heads/*
|
|
||||||
- !master
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
|
|
||||||
- uses: actions/checkout@v2
|
|
||||||
- name: Set up Python 3.9
|
|
||||||
uses: actions/setup-python@v2
|
|
||||||
with:
|
|
||||||
python-version: 3.9
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
pip install flake8 pytest
|
|
||||||
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
|
|
||||||
|
|
||||||
- name: Lint with flake8
|
|
||||||
run: |
|
|
||||||
# stop the build if there are Python syntax errors or undefined names
|
|
||||||
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
|
|
||||||
# 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
|
|
||||||
- name: Test with pytest
|
|
||||||
run: |
|
|
||||||
cd backend; pytest
|
|
||||||
|
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
FROM python:3.8-slim
|
FROM python:3.8-slim
|
||||||
COPY requirements.txt /tmp/requirements.txt
|
COPY requirements.txt /tmp/requirements.txt
|
||||||
RUN apt-get update && apt-get install -y gcc libc-dev libxslt-dev zlib1g-dev g++ --no-install-recommends && rm -rf /var/lib/apt/lists/* /var/cache/apt/*
|
RUN pip3 install -r /tmp/requirements.txt
|
||||||
|
|
||||||
RUN pip3 install --no-cache-dir -r /tmp/requirements.txt
|
|
||||||
|
|
||||||
|
|
||||||
RUN [ ! -d "/app" ] && mkdir /app
|
RUN [ ! -d "/app" ] && mkdir /app
|
||||||
|
|||||||
19
README.md
19
README.md
@@ -1,21 +1,19 @@
|
|||||||
# changedetection.io
|
# changedetection.io
|
||||||

|

|
||||||
<a href="https://hub.docker.com/r/dgtlmoon/changedetection.io" target="_blank" title="Change detection docker hub">
|
<a href="https://hub.docker.com/r/dgtlmoon/changedetection.io" target="_blank" title="Change detection docker hub">
|
||||||
<img src="https://img.shields.io/docker/pulls/dgtlmoon/changedetection.io" alt="Docker Pulls"/>
|
<img src="https://img.shields.io/docker/pulls/dgtlmoon/changedetection.io" alt="Docker Pulls"/>
|
||||||
</a>
|
</a>
|
||||||
<a href="https://hub.docker.com/r/dgtlmoon/changedetection.io" target="_blank" title="Change detection docker hub">
|
<a href="https://hub.docker.com/r/dgtlmoon/changedetection.io" target="_blank" title="Change detection docker hub">
|
||||||
<img src="https://img.shields.io/github/v/release/dgtlmoon/changedetection.io" alt="Change detection latest tag version"/>
|
<img src="https://img.shields.io/docker/v/dgtlmoon/changedetection.io/0.27" alt="Change detection latest tag version"/>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
## Self-hosted change monitoring of web pages.
|
## Self-hosted change monitoring of web pages.
|
||||||
|
|
||||||
_Know when web pages change! Stay ontop of new information!_
|
_Know when web pages change! Stay ontop of new information!_
|
||||||
|
|
||||||
Live your data-life *pro-actively* instead of *re-actively*, do not rely on manipulative social media for consuming important information.
|

|
||||||
|
|
||||||
|
|
||||||
<img src="https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/screenshot.png" style="max-width:100%;" alt="Self-hosted web page change monitoring" title="Self-hosted web page change monitoring" />
|
|
||||||
|
|
||||||
#### Example use cases
|
#### Example use cases
|
||||||
|
|
||||||
Know when ...
|
Know when ...
|
||||||
@@ -50,7 +48,16 @@ docker run -d --restart always -p "127.0.0.1:5000:5000" -v datastore-volume:/dat
|
|||||||
|
|
||||||
Examining differences in content.
|
Examining differences in content.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Future plans
|
||||||
|
|
||||||
|
- Greater configuration of check interval times, page request headers.
|
||||||
|
- ~~General options for timeout, default headers~~
|
||||||
|
- On change detection, callout to another API (handy for notices/issue trackers)
|
||||||
|
- ~~Explore the differences that were detected~~
|
||||||
|
- Add more options to explore versions of differences
|
||||||
|
- Use a graphic/rendered page difference instead of text (see the experimental `selenium-screenshot-diff` branch)
|
||||||
|
|
||||||
<img src="https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/screenshot-diff.png" style="max-width:100%;" alt="Self-hosted web page change monitoring context difference " title="Self-hosted web page change monitoring context difference " />
|
|
||||||
|
|
||||||
Please :star: star :star: this project and help it grow! https://github.com/dgtlmoon/changedetection.io/
|
Please :star: star :star: this project and help it grow! https://github.com/dgtlmoon/changedetection.io/
|
||||||
|
|||||||
@@ -15,15 +15,13 @@
|
|||||||
import time
|
import time
|
||||||
import os
|
import os
|
||||||
import timeago
|
import timeago
|
||||||
import flask_login
|
|
||||||
from flask_login import login_required
|
|
||||||
|
|
||||||
import threading
|
import threading
|
||||||
from threading import Event
|
from threading import Event
|
||||||
|
|
||||||
import queue
|
import queue
|
||||||
|
|
||||||
from flask import Flask, render_template, request, send_from_directory, abort, redirect, url_for
|
from flask import Flask, render_template, request, send_file, send_from_directory, abort, redirect, url_for
|
||||||
|
|
||||||
from feedgen.feed import FeedGenerator
|
from feedgen.feed import FeedGenerator
|
||||||
from flask import make_response
|
from flask import make_response
|
||||||
@@ -50,8 +48,6 @@ app.config.exit = Event()
|
|||||||
|
|
||||||
app.config['NEW_VERSION_AVAILABLE'] = False
|
app.config['NEW_VERSION_AVAILABLE'] = False
|
||||||
|
|
||||||
app.config['LOGIN_DISABLED'] = False
|
|
||||||
|
|
||||||
# Disables caching of the templates
|
# Disables caching of the templates
|
||||||
app.config['TEMPLATES_AUTO_RELOAD'] = True
|
app.config['TEMPLATES_AUTO_RELOAD'] = True
|
||||||
|
|
||||||
@@ -84,127 +80,27 @@ def _jinja2_filter_datetimestamp(timestamp, format="%Y-%m-%d %H:%M:%S"):
|
|||||||
# return datetime.datetime.utcfromtimestamp(timestamp).strftime(format)
|
# return datetime.datetime.utcfromtimestamp(timestamp).strftime(format)
|
||||||
|
|
||||||
|
|
||||||
class User(flask_login.UserMixin):
|
def changedetection_app(config=None, datastore_o=None):
|
||||||
id=None
|
|
||||||
|
|
||||||
def set_password(self, password):
|
|
||||||
return True
|
|
||||||
def get_user(self, email="defaultuser@changedetection.io"):
|
|
||||||
return self
|
|
||||||
def is_authenticated(self):
|
|
||||||
return True
|
|
||||||
def is_active(self):
|
|
||||||
return True
|
|
||||||
def is_anonymous(self):
|
|
||||||
return False
|
|
||||||
def get_id(self):
|
|
||||||
return str(self.id)
|
|
||||||
|
|
||||||
def check_password(self, password):
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import base64
|
|
||||||
|
|
||||||
# Getting the values back out
|
|
||||||
raw_salt_pass = base64.b64decode(datastore.data['settings']['application']['password'])
|
|
||||||
salt_from_storage = raw_salt_pass[:32] # 32 is the length of the salt
|
|
||||||
|
|
||||||
# Use the exact same setup you used to generate the key, but this time put in the password to check
|
|
||||||
new_key = hashlib.pbkdf2_hmac(
|
|
||||||
'sha256',
|
|
||||||
password.encode('utf-8'), # Convert the password to bytes
|
|
||||||
salt_from_storage,
|
|
||||||
100000
|
|
||||||
)
|
|
||||||
new_key = salt_from_storage + new_key
|
|
||||||
|
|
||||||
return new_key == raw_salt_pass
|
|
||||||
|
|
||||||
pass
|
|
||||||
|
|
||||||
def changedetection_app(conig=None, datastore_o=None):
|
|
||||||
global datastore
|
global datastore
|
||||||
datastore = datastore_o
|
datastore = datastore_o
|
||||||
|
|
||||||
app.config.update(dict(DEBUG=True))
|
app.config.update(dict(DEBUG=True))
|
||||||
#app.config.update(config or {})
|
app.config.update(config or {})
|
||||||
|
|
||||||
login_manager = flask_login.LoginManager(app)
|
|
||||||
login_manager.login_view = 'login'
|
|
||||||
|
|
||||||
|
|
||||||
# Setup cors headers to allow all domains
|
# Setup cors headers to allow all domains
|
||||||
# https://flask-cors.readthedocs.io/en/latest/
|
# https://flask-cors.readthedocs.io/en/latest/
|
||||||
# CORS(app)
|
# CORS(app)
|
||||||
|
|
||||||
@login_manager.user_loader
|
|
||||||
def user_loader(email):
|
|
||||||
user = User()
|
|
||||||
user.get_user(email)
|
|
||||||
return user
|
|
||||||
|
|
||||||
@login_manager.unauthorized_handler
|
|
||||||
def unauthorized_handler():
|
|
||||||
# @todo validate its a URL of this host and use that
|
|
||||||
return redirect(url_for('login', next=url_for('index')))
|
|
||||||
|
|
||||||
@app.route('/logout')
|
|
||||||
def logout():
|
|
||||||
flask_login.logout_user()
|
|
||||||
return redirect(url_for('index'))
|
|
||||||
|
|
||||||
# https://github.com/pallets/flask/blob/93dd1709d05a1cf0e886df6223377bdab3b077fb/examples/tutorial/flaskr/__init__.py#L39
|
# https://github.com/pallets/flask/blob/93dd1709d05a1cf0e886df6223377bdab3b077fb/examples/tutorial/flaskr/__init__.py#L39
|
||||||
# You can divide up the stuff like this
|
# You can divide up the stuff like this
|
||||||
@app.route('/login', methods=['GET', 'POST'])
|
|
||||||
def login():
|
|
||||||
|
|
||||||
global messages
|
|
||||||
|
|
||||||
if request.method == 'GET':
|
|
||||||
output = render_template("login.html", messages=messages)
|
|
||||||
# Show messages but once.
|
|
||||||
messages = []
|
|
||||||
return output
|
|
||||||
|
|
||||||
user = User()
|
|
||||||
user.id = "defaultuser@changedetection.io"
|
|
||||||
|
|
||||||
password = request.form.get('password')
|
|
||||||
|
|
||||||
if (user.check_password(password)):
|
|
||||||
flask_login.login_user(user, remember=True)
|
|
||||||
next = request.args.get('next')
|
|
||||||
# if not is_safe_url(next):
|
|
||||||
# return flask.abort(400)
|
|
||||||
return redirect(next or url_for('index'))
|
|
||||||
|
|
||||||
else:
|
|
||||||
messages.append({'class': 'error', 'message': 'Incorrect password'})
|
|
||||||
|
|
||||||
return redirect(url_for('login'))
|
|
||||||
|
|
||||||
@app.before_request
|
|
||||||
def do_something_whenever_a_request_comes_in():
|
|
||||||
# Disable password loginif there is not one set
|
|
||||||
app.config['LOGIN_DISABLED'] = datastore.data['settings']['application']['password'] == False
|
|
||||||
|
|
||||||
@app.route("/", methods=['GET'])
|
@app.route("/", methods=['GET'])
|
||||||
@login_required
|
|
||||||
def index():
|
def index():
|
||||||
global messages
|
global messages
|
||||||
|
|
||||||
limit_tag = request.args.get('tag')
|
limit_tag = request.args.get('tag')
|
||||||
|
rss = request.args.get('rss')
|
||||||
pause_uuid = request.args.get('pause')
|
mode = request.args.get('mode')
|
||||||
|
|
||||||
if pause_uuid:
|
|
||||||
try:
|
|
||||||
datastore.data['watching'][pause_uuid]['paused'] ^= True
|
|
||||||
datastore.needs_write = True
|
|
||||||
|
|
||||||
return redirect(url_for('index', tag = limit_tag))
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# Sort by last_changed and add the uuid which is usually the key..
|
# Sort by last_changed and add the uuid which is usually the key..
|
||||||
sorted_watches = []
|
sorted_watches = []
|
||||||
@@ -225,7 +121,63 @@ def changedetection_app(conig=None, datastore_o=None):
|
|||||||
sorted_watches.sort(key=lambda x: x['last_changed'], reverse=True)
|
sorted_watches.sort(key=lambda x: x['last_changed'], reverse=True)
|
||||||
|
|
||||||
existing_tags = datastore.get_all_tags()
|
existing_tags = datastore.get_all_tags()
|
||||||
rss = request.args.get('rss')
|
|
||||||
|
if mode == 'stream':
|
||||||
|
import difflib
|
||||||
|
|
||||||
|
import pprint
|
||||||
|
streams = []
|
||||||
|
|
||||||
|
extra_stylesheets = ['/static/css/diff.css']
|
||||||
|
for watch in sorted_watches:
|
||||||
|
if not watch['viewed']:
|
||||||
|
|
||||||
|
# get last two date keys
|
||||||
|
dates = list(watch['history'].keys())
|
||||||
|
# Convert to int, sort and back to str again
|
||||||
|
dates = [int(i) for i in dates]
|
||||||
|
dates.sort(reverse=True)
|
||||||
|
dates = [str(i) for i in dates]
|
||||||
|
print ("OK", watch['uuid'])
|
||||||
|
|
||||||
|
if len(dates) < 2:
|
||||||
|
print ("Skipping", watch['url'])
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
path = datastore.data['watching'][watch['uuid']]['history'][str(dates[1])]
|
||||||
|
with open(path,
|
||||||
|
encoding='utf-8') as file:
|
||||||
|
txt1=[line.rstrip() for line in file.readlines()]
|
||||||
|
|
||||||
|
path = datastore.data['watching'][watch['uuid']]['history'][str(dates[0])]
|
||||||
|
with open(path,
|
||||||
|
encoding='utf-8') as file:
|
||||||
|
txt2 = [line.rstrip() for line in file.readlines()]
|
||||||
|
except FileNotFoundError:
|
||||||
|
print ("Skipping", watch['url'])
|
||||||
|
continue
|
||||||
|
|
||||||
|
df = list(difflib.unified_diff(txt1, txt2,n=1))
|
||||||
|
diff_entry=[]
|
||||||
|
for line in df:
|
||||||
|
if line[0] == '-' or line[0] == '+':
|
||||||
|
diff_entry.append(line)
|
||||||
|
|
||||||
|
|
||||||
|
# pprint(df)
|
||||||
|
#s = pprint.pformat(df)
|
||||||
|
streams.append(diff_entry)
|
||||||
|
|
||||||
|
|
||||||
|
print ("###########", len(streams))
|
||||||
|
|
||||||
|
output = render_template("watch-diff-stream.html",
|
||||||
|
streams=streams,
|
||||||
|
extra_stylesheets=extra_stylesheets
|
||||||
|
)
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
if rss:
|
if rss:
|
||||||
fg = FeedGenerator()
|
fg = FeedGenerator()
|
||||||
@@ -249,7 +201,8 @@ def changedetection_app(conig=None, datastore_o=None):
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
else:
|
else:
|
||||||
output = render_template("watch-overview.html",
|
#table = render_template('watch-table.html', watches=sorted_watches)
|
||||||
|
output = render_template("watch-table.html",
|
||||||
watches=sorted_watches,
|
watches=sorted_watches,
|
||||||
messages=messages,
|
messages=messages,
|
||||||
tags=existing_tags,
|
tags=existing_tags,
|
||||||
@@ -262,7 +215,6 @@ def changedetection_app(conig=None, datastore_o=None):
|
|||||||
return output
|
return output
|
||||||
|
|
||||||
@app.route("/scrub", methods=['GET', 'POST'])
|
@app.route("/scrub", methods=['GET', 'POST'])
|
||||||
@login_required
|
|
||||||
def scrub_page():
|
def scrub_page():
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -270,16 +222,19 @@ def changedetection_app(conig=None, datastore_o=None):
|
|||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
confirmtext = request.form.get('confirmtext')
|
confirmtext = request.form.get('confirmtext')
|
||||||
limit_timestamp = int(request.form.get('limit_date'))
|
|
||||||
|
|
||||||
if confirmtext == 'scrub':
|
if confirmtext == 'scrub':
|
||||||
|
|
||||||
for uuid, watch in datastore.data['watching'].items():
|
for txt_file_path in Path(app.config['datastore_path']).rglob('*.txt'):
|
||||||
if len(str(limit_timestamp)) == 10:
|
os.unlink(txt_file_path)
|
||||||
datastore.scrub_watch(uuid, limit_timestamp = limit_timestamp)
|
|
||||||
else:
|
|
||||||
datastore.scrub_watch(uuid)
|
|
||||||
|
|
||||||
|
for uuid, watch in datastore.data['watching'].items():
|
||||||
|
watch['last_checked'] = 0
|
||||||
|
watch['last_changed'] = 0
|
||||||
|
watch['previous_md5'] = None
|
||||||
|
watch['history'] = {}
|
||||||
|
|
||||||
|
datastore.needs_write = True
|
||||||
messages.append({'class': 'ok', 'message': 'Cleaned all version history.'})
|
messages.append({'class': 'ok', 'message': 'Cleaned all version history.'})
|
||||||
else:
|
else:
|
||||||
messages.append({'class': 'error', 'message': 'Wrong confirm text.'})
|
messages.append({'class': 'error', 'message': 'Wrong confirm text.'})
|
||||||
@@ -317,7 +272,6 @@ def changedetection_app(conig=None, datastore_o=None):
|
|||||||
return datastore.data['watching'][uuid]['previous_md5']
|
return datastore.data['watching'][uuid]['previous_md5']
|
||||||
|
|
||||||
@app.route("/edit/<string:uuid>", methods=['GET', 'POST'])
|
@app.route("/edit/<string:uuid>", methods=['GET', 'POST'])
|
||||||
@login_required
|
|
||||||
def edit_page(uuid):
|
def edit_page(uuid):
|
||||||
global messages
|
global messages
|
||||||
import validators
|
import validators
|
||||||
@@ -375,38 +329,9 @@ def changedetection_app(conig=None, datastore_o=None):
|
|||||||
return output
|
return output
|
||||||
|
|
||||||
@app.route("/settings", methods=['GET', "POST"])
|
@app.route("/settings", methods=['GET', "POST"])
|
||||||
@login_required
|
|
||||||
def settings_page():
|
def settings_page():
|
||||||
global messages
|
global messages
|
||||||
|
|
||||||
if request.method == 'GET':
|
|
||||||
if request.values.get('removepassword'):
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
datastore.data['settings']['application']['password'] = False
|
|
||||||
messages.append({'class': 'notice', 'message': "Password protection removed."})
|
|
||||||
flask_login.logout_user()
|
|
||||||
|
|
||||||
return redirect(url_for('settings_page'))
|
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
|
|
||||||
password = request.values.get('password')
|
|
||||||
if password:
|
|
||||||
import hashlib
|
|
||||||
import base64
|
|
||||||
import secrets
|
|
||||||
|
|
||||||
# Make a new salt on every new password and store it with the password
|
|
||||||
salt = secrets.token_bytes(32)
|
|
||||||
|
|
||||||
key = hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), salt, 100000)
|
|
||||||
store = base64.b64encode(salt + key).decode('ascii')
|
|
||||||
datastore.data['settings']['application']['password'] = store
|
|
||||||
messages.append({'class': 'notice', 'message': "Password protection enabled."})
|
|
||||||
flask_login.logout_user()
|
|
||||||
return redirect(url_for('index'))
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
minutes = int(request.values.get('minutes').strip())
|
minutes = int(request.values.get('minutes').strip())
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -422,8 +347,6 @@ def changedetection_app(conig=None, datastore_o=None):
|
|||||||
messages.append(
|
messages.append(
|
||||||
{'class': 'error', 'message': "Must be atleast 5 minutes."})
|
{'class': 'error', 'message': "Must be atleast 5 minutes."})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
output = render_template("settings.html", messages=messages,
|
output = render_template("settings.html", messages=messages,
|
||||||
minutes=datastore.data['settings']['requests']['minutes_between_check'])
|
minutes=datastore.data['settings']['requests']['minutes_between_check'])
|
||||||
messages = []
|
messages = []
|
||||||
@@ -431,7 +354,6 @@ def changedetection_app(conig=None, datastore_o=None):
|
|||||||
return output
|
return output
|
||||||
|
|
||||||
@app.route("/import", methods=['GET', "POST"])
|
@app.route("/import", methods=['GET', "POST"])
|
||||||
@login_required
|
|
||||||
def import_page():
|
def import_page():
|
||||||
import validators
|
import validators
|
||||||
global messages
|
global messages
|
||||||
@@ -469,7 +391,6 @@ def changedetection_app(conig=None, datastore_o=None):
|
|||||||
|
|
||||||
# Clear all statuses, so we do not see the 'unviewed' class
|
# Clear all statuses, so we do not see the 'unviewed' class
|
||||||
@app.route("/api/mark-all-viewed", methods=['GET'])
|
@app.route("/api/mark-all-viewed", methods=['GET'])
|
||||||
@login_required
|
|
||||||
def mark_all_viewed():
|
def mark_all_viewed():
|
||||||
|
|
||||||
# Save the current newest history as the most recently viewed
|
# Save the current newest history as the most recently viewed
|
||||||
@@ -480,7 +401,6 @@ def changedetection_app(conig=None, datastore_o=None):
|
|||||||
return redirect(url_for('index'))
|
return redirect(url_for('index'))
|
||||||
|
|
||||||
@app.route("/diff/<string:uuid>", methods=['GET'])
|
@app.route("/diff/<string:uuid>", methods=['GET'])
|
||||||
@login_required
|
|
||||||
def diff_history_page(uuid):
|
def diff_history_page(uuid):
|
||||||
global messages
|
global messages
|
||||||
|
|
||||||
@@ -536,40 +456,13 @@ def changedetection_app(conig=None, datastore_o=None):
|
|||||||
|
|
||||||
return output
|
return output
|
||||||
|
|
||||||
@app.route("/preview/<string:uuid>", methods=['GET'])
|
|
||||||
@login_required
|
|
||||||
def preview_page(uuid):
|
|
||||||
global messages
|
|
||||||
|
|
||||||
# More for testing, possible to return the first/only
|
|
||||||
if uuid == 'first':
|
|
||||||
uuid = list(datastore.data['watching'].keys()).pop()
|
|
||||||
|
|
||||||
extra_stylesheets = ['/static/css/diff.css']
|
|
||||||
|
|
||||||
try:
|
|
||||||
watch = datastore.data['watching'][uuid]
|
|
||||||
except KeyError:
|
|
||||||
messages.append({'class': 'error', 'message': "No history found for the specified link, bad link?"})
|
|
||||||
return redirect(url_for('index'))
|
|
||||||
|
|
||||||
print(watch)
|
|
||||||
with open(list(watch['history'].values())[-1], 'r') as f:
|
|
||||||
content = f.readlines()
|
|
||||||
|
|
||||||
output = render_template("preview.html", content=content, extra_stylesheets=extra_stylesheets)
|
|
||||||
return output
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/favicon.ico", methods=['GET'])
|
@app.route("/favicon.ico", methods=['GET'])
|
||||||
def favicon():
|
def favicon():
|
||||||
return send_from_directory("/app/static/images", filename="favicon.ico")
|
return send_from_directory("/app/static/images", filename="favicon.ico")
|
||||||
|
|
||||||
# We're good but backups are even better!
|
# We're good but backups are even better!
|
||||||
@app.route("/backup", methods=['GET'])
|
@app.route("/backup", methods=['GET'])
|
||||||
@login_required
|
|
||||||
def get_backup():
|
def get_backup():
|
||||||
|
|
||||||
import zipfile
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -578,31 +471,26 @@ def changedetection_app(conig=None, datastore_o=None):
|
|||||||
|
|
||||||
# We only care about UUIDS from the current index file
|
# We only care about UUIDS from the current index file
|
||||||
uuids = list(datastore.data['watching'].keys())
|
uuids = list(datastore.data['watching'].keys())
|
||||||
backup_filepath = os.path.join(app.config['datastore_path'], backupname)
|
|
||||||
|
|
||||||
with zipfile.ZipFile(backup_filepath, "w",
|
with zipfile.ZipFile(os.path.join(app.config['datastore_path'], backupname), 'w',
|
||||||
compression=zipfile.ZIP_DEFLATED,
|
compression=zipfile.ZIP_DEFLATED,
|
||||||
compresslevel=8) as zipObj:
|
compresslevel=6) as zipObj:
|
||||||
|
|
||||||
# Be sure we're written fresh
|
# Be sure we're written fresh
|
||||||
datastore.sync_to_json()
|
datastore.sync_to_json()
|
||||||
|
|
||||||
# Add the index
|
# Add the index
|
||||||
zipObj.write(os.path.join(app.config['datastore_path'], "url-watches.json"), arcname="url-watches.json")
|
zipObj.write(os.path.join(app.config['datastore_path'], "url-watches.json"))
|
||||||
|
# Add any snapshot data we find
|
||||||
# Add the flask app secret
|
|
||||||
zipObj.write(os.path.join(app.config['datastore_path'], "secret.txt"), arcname="secret.txt")
|
|
||||||
|
|
||||||
# Add any snapshot data we find, use the full path to access the file, but make the file 'relative' in the Zip.
|
|
||||||
for txt_file_path in Path(app.config['datastore_path']).rglob('*.txt'):
|
for txt_file_path in Path(app.config['datastore_path']).rglob('*.txt'):
|
||||||
parent_p = txt_file_path.parent
|
parent_p = txt_file_path.parent
|
||||||
if parent_p.name in uuids:
|
if parent_p.name in uuids:
|
||||||
zipObj.write(txt_file_path,
|
zipObj.write(txt_file_path)
|
||||||
arcname=str(txt_file_path).replace(app.config['datastore_path'], ''),
|
|
||||||
compress_type=zipfile.ZIP_DEFLATED,
|
|
||||||
compresslevel=8)
|
|
||||||
|
|
||||||
return send_from_directory(app.config['datastore_path'], backupname)
|
return send_file(os.path.join(app.config['datastore_path'], backupname),
|
||||||
|
as_attachment=True,
|
||||||
|
mimetype="application/zip",
|
||||||
|
attachment_filename=backupname)
|
||||||
|
|
||||||
@app.route("/static/<string:group>/<string:filename>", methods=['GET'])
|
@app.route("/static/<string:group>/<string:filename>", methods=['GET'])
|
||||||
def static_content(group, filename):
|
def static_content(group, filename):
|
||||||
@@ -616,17 +504,11 @@ def changedetection_app(conig=None, datastore_o=None):
|
|||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
@app.route("/api/add", methods=['POST'])
|
@app.route("/api/add", methods=['POST'])
|
||||||
@login_required
|
|
||||||
def api_watch_add():
|
def api_watch_add():
|
||||||
global messages
|
global messages
|
||||||
|
|
||||||
url = request.form.get('url').strip()
|
|
||||||
if datastore.url_exists(url):
|
|
||||||
messages.append({'class': 'error', 'message': 'The URL {} already exists'.format(url)})
|
|
||||||
return redirect(url_for('index'))
|
|
||||||
|
|
||||||
# @todo add_watch should throw a custom Exception for validation etc
|
# @todo add_watch should throw a custom Exception for validation etc
|
||||||
new_uuid = datastore.add_watch(url=url, tag=request.form.get('tag').strip())
|
new_uuid = datastore.add_watch(url=request.form.get('url').strip(), tag=request.form.get('tag').strip())
|
||||||
# Straight into the queue.
|
# Straight into the queue.
|
||||||
update_q.put(new_uuid)
|
update_q.put(new_uuid)
|
||||||
|
|
||||||
@@ -634,7 +516,6 @@ def changedetection_app(conig=None, datastore_o=None):
|
|||||||
return redirect(url_for('index'))
|
return redirect(url_for('index'))
|
||||||
|
|
||||||
@app.route("/api/delete", methods=['GET'])
|
@app.route("/api/delete", methods=['GET'])
|
||||||
@login_required
|
|
||||||
def api_delete():
|
def api_delete():
|
||||||
global messages
|
global messages
|
||||||
uuid = request.args.get('uuid')
|
uuid = request.args.get('uuid')
|
||||||
@@ -644,7 +525,6 @@ def changedetection_app(conig=None, datastore_o=None):
|
|||||||
return redirect(url_for('index'))
|
return redirect(url_for('index'))
|
||||||
|
|
||||||
@app.route("/api/checknow", methods=['GET'])
|
@app.route("/api/checknow", methods=['GET'])
|
||||||
@login_required
|
|
||||||
def api_watch_checknow():
|
def api_watch_checknow():
|
||||||
|
|
||||||
global messages
|
global messages
|
||||||
@@ -668,17 +548,15 @@ def changedetection_app(conig=None, datastore_o=None):
|
|||||||
# Items that have this current tag
|
# Items that have this current tag
|
||||||
for watch_uuid, watch in datastore.data['watching'].items():
|
for watch_uuid, watch in datastore.data['watching'].items():
|
||||||
if (tag != None and tag in watch['tag']):
|
if (tag != None and tag in watch['tag']):
|
||||||
if watch_uuid not in running_uuids and not datastore.data['watching'][watch_uuid]['paused']:
|
i += 1
|
||||||
|
if watch_uuid not in running_uuids:
|
||||||
update_q.put(watch_uuid)
|
update_q.put(watch_uuid)
|
||||||
i += 1
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# No tag, no uuid, add everything.
|
# No tag, no uuid, add everything.
|
||||||
for watch_uuid, watch in datastore.data['watching'].items():
|
for watch_uuid, watch in datastore.data['watching'].items():
|
||||||
|
i += 1
|
||||||
if watch_uuid not in running_uuids and not datastore.data['watching'][watch_uuid]['paused']:
|
if watch_uuid not in running_uuids:
|
||||||
update_q.put(watch_uuid)
|
update_q.put(watch_uuid)
|
||||||
i += 1
|
|
||||||
|
|
||||||
messages.append({'class': 'ok', 'message': "{} watches are rechecking.".format(i)})
|
messages.append({'class': 'ok', 'message': "{} watches are rechecking.".format(i)})
|
||||||
return redirect(url_for('index', tag=tag))
|
return redirect(url_for('index', tag=tag))
|
||||||
@@ -742,6 +620,7 @@ class Worker(threading.Thread):
|
|||||||
self.current_uuid = uuid
|
self.current_uuid = uuid
|
||||||
|
|
||||||
if uuid in list(datastore.data['watching'].keys()):
|
if uuid in list(datastore.data['watching'].keys()):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
changed_detected, result, contents = update_handler.run(uuid)
|
changed_detected, result, contents = update_handler.run(uuid)
|
||||||
|
|
||||||
@@ -749,6 +628,7 @@ class Worker(threading.Thread):
|
|||||||
app.logger.error("File permission error updating", uuid, str(s))
|
app.logger.error("File permission error updating", uuid, str(s))
|
||||||
else:
|
else:
|
||||||
if result:
|
if result:
|
||||||
|
|
||||||
datastore.update_watch(uuid=uuid, update_obj=result)
|
datastore.update_watch(uuid=uuid, update_obj=result)
|
||||||
if changed_detected:
|
if changed_detected:
|
||||||
# A change was detected
|
# A change was detected
|
||||||
@@ -769,11 +649,9 @@ def ticker_thread_check_time_launch_checks():
|
|||||||
new_worker.start()
|
new_worker.start()
|
||||||
|
|
||||||
while not app.config.exit.is_set():
|
while not app.config.exit.is_set():
|
||||||
|
|
||||||
running_uuids = []
|
running_uuids = []
|
||||||
for t in running_update_threads:
|
for t in running_update_threads:
|
||||||
if t.current_uuid:
|
running_uuids.append(t.current_uuid)
|
||||||
running_uuids.append(t.current_uuid)
|
|
||||||
|
|
||||||
# Look at the dataset, find a stale watch to process
|
# Look at the dataset, find a stale watch to process
|
||||||
|
|
||||||
@@ -782,7 +660,7 @@ def ticker_thread_check_time_launch_checks():
|
|||||||
|
|
||||||
threshold = time.time() - (minutes * 60)
|
threshold = time.time() - (minutes * 60)
|
||||||
for uuid, watch in datastore.data['watching'].items():
|
for uuid, watch in datastore.data['watching'].items():
|
||||||
if not watch['paused'] and watch['last_checked'] <= threshold:
|
if watch['last_checked'] <= threshold:
|
||||||
if not uuid in running_uuids and uuid not in update_q.queue:
|
if not uuid in running_uuids and uuid not in update_q.queue:
|
||||||
update_q.put(uuid)
|
update_q.put(uuid)
|
||||||
|
|
||||||
|
|||||||
@@ -267,11 +267,7 @@ footer {
|
|||||||
color: #e07171;
|
color: #e07171;
|
||||||
}
|
}
|
||||||
|
|
||||||
.paused-state.state-False img {
|
#diff-stream {
|
||||||
opacity: 0.2;
|
font-size: 10px;
|
||||||
}
|
white-space: pre-wrap;
|
||||||
|
|
||||||
|
|
||||||
.paused-state.state-False:hover img{
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
}
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
||||||
<svg
|
|
||||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
|
||||||
xmlns:cc="http://creativecommons.org/ns#"
|
|
||||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
|
||||||
xmlns:svg="http://www.w3.org/2000/svg"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
version="1.1"
|
|
||||||
id="Capa_1"
|
|
||||||
x="0px"
|
|
||||||
y="0px"
|
|
||||||
viewBox="0 0 15 14.998326"
|
|
||||||
xml:space="preserve"
|
|
||||||
width="15"
|
|
||||||
height="14.998326"><metadata
|
|
||||||
id="metadata39"><rdf:RDF><cc:Work
|
|
||||||
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
|
||||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
|
|
||||||
id="defs37" />
|
|
||||||
<path
|
|
||||||
id="path2"
|
|
||||||
style="fill:#1b98f8;fill-opacity:1;stroke-width:0.0292893"
|
|
||||||
d="M 7.4975161,6.5052867e-4 C 4.549072,-0.04028702 1.7055675,1.8548221 0.58868606,4.5801341 -0.57739762,7.2574642 0.02596981,10.583326 2.069916,12.671949 4.0364753,14.788409 7.2763651,15.56067 9.989207,14.57284 12.801145,13.617602 14.87442,10.855325 14.985833,7.8845744 15.172496,4.9966544 13.49856,2.1100704 10.911002,0.8209349 9.8598067,0.28073592 8.6791261,-0.00114855 7.4975161,6.5052867e-4 Z M 6.5602569,10.251923 c -0.00509,0.507593 -0.5693885,0.488472 -0.9352002,0.468629 -0.3399386,0.0018 -0.8402048,0.07132 -0.9297965,-0.374189 -0.015842,-1.8973128 -0.015872,-3.7979649 0,-5.6952784 0.1334405,-0.5224315 0.7416869,-0.3424086 1.1377562,-0.374189 0.3969969,-0.084515 0.8245634,0.1963256 0.7272405,0.6382917 0,1.7789118 0,3.5578239 0,5.3367357 z m 3.7490371,0 c -0.0051,0.507593 -0.5693888,0.488472 -0.9352005,0.468629 -0.3399386,0.0018 -0.8402048,0.07132 -0.9297965,-0.374189 -0.015842,-1.8973128 -0.015872,-3.7979649 0,-5.6952784 0.1334405,-0.5224315 0.7416869,-0.3424086 1.1377562,-0.374189 0.3969969,-0.084515 0.8245638,0.1963256 0.7272408,0.6382917 0,1.7789118 0,3.5578239 0,5.3367357 z" />
|
|
||||||
<g
|
|
||||||
id="g4"
|
|
||||||
transform="translate(-0.01903604,0.02221043)">
|
|
||||||
</g>
|
|
||||||
<g
|
|
||||||
id="g6"
|
|
||||||
transform="translate(-0.01903604,0.02221043)">
|
|
||||||
</g>
|
|
||||||
<g
|
|
||||||
id="g8"
|
|
||||||
transform="translate(-0.01903604,0.02221043)">
|
|
||||||
</g>
|
|
||||||
<g
|
|
||||||
id="g10"
|
|
||||||
transform="translate(-0.01903604,0.02221043)">
|
|
||||||
</g>
|
|
||||||
<g
|
|
||||||
id="g12"
|
|
||||||
transform="translate(-0.01903604,0.02221043)">
|
|
||||||
</g>
|
|
||||||
<g
|
|
||||||
id="g14"
|
|
||||||
transform="translate(-0.01903604,0.02221043)">
|
|
||||||
</g>
|
|
||||||
<g
|
|
||||||
id="g16"
|
|
||||||
transform="translate(-0.01903604,0.02221043)">
|
|
||||||
</g>
|
|
||||||
<g
|
|
||||||
id="g18"
|
|
||||||
transform="translate(-0.01903604,0.02221043)">
|
|
||||||
</g>
|
|
||||||
<g
|
|
||||||
id="g20"
|
|
||||||
transform="translate(-0.01903604,0.02221043)">
|
|
||||||
</g>
|
|
||||||
<g
|
|
||||||
id="g22"
|
|
||||||
transform="translate(-0.01903604,0.02221043)">
|
|
||||||
</g>
|
|
||||||
<g
|
|
||||||
id="g24"
|
|
||||||
transform="translate(-0.01903604,0.02221043)">
|
|
||||||
</g>
|
|
||||||
<g
|
|
||||||
id="g26"
|
|
||||||
transform="translate(-0.01903604,0.02221043)">
|
|
||||||
</g>
|
|
||||||
<g
|
|
||||||
id="g28"
|
|
||||||
transform="translate(-0.01903604,0.02221043)">
|
|
||||||
</g>
|
|
||||||
<g
|
|
||||||
id="g30"
|
|
||||||
transform="translate(-0.01903604,0.02221043)">
|
|
||||||
</g>
|
|
||||||
<g
|
|
||||||
id="g32"
|
|
||||||
transform="translate(-0.01903604,0.02221043)">
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.9 KiB |
103
backend/store.py
103
backend/store.py
@@ -37,9 +37,6 @@ class ChangeDetectionStore:
|
|||||||
'timeout': 15, # Default 15 seconds
|
'timeout': 15, # Default 15 seconds
|
||||||
'minutes_between_check': 3 * 60, # Default 3 hours
|
'minutes_between_check': 3 * 60, # Default 3 hours
|
||||||
'workers': 10 # Number of threads, lower is better for slow connections
|
'workers': 10 # Number of threads, lower is better for slow connections
|
||||||
},
|
|
||||||
'application': {
|
|
||||||
'password': False
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -50,7 +47,6 @@ class ChangeDetectionStore:
|
|||||||
'tag': None,
|
'tag': None,
|
||||||
'last_checked': 0,
|
'last_checked': 0,
|
||||||
'last_changed': 0,
|
'last_changed': 0,
|
||||||
'paused': False,
|
|
||||||
'last_viewed': 0, # history key value of the last viewed via the [diff] link
|
'last_viewed': 0, # history key value of the last viewed via the [diff] link
|
||||||
'newest_history_key': "",
|
'newest_history_key': "",
|
||||||
'title': None,
|
'title': None,
|
||||||
@@ -87,9 +83,6 @@ class ChangeDetectionStore:
|
|||||||
if 'requests' in from_disk['settings']:
|
if 'requests' in from_disk['settings']:
|
||||||
self.__data['settings']['requests'].update(from_disk['settings']['requests'])
|
self.__data['settings']['requests'].update(from_disk['settings']['requests'])
|
||||||
|
|
||||||
if 'application' in from_disk['settings']:
|
|
||||||
self.__data['settings']['application'].update(from_disk['settings']['application'])
|
|
||||||
|
|
||||||
# Reinitialise each `watching` with our generic_definition in the case that we add a new var in the future.
|
# Reinitialise each `watching` with our generic_definition in the case that we add a new var in the future.
|
||||||
# @todo pretty sure theres a python we todo this with an abstracted(?) object!
|
# @todo pretty sure theres a python we todo this with an abstracted(?) object!
|
||||||
for uuid, watch in self.__data['watching'].items():
|
for uuid, watch in self.__data['watching'].items():
|
||||||
@@ -110,7 +103,7 @@ class ChangeDetectionStore:
|
|||||||
self.add_watch(url='https://changedetection.io', tag='Tech news')
|
self.add_watch(url='https://changedetection.io', tag='Tech news')
|
||||||
|
|
||||||
|
|
||||||
self.__data['version_tag'] = "0.28"
|
self.__data['version_tag'] = "0.27"
|
||||||
|
|
||||||
if not 'app_guid' in self.__data:
|
if not 'app_guid' in self.__data:
|
||||||
self.__data['app_guid'] = str(uuid_builder.uuid4())
|
self.__data['app_guid'] = str(uuid_builder.uuid4())
|
||||||
@@ -141,10 +134,6 @@ class ChangeDetectionStore:
|
|||||||
|
|
||||||
def update_watch(self, uuid, update_obj):
|
def update_watch(self, uuid, update_obj):
|
||||||
|
|
||||||
# Skip if 'paused' state
|
|
||||||
if self.__data['watching'][uuid]['paused']:
|
|
||||||
return
|
|
||||||
|
|
||||||
with self.lock:
|
with self.lock:
|
||||||
|
|
||||||
# In python 3.9 we have the |= dict operator, but that still will lose data on nested structures...
|
# In python 3.9 we have the |= dict operator, but that still will lose data on nested structures...
|
||||||
@@ -163,6 +152,7 @@ class ChangeDetectionStore:
|
|||||||
def data(self):
|
def data(self):
|
||||||
|
|
||||||
has_unviewed = False
|
has_unviewed = False
|
||||||
|
|
||||||
for uuid, v in self.__data['watching'].items():
|
for uuid, v in self.__data['watching'].items():
|
||||||
self.__data['watching'][uuid]['newest_history_key'] = self.get_newest_history_key(uuid)
|
self.__data['watching'][uuid]['newest_history_key'] = self.get_newest_history_key(uuid)
|
||||||
if int(v['newest_history_key']) <= int(v['last_viewed']):
|
if int(v['newest_history_key']) <= int(v['last_viewed']):
|
||||||
@@ -189,35 +179,19 @@ class ChangeDetectionStore:
|
|||||||
tags.sort()
|
tags.sort()
|
||||||
return tags
|
return tags
|
||||||
|
|
||||||
def unlink_history_file(self, path):
|
|
||||||
try:
|
|
||||||
os.unlink(path)
|
|
||||||
except (FileNotFoundError, IOError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Delete a single watch by UUID
|
|
||||||
def delete(self, uuid):
|
def delete(self, uuid):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
if uuid == 'all':
|
if uuid == 'all':
|
||||||
self.__data['watching'] = {}
|
self.__data['watching'] = {}
|
||||||
|
|
||||||
# GitHub #30 also delete history records
|
|
||||||
for uuid in self.data['watching']:
|
|
||||||
for path in self.data['watching'][uuid]['history'].values():
|
|
||||||
self.unlink_history_file(path)
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
for path in self.data['watching'][uuid]['history'].values():
|
del (self.__data['watching'][uuid])
|
||||||
self.unlink_history_file(path)
|
|
||||||
|
|
||||||
del self.data['watching'][uuid]
|
|
||||||
|
|
||||||
self.needs_write = True
|
self.needs_write = True
|
||||||
|
|
||||||
def url_exists(self, url):
|
def url_exists(self, url):
|
||||||
|
|
||||||
# Probably their should be dict...
|
# Probably their should be dict...
|
||||||
for watch in self.data['watching'].values():
|
for watch in self.data['watching']:
|
||||||
if watch['url'] == url:
|
if watch['url'] == url:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -227,47 +201,6 @@ class ChangeDetectionStore:
|
|||||||
# Probably their should be dict...
|
# Probably their should be dict...
|
||||||
return self.data['watching'][uuid].get(val)
|
return self.data['watching'][uuid].get(val)
|
||||||
|
|
||||||
# Remove a watchs data but keep the entry (URL etc)
|
|
||||||
def scrub_watch(self, uuid, limit_timestamp = False):
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
del_timestamps = []
|
|
||||||
|
|
||||||
for timestamp, path in self.data['watching'][uuid]['history'].items():
|
|
||||||
if not limit_timestamp or (limit_timestamp is not False and int(timestamp) > limit_timestamp):
|
|
||||||
self.unlink_history_file(path)
|
|
||||||
del_timestamps.append(timestamp)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if not limit_timestamp:
|
|
||||||
self.data['watching'][uuid]['last_checked'] = 0
|
|
||||||
self.data['watching'][uuid]['last_changed'] = 0
|
|
||||||
self.data['watching'][uuid]['previous_md5'] = 0
|
|
||||||
|
|
||||||
for timestamp in del_timestamps:
|
|
||||||
del self.data['watching'][uuid]['history'][str(timestamp)]
|
|
||||||
|
|
||||||
# If there was a limitstamp, we need to reset some meta data about the entry
|
|
||||||
# This has to happen after we remove the others from the list
|
|
||||||
if limit_timestamp:
|
|
||||||
newest_key = self.get_newest_history_key(uuid)
|
|
||||||
if newest_key:
|
|
||||||
self.data['watching'][uuid]['last_checked'] = int(newest_key)
|
|
||||||
# @todo should be the original value if it was less than newest key
|
|
||||||
self.data['watching'][uuid]['last_changed'] = int(newest_key)
|
|
||||||
try:
|
|
||||||
with open(self.data['watching'][uuid]['history'][str(newest_key)], "rb") as fp:
|
|
||||||
content = fp.read()
|
|
||||||
self.data['watching'][uuid]['previous_md5'] = hashlib.md5(content).hexdigest()
|
|
||||||
except (FileNotFoundError, IOError):
|
|
||||||
self.data['watching'][uuid]['previous_md5'] = False
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
self.needs_write = True
|
|
||||||
|
|
||||||
|
|
||||||
def add_watch(self, url, tag):
|
def add_watch(self, url, tag):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
# @todo use a common generic version of this
|
# @todo use a common generic version of this
|
||||||
@@ -295,6 +228,12 @@ class ChangeDetectionStore:
|
|||||||
# result_obj from fetch_site_status.run()
|
# result_obj from fetch_site_status.run()
|
||||||
def save_history_text(self, uuid, result_obj, contents):
|
def save_history_text(self, uuid, result_obj, contents):
|
||||||
|
|
||||||
|
output_path = "{}/{}".format(self.datastore_path, uuid)
|
||||||
|
try:
|
||||||
|
os.mkdir(output_path)
|
||||||
|
except FileExistsError:
|
||||||
|
pass
|
||||||
|
|
||||||
output_path = "{}/{}".format(self.datastore_path, uuid)
|
output_path = "{}/{}".format(self.datastore_path, uuid)
|
||||||
fname = "{}/{}-{}.stripped.txt".format(output_path, result_obj['previous_md5'], str(time.time()))
|
fname = "{}/{}-{}.stripped.txt".format(output_path, result_obj['previous_md5'], str(time.time()))
|
||||||
with open(fname, 'w') as f:
|
with open(fname, 'w') as f:
|
||||||
@@ -309,21 +248,11 @@ class ChangeDetectionStore:
|
|||||||
|
|
||||||
def sync_to_json(self):
|
def sync_to_json(self):
|
||||||
print("Saving..")
|
print("Saving..")
|
||||||
data ={}
|
with open(self.json_store_path, 'w') as json_file:
|
||||||
|
json.dump(self.__data, json_file, indent=4)
|
||||||
|
logging.info("Re-saved index")
|
||||||
|
|
||||||
try:
|
self.needs_write = False
|
||||||
data = deepcopy(self.__data)
|
|
||||||
except RuntimeError:
|
|
||||||
time.sleep(0.5)
|
|
||||||
print ("! Data changed when writing to JSON, trying again..")
|
|
||||||
self.sync_to_json()
|
|
||||||
return
|
|
||||||
else:
|
|
||||||
with open(self.json_store_path, 'w') as json_file:
|
|
||||||
json.dump(data, json_file, indent=4)
|
|
||||||
logging.info("Re-saved index")
|
|
||||||
|
|
||||||
self.needs_write = False
|
|
||||||
|
|
||||||
# Thread runner, this helps with thread/write issues when there are many operations that want to update the JSON
|
# Thread runner, this helps with thread/write issues when there are many operations that want to update the JSON
|
||||||
# by just running periodically in one thread, according to python, dict updates are threadsafe.
|
# by just running periodically in one thread, according to python, dict updates are threadsafe.
|
||||||
@@ -333,8 +262,8 @@ class ChangeDetectionStore:
|
|||||||
if self.stop_thread:
|
if self.stop_thread:
|
||||||
print("Shutting down datastore thread")
|
print("Shutting down datastore thread")
|
||||||
return
|
return
|
||||||
|
|
||||||
if self.needs_write:
|
if self.needs_write:
|
||||||
self.sync_to_json()
|
self.sync_to_json()
|
||||||
time.sleep(3)
|
time.sleep(1)
|
||||||
|
|
||||||
|
# body of the constructor
|
||||||
|
|||||||
@@ -17,11 +17,7 @@
|
|||||||
|
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<div class="home-menu pure-menu pure-menu-horizontal pure-menu-fixed">
|
<div class="home-menu pure-menu pure-menu-horizontal pure-menu-fixed">
|
||||||
{% if not current_user.is_authenticated %}
|
<a class="pure-menu-heading" href="/"><strong>Change</strong>Detection.io</a>
|
||||||
<a class="pure-menu-heading" href="https://github.com/dgtlmoon/changedetection.io" rel="noopener"><strong>Change</strong>Detection.io</a>
|
|
||||||
{% else %}
|
|
||||||
<a class="pure-menu-heading" href="/"><strong>Change</strong>Detection.io</a>
|
|
||||||
{% endif %}
|
|
||||||
{% if current_diff_url %}
|
{% if current_diff_url %}
|
||||||
<a class=current-diff-url href="{{ current_diff_url }}"><span style="max-width: 30%; overflow: hidden;">{{ current_diff_url }}</span></a>
|
<a class=current-diff-url href="{{ current_diff_url }}"><span style="max-width: 30%; overflow: hidden;">{{ current_diff_url }}</span></a>
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -31,7 +27,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<ul class="pure-menu-list">
|
<ul class="pure-menu-list">
|
||||||
{% if current_user.is_authenticated %}
|
|
||||||
<li class="pure-menu-item">
|
<li class="pure-menu-item">
|
||||||
<a href="/backup" class="pure-menu-link">BACKUP</a>
|
<a href="/backup" class="pure-menu-link">BACKUP</a>
|
||||||
</li>
|
</li>
|
||||||
@@ -41,15 +37,6 @@
|
|||||||
<li class="pure-menu-item">
|
<li class="pure-menu-item">
|
||||||
<a href="/settings" class="pure-menu-link">SETTINGS</a>
|
<a href="/settings" class="pure-menu-link">SETTINGS</a>
|
||||||
</li>
|
</li>
|
||||||
{% else %}
|
|
||||||
<li class="pure-menu-item">
|
|
||||||
<a class="pure-menu-link" href="https://github.com/dgtlmoon/changedetection.io">Website Change Detection and Notification.</a>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if current_user.is_authenticated %}
|
|
||||||
<li class="pure-menu-item"><a href="/logout" class="pure-menu-link">LOG OUT</a></li>
|
|
||||||
{% endif %}
|
|
||||||
<li class="pure-menu-item"><a class="github-link" href="https://github.com/dgtlmoon/changedetection.io">
|
<li class="pure-menu-item"><a class="github-link" href="https://github.com/dgtlmoon/changedetection.io">
|
||||||
<svg class="octicon octicon-mark-github v-align-middle" height="32" viewBox="0 0 16 16"
|
<svg class="octicon octicon-mark-github v-align-middle" height="32" viewBox="0 0 16 16"
|
||||||
version="1.1"
|
version="1.1"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<fieldset>
|
<fieldset>
|
||||||
|
|
||||||
<label for="diffWords" class="pure-checkbox">
|
<label for="diffWords" class="pure-checkbox">
|
||||||
<input type="radio" name="diff_type" id="diffWords" value="diffWords"/> Words</label>
|
<input type="radio" name="diff_type" id="diffWords" value="diffWords" /> Words</label>
|
||||||
<label for="diffLines" class="pure-checkbox">
|
<label for="diffLines" class="pure-checkbox">
|
||||||
<input type="radio" name="diff_type" id="diffLines" value="diffLines" checked=""/> Lines</label>
|
<input type="radio" name="diff_type" id="diffLines" value="diffLines" checked=""/> Lines</label>
|
||||||
|
|
||||||
@@ -19,9 +19,9 @@
|
|||||||
<label for="diff-version">Compare newest (<span id="current-v-date"></span>) with</label>
|
<label for="diff-version">Compare newest (<span id="current-v-date"></span>) with</label>
|
||||||
<select id="diff-version" name="previous_version">
|
<select id="diff-version" name="previous_version">
|
||||||
{% for version in versions %}
|
{% for version in versions %}
|
||||||
<option value="{{version}}" {% if version== current_previous_version %} selected="" {% endif %}>
|
<option value="{{version}}" {% if version== current_previous_version %} selected="" {% endif %}>
|
||||||
{{version}}
|
{{version}}
|
||||||
</option>
|
</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<button type="submit" class="pure-button pure-button-primary">Go</button>
|
<button type="submit" class="pure-button pure-button-primary">Go</button>
|
||||||
@@ -90,10 +90,6 @@ function changed() {
|
|||||||
|
|
||||||
result.textContent = '';
|
result.textContent = '';
|
||||||
result.appendChild(fragment);
|
result.appendChild(fragment);
|
||||||
|
|
||||||
// Jump at start
|
|
||||||
inputs.current=0;
|
|
||||||
next_diff();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
window.onload = function() {
|
window.onload = function() {
|
||||||
@@ -116,7 +112,6 @@ window.onload = function() {
|
|||||||
|
|
||||||
onDiffTypeChange(document.querySelector('#settings [name="diff_type"]:checked'));
|
onDiffTypeChange(document.querySelector('#settings [name="diff_type"]:checked'));
|
||||||
changed();
|
changed();
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
a.onpaste = a.onchange =
|
a.onpaste = a.onchange =
|
||||||
@@ -145,7 +140,6 @@ for (var i = 0; i < radio.length; i++) {
|
|||||||
var inputs = document.getElementsByClassName('change');
|
var inputs = document.getElementsByClassName('change');
|
||||||
inputs.current=0;
|
inputs.current=0;
|
||||||
|
|
||||||
|
|
||||||
function next_diff() {
|
function next_diff() {
|
||||||
|
|
||||||
var element = inputs[inputs.current];
|
var element = inputs[inputs.current];
|
||||||
@@ -165,7 +159,6 @@ function next_diff() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
{% extends 'base.html' %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="edit-form">
|
|
||||||
<form class="pure-form pure-form-stacked" action="/login" method="POST">
|
|
||||||
<fieldset>
|
|
||||||
<div class="pure-control-group">
|
|
||||||
<label for="password">Password</label>
|
|
||||||
<input type="password" id="password" required="" name="password" value=""
|
|
||||||
size="15"/>
|
|
||||||
<input type="hidden" id="email" name="email" value="defaultuser@changedetection.io" />
|
|
||||||
</div>
|
|
||||||
<div class="pure-control-group">
|
|
||||||
<button type="submit" class="pure-button pure-button-primary">Submit</button>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
{% extends 'base.html' %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
|
|
||||||
<div id="settings">
|
|
||||||
<h1>Current</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<div id="diff-ui">
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<!-- just proof of concept copied straight from github.com/kpdecker/jsdiff -->
|
|
||||||
|
|
||||||
<td id="diff-col">
|
|
||||||
<span id="result">{% for row in content %}<pre>{{row}}</pre>{% endfor %}</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
@@ -17,19 +17,14 @@
|
|||||||
|
|
||||||
<div class="pure-control-group">
|
<div class="pure-control-group">
|
||||||
<br/>
|
<br/>
|
||||||
<label for="confirmtext">Confirm text</label><br/>
|
<label for="confirmtext">Confirm</label><br/>
|
||||||
<input type="text" id="confirmtext" required="" name="confirmtext" value="" size="10"/>
|
<input type="text" id="confirmtext" required="" name="confirmtext" value="" size="10"/>
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<div class="pure-control-group">
|
|
||||||
<br/>
|
|
||||||
<label for="confirmtext">Limit delete history including and after date</label><br/>
|
|
||||||
<input type="text" id="limit_date" required="" name="limit_date" value="" size="10"/>
|
|
||||||
<br/>
|
<br/>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div class="pure-control-group">
|
<div class="pure-control-group">
|
||||||
<button type="submit" class="pure-button pure-button-primary">Scrub!</button>
|
<button type="submit" class="pure-button pure-button-primary">Scrub!</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,14 +12,7 @@
|
|||||||
size="5"/>
|
size="5"/>
|
||||||
<span class="pure-form-message-inline">This is a required field.</span>
|
<span class="pure-form-message-inline">This is a required field.</span>
|
||||||
</div>
|
</div>
|
||||||
<br/>
|
|
||||||
<div class="pure-control-group">
|
|
||||||
<label for="minutes">Password protection</label>
|
|
||||||
<input type="password" id="password" name="password" size="15"/>
|
|
||||||
{% if current_user.is_authenticated %}
|
|
||||||
<a href="/settings?removepassword=true" class="pure-button pure-button-primary">Remove password</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<br/>
|
<br/>
|
||||||
<div class="pure-control-group">
|
<div class="pure-control-group">
|
||||||
@@ -29,7 +22,7 @@
|
|||||||
|
|
||||||
<div class="pure-control-group">
|
<div class="pure-control-group">
|
||||||
<a href="/" class="pure-button button-small button-cancel">Back</a>
|
<a href="/" class="pure-button button-small button-cancel">Back</a>
|
||||||
<a href="/scrub" class="pure-button button-small button-cancel">Delete history version data</a>
|
<a href="/scrub" class="pure-button button-small button-cancel">Reset all version data</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
12
backend/templates/watch-diff-stream.html
Normal file
12
backend/templates/watch-diff-stream.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{% extends 'watch-overview.html' %}
|
||||||
|
{% block innercontent %}
|
||||||
|
Entries: {{ streams|length }}
|
||||||
|
|
||||||
|
<div id="diff-stream" class="edit-form">
|
||||||
|
{% for item in streams %}
|
||||||
|
{{ loop.index }}
|
||||||
|
{% for diff in item %}{% if diff[0] =='+' %}<ins>{{ diff }}</ins>{% endif %}{% if diff[0] =='-' %}<del>{{ diff }}</del>{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -24,74 +24,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="watch-table-wrapper">
|
<div id="watch-table-wrapper">
|
||||||
<table class="pure-table pure-table-striped watch-table">
|
{% block innercontent %}
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>#</th>
|
|
||||||
<th></th>
|
|
||||||
<th></th>
|
|
||||||
<th>Last Checked</th>
|
|
||||||
<th>Last Changed</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
{% for watch in watches %}
|
|
||||||
<tr id="{{ watch.uuid }}"
|
|
||||||
class="{{ loop.cycle('pure-table-odd', 'pure-table-even') }}
|
|
||||||
{% if watch.last_error is defined and watch.last_error != False %}error{% endif %}
|
|
||||||
{% if watch.paused is defined and watch.paused != False %}paused{% endif %}
|
|
||||||
{% if watch.newest_history_key| int > watch.last_viewed| int %}unviewed{% endif %}">
|
|
||||||
<td>{{ loop.index }}</td>
|
|
||||||
<td class="paused-state state-{{watch.paused}}"><a href="/?pause={{ watch.uuid}}{% if active_tag %}&tag={{active_tag}}{% endif %}"><img src="/static/images/pause.svg" alt="Pause"/></a></td>
|
|
||||||
<td class="title-col">{{watch.title if watch.title is not none else watch.url}}
|
|
||||||
<a class="external" target="_blank" rel="noopener" href="{{ watch.url }}"></a>
|
|
||||||
{% if watch.last_error is defined and watch.last_error != False %}
|
|
||||||
<div class="fetch-error">{{ watch.last_error }}</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if not active_tag %}
|
|
||||||
<span class="watch-tag-list">{{ watch.tag}}</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>{{watch|format_last_checked_time}}</td>
|
|
||||||
<td>{% if watch.history|length >= 2 and watch.last_changed %}
|
|
||||||
{{watch.last_changed|format_timestamp_timeago}}
|
|
||||||
{% else %}
|
|
||||||
Not yet
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<a href="/api/checknow?uuid={{ watch.uuid}}{% if request.args.get('tag') %}&tag={{request.args.get('tag')}}{% endif %}"
|
|
||||||
class="pure-button button-small pure-button-primary">Recheck</a>
|
|
||||||
<a href="/edit/{{ watch.uuid}}" class="pure-button button-small pure-button-primary">Edit</a>
|
|
||||||
{% if watch.history|length >= 2 %}
|
|
||||||
<a href="/diff/{{ watch.uuid}}" target="{{watch.uuid}}" class="pure-button button-small pure-button-primary">Diff</a>
|
|
||||||
{% else %}
|
|
||||||
{% if watch.history|length == 1 %}
|
|
||||||
<a href="/preview/{{ watch.uuid}}" target="{{watch.uuid}}" class="pure-button button-small pure-button-primary">Preview</a>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<ul id="post-list-buttons">
|
|
||||||
{% if has_unviewed %}
|
|
||||||
<li>
|
|
||||||
<a href="/api/mark-all-viewed" class="pure-button button-tag ">Mark all viewed</a>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
<li>
|
|
||||||
<a href="/api/checknow{% if active_tag%}?tag={{active_tag}}{%endif%}" class="pure-button button-tag ">Recheck
|
|
||||||
all {% if active_tag%}in "{{active_tag}}"{%endif%}</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="{{ url_for('index', tag=active_tag , rss=true)}}"><img id="feed-icon" src="/static/images/Generic_Feed-icon.svg" height="15px"></a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
64
backend/templates/watch-table.html
Normal file
64
backend/templates/watch-table.html
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
{% extends 'watch-overview.html' %}
|
||||||
|
{% block innercontent %}
|
||||||
|
<table class="pure-table pure-table-striped watch-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th></th>
|
||||||
|
<th>Last Checked</th>
|
||||||
|
<th>Last Changed</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
|
||||||
|
|
||||||
|
{% for watch in watches %}
|
||||||
|
<tr id="{{ watch.uuid }}"
|
||||||
|
class="{{ loop.cycle('pure-table-odd', 'pure-table-even') }}
|
||||||
|
{% if watch.last_error is defined and watch.last_error != False %}error{% endif %}
|
||||||
|
{% if watch.newest_history_key| int > watch.last_viewed| int %}unviewed{% endif %}">
|
||||||
|
<td>{{ loop.index }}</td>
|
||||||
|
<td class="title-col">{{watch.title if watch.title is not none else watch.url}}
|
||||||
|
<a class="external" target=_blank href="{{ watch.url }}"></a>
|
||||||
|
{% if watch.last_error is defined and watch.last_error != False %}
|
||||||
|
<div class="fetch-error">{{ watch.last_error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if not active_tag %}
|
||||||
|
<span class="watch-tag-list">{{ watch.tag}}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{watch|format_last_checked_time}}</td>
|
||||||
|
<td>{% if watch.history|length >= 2 and watch.last_changed %}
|
||||||
|
{{watch.last_changed|format_timestamp_timeago}}
|
||||||
|
{% else %}
|
||||||
|
Not yet
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="/api/checknow?uuid={{ watch.uuid}}{% if request.args.get('tag') %}&tag={{request.args.get('tag')}}{% endif %}"
|
||||||
|
class="pure-button button-small pure-button-primary">Recheck</a>
|
||||||
|
<a href="/edit/{{ watch.uuid}}" class="pure-button button-small pure-button-primary">Edit</a>
|
||||||
|
{% if watch.history|length >= 2 %}
|
||||||
|
<a href="/diff/{{ watch.uuid}}" class="pure-button button-small pure-button-primary">Diff</a>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<ul id="post-list-buttons">
|
||||||
|
{% if has_unviewed %}
|
||||||
|
<li>
|
||||||
|
<a href="/api/mark-all-viewed" class="pure-button button-tag ">Mark all viewed</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
<li>
|
||||||
|
<a href="/api/checknow{% if active_tag%}?tag={{active_tag}}{%endif%}" class="pure-button button-tag ">Recheck
|
||||||
|
all {% if active_tag%}in "{{active_tag}}"{%endif%}</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('index', tag=active_tag , rss=true)}}"><img id="feed-icon" src="/static/images/Generic_Feed-icon.svg" height="15px"></a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
{% endblock %}
|
||||||
@@ -121,40 +121,3 @@ def test_check_basic_change_detection_functionality(client, live_server):
|
|||||||
# Cleanup everything
|
# Cleanup everything
|
||||||
res = client.get(url_for("api_delete", uuid="all"), follow_redirects=True)
|
res = client.get(url_for("api_delete", uuid="all"), follow_redirects=True)
|
||||||
assert b'Deleted' in res.data
|
assert b'Deleted' in res.data
|
||||||
|
|
||||||
|
|
||||||
def test_check_access_control(client):
|
|
||||||
return
|
|
||||||
# @note: does not seem to handle the last logout step correctly, we're still logged in.. but yet..
|
|
||||||
# pytest team keep telling us that we have a new context.. i'm lost :(
|
|
||||||
|
|
||||||
# Add our URL to the import page
|
|
||||||
res = client.post(
|
|
||||||
url_for("settings_page"),
|
|
||||||
data={"password": "foobar"},
|
|
||||||
follow_redirects=True
|
|
||||||
)
|
|
||||||
assert b"LOG OUT" not in res.data
|
|
||||||
|
|
||||||
client.get(url_for("import_page"), follow_redirects=True)
|
|
||||||
assert b"Password" in res.data
|
|
||||||
|
|
||||||
#defaultuser@changedetection.io is actually hardcoded for now, we only use a single password
|
|
||||||
res = client.post(
|
|
||||||
url_for("login"),
|
|
||||||
data={"password": "foobar", "email": "defaultuser@changedetection.io"},
|
|
||||||
follow_redirects=True
|
|
||||||
)
|
|
||||||
|
|
||||||
assert b"LOG OUT" in res.data
|
|
||||||
|
|
||||||
client.get(url_for("settings_page"), follow_redirects=True)
|
|
||||||
assert b"LOG OUT" in res.data
|
|
||||||
|
|
||||||
# Now remove the password so other tests function, @todo this should happen before each test automatically
|
|
||||||
|
|
||||||
print(res.data)
|
|
||||||
client.get(url_for("settings_page", removepassword="true"), follow_redirects=True)
|
|
||||||
|
|
||||||
client.get(url_for("import_page", removepassword="true"), follow_redirects=True)
|
|
||||||
assert b"LOG OUT" not in res.data
|
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
# Launch as a eventlet.wsgi server instance.
|
# Launch as a eventlet.wsgi server instance.
|
||||||
|
|
||||||
import getopt
|
import getopt
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
import eventlet
|
import eventlet
|
||||||
@@ -13,31 +12,10 @@ import backend
|
|||||||
from backend import store
|
from backend import store
|
||||||
|
|
||||||
|
|
||||||
def init_app_secret(datastore_path):
|
|
||||||
secret = ""
|
|
||||||
|
|
||||||
path = "{}/secret.txt".format(datastore_path)
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(path, "r") as f:
|
|
||||||
secret = f.read()
|
|
||||||
|
|
||||||
except FileNotFoundError:
|
|
||||||
|
|
||||||
import secrets
|
|
||||||
with open(path, "w") as f:
|
|
||||||
secret = secrets.token_hex(32)
|
|
||||||
f.write(secret)
|
|
||||||
|
|
||||||
return secret
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv):
|
def main(argv):
|
||||||
ssl_mode = False
|
ssl_mode = False
|
||||||
port = 5000
|
port = 5000
|
||||||
|
datastore_path = "./datastore"
|
||||||
# Must be absolute so that send_from_directory doesnt try to make it relative to backend/
|
|
||||||
datastore_path = os.path.join(os.getcwd(), "datastore")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
opts, args = getopt.getopt(argv, "sd:p:", "purge")
|
opts, args = getopt.getopt(argv, "sd:p:", "purge")
|
||||||
@@ -60,14 +38,18 @@ def main(argv):
|
|||||||
if opt == '-d':
|
if opt == '-d':
|
||||||
datastore_path = arg
|
datastore_path = arg
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# threads can read from disk every x seconds right?
|
||||||
|
# front end can just save
|
||||||
|
# We just need to know which threads are looking at which UUIDs
|
||||||
|
|
||||||
# isnt there some @thingy to attach to each route to tell it, that this route needs a datastore
|
# isnt there some @thingy to attach to each route to tell it, that this route needs a datastore
|
||||||
app_config = {'datastore_path': datastore_path}
|
app_config = {'datastore_path': datastore_path}
|
||||||
|
|
||||||
datastore = store.ChangeDetectionStore(datastore_path=app_config['datastore_path'])
|
datastore = store.ChangeDetectionStore(datastore_path=app_config['datastore_path'])
|
||||||
app = backend.changedetection_app(app_config, datastore)
|
app = backend.changedetection_app(app_config, datastore)
|
||||||
|
|
||||||
app.secret_key = init_app_secret(app_config['datastore_path'])
|
|
||||||
|
|
||||||
@app.context_processor
|
@app.context_processor
|
||||||
def inject_version():
|
def inject_version():
|
||||||
return dict(version=datastore.data['version_tag'])
|
return dict(version=datastore.data['version_tag'])
|
||||||
|
|||||||
23
docker-compose-development.yml
Normal file
23
docker-compose-development.yml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
version: "2"
|
||||||
|
services:
|
||||||
|
|
||||||
|
# I have a feeling we can get rid of this, and just use one docker-compose.yml, and just set a ENV var if
|
||||||
|
# we want dev mode (just gives a docker shell) or not.
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build: ./backend/dev-docker
|
||||||
|
image: dgtlmoon/changedetection.io:dev
|
||||||
|
container_name: changedetection.io-dev
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
- ./requirements.txt:/requirements.txt # Normally COPY'ed in the Dockerfile
|
||||||
|
- ./datastore:/datastore
|
||||||
|
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:5001:5000"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
- changenet
|
||||||
|
|
||||||
|
networks:
|
||||||
|
changenet:
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
chardet==2.3.0
|
chardet==2.3.0
|
||||||
|
yarl
|
||||||
flask~= 1.0
|
flask~= 1.0
|
||||||
pytest ~=6.2
|
pytest ~=6.2
|
||||||
pytest-flask ~=1.1
|
pytest-flask ~=1.1
|
||||||
eventlet ~= 0.30
|
eventlet ~= 0.30
|
||||||
requests ~= 2.15
|
requests
|
||||||
validators
|
validators
|
||||||
timeago ~=1.0
|
timeago ~=1.0
|
||||||
inscriptis ~= 1.1
|
inscriptis ~= 1.1
|
||||||
feedgen ~= 0.9
|
feedgen ~= 0.9
|
||||||
flask-login ~= 0.5
|
|
||||||
pytz
|
pytz
|
||||||
urllib3
|
urllib3
|
||||||
Reference in New Issue
Block a user