├── .coveragerc ├── .dockerignore ├── .gitattributes ├── .github ├── dependabot.yml └── workflows │ ├── docker-image.yml │ └── python-app.yml ├── .gitignore ├── .travis.yml ├── Dockerfile ├── LICENSE ├── README.md ├── bot ├── __init__.py ├── core.py ├── menus │ ├── __init__.py │ ├── mainmenu.py │ ├── newpriceagentmenu.py │ ├── showppriceagentsmenu.py │ ├── showpriceagentsmenu.py │ ├── showwlpriceagentsmenu.py │ └── util.py ├── tests │ └── __init__.py └── user.py ├── config.sample.py ├── database ├── __init__.py ├── db_wrapper.py └── tests │ ├── __init__.py │ └── db_wrapper_test.py ├── docker-compose.yml ├── geizhals ├── __init__.py ├── charts │ ├── __init__.py │ ├── dataset.py │ ├── day.py │ └── price.py ├── core.py ├── entities │ ├── __init__.py │ ├── entity.py │ ├── entitytype.py │ ├── product.py │ └── wishlist.py ├── exceptions │ ├── __init__.py │ ├── httplimitedexception.py │ ├── invalidproducturlexception.py │ └── invalidwishlisturlexception.py ├── state_handler.py ├── tests │ ├── __init__.py │ ├── core_test.py │ ├── entity_test.py │ ├── example.html │ ├── product_test.py │ ├── state_handler_test.py │ ├── test_product.html │ ├── test_wishlist.html │ └── wishlist_test.py └── util │ ├── __init__.py │ ├── ringbuffer.py │ └── tests │ └── ringbuffer_test.py ├── main.py ├── proxies.sample.txt ├── requirements.txt ├── state.py ├── tests └── __init__.py └── util ├── __init__.py ├── exceptions.py ├── formatter.py └── tests ├── __init__.py └── formatter_test.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = *__init__.py 3 | *_test.py 4 | */virtualenv* -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # dotenv 79 | .env 80 | 81 | # virtualenv 82 | venv/ 83 | ENV/ 84 | .venv 85 | 86 | # Spyder project settings 87 | .spyderproject 88 | 89 | # Rope project settings 90 | .ropeproject 91 | 92 | 93 | .idea 94 | .svn 95 | .git 96 | __pycache__ 97 | *.db 98 | .vs 99 | .github 100 | 101 | # Keep logrotated files out of git 102 | logs/*.log* 103 | config.py 104 | 105 | *.md 106 | LICENSE 107 | .travis.yml 108 | *_test.py 109 | 110 | .gitignore 111 | .coveragec 112 | .gitattributes 113 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.html linguist-documentation=true 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "04:00" 8 | open-pull-requests-limit: 10 9 | target-branch: dev 10 | ignore: 11 | - dependency-name: python-telegram-bot 12 | versions: 13 | - "13.2" 14 | - "13.3" 15 | -------------------------------------------------------------------------------- /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Build and Push Docker Image 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'master' 7 | tags: 8 | - 'v*' 9 | 10 | jobs: 11 | # define job to build and publish docker image 12 | build-and-push-docker-image: 13 | name: Build Docker image and push to repositories 14 | # run only when code is compiling and tests are passing 15 | runs-on: ubuntu-latest 16 | if: GitHub.ref == 'refs/heads/master' || GitHub.event_name == 'release' 17 | 18 | # steps to perform in job 19 | steps: 20 | - name: Checkout code 21 | uses: actions/checkout@v2 22 | 23 | - name: Docker meta 24 | id: meta 25 | uses: docker/metadata-action@v3 26 | with: 27 | images: 0rickyy0/geizhalsbot 28 | tags: | 29 | type=raw,value=latest,enable=${{ endsWith(github.ref, github.event.repository.default_branch) }} 30 | type=ref,event=tag 31 | flavor: | 32 | latest=false 33 | 34 | - name: Login to DockerHub 35 | uses: docker/login-action@v1 36 | with: 37 | username: ${{ secrets.DOCKERHUB_USERNAME }} 38 | password: ${{ secrets.DOCKERHUB_TOKEN }} 39 | 40 | - name: Build image and push 41 | uses: docker/build-push-action@v2 42 | with: 43 | # relative path to the place where source code with Dockerfile is located 44 | context: . 45 | push: ${{ GitHub.event_name != 'pull_request' }} 46 | tags: ${{ steps.meta.outputs.tags }} 47 | labels: ${{ steps.meta.outputs.labels }} 48 | 49 | - name: Image digest 50 | run: echo ${{ steps.docker_build.outputs.digest }} 51 | -------------------------------------------------------------------------------- /.github/workflows/python-app.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: build 5 | on: 6 | push: 7 | branches: [ master, dev ] 8 | pull_request: 9 | branches: [ master, dev ] 10 | 11 | env: 12 | CI: true 13 | 14 | jobs: 15 | build: 16 | 17 | runs-on: ubuntu-latest 18 | 19 | strategy: 20 | matrix: 21 | python-version: [3.6, 3.7, 3.8] 22 | 23 | steps: 24 | - uses: actions/checkout@v2 25 | - name: Set up Python ${{ matrix.python-version }} 26 | uses: actions/setup-python@v2 27 | with: 28 | python-version: ${{ matrix.python-version }} 29 | - name: Install dependencies 30 | run: | 31 | python -m pip install --upgrade pip 32 | pip install flake8 pytest pytest-cov 33 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 34 | - name: Lint with flake8 35 | run: | 36 | # stop the build if there are Python syntax errors or undefined names 37 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 38 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 39 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 40 | - name: Run unit tests and coverage report 41 | run: | 42 | pytest --cov=./ --cov-report=xml 43 | - name: Upload coverage to Codecov 44 | uses: codecov/codecov-action@v1 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | 103 | .idea 104 | .svn 105 | .git 106 | __pycache__ 107 | *.db 108 | .vs 109 | config.py 110 | .vscode/settings.json 111 | proxies.txt 112 | [0-9]*.png -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | sudo: require 3 | python: 4 | - '3.6' 5 | - '3.7' 6 | - '3.8' 7 | env: 8 | global: 9 | - secure: "O2c+teJLsO/f68JSCsX5SiCvQ11h5Oih11b2OwcbFvry8tORjGGgtNaJBiX0wOqFjsgmJIX+HEHeLS0HCDV8yUIsv/SEswIklSk59c75MJjcwWIl+K6GnPqhIBNRM2NpPLbBiuNVVUmHJsXho98kva4QTg5R3HbniaOLkOer9hvklhpXDOxxM9ZhOn2JN+mI0Pxz2YEzoQNJlr6ujGsng2EMBWlQLiCwzMht15MrQLhJAXWoFrVmsYAuCtxRi8VjPxXjYaI9DjHMNuCWR7sxgdZ2VuW1E8J5vyUpQkvwRmhkLjf8Ybj3zoQZzB3JLNKFFrth5QbqYcJHUsQTPatAcLCk0Tt9yi9bnOPVPUyXTK8FthlPU9NRX8VYO7mWy9Tqn6CVfcvSMlHafJS8a7TZZKmPefJlRVNJijXFgdMm85KUhmYNEM32evtTur2Z73yOimz4Bq78QPBT3s3eWIMCWHuL41BgQ23Tuh/WjplfFTiyo3/1mC/w6n/6zjmlod1BskZ9jlgMgMrHYrtmjKI9/aoQAc7tvk5AChj5d3+3WiMwR8IeIc7jRuOR5CsXTvVsslFWF84igYnnk2XEi2PKV/o8BgWIxdxqqMpTNyHxpLBD8BagiskQijOODaZ6bQX0Kby9coiIvrDYjltMDI+9MWHBOUnWMFGJc9pyk/3Q+eo=" 10 | - secure: "Dmx3ttv5mIuMogOYcTScAhFgTYL7Q7hwz2YIIRlxEQKNutOuXIIO8nMNOKesMQ1UWJwtSaolVTKQVrcSxOhOkwRmeTk0pHL9kJrlve5wMm/3CsziLWnXKD+5c3peNXfQqHIyOPDmJt9XWkdwf0VzSrVByuC00JQMdSRVUZdsV/MXV/Rhc8Q8xmHYRlCYPCA2q5f89iUvivNNKxvBwOPzWKAYFi3YYT8i7ezAKPHsH/eUMi/QDfHk49gmu4TKtEcGf27u4OdMuk3uW+Ae2PTsTW1bmX6rANdhtXThlF54UsljFjvLREaYLhKhbXg2wRMaHNzgFXCsw/COv8EctvVWUexsT/LRCcvjacArsrUXNsk5yr5xFTPeXPvQ+SKeJE6MMyasfSIynXuASKiFpvktONpMzHnDBQiryJBru+aPW0U8GDilPlYQNTm/NoqnS0CYw6CSXG7gr9Jqo6c27FMhC83tFrprkGi7ktxv5ansu40QXtCxzAQoz5Yq/8bvLVx5pB5SIVgqbAUpRA+IdDSQKBFjZ5/+3Z8AscLmJAqzaEcXjFXodCCFhlDEJ804l4+nK5sYEI6Tbi/CWS4uTmVsGJKTdblQ/OP1by3AsNeAQjmC4u0ux7NwR1bzEozaAk25OuMQLoUE1B9hTqEflbiOA7sGxppyhJgbfRtEsvkE4e0=" 11 | 12 | install: 13 | - pip install -r requirements.txt 14 | - pip install coveralls 15 | script: 16 | - python -m compileall ./ 17 | - coverage run -m unittest discover -s . -v -p "*_test.py" 18 | after_success: 19 | - coveralls 20 | notifications: 21 | email: false 22 | deploy: 23 | - provider: script 24 | script: >- 25 | curl https://rico-j.de/travis/deploy-gh.sh 26 | skip_cleanup: true 27 | on: 28 | branch: master 29 | tags: false 30 | python: "3.6" 31 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3-slim 2 | 3 | LABEL maintainer="d-Rickyy-b " 4 | LABEL site="https://github.com/d-Rickyy-b/Python-GeizhalsBot" 5 | 6 | # Create bot & log directories 7 | RUN mkdir -p /geizhalsbot/logs 8 | WORKDIR /geizhalsbot 9 | 10 | # Copy the source code to the container 11 | COPY . /geizhalsbot 12 | 13 | RUN pip3 install --no-cache -r /geizhalsbot/requirements.txt 14 | 15 | # Start the main file when the container is started 16 | ENTRYPOINT ["python3", "/geizhalsbot/main.py"] 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | {one line to give the program's name and a brief idea of what it does.} 635 | Copyright (C) {year} {name of author} 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | {project} Copyright (C) {year} {fullname} 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . 675 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://github.com/d-Rickyy-b/Python-GeizhalsBot/workflows/build/badge.svg?branch=master)](https://github.com/d-Rickyy-b/Python-GeizhalsBot/actions?query=workflow%3Abuild+branch%3Amaster) 2 | [![codecov](https://codecov.io/gh/d-Rickyy-b/Python-GeizhalsBot/branch/master/graph/badge.svg?token=FMP0JX7HKA)](https://codecov.io/gh/d-Rickyy-b/Python-GeizhalsBot) 3 | [![Codacy Badge](https://api.codacy.com/project/badge/Grade/c923f31dca164626bedb1b21c663cc94)](https://www.codacy.com/manual/d-Rickyy-b/Python-GeizhalsBot?utm_source=github.com&utm_medium=referral&utm_content=d-Rickyy-b/Python-GeizhalsBot&utm_campaign=Badge_Grade) 4 | 5 | # Python-GeizhalsBot 6 | A bot to get notified about changes of the price of a [geizhals.de](https://geizhals.de) wishlist on Telegram. It uses the [python-telegram-bot](https://github.com/python-telegram-bot/python-telegram-bot) Framework for talking to Telegram servers. 7 | To get the price of the site it uses pyquery as html parser, since there is no official API for grabbing prices. 8 | 9 | ## ⚠ Project Status 10 | This project is no longer maintained! I created a new version of this project written in Go. You can find it at [d-Rickyy-b/GoGeizhalsBot](https://github.com/d-Rickyy-b/GoGeizhalsBot). 11 | 12 | ## Requirements 13 | This project requires **Python >= 3.6** to run. 14 | 15 | ## Setup with Docker 16 | The easiest way to get the bot up and running is via Docker. 17 | Set up a directory with a `config.py` file (you can find an example of it [here](https://github.com/d-Rickyy-b/Python-GeizhalsBot/blob/master/config.sample.py)). If you want to make your logs accessible from the outside, add another volume as follows. 18 | 19 | Just run the following command to run the bot: 20 | 21 | `docker run -d --name ghbot --restart always -v /path/to/logs/:/usr/src/bot/logs -v /path/to/config.py:/usr/src/bot/config.py geizhalsbot` 22 | 23 | Don't forget to **exchange the paths mentioned above** with paths to your config file and logging directory. 24 | 25 | ## Setup as systemd service 26 | 27 | If you don't want to use docker but still want a comfortable way to control the bot, you can create a systemd service. 28 | 29 | Create a new file as root in the systemd folder: `/etc/systemd/system/geizhalsbot.service`. 30 | An example systemd configuration can be found in [this GitHub Gist](https://gist.github.com/d-Rickyy-b/6ef4c95bed57da1056e0c696a36e8559). Make sure to change the user and the paths accordingly. 31 | 32 | With `systemctl start geizhalsbot` you can start the bot. 33 | With `systemctl status geizhalsbot` the current status of the service is shown. 34 | Using `systemctl stop geizhalsbot` you can stop the service, 35 | 36 | More on systemd services can be found on the [freedesktop wiki](https://www.freedesktop.org/wiki/Software/systemd/). 37 | 38 | ## Known-Issues 39 | - The bot is triggered on every change - also if that change is only 0,01€. Later one should be able to set threshold values. 40 | -------------------------------------------------------------------------------- /bot/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d-Rickyy-b/Python-GeizhalsBot/a64f12178d16473f03c350bbef0dd7722e87188e/bot/__init__.py -------------------------------------------------------------------------------- /bot/core.py: -------------------------------------------------------------------------------- 1 | """Core file for the business logic to interact with the backend""" 2 | # -*- coding: utf-8 -*- 3 | 4 | import re 5 | 6 | from database.db_wrapper import DBwrapper 7 | from geizhals.entities import EntityType, Product, Wishlist 8 | from util.exceptions import AlreadySubscribedException, WishlistNotFoundException, ProductNotFoundException, \ 9 | InvalidURLException 10 | 11 | 12 | def add_user_if_new(user): 13 | """Save a user to the database, if the user is not already stored""" 14 | db = DBwrapper.get_instance() 15 | if not db.is_user_saved(user.user_id): 16 | db.add_user(user_id=user.user_id, first_name=user.first_name, last_name=user.last_name, username=user.username, lang_code=user.lang_code) 17 | 18 | 19 | def add_wishlist_if_new(wishlist): 20 | """Save a wishlist to the database, if it is not already stored""" 21 | db = DBwrapper.get_instance() 22 | 23 | if not db.is_wishlist_saved(wishlist.entity_id): 24 | # logger.debug("URL not in database!") 25 | db.add_wishlist(wishlist.entity_id, wishlist.name, wishlist.price, wishlist.url) 26 | else: 27 | pass 28 | # logger.debug("URL in database!") 29 | 30 | 31 | def add_product_if_new(product): 32 | """Save a product to the database, if it is not already stored""" 33 | db = DBwrapper.get_instance() 34 | 35 | if not db.is_product_saved(product.entity_id): 36 | db.add_product(product.entity_id, product.name, product.price, product.url) 37 | else: 38 | pass 39 | 40 | 41 | def add_entity_if_new(entity): 42 | db = DBwrapper.get_instance() 43 | if entity.TYPE == EntityType.WISHLIST: 44 | if db.is_wishlist_saved(entity.entity_id): 45 | return 46 | db.add_wishlist(entity.entity_id, entity.name, entity.price, entity.url) 47 | elif entity.TYPE == EntityType.PRODUCT: 48 | if db.is_product_saved(entity.entity_id): 49 | return 50 | db.add_product(entity.entity_id, entity.name, entity.price, entity.url) 51 | else: 52 | raise ValueError("Unknown EntityType") 53 | 54 | 55 | def is_user_wishlist_subscriber(user, wishlist): 56 | """Returns if a user is a wishlist subscriber""" 57 | db = DBwrapper.get_instance() 58 | 59 | return db.is_user_wishlist_subscriber(user.user_id, wishlist.entity_id) 60 | 61 | 62 | def subscribe_entity(user, entity): 63 | """Subscribe to an entity as a user""" 64 | db = DBwrapper.get_instance() 65 | if entity.TYPE == EntityType.WISHLIST: 66 | if not db.is_user_wishlist_subscriber(user.user_id, entity.entity_id): 67 | db.subscribe_wishlist(entity.entity_id, user.user_id) 68 | else: 69 | raise AlreadySubscribedException 70 | elif entity.TYPE == EntityType.PRODUCT: 71 | if not db.is_user_product_subscriber(user.user_id, entity.entity_id): 72 | db.subscribe_product(entity.entity_id, user.user_id) 73 | else: 74 | raise AlreadySubscribedException 75 | else: 76 | raise ValueError("Unknown EntityType") 77 | 78 | 79 | def unsubscribe_entity(user, entity): 80 | db = DBwrapper.get_instance() 81 | if entity.TYPE == EntityType.WISHLIST: 82 | db.unsubscribe_wishlist(user.user_id, entity.entity_id) 83 | elif entity.TYPE == EntityType.PRODUCT: 84 | db.unsubscribe_product(user.user_id, entity.entity_id) 85 | else: 86 | raise ValueError("Unknown EntityType") 87 | 88 | 89 | def get_all_entities(): 90 | """Returns all the entities in the database""" 91 | db = DBwrapper.get_instance() 92 | wishlists = db.get_all_wishlists() 93 | products = db.get_all_products() 94 | 95 | entities = wishlists + products 96 | 97 | return entities 98 | 99 | 100 | def get_all_entities_with_subscribers(): 101 | """Returns all the entities with subscribers in the database""" 102 | db = DBwrapper.get_instance() 103 | wishlists = db.get_all_subscribed_wishlists() 104 | products = db.get_all_subscribed_products() 105 | 106 | entities = wishlists + products 107 | 108 | return entities 109 | 110 | 111 | def get_all_wishlists_with_subscribers(): 112 | db = DBwrapper.get_instance() 113 | return db.get_all_subscribed_wishlists() 114 | 115 | 116 | def get_all_products_with_subscribers(): 117 | db = DBwrapper.get_instance() 118 | return db.get_all_subscribed_products() 119 | 120 | 121 | def get_wishlist(wishlist_id): 122 | """Returns the wishlist object for an product_id""" 123 | db = DBwrapper.get_instance() 124 | wishlist = db.get_wishlist_info(wishlist_id) 125 | 126 | if wishlist is None: 127 | raise WishlistNotFoundException 128 | 129 | return wishlist 130 | 131 | 132 | def get_product(product_id): 133 | """Returns the product object for an product_id""" 134 | db = DBwrapper.get_instance() 135 | product = db.get_product_info(product_id) 136 | 137 | if product is None: 138 | raise ProductNotFoundException 139 | 140 | return product 141 | 142 | 143 | def get_entity(entity_id, entity_type): 144 | if entity_type == EntityType.PRODUCT: 145 | return get_product(product_id=entity_id) 146 | elif entity_type == EntityType.WISHLIST: 147 | return get_wishlist(wishlist_id=entity_id) 148 | else: 149 | raise ValueError("Unknown EntityType") 150 | 151 | 152 | def get_wishlist_count(user_id): 153 | """Returns the count of subscribed wishlists for a user""" 154 | db = DBwrapper.get_instance() 155 | return db.get_subscribed_wishlist_count(user_id) 156 | 157 | 158 | def get_product_count(user_id): 159 | db = DBwrapper.get_instance() 160 | return db.get_subscribed_product_count(user_id) 161 | 162 | 163 | def get_wishlists_for_user(user_id): 164 | """Returns the subscribed wishlists for a certain user""" 165 | db = DBwrapper.get_instance() 166 | return db.get_wishlists_for_user(user_id) 167 | 168 | 169 | def get_products_for_user(user_id): 170 | """Returns the subscribed wishlists for a certain user""" 171 | db = DBwrapper.get_instance() 172 | return db.get_products_for_user(user_id) 173 | 174 | 175 | def get_user_by_id(user_id): 176 | db = DBwrapper.get_instance() 177 | return db.get_user(user_id) 178 | 179 | 180 | def get_wl_url(text): 181 | if re.match(Wishlist.url_pattern, text): 182 | return text 183 | else: 184 | raise InvalidURLException 185 | 186 | 187 | def get_p_url(text): 188 | if re.match(Product.url_pattern, text): 189 | return text 190 | else: 191 | raise InvalidURLException 192 | 193 | 194 | def get_e_url(text, entity_type): 195 | if entity_type == EntityType.WISHLIST: 196 | if re.match(Wishlist.url_pattern, text): 197 | return text 198 | elif entity_type == EntityType.PRODUCT: 199 | if re.match(Product.url_pattern, text): 200 | return text 201 | 202 | raise InvalidURLException 203 | 204 | 205 | def get_type_by_url(text): 206 | if re.match(Wishlist.url_pattern, text): 207 | return EntityType.WISHLIST 208 | elif re.match(Product.url_pattern, text): 209 | return EntityType.PRODUCT 210 | else: 211 | raise InvalidURLException 212 | 213 | 214 | def get_entity_subscribers(entity): 215 | """Returns the subscribers of an entity""" 216 | db = DBwrapper.get_instance() 217 | if entity.TYPE == EntityType.WISHLIST: 218 | return db.get_userids_for_wishlist(entity.entity_id) 219 | elif entity.TYPE == EntityType.PRODUCT: 220 | return db.get_userids_for_product(entity.entity_id) 221 | else: 222 | raise ValueError("Unknown EntityType") 223 | 224 | 225 | def update_entity_price(entity, price): 226 | """Update the price of an entity""" 227 | db = DBwrapper.get_instance() 228 | if entity.TYPE == EntityType.WISHLIST: 229 | db.update_wishlist_price(entity.entity_id, price) 230 | elif entity.TYPE == EntityType.PRODUCT: 231 | db.update_product_price(entity.entity_id, price) 232 | else: 233 | raise ValueError("Unknown EntityType") 234 | 235 | 236 | def update_entity_name(entity, name): 237 | """Update the name of an entity""" 238 | db = DBwrapper.get_instance() 239 | if entity.TYPE == EntityType.WISHLIST: 240 | db.update_wishlist_name(entity.entity_id, name) 241 | elif entity.TYPE == EntityType.PRODUCT: 242 | db.update_product_name(entity.entity_id, name) 243 | else: 244 | raise ValueError("Unknown EntityType") 245 | 246 | 247 | def rm_entity(entity): 248 | db = DBwrapper.get_instance() 249 | if entity.TYPE == EntityType.WISHLIST: 250 | db.rm_wishlist(entity.entity_id) 251 | elif entity.TYPE == EntityType.PRODUCT: 252 | db.rm_product(entity.entity_id) 253 | else: 254 | raise ValueError("Unknown EntityType") 255 | 256 | 257 | def get_price_history(entity, weeks=4): 258 | db = DBwrapper.get_instance() 259 | if entity.TYPE == EntityType.WISHLIST: 260 | return db.get_wishlist_price_history(entity.entity_id, weeks) 261 | elif entity.TYPE == EntityType.PRODUCT: 262 | return db.get_product_price_history(entity.entity_id, weeks) 263 | 264 | 265 | def get_price_count(): 266 | db = DBwrapper.get_instance() 267 | p_c = db.get_product_pricecount() 268 | wl_c = db.get_wishlist_pricecount() 269 | 270 | return p_c + wl_c 271 | 272 | 273 | def delete_user(user_id): 274 | db = DBwrapper.get_instance() 275 | db.delete_user(user_id) 276 | 277 | 278 | def get_all_subscribers(): 279 | db = DBwrapper.get_instance() 280 | return db.get_all_subscribers() 281 | 282 | 283 | def get_all_users(): 284 | db = DBwrapper.get_instance() 285 | return db.get_all_users() 286 | -------------------------------------------------------------------------------- /bot/menus/__init__.py: -------------------------------------------------------------------------------- 1 | from .mainmenu import MainMenu 2 | from .newpriceagentmenu import NewPriceAgentMenu 3 | from .showpriceagentsmenu import ShowPriceAgentsMenu 4 | from .showwlpriceagentsmenu import ShowWLPriceAgentsMenu 5 | from .showppriceagentsmenu import ShowPPriceAgentsMenu 6 | 7 | __all__ = ["MainMenu", "NewPriceAgentMenu", "ShowPriceAgentsMenu", "ShowWLPriceAgentsMenu", "ShowPPriceAgentsMenu"] 8 | -------------------------------------------------------------------------------- /bot/menus/mainmenu.py: -------------------------------------------------------------------------------- 1 | from telegram import InlineKeyboardButton, InlineKeyboardMarkup 2 | 3 | 4 | class MainMenu(object): 5 | prev_menu = None 6 | text = "Was möchtest du tun?" 7 | __keyboard_list = [[InlineKeyboardButton("Neuer Preisagent", callback_data="m00_newpriceagent"), 8 | InlineKeyboardButton("Meine Preisagenten", callback_data="m00_showpriceagents")]] 9 | keyboard = InlineKeyboardMarkup(__keyboard_list) 10 | -------------------------------------------------------------------------------- /bot/menus/newpriceagentmenu.py: -------------------------------------------------------------------------------- 1 | from telegram import InlineKeyboardButton, InlineKeyboardMarkup 2 | from .mainmenu import MainMenu 3 | 4 | 5 | class NewPriceAgentMenu(object): 6 | prev_menu = MainMenu 7 | text = "Wofür möchtest du einen Preisagenten einrichten?" 8 | __keyboard_list = [[InlineKeyboardButton("Wunschliste", callback_data='m01_addwishlist'), 9 | InlineKeyboardButton("Produkt", callback_data='m01_addproduct')], 10 | [InlineKeyboardButton("\U000021a9\U0000fe0f Zurück", callback_data='m01_back')]] 11 | keyboard = InlineKeyboardMarkup(__keyboard_list) 12 | -------------------------------------------------------------------------------- /bot/menus/showppriceagentsmenu.py: -------------------------------------------------------------------------------- 1 | from telegram import InlineKeyboardButton, InlineKeyboardMarkup 2 | from .showpriceagentsmenu import ShowPriceAgentsMenu 3 | 4 | 5 | class ShowPPriceAgentsMenu(object): 6 | prev_menu = ShowPriceAgentsMenu 7 | text = "Das sind deine Preisagenten für deine Produkte:" 8 | __keyboard_list = [[InlineKeyboardButton("\U000021a9\U0000fe0f Zurück", callback_data='m02_back')]] 9 | keyboard = InlineKeyboardMarkup(__keyboard_list) 10 | -------------------------------------------------------------------------------- /bot/menus/showpriceagentsmenu.py: -------------------------------------------------------------------------------- 1 | from telegram import InlineKeyboardButton, InlineKeyboardMarkup 2 | from .mainmenu import MainMenu 3 | 4 | 5 | class ShowPriceAgentsMenu(object): 6 | prev_menu = MainMenu 7 | text = "Welche Preisagenten möchtest du einsehen?" 8 | __keyboard_list = [[InlineKeyboardButton("Wunschlisten", callback_data='m02_showwishlists'), 9 | InlineKeyboardButton("Produkte", callback_data='m02_showproducts')], 10 | [InlineKeyboardButton("\U000021a9\U0000fe0f Zurück", callback_data='m02_back')]] 11 | keyboard = InlineKeyboardMarkup(__keyboard_list) 12 | -------------------------------------------------------------------------------- /bot/menus/showwlpriceagentsmenu.py: -------------------------------------------------------------------------------- 1 | from telegram import InlineKeyboardButton, InlineKeyboardMarkup 2 | from .showpriceagentsmenu import ShowPriceAgentsMenu 3 | 4 | 5 | class ShowWLPriceAgentsMenu(object): 6 | prev_menu = ShowPriceAgentsMenu 7 | text = "Das sind deine Preisagenten für deine Wunschlisten:" 8 | __keyboard_list = [[InlineKeyboardButton("\U000021a9\U0000fe0f Zurück", callback_data='m03_back')]] 9 | keyboard = InlineKeyboardMarkup(__keyboard_list) 10 | -------------------------------------------------------------------------------- /bot/menus/util.py: -------------------------------------------------------------------------------- 1 | from telegram import InlineKeyboardButton, InlineKeyboardMarkup 2 | 3 | 4 | cancel_button = InlineKeyboardButton("🚫 Abbrechen", callback_data='cancel') 5 | 6 | 7 | def get_entity_keyboard(entity_type, entity_id): 8 | """Returns an action keyboard for a single entity""" 9 | from geizhals.entities.entitytype import EntityType 10 | if entity_type == EntityType.WISHLIST: 11 | back_action = "m02_showwishlists" 12 | elif entity_type == EntityType.PRODUCT: 13 | back_action = "m02_showproducts" 14 | else: 15 | raise ValueError("Unknown EntityType") 16 | back_button = InlineKeyboardButton("\U000021a9\U0000fe0f Zurück", callback_data=back_action) 17 | delete_button = InlineKeyboardButton("❌ Löschen", callback_data="m04_delete_{entity_id}_{entity_type}".format( 18 | entity_id=entity_id, entity_type=entity_type.value)) 19 | history_button = InlineKeyboardButton("📊 Preisverlauf", callback_data="m04_history_{entity_id}_{entity_type}".format( 20 | entity_id=entity_id, entity_type=entity_type.value)) 21 | 22 | return InlineKeyboardMarkup([[delete_button, history_button], [back_button]]) 23 | 24 | 25 | def get_entities_keyboard(action, entities, prefix_text="", cancel=False, columns=2): 26 | """Returns a formatted inline keyboard for entity buttons for the m03 menu""" 27 | buttons = [] 28 | 29 | for entity in entities: 30 | callback_data = 'm04_{action}_{id}_{type}'.format(action=action, id=entity.entity_id, type=entity.TYPE.value) 31 | button = InlineKeyboardButton(prefix_text + entity.name, callback_data=callback_data) 32 | buttons.append(button) 33 | 34 | return generate_keyboard(buttons, columns, cancel, back_action="m00_showpriceagents") 35 | 36 | 37 | def generate_keyboard(buttons, columns, cancel=False, back_action=None): 38 | """Generate an inline keyboard with the specified amount of columns""" 39 | keyboard = [] 40 | 41 | row = [] 42 | for button in buttons: 43 | row.append(button) 44 | if len(row) >= columns: 45 | keyboard.append(row) 46 | row = [] 47 | 48 | if len(row) > 0: 49 | keyboard.append(row) 50 | 51 | if cancel: 52 | keyboard.append([cancel_button]) 53 | 54 | if back_action: 55 | keyboard.append([InlineKeyboardButton("\U000021a9\U0000fe0f Zurück", callback_data=back_action)]) 56 | 57 | return InlineKeyboardMarkup(keyboard) 58 | -------------------------------------------------------------------------------- /bot/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d-Rickyy-b/Python-GeizhalsBot/a64f12178d16473f03c350bbef0dd7722e87188e/bot/tests/__init__.py -------------------------------------------------------------------------------- /bot/user.py: -------------------------------------------------------------------------------- 1 | """User entity data model""" 2 | 3 | 4 | # -*- coding: utf-8 -*- 5 | 6 | 7 | class User(object): 8 | 9 | def __init__(self, user_id: int, first_name: str, last_name: str, username: str, lang_code: str): 10 | self.user_id = user_id 11 | self.first_name = first_name 12 | self.last_name = last_name or "" 13 | self.username = username 14 | self.lang_code = lang_code or "de-DE" 15 | -------------------------------------------------------------------------------- /config.sample.py: -------------------------------------------------------------------------------- 1 | BOT_TOKEN = "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" 2 | USE_WEBHOOK = False 3 | WEBHOOK_IP = "0.0.0.0" 4 | WEBHOOK_PORT = 9001 5 | WEBHOOK_URL = "https://domain.example.com/" + BOT_TOKEN 6 | CERTPATH = "/etc/certs/example.com/fullchain.cer" 7 | USE_PROXIES = True 8 | PROXY_LIST = "proxies.txt" 9 | ADMIN_IDs = [1234, 4321] 10 | 11 | MAX_WISHLISTS = 5 12 | MAX_PRODUCTS = 5 13 | -------------------------------------------------------------------------------- /database/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d-Rickyy-b/Python-GeizhalsBot/a64f12178d16473f03c350bbef0dd7722e87188e/database/__init__.py -------------------------------------------------------------------------------- /database/db_wrapper.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import logging 3 | import os 4 | import sqlite3 5 | from datetime import datetime 6 | 7 | from bot.user import User 8 | from geizhals.entities import Product, Wishlist 9 | 10 | __author__ = 'Rico' 11 | 12 | 13 | class DBwrapper(object): 14 | class __DBwrapper(object): 15 | dir_path = os.path.dirname(os.path.abspath(__file__)) 16 | logger = logging.getLogger(__name__) 17 | 18 | def __init__(self, db_name="users.db"): 19 | database_path = os.path.join(self.dir_path, db_name) 20 | 21 | self.connection = None 22 | self.cursor = None 23 | 24 | self.create_database(database_path) 25 | self.setup_connection(database_path) 26 | self.create_tables() 27 | self.migrate_db() 28 | 29 | def delete_all_tables(self): 30 | self.logger.info("Dropping all tables!") 31 | self.cursor.execute("DROP TABLE IF EXISTS wishlist_subscribers;") 32 | self.cursor.execute("DROP TABLE IF EXISTS product_subscribers;") 33 | self.cursor.execute("DROP TABLE IF EXISTS wishlist_prices;") 34 | self.cursor.execute("DROP TABLE IF EXISTS product_prices;") 35 | self.cursor.execute("DROP TABLE IF EXISTS wishlists;") 36 | self.cursor.execute("DROP TABLE IF EXISTS products;") 37 | self.cursor.execute("DROP TABLE IF EXISTS users;") 38 | self.connection.commit() 39 | self.logger.info("Dropping complete!") 40 | 41 | def create_database(self, database_path): 42 | """Create database file and add admin and users table to the database""" 43 | if not os.path.exists(database_path): 44 | self.logger.info("File '{}' does not exist! Trying to create one.".format(database_path)) 45 | try: 46 | open(database_path, 'a').close() 47 | except Exception as e: 48 | self.logger.error("An error has occurred while creating the database!") 49 | self.logger.error(e) 50 | 51 | def create_tables(self): 52 | """Creates all the tables of the database, if they don't exist""" 53 | version = int(self.cursor.execute("PRAGMA user_version").fetchone()[0]) 54 | self.logger.info("Current db version: {}".format(version)) 55 | if version > 0: 56 | return 57 | 58 | self.logger.info("Creating tables if they don't exist!") 59 | 60 | self.cursor.execute("CREATE TABLE IF NOT EXISTS 'users' \ 61 | ('user_id' INTEGER NOT NULL PRIMARY KEY UNIQUE, \ 62 | 'first_name' TEXT, \ 63 | 'last_name' TEXT, \ 64 | 'username' TEXT, \ 65 | 'first_use' INTEGER NOT NULL DEFAULT 0, \ 66 | 'lang_code' TEXT NOT NULL DEFAULT 'en_US');") 67 | 68 | self.cursor.execute("CREATE TABLE IF NOT EXISTS 'products' \ 69 | ('product_id' INTEGER NOT NULL PRIMARY KEY UNIQUE, \ 70 | 'name' TEXT NOT NULL DEFAULT 'No title', \ 71 | 'price' REAL NOT NULL DEFAULT 0, \ 72 | 'url' TEXT NOT NULL);") 73 | 74 | self.cursor.execute("CREATE TABLE IF NOT EXISTS 'wishlists' \ 75 | ('wishlist_id' INTEGER NOT NULL PRIMARY KEY UNIQUE, \ 76 | 'name' TEXT NOT NULL DEFAULT 'No title', \ 77 | 'price' REAL NOT NULL DEFAULT 0, \ 78 | 'url' TEXT NOT NULL);") 79 | 80 | self.cursor.execute("CREATE TABLE IF NOT EXISTS 'product_prices' \ 81 | ('product_id' INTEGER NOT NULL, \ 82 | 'price' REAL NOT NULL DEFAULT 0, \ 83 | 'timestamp' INTEGER NOT NULL DEFAULT 0, \ 84 | FOREIGN KEY('product_id') REFERENCES products(product_id) ON DELETE CASCADE ON UPDATE CASCADE);") 85 | 86 | self.cursor.execute("CREATE TABLE IF NOT EXISTS 'wishlist_prices' \ 87 | ('wishlist_id' INTEGER NOT NULL, \ 88 | 'price' REAL NOT NULL DEFAULT 0, \ 89 | 'timestamp' INTEGER NOT NULL DEFAULT 0, \ 90 | FOREIGN KEY('wishlist_id') REFERENCES wishlists(wishlist_id) ON DELETE CASCADE ON UPDATE CASCADE);") 91 | 92 | self.cursor.execute("CREATE TABLE IF NOT EXISTS 'product_subscribers' \ 93 | ('product_id' INTEGER NOT NULL, \ 94 | 'user_id' INTEGER NOT NULL, \ 95 | FOREIGN KEY('product_id') REFERENCES products(product_id) ON DELETE CASCADE ON UPDATE CASCADE,\ 96 | FOREIGN KEY('user_id') REFERENCES users(user_id) ON DELETE CASCADE);") 97 | 98 | self.cursor.execute("CREATE TABLE IF NOT EXISTS 'wishlist_subscribers' \ 99 | ('wishlist_id' INTEGER NOT NULL, \ 100 | 'user_id' INTEGER NOT NULL, \ 101 | FOREIGN KEY('wishlist_id') REFERENCES wishlists(wishlist_id) ON DELETE CASCADE ON UPDATE CASCADE, \ 102 | FOREIGN KEY('user_id') REFERENCES users(user_id) ON DELETE CASCADE);") 103 | 104 | self.cursor.execute("PRAGMA user_version = 1;") 105 | self.connection.commit() 106 | 107 | def migrate_db(self): 108 | """Run migrations, when there needs to be specific database changes, after the software is productive""" 109 | version = int(self.cursor.execute("PRAGMA user_version").fetchone()[0]) 110 | self.logger.info("Using geizhalsbot database version {}".format(version)) 111 | 112 | if version < 1: 113 | # Migration 1 114 | # Adding last_name and first_use variables to users table 115 | self.logger.info("Running migration 0!") 116 | self.cursor.execute("ALTER TABLE users ADD 'last_name' TEXT;") 117 | self.cursor.execute("ALTER TABLE users ADD 'first_use' INTEGER NOT NULL DEFAULT 0;") 118 | self.cursor.execute("PRAGMA user_version = 1;") 119 | self.connection.commit() 120 | self.logger.info("Migration 0 successfully executed!") 121 | # if version < 2: 122 | # Migration 2 123 | # self.logger.info("Running migration 2!") 124 | 125 | def setup_connection(self, database_path): 126 | self.connection = sqlite3.connect(database_path, check_same_thread=False) 127 | self.connection.execute("PRAGMA foreign_keys = ON;") 128 | self.connection.text_factory = lambda x: str(x, 'utf-8', "ignore") 129 | self.cursor = self.connection.cursor() 130 | 131 | def get_subscribed_wishlist_count(self, user_id): 132 | self.cursor.execute("SELECT COUNT(*) " 133 | "FROM wishlists " 134 | "INNER JOIN wishlist_subscribers on wishlist_subscribers.wishlist_id=wishlists.wishlist_id " 135 | "WHERE wishlist_subscribers.user_id=?;", [str(user_id)]) 136 | return self.cursor.fetchone()[0] 137 | 138 | def get_subscribed_product_count(self, user_id): 139 | self.cursor.execute("SELECT COUNT(*) " 140 | "FROM products " 141 | "INNER JOIN product_subscribers on product_subscribers.product_id=products.product_id " 142 | "WHERE product_subscribers.user_id=?;", [str(user_id)]) 143 | return self.cursor.fetchone()[0] 144 | 145 | def get_all_wishlists(self): 146 | self.cursor.execute("SELECT wishlist_id, name, price, url FROM wishlists;") 147 | wishlist_l = self.cursor.fetchall() 148 | wishlists = [] 149 | 150 | for line in wishlist_l: 151 | wishlists.append(Wishlist(entity_id=line[0], name=line[1], price=line[2], url=line[3])) 152 | 153 | return wishlists 154 | 155 | def get_all_subscribed_wishlists(self): 156 | self.cursor.execute("SELECT w.wishlist_id, name, price, url " 157 | "FROM wishlists w " 158 | "INNER JOIN wishlist_subscribers ws ON ws.wishlist_id=w.wishlist_id " 159 | "GROUP BY w.wishlist_id;") 160 | wishlist_l = self.cursor.fetchall() 161 | wishlists = [] 162 | 163 | for line in wishlist_l: 164 | wishlists.append(Wishlist(entity_id=line[0], name=line[1], price=line[2], url=line[3])) 165 | 166 | return wishlists 167 | 168 | def get_all_products(self): 169 | self.cursor.execute("SELECT product_id, name, price, url FROM products;") 170 | product_l = self.cursor.fetchall() 171 | products = [] 172 | 173 | for line in product_l: 174 | products.append(Product(entity_id=line[0], name=line[1], price=line[2], url=line[3])) 175 | 176 | return products 177 | 178 | def get_all_subscribed_products(self): 179 | self.cursor.execute("SELECT p.product_id, name, price, url " 180 | "FROM products p " 181 | "INNER JOIN product_subscribers ps ON ps.product_id=p.product_id " 182 | "GROUP BY p.product_id;") 183 | product_l = self.cursor.fetchall() 184 | products = [] 185 | 186 | for line in product_l: 187 | products.append(Product(entity_id=line[0], name=line[1], price=line[2], url=line[3])) 188 | 189 | return products 190 | 191 | def get_wishlist_info(self, wishlist_id): 192 | self.cursor.execute("SELECT wishlist_id, name, price, url FROM wishlists WHERE wishlist_id=?;", [str(wishlist_id)]) 193 | wishlist = self.cursor.fetchone() 194 | 195 | if wishlist is not None: 196 | wl_id, name, price, url = wishlist 197 | return Wishlist(entity_id=wl_id, name=name, price=price, url=url) 198 | 199 | return None 200 | 201 | def get_product_info(self, product_id): 202 | self.cursor.execute("SELECT product_id, name, price, url FROM products WHERE product_id=?;", [str(product_id)]) 203 | product = self.cursor.fetchone() 204 | 205 | if product is None: 206 | return None 207 | 208 | p_id, name, price, url = product 209 | return Product(entity_id=p_id, name=name, price=price, url=url) 210 | 211 | def is_wishlist_saved(self, wishlist_id): 212 | self.cursor.execute("SELECT count(*) FROM wishlists WHERE wishlist_id=?;", [str(wishlist_id)]) 213 | result = self.cursor.fetchone()[0] 214 | 215 | return result > 0 216 | 217 | def is_product_saved(self, product_id): 218 | self.cursor.execute("SELECT count(*) FROM products WHERE product_id=?;", [str(product_id)]) 219 | result = self.cursor.fetchone()[0] 220 | return result > 0 221 | 222 | def add_wishlist(self, wishlist_id, name, price, url): 223 | self.cursor.execute("INSERT INTO wishlists (wishlist_id, name, price, url) VALUES (?, ?, ?, ?);", 224 | [str(wishlist_id), str(name), str(price), str(url)]) 225 | self.connection.commit() 226 | 227 | def add_product(self, product_id, name, price, url): 228 | self.cursor.execute("INSERT INTO products (product_id, name, price, url) VALUES (?, ?, ?, ?);", 229 | [str(product_id), str(name), str(price), str(url)]) 230 | self.connection.commit() 231 | 232 | def rm_wishlist(self, wishlist_id): 233 | self.cursor.execute("DELETE FROM wishlists WHERE wishlists.wishlist_id=?", [str(wishlist_id)]) 234 | self.connection.commit() 235 | 236 | def rm_product(self, product_id): 237 | self.cursor.execute("DELETE FROM products WHERE products.product_id=?", [str(product_id)]) 238 | self.connection.commit() 239 | 240 | def subscribe_wishlist(self, wishlist_id, user_id): 241 | self.cursor.execute("INSERT INTO wishlist_subscribers (wishlist_id, user_id) VALUES (?, ?);", [str(wishlist_id), str(user_id)]) 242 | self.connection.commit() 243 | 244 | def subscribe_product(self, product_id, user_id): 245 | self.cursor.execute("INSERT INTO product_subscribers (product_id, user_id) VALUES (?, ?);", [str(product_id), str(user_id)]) 246 | self.connection.commit() 247 | 248 | def unsubscribe_wishlist(self, user_id, wishlist_id): 249 | self.cursor.execute("DELETE FROM wishlist_subscribers WHERE user_id=? and wishlist_id=?;", [str(user_id), str(wishlist_id)]) 250 | self.connection.commit() 251 | 252 | def unsubscribe_product(self, user_id, product_id): 253 | self.cursor.execute("DELETE FROM product_subscribers WHERE user_id=? and product_id=?;", [str(user_id), str(product_id)]) 254 | self.connection.commit() 255 | 256 | def get_user(self, user_id): 257 | self.cursor.execute("SELECT user_id, first_name, last_name, username, lang_code FROM users WHERE user_id=?;", [str(user_id)]) 258 | user_data = self.cursor.fetchone() 259 | if user_data: 260 | return User(user_id=user_data[0], first_name=user_data[1], last_name=user_data[2], username=user_data[3], lang_code=user_data[4]) 261 | return None 262 | 263 | def get_userids_for_wishlist(self, wishlist_id): 264 | self.cursor.execute("SELECT user_id FROM 'wishlist_subscribers' AS ws " 265 | "INNER JOIN wishlists " 266 | "ON wishlists.wishlist_id=ws.wishlist_id " 267 | "WHERE ws.wishlist_id=?;", [str(wishlist_id)]) 268 | user_list = self.cursor.fetchall() 269 | user_ids = [] 270 | 271 | for line in user_list: 272 | user_ids.append(line[0]) 273 | 274 | return user_ids 275 | 276 | def get_userids_for_product(self, product_id): 277 | self.cursor.execute("SELECT user_id FROM 'product_subscribers' AS ps " 278 | "INNER JOIN products " 279 | "ON products.product_id=ps.product_id " 280 | "WHERE ps.product_id=?;", [str(product_id)]) 281 | 282 | user_list = self.cursor.fetchall() 283 | user_ids = [] 284 | 285 | for line in user_list: 286 | user_ids.append(line[0]) 287 | 288 | return user_ids 289 | 290 | def get_wishlists_for_user(self, user_id): 291 | """Return all wishlists a user subscribed to""" 292 | self.cursor.execute( 293 | "SELECT wishlists.wishlist_id, wishlists.name, wishlists.price, wishlists.url \ 294 | FROM 'wishlist_subscribers' AS ws \ 295 | INNER JOIN wishlists on wishlists.wishlist_id=ws.wishlist_id \ 296 | WHERE ws.user_id=?;", [str(user_id)]) 297 | wishlist_l = self.cursor.fetchall() 298 | wishlists = [] 299 | 300 | for line in wishlist_l: 301 | wishlists.append(Wishlist(entity_id=line[0], name=line[1], price=line[2], url=line[3])) 302 | 303 | return wishlists 304 | 305 | def get_products_for_user(self, user_id): 306 | """Returns all products a user subscribed to""" 307 | self.cursor.execute( 308 | "SELECT products.product_id, products.name, products.price, products.url \ 309 | FROM 'product_subscribers' AS ps \ 310 | INNER JOIN products on products.product_id=ps.product_id \ 311 | WHERE ps.user_id=?;", [str(user_id)]) 312 | product_l = self.cursor.fetchall() 313 | products = [] 314 | 315 | for line in product_l: 316 | products.append(Product(entity_id=line[0], name=line[1], price=line[2], url=line[3])) 317 | 318 | return products 319 | 320 | def is_user_wishlist_subscriber(self, user_id, wishlist_id): 321 | self.cursor.execute("SELECT * FROM wishlist_subscribers AS ws WHERE ws.user_id=? AND ws.wishlist_id=?;", [str(user_id), str(wishlist_id)]) 322 | result = self.cursor.fetchone() 323 | return result and len(result) > 0 324 | 325 | def is_user_product_subscriber(self, user_id, product_id): 326 | self.cursor.execute("SELECT * FROM product_subscribers AS ps WHERE ps.user_id=? AND ps.product_id=?;", [str(user_id), str(product_id)]) 327 | result = self.cursor.fetchone() 328 | return result and len(result) > 0 329 | 330 | def update_wishlist_name(self, wishlist_id, name): 331 | self.cursor.execute("UPDATE wishlists SET name=? WHERE wishlist_id=?;", [str(name), str(wishlist_id)]) 332 | self.connection.commit() 333 | 334 | def update_product_name(self, product_id, name): 335 | self.cursor.execute("UPDATE products SET name=? WHERE product_id=?;", [str(name), str(product_id)]) 336 | self.connection.commit() 337 | 338 | def update_wishlist_price(self, wishlist_id, price): 339 | """Update the price of a wishlist in the database and add a price entry in the wishlist_prices table""" 340 | self.cursor.execute("UPDATE wishlists SET price=? WHERE wishlist_id=?;", [str(price), str(wishlist_id)]) 341 | try: 342 | utc_timestamp_now = int(datetime.utcnow().timestamp()) 343 | self.cursor.execute("INSERT INTO wishlist_prices (wishlist_id, price, timestamp) VALUES (?, ?, ?)", [str(wishlist_id), str(price), str(utc_timestamp_now)]) 344 | except sqlite3.IntegrityError: 345 | self.logger.error("Insert into wishlist_prices not possible: {}, {}".format(wishlist_id, price)) 346 | self.connection.commit() 347 | 348 | def update_product_price(self, product_id, price): 349 | """Update the price of a product in the database and add a price entry in the product_prices table""" 350 | self.cursor.execute("UPDATE products SET price=? WHERE product_id=?;", [str(price), str(product_id)]) 351 | try: 352 | utc_timestamp_now = int(datetime.utcnow().timestamp()) 353 | self.cursor.execute("INSERT INTO product_prices (product_id, price, timestamp) VALUES (?, ?, ?)", [str(product_id), str(price), str(utc_timestamp_now)]) 354 | except sqlite3.IntegrityError: 355 | self.logger.error("Insert into product_prices not possible: {}, {}".format(product_id, price)) 356 | self.connection.commit() 357 | 358 | def get_product_price_history(self, product_id, weeks): 359 | """Returns a sorted list of prices and timestamps when those prices got seen""" 360 | utc_timestamp_now = int(datetime.utcnow().timestamp()) 361 | week_in_seconds = (60 * 60 * 24 * 7 * weeks) 362 | utc_timestamp_last_week = utc_timestamp_now - week_in_seconds 363 | self.cursor.execute("SELECT product_prices.price, product_prices.timestamp, products.name from product_prices \ 364 | INNER JOIN products \ 365 | ON product_prices.product_id=products.product_id \ 366 | WHERE product_prices.product_id=? AND product_prices.timestamp>? \ 367 | ORDER BY product_prices.timestamp DESC", [str(product_id), str(utc_timestamp_last_week)]) 368 | results = self.cursor.fetchall() 369 | return results 370 | 371 | def get_wishlist_price_history(self, wishlist_id, weeks): 372 | """Returns a sorted list of prices and timestamps when those prices got seen""" 373 | utc_timestamp_now = int(datetime.utcnow().timestamp()) 374 | week_in_seconds = (60 * 60 * 24 * 7 * weeks) 375 | utc_timestamp_last_week = utc_timestamp_now - week_in_seconds 376 | self.cursor.execute("SELECT wishlist_prices.price, wishlist_prices.timestamp, wishlists.name from wishlist_prices \ 377 | INNER JOIN wishlists \ 378 | ON wishlist_prices.wishlist_id=wishlists.wishlist_id \ 379 | WHERE wishlist_prices.wishlist_id=? AND wishlist_prices.timestamp>? \ 380 | ORDER BY wishlist_prices.timestamp DESC", [str(wishlist_id), str(utc_timestamp_last_week)]) 381 | results = self.cursor.fetchall() 382 | return results 383 | 384 | def get_wishlist_pricecount(self): 385 | """Returns the amount of stored prices""" 386 | self.cursor.execute("SELECT count(*) FROM wishlist_prices;") 387 | result = self.cursor.fetchone()[0] 388 | 389 | return result 390 | 391 | def get_product_pricecount(self): 392 | """Returns the amount of stored prices""" 393 | self.cursor.execute("SELECT count(*) FROM product_prices;") 394 | result = self.cursor.fetchone()[0] 395 | 396 | return result 397 | 398 | def get_all_users(self): 399 | self.cursor.execute("SELECT user_id, first_name, username, lang_code FROM users;") 400 | result = self.cursor.fetchall() 401 | users = [] 402 | 403 | if result: 404 | for user in result: 405 | users.append({"user_id": user[0], "first_name": user[1], "username": user[2], "lang_code": user[3]}) 406 | 407 | return users 408 | 409 | def get_all_subscribers(self): 410 | self.cursor.execute("SELECT user_id from wishlist_subscribers UNION SELECT user_id from product_subscribers GROUP BY user_id;") 411 | result = self.cursor.fetchall() 412 | users = [] 413 | 414 | if result: 415 | for user in result: 416 | users.append(user[0]) 417 | 418 | return users 419 | 420 | def get_lang_id(self, user_id): 421 | self.cursor.execute("SELECT lang_code FROM users WHERE user_id=?;", [str(user_id)]) 422 | result = self.cursor.fetchone() 423 | if result: 424 | return result[0] 425 | else: 426 | return "en" 427 | 428 | def add_user(self, user_id, first_name, last_name, username, lang_code="de-DE"): 429 | lang_code = lang_code or "de-DE" 430 | first_use = int(datetime.utcnow().timestamp()) 431 | try: 432 | self.cursor.execute("INSERT INTO users (user_id, first_name, last_name, username, first_use, lang_code) VALUES (?, ?, ?, ?, ?, ?);", 433 | (str(user_id), str(first_name), last_name, username, first_use, str(lang_code))) 434 | self.connection.commit() 435 | except sqlite3.IntegrityError: 436 | # print("User already exists") 437 | pass 438 | 439 | def delete_user(self, user_id): 440 | """Delete a user and remove its subscriptions from the database""" 441 | try: 442 | self.cursor.execute("DELETE FROM users WHERE user_id=?;", [user_id]) 443 | self.connection.commit() 444 | except Exception as e: 445 | logging.error(e) 446 | 447 | def is_user_saved(self, user_id): 448 | self.cursor.execute("SELECT rowid, * FROM users WHERE user_id=?;", [str(user_id)]) 449 | result = self.cursor.fetchall() 450 | 451 | if len(result) > 0: 452 | return True 453 | 454 | return False 455 | 456 | def close_conn(self): 457 | self.connection.close() 458 | 459 | instance = None 460 | 461 | def __init__(self, db_name="users.db"): 462 | if not DBwrapper.instance: 463 | DBwrapper.instance = DBwrapper.__DBwrapper(db_name) 464 | 465 | @staticmethod 466 | def get_instance(db_name="users.db"): 467 | if not DBwrapper.instance: 468 | DBwrapper.instance = DBwrapper.__DBwrapper(db_name) 469 | 470 | return DBwrapper.instance 471 | -------------------------------------------------------------------------------- /database/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d-Rickyy-b/Python-GeizhalsBot/a64f12178d16473f03c350bbef0dd7722e87188e/database/tests/__init__.py -------------------------------------------------------------------------------- /database/tests/db_wrapper_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | import unittest 5 | 6 | from database.db_wrapper import DBwrapper 7 | from geizhals.entities import Product, Wishlist 8 | 9 | 10 | class DBWrapperTest(unittest.TestCase): 11 | 12 | def setUp(self): 13 | self.db_name = "test.db" 14 | self.db_name_test_create = "test_create.db" 15 | self.db = DBwrapper.get_instance(self.db_name) 16 | 17 | # Define sample wishlist and product 18 | self.wl = Wishlist(123456, "Wishlist", "https://geizhals.de/?cat=WL-123456", 123.45) 19 | self.p = Product(123456, "Product", "https://geizhals.de/a123456", 123.45) 20 | self.user = {"user_id": 415641, "first_name": "Peter", "last_name": "Müller", "username": "jkopsdfjk", "lang_code": "en_US"} 21 | self.user2 = {"user_id": 123456, "first_name": "John", "last_name": "Doe", "username": "ölyjsdf", "lang_code": "de"} 22 | 23 | def tearDown(self): 24 | self.db.delete_all_tables() 25 | self.db.close_conn() 26 | try: 27 | test_db = os.path.join(self.db.dir_path, self.db_name) 28 | test_create_db = os.path.join(self.db.dir_path, self.db_name_test_create) 29 | os.remove(test_db) 30 | os.remove(test_create_db) 31 | except OSError as e: 32 | pass 33 | 34 | DBwrapper.instance = None 35 | 36 | def helper_add_user(self, user): 37 | self.db.add_user(user.get("user_id"), user.get("first_name"), user.get("last_name"), user.get("username"), user.get("lang_code")) 38 | 39 | def test_create_database(self): 40 | """Test for checking if the database gets created correctly""" 41 | # Use another path, since we want to check that method independendly from the initialization 42 | path = self.db.dir_path 43 | db_path = os.path.join(path, self.db_name_test_create) 44 | 45 | # Check if db file doesn't already exist 46 | self.assertFalse(os.path.exists(db_path)) 47 | 48 | # Create database file 49 | self.db.create_database(db_path) 50 | 51 | # Check if the db file was created in the directory 52 | self.assertTrue(os.path.exists(db_path)) 53 | 54 | def test_create_tables(self): 55 | """Test for checking if the database tables are created correctly""" 56 | table_names = ["users", "products", "wishlists", "product_prices", "wishlist_prices", "product_subscribers", "wishlist_subscribers"] 57 | 58 | # Use another path, since we want to check that method independendly from the initialization 59 | path = self.db.dir_path 60 | db_path = os.path.join(path, self.db_name_test_create) 61 | 62 | # Create database file 63 | self.db.create_database(db_path) 64 | self.db.setup_connection(db_path) 65 | 66 | # Make sure that tables are not already present in the database 67 | for table_name in table_names: 68 | result = self.db.cursor.execute("SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?;", [table_name]).fetchone()[0] 69 | self.assertEqual(result, 0, msg="Table '{}' does already exist!".format(table_name)) 70 | 71 | # Create tables in the database 72 | self.db.create_tables() 73 | 74 | # Make sure that all the tables are correctly created 75 | for table_name in table_names: 76 | result = self.db.cursor.execute("SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?;", [table_name]).fetchone()[0] 77 | self.assertEqual(result, 1, msg="Table '{}' does not exist!".format(table_name)) 78 | 79 | def test_get_subscribed_wishlist_count(self): 80 | """Test to check if the subscribed wishlist count is correct""" 81 | user_id = 11223344 82 | first_name = "John" 83 | last_name = "Doe" 84 | username = "JohnDoe" 85 | wl2 = Wishlist("9922113", "TestName", "https://geizhals.de/?cat=WL-123456", 12.12) 86 | 87 | self.db.add_wishlist(wishlist_id=self.wl.entity_id, name=self.wl.name, url=self.wl.url, price=self.wl.price) 88 | self.db.add_wishlist(wishlist_id=wl2.entity_id, name=wl2.name, url=wl2.url, price=wl2.price) 89 | 90 | # Make sure that count is 0 in the beginning 91 | self.db.add_user(user_id, first_name, last_name, username) 92 | count = self.db.get_subscribed_wishlist_count(user_id) 93 | self.assertEqual(count, 0) 94 | 95 | # Subscribe to first wishlist and check that count equals to 1 96 | self.db.subscribe_wishlist(self.wl.entity_id, user_id) 97 | count = self.db.get_subscribed_wishlist_count(user_id) 98 | self.assertEqual(count, 1) 99 | 100 | # Subscribe to second wishlist and check that count equals to 2 101 | self.db.subscribe_wishlist(wl2.entity_id, user_id) 102 | count = self.db.get_subscribed_wishlist_count(user_id) 103 | self.assertEqual(count, 2) 104 | 105 | # Check that after unsubscribing the count decreases 106 | self.db.unsubscribe_wishlist(user_id, self.wl.entity_id) 107 | count = self.db.get_subscribed_wishlist_count(user_id) 108 | self.assertEqual(count, 1) 109 | 110 | def test_get_subscribed_product_count(self): 111 | """Test to check if the subscribed product count is correct""" 112 | user_id = 11223344 113 | first_name = "John" 114 | last_name = "Doe" 115 | username = "JohnDoe" 116 | p2 = Product("9922113", "TestName", "https://geizhals.de/?cat=WL-123456", 12.12) 117 | 118 | self.db.add_product(product_id=self.p.entity_id, name=self.p.name, url=self.p.url, price=self.p.price) 119 | self.db.add_product(product_id=p2.entity_id, name=p2.name, url=p2.url, price=p2.price) 120 | 121 | # Make sure that count is 0 in the beginning 122 | self.db.add_user(user_id, first_name, last_name, username) 123 | count = self.db.get_subscribed_product_count(user_id) 124 | self.assertEqual(count, 0) 125 | 126 | # Subscribe to first product and check that count equals to 1 127 | self.db.subscribe_product(self.p.entity_id, user_id) 128 | count = self.db.get_subscribed_product_count(user_id) 129 | self.assertEqual(count, 1) 130 | 131 | # Subscribe to second product and check that count equals to 2 132 | self.db.subscribe_product(p2.entity_id, user_id) 133 | count = self.db.get_subscribed_product_count(user_id) 134 | self.assertEqual(count, 2) 135 | 136 | # Check that after unsubscribing the count decreases 137 | self.db.unsubscribe_product(user_id, self.p.entity_id) 138 | count = self.db.get_subscribed_product_count(user_id) 139 | self.assertEqual(count, 1) 140 | 141 | def test_get_all_wishlists(self): 142 | """Test to check if all wishlists can be retreived from the db""" 143 | wishlists = [{"entity_id": 962572, "name": "NIU2E0RRWX", "url": "https://geizhals.de/?cat=WL-962572", "price": 62.80}, 144 | {"entity_id": 924729, "name": "3W5NQ1QIHT", "url": "https://geizhals.de/?cat=WL-924729", "price": 46.00}, 145 | {"entity_id": 614044, "name": "CTYCTW798V", "url": "https://geizhals.de/?cat=WL-614044", "price": 96.95}, 146 | {"entity_id": 245759, "name": "VDY66U0AWM", "url": "https://geizhals.de/?cat=WL-245759", "price": 53.94}, 147 | {"entity_id": 490792, "name": "N6MCC1Z38O", "url": "https://geizhals.de/?cat=WL-490792", "price": 144.85}, 148 | {"entity_id": 533484, "name": "NOJJ8KVE9T", "url": "https://geizhals.de/?cat=WL-533484", "price": 122.77}, 149 | {"entity_id": 577007, "name": "ELV51DSL2A", "url": "https://geizhals.de/?cat=WL-577007", "price": 62.68}, 150 | {"entity_id": 448441, "name": "6RM9F6IWIO", "url": "https://geizhals.de/?cat=WL-448441", "price": 45.97}, 151 | {"entity_id": 567418, "name": "C2W75RPRFS", "url": "https://geizhals.de/?cat=WL-567418", "price": 137.53}, 152 | {"entity_id": 590717, "name": "JEXP2E5Y06", "url": "https://geizhals.de/?cat=WL-590717", "price": 117.84}] 153 | 154 | for wl in wishlists: 155 | self.db.add_wishlist(wishlist_id=wl.get("entity_id"), name=wl.get("name"), url=wl.get("url"), price=wl.get("price")) 156 | 157 | db_wishlists = self.db.get_all_wishlists() 158 | 159 | for wl in wishlists: 160 | for db_wl in db_wishlists: 161 | if db_wl.entity_id == wl.get("entity_id"): 162 | break 163 | else: 164 | self.fail(msg="Inserted wishlist {} was not found!".format(wl.get("entity_id"))) 165 | 166 | def test_get_all_subscribed_wishlists(self): 167 | """Test to check if retrieving subscribed wishlists works""" 168 | wishlists = [{"entity_id": 962572, "name": "NIU2E0RRWX", "url": "https://geizhals.de/?cat=WL-962572", "price": 62.80}, 169 | {"entity_id": 924729, "name": "3W5NQ1QIHT", "url": "https://geizhals.de/?cat=WL-924729", "price": 46.00}, 170 | {"entity_id": 614044, "name": "CTYCTW798V", "url": "https://geizhals.de/?cat=WL-614044", "price": 96.95}, 171 | {"entity_id": 245759, "name": "VDY66U0AWM", "url": "https://geizhals.de/?cat=WL-245759", "price": 53.94}, 172 | {"entity_id": 490792, "name": "N6MCC1Z38O", "url": "https://geizhals.de/?cat=WL-490792", "price": 144.85}, 173 | {"entity_id": 533484, "name": "NOJJ8KVE9T", "url": "https://geizhals.de/?cat=WL-533484", "price": 122.77}, 174 | {"entity_id": 577007, "name": "ELV51DSL2A", "url": "https://geizhals.de/?cat=WL-577007", "price": 62.68}, 175 | {"entity_id": 448441, "name": "6RM9F6IWIO", "url": "https://geizhals.de/?cat=WL-448441", "price": 45.97}, 176 | {"entity_id": 567418, "name": "C2W75RPRFS", "url": "https://geizhals.de/?cat=WL-567418", "price": 137.53}, 177 | {"entity_id": 590717, "name": "JEXP2E5Y06", "url": "https://geizhals.de/?cat=WL-590717", "price": 117.84}] 178 | 179 | for wl in wishlists: 180 | self.db.add_wishlist(wishlist_id=wl.get("entity_id"), name=wl.get("name"), url=wl.get("url"), price=wl.get("price")) 181 | 182 | # Add two users - otherwise we cannot subscribe 183 | self.db.add_user(1234, "Test user 1", "Doe", "Testie") 184 | self.db.add_user(1337, "Test user 2", "Doe", "Tester") 185 | 186 | # No subscriptions to start with 187 | self.assertEqual(0, len(self.db.get_all_subscribed_wishlists()), "There are already subscriptions!") 188 | 189 | # Subscribe by the first user - must be 1 190 | self.db.subscribe_wishlist(924729, 1234) 191 | self.assertEqual(1, len(self.db.get_all_subscribed_wishlists()), "Subscribed user is not counted!") 192 | 193 | # Subscribe by another user and check if it's still 1 194 | self.db.subscribe_wishlist(924729, 1337) 195 | self.assertEqual(1, len(self.db.get_all_subscribed_wishlists()), "Wishlist with two subscribers is counted twice!") 196 | 197 | # Subscribe another product by a user and check if it's 2 198 | self.db.subscribe_wishlist(245759, 1337) 199 | self.assertEqual(2, len(self.db.get_all_subscribed_wishlists()), "Two subscribed wishlists are not counted correctly") 200 | 201 | def test_get_all_products(self): 202 | """Test to check if retreiving all products works""" 203 | products = [{"entity_id": 962572, "name": "NIU2E0RRWX", "url": "https://geizhals.de/a962572", "price": 62.80}, 204 | {"entity_id": 924729, "name": "3W5NQ1QIHT", "url": "https://geizhals.de/a924729", "price": 46.00}, 205 | {"entity_id": 614044, "name": "CTYCTW798V", "url": "https://geizhals.de/a614044", "price": 96.95}, 206 | {"entity_id": 245759, "name": "VDY66U0AWM", "url": "https://geizhals.de/a245759", "price": 53.94}, 207 | {"entity_id": 490792, "name": "N6MCC1Z38O", "url": "https://geizhals.de/a490792", "price": 144.85}, 208 | {"entity_id": 533484, "name": "NOJJ8KVE9T", "url": "https://geizhals.de/a533484", "price": 122.77}, 209 | {"entity_id": 577007, "name": "ELV51DSL2A", "url": "https://geizhals.de/a577007", "price": 62.68}, 210 | {"entity_id": 448441, "name": "6RM9F6IWIO", "url": "https://geizhals.de/a448441", "price": 45.97}, 211 | {"entity_id": 567418, "name": "C2W75RPRFS", "url": "https://geizhals.de/a567418", "price": 137.53}, 212 | {"entity_id": 590717, "name": "JEXP2E5Y06", "url": "https://geizhals.de/a590717", "price": 117.84}] 213 | for p in products: 214 | self.db.add_product(product_id=p.get("entity_id"), name=p.get("name"), url=p.get("url"), price=p.get("price")) 215 | 216 | db_products = self.db.get_all_products() 217 | 218 | for db_p in db_products: 219 | for p in products: 220 | if db_p.entity_id == p.get("entity_id"): 221 | break 222 | else: 223 | self.fail(msg="Inserted product was not found!") 224 | 225 | def test_get_all_subscribed_products(self): 226 | """Test to check if retrieving subscribed products works""" 227 | products = [{"entity_id": 962572, "name": "NIU2E0RRWX", "url": "https://geizhals.de/a962572", "price": 62.80}, 228 | {"entity_id": 924729, "name": "3W5NQ1QIHT", "url": "https://geizhals.de/a924729", "price": 46.00}, 229 | {"entity_id": 614044, "name": "CTYCTW798V", "url": "https://geizhals.de/a614044", "price": 96.95}, 230 | {"entity_id": 245759, "name": "VDY66U0AWM", "url": "https://geizhals.de/a245759", "price": 53.94}, 231 | {"entity_id": 490792, "name": "N6MCC1Z38O", "url": "https://geizhals.de/a490792", "price": 144.85}, 232 | {"entity_id": 533484, "name": "NOJJ8KVE9T", "url": "https://geizhals.de/a533484", "price": 122.77}, 233 | {"entity_id": 577007, "name": "ELV51DSL2A", "url": "https://geizhals.de/a577007", "price": 62.68}, 234 | {"entity_id": 448441, "name": "6RM9F6IWIO", "url": "https://geizhals.de/a448441", "price": 45.97}, 235 | {"entity_id": 567418, "name": "C2W75RPRFS", "url": "https://geizhals.de/a567418", "price": 137.53}, 236 | {"entity_id": 590717, "name": "JEXP2E5Y06", "url": "https://geizhals.de/a590717", "price": 117.84}] 237 | for p in products: 238 | self.db.add_product(product_id=p.get("entity_id"), name=p.get("name"), url=p.get("url"), price=p.get("price")) 239 | 240 | # Add two users - otherwise we cannot subscribe 241 | self.db.add_user(1234, "Test user 1", "Doe", "Testie") 242 | self.db.add_user(1337, "Test user 2", "Doe", "Tester") 243 | 244 | # No subscriptions to start with 245 | self.assertEqual(0, len(self.db.get_all_subscribed_products()), "There are already subscriptions!") 246 | 247 | # Subscribe by the first user - must be 1 248 | self.db.subscribe_product(924729, 1234) 249 | self.assertEqual(1, len(self.db.get_all_subscribed_products()), "Subscribed user is not counted!") 250 | 251 | # Subscribe by another user and check if it's still 1 252 | self.db.subscribe_product(924729, 1337) 253 | self.assertEqual(1, len(self.db.get_all_subscribed_products()), "Product with two subscribers is counted twice!") 254 | 255 | # Subscribe another product by a user and check if it's 2 256 | self.db.subscribe_product(245759, 1337) 257 | self.assertEqual(2, len(self.db.get_all_subscribed_products()), "Two subscribed products are not counted correctly") 258 | 259 | def test_get_wishlist_info(self): 260 | """Test to check if fetching information for a wishlist works""" 261 | self.assertFalse(self.db.is_wishlist_saved(self.wl.entity_id), "Wishlist is already saved!") 262 | 263 | self.db.add_wishlist(self.wl.entity_id, self.wl.name, self.wl.price, self.wl.url) 264 | wishlist = self.db.get_wishlist_info(self.wl.entity_id) 265 | 266 | self.assertEqual(wishlist.entity_id, self.wl.entity_id) 267 | self.assertEqual(wishlist.name, self.wl.name) 268 | self.assertEqual(wishlist.url, self.wl.url) 269 | self.assertEqual(wishlist.price, self.wl.price) 270 | 271 | self.assertIsNone(self.db.get_wishlist_info("23123123")) 272 | 273 | def test_get_product_info(self): 274 | """Test to check if fetching information for a product works""" 275 | self.assertFalse(self.db.is_product_saved(self.p.entity_id), "Product is already saved!") 276 | 277 | self.db.add_product(self.p.entity_id, self.p.name, self.p.price, self.p.url) 278 | product = self.db.get_product_info(self.p.entity_id) 279 | 280 | self.assertEqual(product.entity_id, self.p.entity_id) 281 | self.assertEqual(product.name, self.p.name) 282 | self.assertEqual(product.url, self.p.url) 283 | self.assertEqual(product.price, self.p.price) 284 | 285 | self.assertIsNone(self.db.get_product_info("23123123")) 286 | 287 | def test_is_wishlist_saved(self): 288 | """Test to check if is_wishlist_saved method works as intended""" 289 | # Check if wishlist is already saved 290 | self.assertFalse(self.db.is_wishlist_saved(self.wl.entity_id), "Wishlist is already saved!") 291 | 292 | # Add wishlist to the database 293 | self.db.add_wishlist(self.wl.entity_id, self.wl.name, self.wl.price, self.wl.url) 294 | 295 | # Check if wishlist is now saved in the db 296 | self.assertTrue(self.db.is_wishlist_saved(self.wl.entity_id), "Wishlist is not saved in the db!") 297 | 298 | def test_is_product_saved(self): 299 | """Test to check if is_product_saved method works as intended""" 300 | # Make sure product is not already saved 301 | self.assertFalse(self.db.is_product_saved(self.p.entity_id), "Product should not be saved yet!") 302 | 303 | # Add product to the db 304 | self.db.add_product(self.p.entity_id, self.p.name, self.p.price, self.p.url) 305 | 306 | # Check if product is saved afterwards 307 | self.assertTrue(self.db.is_product_saved(self.p.entity_id), "Product is not saved in the db!") 308 | 309 | def test_add_wishlist(self): 310 | """Test for checking if wishlists are being added correctly""" 311 | # Make sure that element is not already in database 312 | result = self.db.cursor.execute("SELECT count(*) FROM wishlists WHERE wishlist_id=?", [self.wl.entity_id]).fetchone()[0] 313 | self.assertEqual(0, result) 314 | 315 | self.db.add_wishlist(wishlist_id=self.wl.entity_id, name=self.wl.name, url=self.wl.url, price=self.wl.price) 316 | result = self.db.cursor.execute("SELECT wishlist_id, name, url, price FROM wishlists WHERE wishlist_id=?", [self.wl.entity_id]).fetchone() 317 | 318 | self.assertEqual(self.wl.entity_id, result[0], msg="ID is not equal!") 319 | self.assertEqual(self.wl.name, result[1], msg="Name is not equal!") 320 | self.assertEqual(self.wl.url, result[2], msg="Url is not equal!") 321 | self.assertEqual(self.wl.price, result[3], msg="Price is not equal!") 322 | 323 | def test_add_product(self): 324 | """Test for checking if products are being added correctly""" 325 | # Make sure that element is not already in database 326 | result = self.db.cursor.execute("SELECT count(*) FROM products WHERE product_id=?", [self.p.entity_id]).fetchone()[0] 327 | self.assertEqual(0, result) 328 | 329 | # Check if product is saved afterwards 330 | self.db.add_product(product_id=self.p.entity_id, name=self.p.name, url=self.p.url, price=self.p.price) 331 | result = self.db.cursor.execute("SELECT product_id, name, url, price FROM products WHERE product_id=?", [self.p.entity_id]).fetchone() 332 | 333 | self.assertEqual(result[0], self.p.entity_id, msg="ID is not equal!") 334 | self.assertEqual(result[1], self.p.name, msg="Name is not equal!") 335 | self.assertEqual(result[2], self.p.url, msg="Url is not equal!") 336 | self.assertEqual(result[3], self.p.price, msg="Price is not equal!") 337 | 338 | def test_rm_wishlist(self): 339 | """Test for checking if removing a wishlist works as intended""" 340 | # Add wishlist and check if it's in the db 341 | self.assertFalse(self.db.is_wishlist_saved(self.wl.entity_id)) 342 | self.db.add_wishlist(self.wl.entity_id, self.wl.name, self.wl.price, self.wl.url) 343 | self.assertTrue(self.db.is_wishlist_saved(self.wl.entity_id)) 344 | 345 | # Check if wishlist gets removed properly 346 | self.db.rm_wishlist(self.wl.entity_id) 347 | self.assertFalse(self.db.is_wishlist_saved(self.wl.entity_id)) 348 | 349 | def test_rm_product(self): 350 | """Test for checking if removing a product works as intended""" 351 | # Add product and check if it's in the db 352 | self.assertFalse(self.db.is_product_saved(self.p.entity_id)) 353 | self.db.add_product(self.p.entity_id, self.p.name, self.p.price, self.p.url) 354 | self.assertTrue(self.db.is_product_saved(self.p.entity_id)) 355 | 356 | # Check if product gets removed properly 357 | self.db.rm_product(self.p.entity_id) 358 | self.assertFalse(self.db.is_product_saved(self.p.entity_id)) 359 | 360 | def test_subscribe_wishlist(self): 361 | """Test for checking if subscribing a wishlist works as intended""" 362 | user_id = 11223344 363 | first_name = "John" 364 | last_name = "Doe" 365 | username = "JohnDoe" 366 | 367 | self.db.add_wishlist(wishlist_id=self.wl.entity_id, name=self.wl.name, url=self.wl.url, price=self.wl.price) 368 | self.db.add_user(user_id, first_name, last_name, username) 369 | 370 | result = self.db.cursor.execute("SELECT wishlist_id FROM wishlist_subscribers AS ws WHERE ws.user_id=? AND ws.wishlist_id=?;", 371 | [str(user_id), str(self.wl.entity_id)]).fetchone() 372 | self.assertEqual(result, None) 373 | 374 | self.db.subscribe_wishlist(self.wl.entity_id, user_id) 375 | result = self.db.cursor.execute("SELECT wishlist_id FROM wishlist_subscribers AS ws WHERE ws.user_id=? AND ws.wishlist_id=?;", 376 | [str(user_id), str(self.wl.entity_id)]).fetchone() 377 | 378 | self.assertEqual(len(result), 1) 379 | 380 | def test_subscribe_product(self): 381 | """Test for checking if subscribing a product works as intended""" 382 | user_id = 11223344 383 | first_name = "John" 384 | last_name = "Doe" 385 | username = "JohnDoe" 386 | 387 | self.db.add_product(product_id=self.p.entity_id, name=self.p.name, url=self.p.url, price=self.p.price) 388 | self.db.add_user(user_id, first_name, last_name, username) 389 | 390 | result = self.db.cursor.execute("SELECT product_id FROM product_subscribers AS ps WHERE ps.user_id=? AND ps.product_id=?;", 391 | [str(user_id), str(self.p.entity_id)]).fetchone() 392 | self.assertIsNone(result) 393 | 394 | self.db.subscribe_product(self.p.entity_id, user_id) 395 | result = self.db.cursor.execute("SELECT product_id FROM product_subscribers AS ps WHERE ps.user_id=? AND ps.product_id=?;", 396 | [str(user_id), str(self.p.entity_id)]).fetchone() 397 | 398 | self.assertEqual(len(result), 1) 399 | 400 | def test_unsubscribe_wishlist(self): 401 | """Test for checking if unsubscribing a wishlist works as intended""" 402 | user_id = 11223344 403 | first_name = "John" 404 | last_name = "Doe" 405 | username = "JohnDoe" 406 | 407 | self.db.add_wishlist(wishlist_id=self.wl.entity_id, name=self.wl.name, url=self.wl.url, price=self.wl.price) 408 | self.db.add_user(user_id, first_name, last_name, username) 409 | self.db.subscribe_wishlist(self.wl.entity_id, user_id) 410 | 411 | result = self.db.cursor.execute("SELECT wishlist_id FROM wishlist_subscribers AS ws WHERE ws.user_id=? AND ws.wishlist_id=?;", 412 | [str(user_id), str(self.wl.entity_id)]).fetchone() 413 | self.assertEqual(len(result), 1) 414 | 415 | self.db.unsubscribe_wishlist(user_id, self.wl.entity_id) 416 | result = self.db.cursor.execute("SELECT wishlist_id FROM wishlist_subscribers AS ws WHERE ws.user_id=? AND ws.wishlist_id=?;", 417 | [str(user_id), str(self.wl.entity_id)]).fetchone() 418 | 419 | self.assertIsNone(result) 420 | 421 | def test_unsubscribe_product(self): 422 | """Test for checking if unsubscribing a product works as intended""" 423 | user_id = 11223344 424 | first_name = "John" 425 | last_name = "Doe" 426 | username = "JohnDoe" 427 | 428 | self.db.add_product(product_id=self.p.entity_id, name=self.p.name, url=self.p.url, price=self.p.price) 429 | self.db.add_user(user_id, first_name, last_name, username) 430 | self.db.subscribe_product(self.p.entity_id, user_id) 431 | result = self.db.cursor.execute("SELECT product_id FROM product_subscribers AS ps WHERE ps.user_id=? AND ps.product_id=?;", 432 | [str(user_id), str(self.p.entity_id)]).fetchone() 433 | self.assertEqual(len(result), 1) 434 | 435 | self.db.unsubscribe_product(user_id, self.p.entity_id) 436 | result = self.db.cursor.execute("SELECT product_id FROM product_subscribers AS ps WHERE ps.user_id=? AND ps.product_id=?;", 437 | [str(user_id), str(self.p.entity_id)]).fetchone() 438 | 439 | self.assertIsNone(result) 440 | 441 | def test_get_user(self): 442 | """Test to check if getting user information works as intended""" 443 | # Check that None is returned if no user is saved 444 | user = self.user 445 | 446 | user_db = self.db.get_user(user.get("user_id")) 447 | self.assertIsNone(user_db) 448 | 449 | self.db.add_user(user.get("user_id"), user.get("first_name"), user.get("last_name"), user.get("username"), user.get("lang_code")) 450 | user_db = self.db.get_user(user.get("user_id")) 451 | self.assertEqual(user.get("user_id"), user_db.user_id) 452 | self.assertEqual(user.get("first_name"), user_db.first_name) 453 | self.assertEqual(user.get("last_name"), user_db.last_name) 454 | self.assertEqual(user.get("username"), user_db.username) 455 | self.assertEqual(user.get("lang_code"), user_db.lang_code) 456 | 457 | def test_get_userids_for_wishlist(self): 458 | """Test to check if getting the (subscriber) userid from a wishlist works as intended""" 459 | # Users should be 0 in the beginning 460 | users = self.db.get_userids_for_wishlist(self.wl.entity_id) 461 | self.assertEqual(0, len(users)) 462 | 463 | # Add users and wishlist 464 | user = self.user 465 | user2 = self.user2 466 | 467 | self.db.add_user(user.get("user_id"), user.get("first_name"), user.get("last_name"), user.get("username"), user.get("lang_code")) 468 | self.db.add_user(user2.get("user_id"), user2.get("first_name"), user2.get("last_name"), user2.get("username"), user2.get("lang_code")) 469 | self.db.add_wishlist(self.wl.entity_id, self.wl.name, self.wl.price, self.wl.url) 470 | 471 | # Subscribe user to wishlist 472 | self.db.subscribe_wishlist(wishlist_id=self.wl.entity_id, user_id=user.get("user_id")) 473 | users = self.db.get_userids_for_wishlist(self.wl.entity_id) 474 | 475 | # Check if wishlist got one subscriber and it's the correct one 476 | self.assertEqual(1, len(users)) 477 | self.assertEqual(user.get("user_id"), users[0]) 478 | 479 | # Subscribe another user and check if both users are now subscribers 480 | self.db.subscribe_wishlist(wishlist_id=self.wl.entity_id, user_id=user2.get("user_id")) 481 | users = self.db.get_userids_for_wishlist(self.wl.entity_id) 482 | self.assertEqual(2, len(users)) 483 | 484 | for user_id in users: 485 | if user_id != user.get("user_id") and user_id != user2.get("user_id"): 486 | self.fail("I don't know that userID") 487 | 488 | def test_get_users_for_product(self): 489 | """Test to check if getting the (subscriber) userid from a wishlist works as intended""" 490 | # Users should be 0 in the beginning 491 | users = self.db.get_userids_for_product(self.p.entity_id) 492 | self.assertEqual(0, len(users)) 493 | 494 | # Add users and wishlist 495 | user = self.user 496 | user2 = self.user2 497 | 498 | self.db.add_user(user.get("user_id"), user.get("first_name"), user.get("last_name"), user.get("username"), user.get("lang_code")) 499 | self.db.add_user(user2.get("user_id"), user2.get("first_name"), user2.get("last_name"), user2.get("username"), user2.get("lang_code")) 500 | self.db.add_product(self.p.entity_id, self.p.name, self.p.price, self.p.url) 501 | 502 | # Subscribe user to wishlist 503 | self.db.subscribe_product(product_id=self.p.entity_id, user_id=user.get("user_id")) 504 | users = self.db.get_userids_for_product(self.p.entity_id) 505 | 506 | # Check if wishlist got one subscriber and it's the correct one 507 | self.assertEqual(1, len(users)) 508 | self.assertEqual(user.get("user_id"), users[0]) 509 | 510 | # Subscribe another user and check if both users are now subscribers 511 | self.db.subscribe_product(product_id=self.p.entity_id, user_id=user2.get("user_id")) 512 | users = self.db.get_userids_for_product(self.p.entity_id) 513 | self.assertEqual(2, len(users)) 514 | 515 | for user_id in users: 516 | if user_id != user.get("user_id") and user_id != user2.get("user_id"): 517 | self.fail("I don't know that userID") 518 | 519 | def test_get_wishlists_for_user(self): 520 | """Test to check if getting wishlists for a user works as intended""" 521 | user = self.user 522 | 523 | wl1 = Wishlist(123123, "Wishlist", "https://geizhals.de/?cat=WL-123123", 123.45) 524 | wl2 = Wishlist(987123, "Wishlist2", "https://geizhals.de/?cat=WL-987123", 1.23) 525 | wl3 = Wishlist(4567418, "Wishlist3", "https://geizhals.de/?cat=WL-987123", 154.00) 526 | local_wishlists = [wl1, wl2] 527 | 528 | # Add user 529 | self.db.add_user(user.get("user_id"), user.get("first_name"), user.get("username"), user.get("lang_code")) 530 | 531 | # Add wishlist1 & wishlist2 & wishlist3 532 | self.db.add_wishlist(wl1.entity_id, wl1.name, wl1.price, wl1.url) 533 | self.db.add_wishlist(wl2.entity_id, wl2.name, wl2.price, wl2.url) 534 | self.db.add_wishlist(wl3.entity_id, wl3.name, wl3.price, wl3.url) 535 | 536 | # Subscribe user to wishlist1 & wishlist2 537 | self.db.subscribe_wishlist(wl1.entity_id, user.get("user_id")) 538 | self.db.subscribe_wishlist(wl2.entity_id, user.get("user_id")) 539 | 540 | # Check if wishlists are in the return value 541 | wishlists = self.db.get_wishlists_for_user(user.get("user_id")) 542 | 543 | # Make sure that both lists are the same length 544 | self.assertEqual(len(wishlists), len(local_wishlists)) 545 | 546 | for local_wishlist in local_wishlists: 547 | for wishlist in wishlists: 548 | if wishlist.entity_id == local_wishlist.entity_id: 549 | break 550 | else: 551 | # Make sure that each subscribed wishlist is in the list 552 | self.fail("Subscribed wishlist is not in the list") 553 | 554 | def test_get_products_for_user(self): 555 | """Test to check if getting products for a user works as intended""" 556 | user = self.user 557 | 558 | p1 = Product(123123, "Product", "https://geizhals.de/?cat=WL-123123", 123.45) 559 | p2 = Product(987123, "Product2", "https://geizhals.de/?cat=WL-987123", 1.23) 560 | p3 = Product(4567418, "Product3", "https://geizhals.de/?cat=WL-987123", 154.00) 561 | local_products = [p1, p2] 562 | 563 | # Add user 564 | self.db.add_user(user.get("user_id"), user.get("first_name"), user.get("last_name"), user.get("username"), user.get("lang_code")) 565 | 566 | # Add product1 & product2 & product3 567 | self.db.add_product(p1.entity_id, p1.name, p1.price, p1.url) 568 | self.db.add_product(p2.entity_id, p2.name, p2.price, p2.url) 569 | self.db.add_product(p3.entity_id, p3.name, p3.price, p3.url) 570 | 571 | # Subscribe user to product1 & product2 572 | self.db.subscribe_product(p1.entity_id, user.get("user_id")) 573 | self.db.subscribe_product(p2.entity_id, user.get("user_id")) 574 | 575 | # Check if products are in the return value 576 | products = self.db.get_products_for_user(user.get("user_id")) 577 | 578 | # Make sure that both lists are the same length 579 | self.assertEqual(len(products), len(local_products)) 580 | 581 | for local_product in local_products: 582 | for product in products: 583 | if product.entity_id == local_product.entity_id: 584 | break 585 | else: 586 | # Make sure that each subscribed product is in the list 587 | self.fail("Subscribed product is not in the list") 588 | 589 | def test_is_user_wishlist_subscriber(self): 590 | """Check if checking for wishlist subscribers works as intended""" 591 | user1 = self.user 592 | user2 = self.user2 593 | 594 | wl1 = Wishlist(123123, "Wishlist", "https://geizhals.de/?cat=WL-123123", 123.45) 595 | wl2 = Wishlist(987123, "Wishlist2", "https://geizhals.de/?cat=WL-987123", 1.23) 596 | 597 | # Add user1, add user2 598 | self.helper_add_user(user1) 599 | self.helper_add_user(user2) 600 | # Add wishlist1, add wishlist2 601 | self.db.add_wishlist(wl1.entity_id, wl1.name, wl1.price, wl1.url) 602 | self.db.add_wishlist(wl2.entity_id, wl2.name, wl2.price, wl2.url) 603 | 604 | # subscribe user1 to wishlist1 605 | self.db.subscribe_wishlist(wl1.entity_id, user1.get("user_id")) 606 | 607 | # subscribe user2 to wishlist2 608 | self.db.subscribe_wishlist(wl2.entity_id, user2.get("user_id")) 609 | 610 | # check if user1 is subscribed to wishlist1 -> True 611 | self.assertTrue(self.db.is_user_wishlist_subscriber(user1.get("user_id"), wl1.entity_id)) 612 | 613 | # check if user2 is subscribed to wishlist1 -> False 614 | self.assertFalse(self.db.is_user_wishlist_subscriber(user2.get("user_id"), wl1.entity_id)) 615 | 616 | # check if user1 is subscribed to wishlist2 -> False 617 | self.assertFalse(self.db.is_user_wishlist_subscriber(user1.get("user_id"), wl2.entity_id)) 618 | 619 | # check if user2 is subscribed to wishlist2 -> True 620 | self.assertTrue(self.db.is_user_wishlist_subscriber(user2.get("user_id"), wl2.entity_id)) 621 | 622 | def test_is_user_product_subscriber(self): 623 | """Check if checking for product subscribers works as intended""" 624 | user1 = self.user 625 | user2 = self.user2 626 | 627 | p1 = Product(123123, "Product", "https://geizhals.de/?cat=WL-123123", 123.45) 628 | p2 = Product(987123, "Product2", "https://geizhals.de/?cat=WL-987123", 1.23) 629 | 630 | # Add user1, add user2 631 | self.helper_add_user(user1) 632 | self.helper_add_user(user2) 633 | # Add product1, add product2 634 | self.db.add_product(p1.entity_id, p1.name, p1.price, p1.url) 635 | self.db.add_product(p2.entity_id, p2.name, p2.price, p2.url) 636 | 637 | # subscribe user1 to product1 638 | self.db.subscribe_product(p1.entity_id, user1.get("user_id")) 639 | 640 | # subscribe user2 to product2 641 | self.db.subscribe_product(p2.entity_id, user2.get("user_id")) 642 | 643 | # check if user1 is subscribed to product1 -> True 644 | self.assertTrue(self.db.is_user_product_subscriber(user1.get("user_id"), p1.entity_id)) 645 | 646 | # check if user2 is subscribed to product1 -> False 647 | self.assertFalse(self.db.is_user_product_subscriber(user2.get("user_id"), p1.entity_id)) 648 | 649 | # check if user1 is subscribed to product2 -> False 650 | self.assertFalse(self.db.is_user_product_subscriber(user1.get("user_id"), p2.entity_id)) 651 | 652 | # check if user2 is subscribed to product2 -> True 653 | self.assertTrue(self.db.is_user_product_subscriber(user2.get("user_id"), p2.entity_id)) 654 | 655 | def test_update_wishlist_name(self): 656 | """Test to check if updating wishlist names works as intended""" 657 | self.db.add_wishlist(self.wl.entity_id, self.wl.name, self.wl.price, self.wl.url) 658 | self.assertEqual(self.db.get_wishlist_info(self.wl.entity_id).name, self.wl.name) 659 | 660 | self.db.update_wishlist_name(self.wl.entity_id, "New Wishlist") 661 | self.assertEqual(self.db.get_wishlist_info(self.wl.entity_id).name, "New Wishlist") 662 | 663 | def test_update_product_name(self): 664 | """Test to check if updating product names works as intended""" 665 | self.db.add_product(self.p.entity_id, self.p.name, self.p.price, self.p.url) 666 | self.assertEqual(self.db.get_product_info(self.p.entity_id).name, self.p.name) 667 | 668 | self.db.update_product_name(self.p.entity_id, "New Product") 669 | self.assertEqual(self.db.get_product_info(self.p.entity_id).name, "New Product") 670 | 671 | def test_update_wishlist_price(self): 672 | """Test to check if updating the wishlist price in the database works as intended""" 673 | new_price = 999.99 674 | self.db.add_wishlist(self.wl.entity_id, self.wl.name, self.wl.price, self.wl.url) 675 | self.db.update_wishlist_price(wishlist_id=self.wl.entity_id, price=new_price) 676 | 677 | price = self.db.cursor.execute("SELECT price FROM wishlists WHERE wishlist_id=?", [self.wl.entity_id]).fetchone()[0] 678 | 679 | self.assertEqual(price, new_price) 680 | 681 | def test_update_product_price(self): 682 | """Test to check if updating the product price in the database works as intended""" 683 | new_price = 999.99 684 | self.db.add_product(self.p.entity_id, self.p.name, self.p.price, self.p.url) 685 | self.db.update_product_price(product_id=self.p.entity_id, price=new_price) 686 | 687 | price = self.db.cursor.execute("SELECT price FROM products WHERE product_id=?", [self.p.entity_id]).fetchone()[0] 688 | 689 | self.assertEqual(price, new_price) 690 | 691 | def test_get_all_users(self): 692 | """Test to check if retreiving all users from the database works""" 693 | users = [{"user_id": 415641, "first_name": "Peter", "last_name": "Müller", "username": "name2", "lang_code": "en_US"}, 694 | {"user_id": 564864654, "first_name": "asdf", "last_name": "jhkasd", "username": "AnotherUser", "lang_code": "en_US"}, 695 | {"user_id": 54564162, "first_name": "NoName", "last_name": "123iuj", "username": "Metallica", "lang_code": "en_US"}, 696 | {"user_id": 5555333, "first_name": "1234", "last_name": "koldfg", "username": "d_Rickyy_b", "lang_code": "en_US"}] 697 | 698 | # Check that database is empty 699 | all_users_db = self.db.get_all_users() 700 | self.assertEqual(len(all_users_db), 0, msg="There are already users in the db!") 701 | 702 | for user in users: 703 | self.helper_add_user(user) 704 | 705 | all_users_db = self.db.get_all_users() 706 | 707 | self.assertEqual(len(all_users_db), len(users), msg="Users in database is not same amount as users in test!") 708 | 709 | for db_user in all_users_db: 710 | for user in users: 711 | if user.get("user_id") == db_user.get("user_id"): 712 | self.assertEqual(user.get("first_name"), db_user.get("first_name")) 713 | self.assertEqual(user.get("username"), db_user.get("username")) 714 | self.assertEqual(user.get("lang_code"), db_user.get("lang_code")) 715 | break 716 | else: 717 | self.fail("User not found!") 718 | 719 | def test_get_all_subscribers(self): 720 | self.assertEqual([], self.db.get_all_subscribers(), msg="Initial user list not empty!") 721 | 722 | self.db.add_user(12345, "Test", "lastName", "User") 723 | self.db.add_user(54321, "Test2", "lastName2", "User2") 724 | self.assertEqual([], self.db.get_all_subscribers(), msg="User list not empty although no subscribers!") 725 | 726 | self.db.add_product(1, "Testproduct", 30, "https://example.com") 727 | self.db.subscribe_product(1, 12345) 728 | self.assertEqual([12345], self.db.get_all_subscribers(), msg="User list still empty although product subscription!") 729 | 730 | self.db.add_wishlist(333, "Wishlist", 15, "https://example.com/wishlist") 731 | self.db.subscribe_wishlist(333, 54321) 732 | self.assertEqual(2, len(self.db.get_all_subscribers()), msg="User list missing user although wishlist subscription!") 733 | self.assertEqual([12345, 54321], self.db.get_all_subscribers(), msg="User list missing user although wishlist subscription!") 734 | 735 | self.db.subscribe_product(1, 54321) 736 | self.assertEqual(2, len(self.db.get_all_subscribers()), msg="User counting multiple times after product subscription!") 737 | self.assertEqual([12345, 54321], self.db.get_all_subscribers(), msg="User counting multiple times after product subscription!") 738 | 739 | def test_get_lang_id(self): 740 | """Test to check if receiving the lang_code works""" 741 | user = self.user 742 | 743 | # Check that user does not already exist 744 | user_db = self.db.get_user(user.get("user_id")) 745 | self.assertEqual(user_db, None) 746 | 747 | # Add user to database 748 | self.helper_add_user(user) 749 | 750 | lang_id = self.db.get_lang_id(user.get("user_id")) 751 | self.assertEqual(lang_id, user.get("lang_code")) 752 | 753 | # Trying a random user_id that is not stored yet 754 | self.assertEqual(self.db.get_lang_id(19283746), "en") 755 | 756 | def test_add_user(self): 757 | """Test to check if adding users works as expected""" 758 | user = {"user_id": 123456, "first_name": "John", "username": "testUsername", "lang_code": "en_US"} 759 | 760 | # Check that user does not already exist 761 | user_db = self.db.get_user(user.get("user_id")) 762 | self.assertEqual(user_db, None) 763 | 764 | # Add user to database 765 | self.helper_add_user(user) 766 | 767 | # Check if user was added 768 | user_db = self.db.get_user(user.get("user_id")) 769 | self.assertEqual(user_db.user_id, user.get("user_id")) 770 | 771 | # Test default value of lang_code 772 | user2 = {"user_id": 4321, "first_name": "Peter", "username": "AsDf", "lang_code": "en_US"} 773 | self.db.add_user(user_id=user2.get("user_id"), first_name=user2.get("first_name"), last_name=user2.get("last_name"), username=user2.get("username")) 774 | user_db2 = self.db.get_user(user2.get("user_id")) 775 | self.assertEqual("de-DE", user_db2.lang_code) 776 | 777 | def test_delete_user(self): 778 | """Test to check if users (and their wishlists/products) are properly deleted""" 779 | user = {"user_id": 123456, "first_name": "John", "username": "testUsername", "lang_code": "en_US"} 780 | user_id = user.get("user_id") 781 | 782 | # Add user to the database 783 | self.db.add_user(user.get("user_id"), user.get("first_name"), user.get("username"), user.get("lang_code")) 784 | 785 | # Add product and wishlist to the database 786 | self.db.add_product(self.p.entity_id, self.p.name, self.p.price, self.p.url) 787 | self.db.add_wishlist(self.wl.entity_id, self.wl.name, self.wl.price, self.wl.url) 788 | 789 | # Subscribe to the product and to the wishlist 790 | self.db.subscribe_wishlist(self.wl.entity_id, user_id) 791 | self.db.subscribe_product(self.p.entity_id, user_id) 792 | 793 | # Make sure subscriber count = 1 794 | wl_count = self.db.get_subscribed_wishlist_count(user_id) 795 | self.assertEqual(1, wl_count, "Subscribed wishlists should be 1 but is not!") 796 | 797 | p_count = self.db.get_subscribed_product_count(user_id) 798 | self.assertEqual(1, p_count, "Subscribed products should be 1 but is not!") 799 | 800 | wl_subs = self.db.cursor.execute("SELECT count(*) FROM wishlist_subscribers;").fetchone()[0] 801 | self.assertEqual(1, wl_subs) 802 | 803 | p_subs = self.db.cursor.execute("SELECT count(*) FROM product_subscribers;").fetchone()[0] 804 | self.assertEqual(1, p_subs) 805 | 806 | # Delete user 807 | self.db.delete_user(user_id) 808 | 809 | # Make sure the product and the wishlist still exist 810 | wishlist = self.db.get_wishlist_info(self.wl.entity_id) 811 | self.assertIsNotNone(wishlist) 812 | 813 | product = self.db.get_product_info(self.p.entity_id) 814 | self.assertIsNotNone(product) 815 | 816 | db_user = self.db.get_user(user_id) 817 | self.assertIsNone(db_user) 818 | 819 | # Make sure the user is no longer subscribed 820 | wl_subs = self.db.cursor.execute("SELECT count(*) FROM wishlist_subscribers;").fetchone()[0] 821 | self.assertEqual(0, wl_subs) 822 | 823 | p_subs = self.db.cursor.execute("SELECT count(*) FROM product_subscribers;").fetchone()[0] 824 | self.assertEqual(0, p_subs) 825 | 826 | def test_is_user_saved(self): 827 | """Test to check if the 'check if a user exists' works as expected""" 828 | user = {"user_id": 123456, "first_name": "John", "username": "testUsername", "lang_code": "en_US"} 829 | 830 | # Check that user does not already exist 831 | user_db = self.db.get_user(user.get("user_id")) 832 | self.assertIsNone(user_db, "User is not None!") 833 | self.assertFalse(self.db.is_user_saved(user.get("user_id"))) 834 | 835 | self.db.add_user(user.get("user_id"), user.get("first_name"), user.get("username"), user.get("lang_code")) 836 | 837 | self.assertTrue(self.db.is_user_saved(user.get("user_id"))) 838 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.3" 2 | services: 3 | geizhalsbot: 4 | container_name: ghbot 5 | restart: always 6 | image: 0rickyy0/geizhalsbot 7 | volumes: 8 | - /path/to/logs/:/geizhalsbot/logs 9 | - /path/to/config.py:/geizhalsbot/config.py 10 | ports: 11 | # Ports for the webhook server 12 | - 127.0.0.1:8080:80 13 | -------------------------------------------------------------------------------- /geizhals/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from .state_handler import GeizhalsStateHandler 4 | 5 | __all__ = ["GeizhalsStateHandler"] 6 | -------------------------------------------------------------------------------- /geizhals/charts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d-Rickyy-b/Python-GeizhalsBot/a64f12178d16473f03c350bbef0dd7722e87188e/geizhals/charts/__init__.py -------------------------------------------------------------------------------- /geizhals/charts/dataset.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from datetime import date 3 | from .price import Price 4 | from .day import Day 5 | import requests 6 | import json 7 | 8 | 9 | class Dataset(object): 10 | 11 | def __init__(self, product_name): 12 | self.days = [] 13 | self.product_name = product_name 14 | 15 | def add_price(self, price, timestamp): 16 | # find correct day to add to 17 | p = Price(price, timestamp) 18 | timestamp_date = date.fromtimestamp(timestamp) 19 | for day in self.days: 20 | if day.date == timestamp_date: 21 | day.add_price(p) 22 | return 23 | 24 | new_day = Day(timestamp_date) 25 | new_day.add_price(p) 26 | self.days.append(new_day) 27 | 28 | def get_best_price(self): 29 | lowest_price = 999999999 30 | for day in self.days: 31 | day_best_price = day.get_best_price() 32 | if lowest_price > day_best_price: 33 | lowest_price = day_best_price 34 | 35 | return lowest_price 36 | 37 | def get_worst_price(self): 38 | highest_price = 0 39 | for day in self.days: 40 | day_worst_price = day.get_worst_price() 41 | if highest_price < day_worst_price: 42 | highest_price = day_worst_price 43 | 44 | return highest_price 45 | 46 | def get_chart(self): 47 | labels = [] 48 | prices = [] 49 | for day in self.days: 50 | labels.append(str(day.date)) 51 | prices.append(day.get_best_price()) 52 | labels.reverse() 53 | prices.reverse() 54 | 55 | best_price = self.get_best_price() 56 | worst_price = self.get_worst_price() 57 | diff = worst_price - best_price 58 | 59 | data = dict(labels=labels, datasets=[dict(label=self.product_name, data=prices)]) 60 | req_data = dict(type="line", data=data, 61 | options=dict( 62 | legend=dict(display=False), 63 | title=dict(display=True, text=self.product_name), 64 | scales=dict(yAxes=[dict(ticks=dict(suggestedMin=best_price - 2 * diff, suggestedMax=worst_price + diff / 2))], 65 | xAxes=[dict(type="time")]) 66 | ) 67 | ) 68 | chart_query = json.dumps(req_data) 69 | response = requests.post(url="https://quickchart.io/chart", json=dict(c=chart_query, backgroundColor="white")) 70 | return response.content 71 | -------------------------------------------------------------------------------- /geizhals/charts/day.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | 4 | class Day(object): 5 | 6 | def __init__(self, date): 7 | self.date = date 8 | self.prices = [] 9 | self.lowest_price = None 10 | self.highest_price = None 11 | 12 | def add_price(self, price): 13 | self.prices.append(price) 14 | 15 | def get_best_price(self): 16 | lowest_price = 999999999 17 | for price in self.prices: 18 | if price.price < lowest_price: 19 | lowest_price = price.price 20 | self.lowest_price = lowest_price 21 | return lowest_price 22 | 23 | def get_worst_price(self): 24 | highest_price = 0 25 | for price in self.prices: 26 | if price.price > highest_price: 27 | highest_price = price.price 28 | self.highest_price = highest_price 29 | return highest_price 30 | 31 | def __hash__(self): 32 | return self.date 33 | -------------------------------------------------------------------------------- /geizhals/charts/price.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | 4 | class Price(object): 5 | 6 | def __init__(self, price, timestamp): 7 | self.price = price 8 | self.timestamp = timestamp 9 | -------------------------------------------------------------------------------- /geizhals/core.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import html 3 | import logging 4 | 5 | import requests 6 | from pyquery import PyQuery 7 | from requests.exceptions import ProxyError 8 | 9 | from geizhals.entities import EntityType 10 | from geizhals.exceptions import HTTPLimitedException 11 | from geizhals.state_handler import GeizhalsStateHandler 12 | 13 | logger = logging.getLogger(__name__) 14 | useragent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_3) " \ 15 | "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/35.0.1916.47 " \ 16 | "Safari/537.36" 17 | 18 | 19 | def send_request(url): 20 | logger.debug("Requesting url '{}'!".format(url)) 21 | statehandler = GeizhalsStateHandler() 22 | 23 | successful_connection = False 24 | r = None 25 | 26 | for i in range(3): 27 | logger.debug("Trying to download site {}/3".format(i + 1)) 28 | if statehandler.use_proxies: 29 | proxy = statehandler.get_next_proxy() 30 | logger.debug("Using proxy: '{}'".format(proxy)) 31 | proxies = dict(http=proxy, https=proxy) 32 | else: 33 | proxy = None 34 | proxies = None 35 | 36 | try: 37 | r = requests.get(url, headers={'User-Agent': useragent}, proxies=proxies, timeout=4) 38 | except ProxyError as e: 39 | logger.warning("An error using the proxy '{}' occurred: {}. Trying another proxy if possible!".format(proxy, e)) 40 | continue 41 | 42 | if r.status_code == 429: 43 | logger.error("Geizhals blocked us from sending that many requests (HTTP 429)!") 44 | if statehandler.use_proxies: 45 | proxy = statehandler.get_next_proxy() 46 | logger.info("Switching proxy to '{}".format(proxy)) 47 | continue 48 | elif r.status_code == 403: 49 | logger.info("URL is not visible publically!") 50 | r.raise_for_status() 51 | elif r.status_code == 200: 52 | successful_connection = True 53 | break 54 | 55 | if not successful_connection: 56 | raise HTTPLimitedException("Geizhals blocked us temporarily!") 57 | 58 | html_str = r.text 59 | logger.debug("HTML content length: {} - status code: {}".format(len(html_str), r.status_code)) 60 | html_str = html.unescape(html_str) 61 | return html_str 62 | 63 | 64 | def parse_html(html_str, selector): 65 | pq = PyQuery(html_str) 66 | return pq(selector).text() 67 | 68 | 69 | def parse_entity_price(html_str, entity_type): 70 | if entity_type == EntityType.WISHLIST: 71 | selector = "div.wishlist_sum_area span.gh_price span.gh_price > span.gh_price" 72 | elif entity_type == EntityType.PRODUCT: 73 | selector = "div#offer__price-0 span.gh_price" 74 | else: 75 | raise ValueError("The given type {} is unknown!".format(entity_type)) 76 | 77 | price = parse_html(html_str, selector) 78 | price = price[2:] # Cut off the '€ ' before the real price 79 | price = price.replace(',', '.') 80 | return price 81 | 82 | 83 | def parse_entity_name(html_str, entity_type): 84 | if entity_type == EntityType.WISHLIST: 85 | selector = "div.wishlist span.wishlist_title" 86 | elif entity_type == EntityType.PRODUCT: 87 | selector = "div.variant__header h1[itemprop='name']" 88 | else: 89 | raise ValueError("The given type {} is unknown!".format(entity_type)) 90 | 91 | name = parse_html(html_str, selector) 92 | 93 | # Temporary fix for new Geizhals pages such as https://geizhals.de/sony-ht-rt3-schwarz-a1400003.html 94 | if name == "" and entity_type == EntityType.PRODUCT: 95 | name = parse_html(html_str, "#productpage__headline") 96 | 97 | # If name is still empty, raise error 98 | if name == "": 99 | # TODO maybe we should notify the user / dev 100 | raise ValueError("Name cannot be parsed!") 101 | 102 | return name 103 | -------------------------------------------------------------------------------- /geizhals/entities/__init__.py: -------------------------------------------------------------------------------- 1 | from .entitytype import EntityType 2 | from .entity import Entity 3 | from .product import Product 4 | from .wishlist import Wishlist 5 | 6 | __all__ = ['Entity', 'EntityType', 'Product', 'Wishlist'] 7 | -------------------------------------------------------------------------------- /geizhals/entities/entity.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import re 3 | 4 | import geizhals.core 5 | 6 | 7 | class Entity(object): 8 | TYPE = None 9 | 10 | def __init__(self, entity_id: int, name: str, url: str, price: float): 11 | self.__html = None 12 | self.entity_id = int(entity_id) 13 | self.name = str(name) 14 | self.url = str(url) 15 | self.price = float(price) 16 | 17 | def get_html(self): 18 | """Check if html for entity is already downloaded - if not download html and save in self.__html""" 19 | if not self.__html: 20 | self.__html = geizhals.core.send_request(self.url) 21 | 22 | def get_current_name(self): 23 | """Get the current name of an entity from Geizhals""" 24 | self.get_html() 25 | 26 | name = geizhals.core.parse_entity_name(self.__html, self.TYPE) 27 | 28 | return name 29 | 30 | def get_current_price(self): 31 | """Get the current price of a wishlist from Geizhals""" 32 | self.get_html() 33 | self.get_current_name() 34 | price = geizhals.core.parse_entity_price(self.__html, self.TYPE) 35 | 36 | # Parse price so that it's a proper comma value (no `,--`) 37 | pattern = r"([0-9]+)\.([0-9]+|[-]+)" 38 | pattern_dash = r"([0-9]+)\.([-]+)" 39 | 40 | if re.match(pattern, price): 41 | if re.match(pattern_dash, price): 42 | price = float(re.search(pattern_dash, price).group(1)) 43 | else: 44 | raise ValueError("Couldn't parse price '{}' for entity '{}'!".format(price, self.url)) 45 | 46 | return float(price) 47 | -------------------------------------------------------------------------------- /geizhals/entities/entitytype.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from enum import Enum 3 | 4 | 5 | class EntityType(Enum): 6 | WISHLIST = 1 7 | PRODUCT = 2 8 | 9 | @staticmethod 10 | def get_type_article_name(entity_type): 11 | if entity_type == EntityType.WISHLIST: 12 | return dict(article="die", name="Wunschliste") 13 | elif entity_type == EntityType.PRODUCT: 14 | return dict(article="das", name="Produkt") 15 | else: 16 | raise ValueError("No such entity type '{}'!".format(entity_type)) 17 | -------------------------------------------------------------------------------- /geizhals/entities/product.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import re 3 | import logging 4 | 5 | import geizhals.core 6 | import geizhals.exceptions 7 | from geizhals.entities import Entity, EntityType 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class Product(Entity): 13 | """Representation of a Geizhals product""" 14 | url_pattern = r"https:\/\/geizhals\.(de|at|eu)\/[0-9a-zA-Z\-]*a([0-9]+).html" 15 | ENTITY_NAME = "Produkt" 16 | TYPE = EntityType.PRODUCT 17 | 18 | @staticmethod 19 | def from_url(url): 20 | if not re.match(Product.url_pattern, url): 21 | raise geizhals.exceptions.InvalidWishlistURLException 22 | 23 | p = Product(entity_id=0, name="", url=url, price=0) 24 | p.price = p.get_current_price() 25 | p.name = p.get_current_name() 26 | p.entity_id = int(re.search(Product.url_pattern, url).group(2)) 27 | 28 | logger.info("Name: {}".format(p.name)) 29 | logger.info("Price: {}".format(p.price)) 30 | logger.info("Id: {}".format(p.entity_id)) 31 | 32 | return p 33 | -------------------------------------------------------------------------------- /geizhals/entities/wishlist.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import re 3 | 4 | import geizhals.core 5 | import geizhals.exceptions 6 | from geizhals.entities import Entity, EntityType 7 | 8 | 9 | class Wishlist(Entity): 10 | """Representation of a Geizhals wishlist""" 11 | url_pattern = r"https:\/\/geizhals\.(de|at|eu)\/\?cat=WL-([0-9]+)" 12 | ENTITY_NAME = "Wunschliste" 13 | TYPE = EntityType.WISHLIST 14 | 15 | @staticmethod 16 | def from_url(url): 17 | """Create a wishlist object by url""" 18 | if not re.match(Wishlist.url_pattern, url): 19 | raise geizhals.exceptions.InvalidWishlistURLException 20 | 21 | wl = Wishlist(entity_id=0, name="", url=url, price=0) 22 | wl.price = wl.get_current_price() 23 | wl.name = wl.get_current_name() 24 | wl.entity_id = int(re.search(Wishlist.url_pattern, url).group(2)) 25 | 26 | return wl 27 | 28 | def get_wishlist_products(self): 29 | raise NotImplementedError("get_wishlist_products is not implemented yet") 30 | -------------------------------------------------------------------------------- /geizhals/exceptions/__init__.py: -------------------------------------------------------------------------------- 1 | from .invalidwishlisturlexception import InvalidWishlistURLException 2 | from .invalidproducturlexception import InvalidProductURLException 3 | from .httplimitedexception import HTTPLimitedException 4 | 5 | __all__ = ['InvalidWishlistURLException', 'InvalidProductURLException', 'HTTPLimitedException'] 6 | -------------------------------------------------------------------------------- /geizhals/exceptions/httplimitedexception.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | 4 | class HTTPLimitedException(Exception): 5 | pass 6 | -------------------------------------------------------------------------------- /geizhals/exceptions/invalidproducturlexception.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | 4 | class InvalidProductURLException(Exception): 5 | pass 6 | -------------------------------------------------------------------------------- /geizhals/exceptions/invalidwishlisturlexception.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | 4 | class InvalidWishlistURLException(Exception): 5 | pass 6 | -------------------------------------------------------------------------------- /geizhals/state_handler.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import logging 3 | import random 4 | 5 | from .util import Ringbuffer 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | class GeizhalsStateHandler(object): 11 | _instance = None 12 | _initialized = False 13 | 14 | def __new__(cls, *args, **kwargs): 15 | if not cls._instance: 16 | cls._instance = super(GeizhalsStateHandler, cls).__new__(cls) 17 | return cls._instance 18 | 19 | def __init__(self, use_proxies=False, proxies=None): 20 | # Make sure that the object does not get overwritten each time the constructor get's called 21 | if GeizhalsStateHandler._initialized: 22 | return 23 | 24 | self.use_proxies = use_proxies 25 | 26 | if use_proxies: 27 | # Randomize order of proxies in the list 28 | random.shuffle(proxies) 29 | self.proxies = Ringbuffer(proxies) 30 | 31 | self.selected_proxy = self.get_next_proxy() 32 | 33 | GeizhalsStateHandler._initialized = True 34 | 35 | def get_next_proxy(self): 36 | logger.debug("Choosing new proxy.") 37 | 38 | if self.use_proxies and self.proxies is not None: 39 | if len(self.proxies) <= 1: 40 | logger.warning("Less than two proxies configured, using the same proxy again!") 41 | proxy = self.proxies.next() 42 | logger.debug("Selected '{}' as new proxy".format(proxy)) 43 | return proxy 44 | else: 45 | logger.warning("No proxies configured!") 46 | return None 47 | -------------------------------------------------------------------------------- /geizhals/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d-Rickyy-b/Python-GeizhalsBot/a64f12178d16473f03c350bbef0dd7722e87188e/geizhals/tests/__init__.py -------------------------------------------------------------------------------- /geizhals/tests/core_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | import re 5 | import unittest 6 | 7 | import geizhals.core 8 | from geizhals.entities import EntityType 9 | 10 | 11 | class GeizhalsCoreTest(unittest.TestCase): 12 | dir_path = os.path.dirname(os.path.abspath(__file__)) 13 | test_wl_file_path = os.path.join(dir_path, "test_wishlist.html") 14 | test_p_file_path = os.path.join(dir_path, "test_product.html") 15 | 16 | def setUp(self): 17 | with open(self.test_wl_file_path, "r", encoding='utf8') as f: 18 | self.html_wl = f.read() 19 | 20 | with open(self.test_p_file_path, "r", encoding='utf8') as f: 21 | self.html_p = f.read() 22 | 23 | def tearDown(self): 24 | pass 25 | 26 | def test_send_request(self): 27 | """Test to check if downloading the html code of a website works""" 28 | regex = re.compile(r'\s') 29 | html = geizhals.core.send_request("http://example.com") 30 | example_path = os.path.join(self.dir_path, "example.html") 31 | 32 | with open(example_path, "r") as f: 33 | example_html = f.read() 34 | 35 | # Replace all the whitespace to not have any issue with too many or little of them. 36 | self.assertEqual(regex.sub("", html), regex.sub("", example_html)) 37 | 38 | def test_parse_entity_price(self): 39 | """Test to check if parsing prices of entities works""" 40 | price = geizhals.core.parse_entity_price(self.html_wl, EntityType.WISHLIST) 41 | self.assertEqual(price, "717.81") 42 | 43 | price = geizhals.core.parse_entity_price(self.html_p, EntityType.PRODUCT) 44 | self.assertEqual(price, "199.65") 45 | 46 | with self.assertRaises(ValueError): 47 | geizhals.core.parse_entity_price("Test", "WrongEntityType") 48 | 49 | with self.assertRaises(ValueError): 50 | geizhals.core.parse_entity_price("Test", None) 51 | 52 | def test_parse_entity_name(self): 53 | """Test to check if parsing names of entities works""" 54 | name = geizhals.core.parse_entity_name(self.html_wl, EntityType.WISHLIST) 55 | self.assertEqual("NAS", name) 56 | 57 | name = geizhals.core.parse_entity_name(self.html_p, EntityType.PRODUCT) 58 | self.assertEqual("Samsung SSD 860 EVO 1TB, SATA (MZ-76E1T0B)", name) 59 | 60 | with self.assertRaises(ValueError): 61 | geizhals.core.parse_entity_name("Test", "WrongEntityType") 62 | 63 | with self.assertRaises(ValueError): 64 | geizhals.core.parse_entity_name("Test", None) 65 | -------------------------------------------------------------------------------- /geizhals/tests/entity_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | -------------------------------------------------------------------------------- /geizhals/tests/example.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Example Domain 5 | 6 | 7 | 8 | 9 | 36 | 37 | 38 | 39 |
40 |

Example Domain

41 |

This domain is for use in illustrative examples in documents. You may use this 42 | domain in literature without prior coordination or asking for permission.

43 |

More information...

44 |
45 | 46 | 47 | -------------------------------------------------------------------------------- /geizhals/tests/product_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import unittest 4 | 5 | from geizhals.exceptions import InvalidWishlistURLException 6 | from geizhals.entities import Product 7 | 8 | 9 | class ProductTest(unittest.TestCase): 10 | 11 | def setUp(self): 12 | self.p = Product(entity_id=1756905, 13 | name="Samsung SSD 860 EVO 1TB, SATA (MZ-76E1T0B)", 14 | url="https://geizhals.de/samsung-ssd-860-evo-1tb-mz-76e1t0b-a1756905.html", 15 | price=195.85) 16 | 17 | def tearDown(self): 18 | del self.p 19 | 20 | # Depending on the environment this test might fail and that's okay - skipping for the moment 21 | @unittest.skip("This test might fail depending on the environment") 22 | def test_from_url(self): 23 | """Test to check if creating a product by url works as intended""" 24 | # Create a product by url - needs a network connection 25 | my_p = Product.from_url(self.p.url) 26 | 27 | self.assertEqual(type(my_p), Product) 28 | 29 | self.assertEqual(my_p.entity_id, self.p.entity_id) 30 | self.assertEqual(my_p.name, self.p.name) 31 | self.assertEqual(my_p.url, self.p.url) 32 | 33 | # The price obviously can't be checked by a precise value 34 | self.assertEqual(type(my_p.price), float) 35 | self.assertGreater(my_p.price, 0.1) 36 | 37 | # Make sure that wrong urls lead to exceptions 38 | with self.assertRaises(InvalidWishlistURLException): 39 | Product.from_url("http://example.com") 40 | -------------------------------------------------------------------------------- /geizhals/tests/state_handler_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import unittest 4 | 5 | from geizhals.state_handler import GeizhalsStateHandler 6 | 7 | 8 | class GeizhalsStateHandlerTest(unittest.TestCase): 9 | 10 | def setUp(self): 11 | self.sh = GeizhalsStateHandler() 12 | 13 | def tearDown(self): 14 | """Reset the instance so that a new clean instance gets created for each test""" 15 | GeizhalsStateHandler._instance = None 16 | GeizhalsStateHandler._initialized = False 17 | 18 | def test_singleton(self): 19 | """ 20 | Make sure that all objects created from the GeizhalsStateHandler 21 | class are actually the same object 22 | """ 23 | self.assertEqual(self.sh, GeizhalsStateHandler()) 24 | self.assertEqual(self.sh, GeizhalsStateHandler()) 25 | self.assertEqual(self.sh, GeizhalsStateHandler()) 26 | 27 | def test_get_next_proxy(self): 28 | # Reset statehandler 29 | GeizhalsStateHandler._instance = None 30 | GeizhalsStateHandler._initialized = False 31 | proxies = ['https://example.com', 'https://test.org', 'http://proxy.net'] 32 | self.sh = GeizhalsStateHandler(use_proxies=True, proxies=proxies) 33 | 34 | self.assertEqual(len(self.sh.proxies), len(proxies)) 35 | 36 | p1 = self.sh.get_next_proxy() 37 | i1 = proxies.index(p1) 38 | 39 | p2 = self.sh.get_next_proxy() 40 | i2 = proxies.index(p2) 41 | 42 | p3 = self.sh.get_next_proxy() 43 | i3 = proxies.index(p3) 44 | 45 | p4 = self.sh.get_next_proxy() 46 | i4 = proxies.index(p4) 47 | 48 | p5 = self.sh.get_next_proxy() 49 | i5 = proxies.index(p5) 50 | 51 | p6 = self.sh.get_next_proxy() 52 | i6 = proxies.index(p6) 53 | 54 | self.assertEqual(i4, i1) 55 | self.assertEqual(p4, p1) 56 | self.assertEqual(i5, i2) 57 | self.assertEqual(p5, p2) 58 | self.assertEqual(i6, i3) 59 | self.assertEqual(p6, p3) 60 | -------------------------------------------------------------------------------- /geizhals/tests/wishlist_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import unittest 4 | 5 | from geizhals.entities import Wishlist 6 | from geizhals.exceptions import InvalidWishlistURLException 7 | 8 | 9 | class WishlistTest(unittest.TestCase): 10 | 11 | def setUp(self): 12 | self.wl = Wishlist(entity_id=676328, 13 | name="NAS", 14 | url="https://geizhals.de/?cat=WL-676328", 15 | price=617.90) 16 | 17 | def tearDown(self): 18 | del self.wl 19 | 20 | # Depending on the environment this test might fail and that's okay - skipping for the moment 21 | @unittest.skip("This test might fail depending on the environment") 22 | def test_from_url(self): 23 | """Test to check if creating a wishlist by url works as intended""" 24 | # Create a wishlist by url - needs a network connection 25 | my_wl = Wishlist.from_url(self.wl.url) 26 | 27 | self.assertEqual(type(my_wl), Wishlist) 28 | 29 | self.assertEqual(my_wl.entity_id, self.wl.entity_id) 30 | self.assertEqual(my_wl.name, self.wl.name) 31 | self.assertEqual(my_wl.url, self.wl.url) 32 | 33 | # The price obviously can't be checked by a precise value 34 | self.assertEqual(type(my_wl.price), float) 35 | self.assertGreater(my_wl.price, 0.1) 36 | 37 | # Make sure that wrong urls lead to exceptions 38 | with self.assertRaises(InvalidWishlistURLException): 39 | Wishlist.from_url("http://example.com") 40 | 41 | def test_get_wishlist_products(self): 42 | """Test to check if getting the products of a wishlist works as intended""" 43 | # Since this is not implemented yet, there should be a exception 44 | with self.assertRaises(NotImplementedError): 45 | self.wl.get_wishlist_products() 46 | -------------------------------------------------------------------------------- /geizhals/util/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from .ringbuffer import Ringbuffer 3 | 4 | __all__ = ['Ringbuffer'] 5 | -------------------------------------------------------------------------------- /geizhals/util/ringbuffer.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import logging 3 | from queue import Queue, Empty 4 | 5 | logger = logging.getLogger(__name__) 6 | 7 | 8 | class Ringbuffer(object): 9 | 10 | def __init__(self, elements): 11 | self.queue = Queue() 12 | for element in elements: 13 | self.queue.put(element) 14 | 15 | def next(self): 16 | try: 17 | element = self.queue.get(block=False) 18 | self.queue.put(element) 19 | except Empty: 20 | logger.error("Queue is empty. Returning None!") 21 | return None 22 | 23 | return element 24 | 25 | def __len__(self): 26 | return self.queue.qsize() 27 | -------------------------------------------------------------------------------- /geizhals/util/tests/ringbuffer_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | 4 | import unittest 5 | 6 | from geizhals.util.ringbuffer import Ringbuffer 7 | 8 | 9 | class RingbufferTest(unittest.TestCase): 10 | 11 | def setUp(self): 12 | pass 13 | 14 | def tearDown(self): 15 | pass 16 | 17 | def test_get_next_proxy(self): 18 | elements = ['Test', '1', 'example3'] 19 | rb = Ringbuffer(elements) 20 | 21 | self.assertEqual(len(rb), len(elements)) 22 | 23 | e1 = rb.next() 24 | i1 = elements.index(e1) 25 | 26 | e2 = rb.next() 27 | i2 = elements.index(e2) 28 | 29 | e3 = rb.next() 30 | i3 = elements.index(e3) 31 | 32 | e4 = rb.next() 33 | i4 = elements.index(e4) 34 | 35 | e5 = rb.next() 36 | i5 = elements.index(e5) 37 | 38 | e6 = rb.next() 39 | i6 = elements.index(e6) 40 | 41 | self.assertEqual(i4, i1) 42 | self.assertEqual(e4, e1) 43 | self.assertEqual(i5, i2) 44 | self.assertEqual(e5, e2) 45 | self.assertEqual(i6, i3) 46 | self.assertEqual(e6, e3) 47 | 48 | self.assertEqual(len(rb), len(elements)) 49 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import datetime 4 | import logging.handlers 5 | import re 6 | import io 7 | 8 | from requests.exceptions import HTTPError 9 | from telegram import InlineKeyboardMarkup, InlineKeyboardButton 10 | from telegram.error import (TelegramError, Unauthorized, BadRequest, 11 | TimedOut, ChatMigrated, NetworkError) 12 | from telegram.ext import Updater, CommandHandler, CallbackQueryHandler, MessageHandler, Filters 13 | from telegram.parsemode import ParseMode 14 | 15 | import bot.core as core 16 | import config 17 | from bot.menus import MainMenu, NewPriceAgentMenu, ShowPriceAgentsMenu, ShowWLPriceAgentsMenu, ShowPPriceAgentsMenu 18 | from bot.menus.util import cancel_button, get_entities_keyboard, get_entity_keyboard 19 | from bot.user import User 20 | from geizhals import GeizhalsStateHandler 21 | from geizhals.entities import EntityType, Wishlist, Product 22 | from state import State 23 | from util.exceptions import AlreadySubscribedException, InvalidURLException 24 | from util.formatter import bold, link, price 25 | import pathlib 26 | 27 | __author__ = 'Rico' 28 | 29 | STATE_SEND_LINK = 0 30 | STATE_SEND_WL_LINK = 1 31 | STATE_SEND_P_LINK = 2 32 | STATE_IDLE = 3 33 | 34 | project_path = pathlib.Path(__file__).parent.absolute() 35 | logdir_path = project_path / "logs" 36 | logfile_path = logdir_path / "bot.log" 37 | 38 | if not logdir_path.exists(): 39 | logdir_path.mkdir() 40 | 41 | logfile_handler = logging.handlers.WatchedFileHandler(logfile_path, 'a', 'utf-8') 42 | logging.basicConfig(format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', 43 | level=logging.INFO, handlers=[logfile_handler]) 44 | logging.getLogger("telegram").setLevel(logging.WARNING) 45 | logging.getLogger("apscheduler").setLevel(logging.ERROR) 46 | 47 | logger = logging.getLogger("geizhals.main") 48 | 49 | if not re.match(r"[0-9]+:[a-zA-Z0-9\-_]+", config.BOT_TOKEN): 50 | logger.error("Bot token not correct - please check.") 51 | exit(1) 52 | 53 | updater = Updater(token=config.BOT_TOKEN, use_context=True) 54 | dp = updater.dispatcher 55 | 56 | 57 | def admin_method(func): 58 | """Decorator for marking methods as admin-only methods, so that strangers can't use them""" 59 | 60 | def admin_check(update, context): 61 | user = update.effective_user 62 | 63 | if user.id in config.ADMIN_IDs: 64 | return func(update, context) 65 | 66 | update.message.reply_text('You have not the required permissions to do that!') 67 | logger.warning("User {} ({}, @{}) tried to use an admin function '{}'!".format(user.id, user.first_name, user.username, 68 | func.__name__)) 69 | 70 | return admin_check 71 | 72 | 73 | # Text commands 74 | def start_cmd(update, context): 75 | """Bot start command""" 76 | user = update.effective_user 77 | 78 | # If user is here for the first time > Save him to the DB 79 | u = User(user_id=user.id, first_name=user.first_name, last_name=user.last_name, username=user.username, lang_code=user.language_code) 80 | core.add_user_if_new(u) 81 | context.bot.sendMessage(user.id, MainMenu.text, reply_markup=MainMenu.keyboard) 82 | context.user_data["state"] = State.IDLE 83 | 84 | 85 | def help_cmd(update, context): 86 | """Bot help command""" 87 | user_id = update.effective_user.id 88 | help_text = "Du brauchst Hilfe? Probiere folgende Befehle:\n\n" \ 89 | "/start - Startmenü\n" \ 90 | "/help - Zeigt diese Hilfe\n" \ 91 | "/show - Zeigt deine Listen an\n" \ 92 | "/add - Fügt neue Wunschliste hinzu\n" \ 93 | # "/remove - Entfernt eine Wunschliste\n" 94 | 95 | context.bot.sendMessage(user_id, help_text) 96 | 97 | 98 | @admin_method 99 | def broadcast(update, context): 100 | """Method to send a broadcast to all of the users of the bot""" 101 | user_id = update.effective_user.id 102 | bot = context.bot 103 | 104 | message_with_prefix = update.message.text 105 | final_message = message_with_prefix.replace("/broadcast ", "") 106 | users = core.get_all_subscribers() 107 | logger.info("Sending message broadcast to all ({}) users! Requested by admin '{}'".format(len(users), user_id)) 108 | for user in users: 109 | try: 110 | logger.debug("Sending broadcast to user '{}'".format(user)) 111 | bot.send_message(chat_id=user, text=final_message) 112 | except Unauthorized: 113 | logger.info("User '{}' blocked the bot!".format(user)) 114 | core.delete_user(user) 115 | 116 | for admin in config.ADMIN_IDs: 117 | bot.send_message(chat_id=admin, 118 | text="Sent message broadcast to all users! Requested by admin '{}' with the text:\n\n{}".format(user_id, final_message)) 119 | 120 | 121 | @admin_method 122 | def get_usage_info(update, context): 123 | subs = len(core.get_all_subscribers()) 124 | products = len(core.get_all_products_with_subscribers()) 125 | wishlists = len(core.get_all_wishlists_with_subscribers()) 126 | all_subbed = len(core.get_all_entities_with_subscribers()) 127 | all_entites = len(core.get_all_entities()) 128 | price_count = core.get_price_count() 129 | total_users = len(core.get_all_users()) 130 | update.message.reply_text("Current statistics for @{}\n\n" 131 | "Subscriber count: {}\n\n" 132 | "Subscribed products: {}\n" 133 | "Subscribed wishlists: {}\n" 134 | "Subscribed entities total: {}\n\n" 135 | "Number of entities in db: {}\n\n" 136 | "Number of stored prices in db: {}\n\n" 137 | "Total users: {}" 138 | "".format(context.bot.username, subs, products, wishlists, all_subbed, all_entites, price_count, total_users), 139 | parse_mode=ParseMode.HTML) 140 | 141 | 142 | # Inline menus 143 | def add_menu(update, _): 144 | """Send inline menu to add a new price agent""" 145 | update.message.reply_text(NewPriceAgentMenu.text, reply_markup=NewPriceAgentMenu.keyboard) 146 | 147 | 148 | def show_menu(update, _): 149 | """Send inline menu to display all price agents""" 150 | update.message.reply_text(ShowPriceAgentsMenu.text, reply_markup=ShowPriceAgentsMenu.keyboard) 151 | 152 | 153 | def handle_text(update, context): 154 | """Handles plain text sent to the bot""" 155 | if not context.user_data: 156 | logger.info("User has no state but sent text!") 157 | return 158 | 159 | state = context.user_data.get("state") 160 | if state == State.SEND_LINK: 161 | add_entity(update, context) 162 | 163 | 164 | def add_entity(update, context): 165 | msg = update.message 166 | text = update.message.text 167 | t_user = update.effective_user 168 | 169 | logger.info("Adding new entity for user '{}'".format(t_user.id)) 170 | 171 | reply_markup = InlineKeyboardMarkup([[cancel_button]]) 172 | user = User(user_id=t_user.id, first_name=t_user.first_name, last_name=t_user.last_name, username=t_user.username, lang_code=t_user.language_code) 173 | core.add_user_if_new(user) 174 | 175 | try: 176 | entity_type = core.get_type_by_url(text) 177 | url = core.get_e_url(text, entity_type) 178 | logger.info("Valid URL for new entity is '{}'".format(url)) 179 | except InvalidURLException: 180 | logger.warning("Invalid url '{}' sent by user {}!".format(text, t_user)) 181 | msg.reply_text(text="Die URL ist ungültig!", reply_markup=reply_markup) 182 | return 183 | 184 | try: 185 | if entity_type == EntityType.WISHLIST: 186 | entity = Wishlist.from_url(url) 187 | elif entity_type == EntityType.PRODUCT: 188 | entity = Product.from_url(url) 189 | else: 190 | raise ValueError("EntityType '{}' not found!".format(entity_type)) 191 | except HTTPError as e: 192 | logger.error(e) 193 | if e.response.status_code == 403: 194 | msg.reply_text(text="Die URL ist nicht öffentlich einsehbar, daher wurde kein neuer Preisagent erstellt!") 195 | elif e.response.status_code == 429: 196 | msg.reply_text(text="Entschuldige, ich bin temporär bei Geizhals blockiert und kann keine Preise auslesen. Bitte probiere es später noch einmal.") 197 | except (ValueError, Exception) as e: 198 | # Raised when price could not be parsed 199 | logger.error(e) 200 | msg.reply_text(text="Name oder Preis konnte nicht ausgelesen werden! Preisagent wurde nicht erstellt!") 201 | else: 202 | core.add_entity_if_new(entity) 203 | 204 | try: 205 | logger.debug("Subscribing to entity.") 206 | core.subscribe_entity(user, entity) 207 | entity_data = EntityType.get_type_article_name(entity_type) 208 | msg.reply_html("Preisagent für {article} {type} {link_name} erstellt! Aktueller Preis: {price}".format( 209 | article=entity_data.get("article"), 210 | type=entity_data.get("name"), 211 | link_name=link(entity.url, entity.name), 212 | price=bold(price(entity.price, signed=False))), 213 | disable_web_page_preview=True) 214 | context.user_data["state"] = State.IDLE 215 | except AlreadySubscribedException: 216 | logger.debug("User already subscribed!") 217 | msg.reply_text("Du hast bereits einen Preisagenten für diese URL! Bitte sende mir eine andere URL.", 218 | reply_markup=InlineKeyboardMarkup([[cancel_button]])) 219 | 220 | 221 | def check_for_price_update(context): 222 | """Check if the price of any subscribed wishlist or product was updated""" 223 | bot = context.bot 224 | logger.debug("Checking for updates!") 225 | bot = context.bot 226 | 227 | entities = core.get_all_entities_with_subscribers() 228 | 229 | # Check all entities for price updates 230 | for entity in entities: 231 | logger.debug("URL is '{}'".format(entity.url)) 232 | old_price = entity.price 233 | old_name = entity.name 234 | try: 235 | new_price = entity.get_current_price() 236 | new_name = entity.get_current_name() 237 | except HTTPError as e: 238 | if e.response.status_code == 403: 239 | logger.error("Entity is not public!") 240 | entity_type_data = EntityType.get_type_article_name(entity.TYPE) 241 | entity_hidden = "{article} {type} {link_name} ist leider nicht mehr einsehbar. " \ 242 | "Ich entferne diesen Preisagenten!".format(article=entity_type_data.get("article").capitalize(), 243 | type=entity_type_data.get("name"), link_name=link(entity.url, entity.name)) 244 | 245 | for user_id in core.get_entity_subscribers(entity): 246 | user = core.get_user_by_id(user_id) 247 | bot.send_message(user_id, entity_hidden, parse_mode=ParseMode.HTML) 248 | core.unsubscribe_entity(user, entity) 249 | 250 | core.rm_entity(entity) 251 | except (ValueError, Exception) as e: 252 | logger.error("Exception while checking for price updates! {}".format(e)) 253 | else: 254 | if old_name != new_name: 255 | core.update_entity_name(entity, new_name) 256 | 257 | # Make sure to update the price no matter if it changed. Helps for generating charts 258 | entity.price = new_price 259 | core.update_entity_price(entity, new_price) 260 | 261 | if old_price == new_price: 262 | continue 263 | 264 | entity_subscribers = core.get_entity_subscribers(entity) 265 | 266 | for user_id in entity_subscribers: 267 | # Notify each subscriber 268 | try: 269 | notify_user(bot, user_id, entity, old_price) 270 | except Unauthorized as e: 271 | if e.message == "Forbidden: user is deactivated": 272 | logger.info("Removing user from db, because account was deleted.") 273 | elif e.message == "Forbidden: bot was blocked by the user": 274 | logger.info("Removing user from db, because they blocked the bot.") 275 | core.delete_user(user_id) 276 | 277 | 278 | def notify_user(bot, user_id, entity, old_price): 279 | """Notify a user of price changes""" 280 | diff = entity.price - old_price 281 | 282 | if diff > 0: 283 | emoji = "📈" 284 | change = "teurer" 285 | else: 286 | emoji = "📉" 287 | change = "billiger" 288 | 289 | logger.info("Notifying user {}!".format(user_id)) 290 | 291 | message = "Der Preis von {link_name} hat sich geändert: {price}\n\n" \ 292 | "{emoji} {diff} {change}".format(link_name=link(entity.url, entity.name), 293 | price=bold(price(entity.price, signed=False)), 294 | emoji=emoji, 295 | diff=bold(price(diff)), 296 | change=change) 297 | bot.sendMessage(user_id, message, parse_mode=ParseMode.HTML, disable_web_page_preview=True) 298 | 299 | 300 | def entity_price_history(update, _): 301 | """Handles button clicks on the price history button""" 302 | cbq = update.callback_query 303 | data = cbq.data 304 | 305 | if "_" in data: 306 | menu, action, entity_id, entity_type_value = data.split("_") 307 | entity_type = EntityType(int(entity_type_value)) 308 | else: 309 | logger.error("Error before unpacking. There is no '_' in the callback query data!") 310 | text = "An error occurred! This error was logged." 311 | cbq.message.reply_text(text=text, parse_mode=ParseMode.HTML, disable_web_page_preview=True) 312 | cbq.answer(text=text) 313 | return 314 | 315 | entity = core.get_entity(entity_id, entity_type) 316 | items = core.get_price_history(entity) 317 | 318 | from geizhals.charts.dataset import Dataset 319 | ds = Dataset(entity.name) 320 | for p, timestamp, name in items: 321 | ds.add_price(price=p, timestamp=timestamp) 322 | 323 | if len(items) <= 3 or len(ds.days) <= 3: 324 | cbq.message.edit_reply_markup(reply_markup=InlineKeyboardMarkup([])) 325 | cbq.message.reply_text("Entschuldige, leider habe ich nicht genügend Daten für diesen Preisagenten, um einen Preisverlauf anzeigen zu können! " 326 | "Schau einfach in ein paar Tagen nochmal vorbei!") 327 | return 328 | 329 | chart = ds.get_chart() 330 | file_name = "{}.png".format(entity.entity_id) 331 | logger.info("Generated new chart '{}' for user '{}'".format(file_name, cbq.from_user.id)) 332 | 333 | file = io.BytesIO(chart) 334 | cbq.message.reply_photo(photo=file) 335 | 336 | cbq.message.edit_text("Hier ist der Preisverlauf für {}".format(link(entity.url, entity.name)), reply_markup=InlineKeyboardMarkup([]), 337 | parse_mode=ParseMode.HTML, disable_web_page_preview=True) 338 | cbq.answer() 339 | 340 | 341 | def main_menu_handler(update, _): 342 | """Handles all the callbackquerys for the main/first menu (m0)""" 343 | cbq = update.callback_query 344 | menu, action = cbq.data.split("_") 345 | 346 | if action == "newpriceagent": 347 | cbq.edit_message_text(text=NewPriceAgentMenu.text, 348 | reply_markup=NewPriceAgentMenu.keyboard) 349 | elif action == "showpriceagents": 350 | cbq.edit_message_text(text=ShowPriceAgentsMenu.text, 351 | reply_markup=ShowPriceAgentsMenu.keyboard) 352 | else: 353 | logger.warning("A user tried to use an unimplemented method: '{}'".format(action)) 354 | cbq.answer(text="Die gewählte Funktion ist noch nicht implementiert!") 355 | 356 | 357 | def show_pa_menu_handler(update, _): 358 | """Handles all the callbackquerys for the second menu (m2) - show price agents""" 359 | cbq = update.callback_query 360 | user_id = cbq.from_user.id 361 | menu, action = cbq.data.split("_") 362 | 363 | if action == "back": 364 | cbq.edit_message_text(text=MainMenu.text, reply_markup=MainMenu.keyboard) 365 | elif action == "showwishlists": 366 | wishlists = core.get_wishlists_for_user(user_id) 367 | back_keyboard = InlineKeyboardMarkup([[InlineKeyboardButton("\U000021a9\U0000fe0f Zurück", callback_data="m00_showpriceagents")]]) 368 | 369 | if len(wishlists) == 0: 370 | cbq.edit_message_text(text="Du hast noch keinen Preisagenten für eine Wunschliste angelegt!", 371 | reply_markup=back_keyboard) 372 | return 373 | 374 | keyboard = get_entities_keyboard("show", wishlists) 375 | cbq.edit_message_text(text=ShowWLPriceAgentsMenu.text, reply_markup=keyboard) 376 | elif action == "showproducts": 377 | products = core.get_products_for_user(user_id) 378 | 379 | if len(products) == 0: 380 | back_keyboard = InlineKeyboardMarkup([[InlineKeyboardButton("\U000021a9\U0000fe0f Zurück", callback_data="m00_showpriceagents")]]) 381 | cbq.edit_message_text(text="Du hast noch keinen Preisagenten für ein Produkt angelegt!", 382 | reply_markup=back_keyboard) 383 | return 384 | 385 | keyboard = get_entities_keyboard("show", products) 386 | cbq.edit_message_text(text=ShowPPriceAgentsMenu.text, reply_markup=keyboard) 387 | else: 388 | logger.warning("A user tried to use an unimplemented method: '{}'".format(action)) 389 | cbq.answer(text="Die gewählte Funktion ist noch nicht implementiert!") 390 | 391 | 392 | def add_pa_menu_handler(update, context): 393 | """Handles all the callbackquerys for the third menu (m1) - add new price agent""" 394 | cbq = update.callback_query 395 | user_id = cbq.from_user.id 396 | menu, action = cbq.data.split("_") 397 | 398 | if action == "back": 399 | cbq.edit_message_text(text=MainMenu.text, reply_markup=MainMenu.keyboard) 400 | elif action == "addwishlist": 401 | if core.get_wishlist_count(user_id) >= config.MAX_WISHLISTS: 402 | keyboard = get_entities_keyboard("delete", core.get_wishlists_for_user(user_id), prefix_text="❌ ", cancel=True) 403 | cbq.edit_message_text(text="Du kannst zu maximal 5 Wunschlisten Benachrichtigungen bekommen. " 404 | "Entferne doch eine Wunschliste, die du nicht mehr benötigst.", 405 | reply_markup=keyboard) 406 | return 407 | context.user_data["state"] = State.SEND_LINK 408 | 409 | cbq.edit_message_text(text="Bitte sende mir eine URL einer Wunschliste!", 410 | reply_markup=InlineKeyboardMarkup([[cancel_button]])) 411 | cbq.answer() 412 | elif action == "addproduct": 413 | if core.get_product_count(user_id) >= config.MAX_PRODUCTS: 414 | cbq.edit_message_text(text="Du kannst zu maximal 5 Wunschlisten Benachrichtigungen bekommen. " 415 | "Entferne doch eine Wunschliste, die du nicht mehr benötigst.", 416 | reply_markup=get_entities_keyboard("delete", core.get_products_for_user(user_id), 417 | prefix_text="❌ ", cancel=True)) 418 | 419 | return 420 | context.user_data["state"] = State.SEND_LINK 421 | 422 | cbq.edit_message_text(text="Bitte sende mir eine URL eines Produkts!", 423 | reply_markup=InlineKeyboardMarkup([[cancel_button]])) 424 | cbq.answer() 425 | else: 426 | logger.warning("A user tried to use an unimplemented method: '{}'".format(action)) 427 | cbq.answer(text="Die gewählte Funktion ist noch nicht implementiert!") 428 | 429 | 430 | def pa_detail_handler(update, context): 431 | """Handler for the price agent detail menu""" 432 | cbq = update.callback_query 433 | user_id = cbq.from_user.id 434 | menu, action, entity_id, entity_type_str = cbq.data.split("_") 435 | entity_type = EntityType(int(entity_type_str)) 436 | 437 | entity = core.get_entity(entity_id, entity_type) 438 | user = core.get_user_by_id(user_id) 439 | 440 | if action == "show": 441 | cbq.edit_message_text(text="{link_name} kostet aktuell {price}".format( 442 | link_name=link(entity.url, entity.name), 443 | price=bold(price(entity.price, signed=False))), 444 | reply_markup=get_entity_keyboard(entity.TYPE, entity.entity_id), 445 | parse_mode=ParseMode.HTML, disable_web_page_preview=True) 446 | cbq.answer() 447 | elif action == "delete": 448 | core.unsubscribe_entity(user, entity) 449 | 450 | callback_data = 'm04_subscribe_{id}_{type}'.format(id=entity.entity_id, 451 | type=entity.TYPE.value) 452 | keyboard = [[InlineKeyboardButton("Rückgängig", callback_data=callback_data)]] 453 | reply_markup = InlineKeyboardMarkup(keyboard) 454 | text = "Preisagent für '{0}' wurde gelöscht!".format(link(entity.url, entity.name)) 455 | 456 | cbq.edit_message_text(text=text, 457 | reply_markup=reply_markup, 458 | parse_mode=ParseMode.HTML, disable_web_page_preview=True) 459 | cbq.answer(text="Preisagent für wurde gelöscht!") 460 | elif action == "subscribe": 461 | entity_info = EntityType.get_type_article_name(entity.TYPE) 462 | try: 463 | core.subscribe_entity(user, entity) 464 | 465 | text = "Du hast {article} {entity_name} {link_name} erneut abboniert!".format( 466 | article=entity_info.get("article"), entity_name=entity_info.get("name"), 467 | link_name=link(entity.url, entity.name)) 468 | cbq.edit_message_text(text=text, parse_mode=ParseMode.HTML, disable_web_page_preview=True) 469 | cbq.answer(text="{} erneut abboniert".format(entity_info.get("name"))) 470 | except AlreadySubscribedException: 471 | text = "{} bereits abboniert!".format(entity_info.get("name")) 472 | cbq.edit_message_text(text=text, parse_mode=ParseMode.HTML, disable_web_page_preview=True) 473 | cbq.answer(text=text) 474 | elif action == "history": 475 | entity_price_history(update, context) 476 | else: 477 | logger.warning("A user tried to use an unimplemented method: '{}'".format(action)) 478 | cbq.answer(text="Die gewählte Funktion ist noch nicht implementiert!") 479 | 480 | 481 | def cancel_handler(update, context): 482 | """Handles clicks on the cancel button""" 483 | cbq = update.callback_query 484 | context.user_data["state"] = STATE_IDLE 485 | text = "Okay, Ich habe die Aktion abgebrochen!" 486 | cbq.edit_message_text(text=text) 487 | cbq.answer(text=text) 488 | 489 | 490 | def callback_handler_f(update, _): 491 | """Handler for all the uncatched methods""" 492 | cbq = update.callback_query 493 | user_id = cbq.from_user.id 494 | 495 | logger.warning("The user '{}' used an undefined callback '{}'!".format(user_id, cbq.data)) 496 | 497 | 498 | def unknown(update, context): 499 | """Bot method which gets called when no command could be recognized""" 500 | context.bot.send_message(chat_id=update.message.chat_id, 501 | text="Sorry, den Befehl kenne ich nicht. Schau doch mal in der /hilfe") 502 | 503 | 504 | def error_callback(_, context): 505 | error = context.error 506 | try: 507 | raise error 508 | except Unauthorized as e: 509 | logger.error(e.message) # remove update.message.chat_id from conversation list 510 | except BadRequest as e: 511 | logger.error(e.message) # handle malformed requests 512 | except TimedOut: 513 | pass # connection issues are ignored for now 514 | except NetworkError as e: 515 | logger.error(e.message) # handle other connection problems 516 | except ChatMigrated as e: 517 | logger.error(e.message) # the chat_id of a group has changed, use e.new_chat_id instead 518 | except TelegramError as e: 519 | logger.error(e.message) # handle all other telegram related errors 520 | 521 | 522 | dp.add_handler(CommandHandler("stats", callback=get_usage_info)) 523 | dp.add_handler(MessageHandler(Filters.regex("!stats"), callback=get_usage_info)) 524 | 525 | # Basic handlers for standard commands 526 | dp.add_handler(CommandHandler("start", callback=start_cmd)) 527 | dp.add_handler(CommandHandler(["help", "hilfe"], callback=help_cmd)) 528 | 529 | # Bot specific commands 530 | dp.add_handler(CommandHandler("add", callback=add_menu)) 531 | dp.add_handler(CommandHandler("show", callback=show_menu)) 532 | dp.add_handler(CommandHandler("broadcast", callback=broadcast)) 533 | 534 | # Callback, Text and fallback handlers 535 | dp.add_handler(CallbackQueryHandler(main_menu_handler, pattern="^m00_")) 536 | dp.add_handler(CallbackQueryHandler(add_pa_menu_handler, pattern="^m01_")) 537 | dp.add_handler(CallbackQueryHandler(show_pa_menu_handler, pattern="^m02_")) 538 | 539 | dp.add_handler(CallbackQueryHandler(pa_detail_handler, pattern="^m04_")) 540 | dp.add_handler(CallbackQueryHandler(entity_price_history, pattern="^m05_")) 541 | dp.add_handler(CallbackQueryHandler(cancel_handler, pattern="^cancel$")) 542 | 543 | dp.add_handler(CallbackQueryHandler(callback_handler_f)) 544 | dp.add_handler(MessageHandler(Filters.text, handle_text)) 545 | dp.add_handler(MessageHandler(Filters.command, unknown)) 546 | dp.add_error_handler(error_callback) 547 | 548 | # Scheduling the check for updates 549 | dt = datetime.datetime.today() 550 | seconds = int(dt.timestamp()) 551 | repeat_in_minutes = 30 552 | repeat_in_seconds = 60 * repeat_in_minutes 553 | delta_t = repeat_in_seconds - (seconds % repeat_in_seconds) 554 | 555 | updater.job_queue.run_repeating(callback=check_for_price_update, interval=repeat_in_seconds, first=delta_t) 556 | updater.job_queue.start() 557 | 558 | if config.USE_WEBHOOK: 559 | updater.start_webhook(listen=config.WEBHOOK_IP, port=config.WEBHOOK_PORT, url_path=config.BOT_TOKEN, cert=config.CERTPATH, webhook_url=config.WEBHOOK_URL) 560 | updater.bot.set_webhook(config.WEBHOOK_URL) 561 | else: 562 | updater.start_polling() 563 | 564 | if config.USE_PROXIES: 565 | proxy_path = project_path / config.PROXY_LIST 566 | with open(proxy_path, "r", encoding="utf-8") as f: 567 | proxies = f.read().split("\n") 568 | # Removing comments from the proxy list starting with a hash symbol and empty lines 569 | # Source: https://stackoverflow.com/questions/7058679/remove-all-list-elements-starting-with-a-hash 570 | proxies[:] = [x for x in proxies if not x.startswith('#') and not x == ''] 571 | if proxies is not None and isinstance(proxies, list): 572 | logger.info("Using proxies!") 573 | GeizhalsStateHandler(use_proxies=config.USE_PROXIES, proxies=proxies) 574 | else: 575 | logger.error("Proxies list is either empty or has mismatching type!") 576 | exit(1) 577 | else: 578 | GeizhalsStateHandler(use_proxies=config.USE_PROXIES, proxies=None) 579 | 580 | logger.info("Bot started as @{}".format(updater.bot.username)) 581 | updater.idle() 582 | -------------------------------------------------------------------------------- /proxies.sample.txt: -------------------------------------------------------------------------------- 1 | # This file must contain unix newline (\n) separated proxies, one proxy per line 2 | # It can contain comments such as this one starting with a hash symbol 3 | # The general syntax for proxies is: `proto://[username:password@]host:port` 4 | # A more specific example of this is: `https://user:pass@example.com:8080` 5 | https://104.152.45.46:80 6 | https://111.63.135.104:8080 -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pyquery>=1.4,<2 2 | python-telegram-bot>=13,<14 3 | requests>=2.25.1,<3 4 | -------------------------------------------------------------------------------- /state.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from enum import Enum 3 | 4 | 5 | class State(Enum): 6 | 7 | SEND_LINK = 0 8 | SEND_WL_LINK = 1 9 | SEND_P_LINK = 2 10 | IDLE = 3 11 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d-Rickyy-b/Python-GeizhalsBot/a64f12178d16473f03c350bbef0dd7722e87188e/tests/__init__.py -------------------------------------------------------------------------------- /util/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d-Rickyy-b/Python-GeizhalsBot/a64f12178d16473f03c350bbef0dd7722e87188e/util/__init__.py -------------------------------------------------------------------------------- /util/exceptions.py: -------------------------------------------------------------------------------- 1 | """Collection of all the exceptions""" 2 | 3 | 4 | # -*- coding: utf-8 -*- 5 | 6 | 7 | class AlreadySubscribedException(Exception): 8 | pass 9 | 10 | 11 | class WishlistNotFoundException(Exception): 12 | pass 13 | 14 | 15 | class ProductNotFoundException(Exception): 16 | pass 17 | 18 | 19 | class TooManyWishlistsException(Exception): 20 | pass 21 | 22 | 23 | class IncompleteRequestException(Exception): 24 | pass 25 | 26 | 27 | class InvalidURLException(Exception): 28 | pass 29 | -------------------------------------------------------------------------------- /util/formatter.py: -------------------------------------------------------------------------------- 1 | import html 2 | 3 | 4 | def bold(text): 5 | """Generates a html formatted bold text""" 6 | return "{text}".format(text=text) 7 | 8 | 9 | def link(url, name): 10 | """Generates a html formatted named link""" 11 | return "{name}".format(url=url, name=html.escape(name)) 12 | 13 | 14 | def price(price_value, signed=True): 15 | """Generates a formatted price tag from a value""" 16 | if signed: 17 | return "{price:+.2f} €".format(price=price_value) 18 | else: 19 | return "{price:.2f} €".format(price=price_value) 20 | -------------------------------------------------------------------------------- /util/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d-Rickyy-b/Python-GeizhalsBot/a64f12178d16473f03c350bbef0dd7722e87188e/util/tests/__init__.py -------------------------------------------------------------------------------- /util/tests/formatter_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import unittest 4 | 5 | from util import formatter 6 | 7 | 8 | class FormatterTest(unittest.TestCase): 9 | 10 | def setUp(self): 11 | pass 12 | 13 | def tearDown(self): 14 | pass 15 | 16 | def test_bold(self): 17 | text = "This is a Text" 18 | expected = "This is a Text" 19 | 20 | self.assertEqual(formatter.bold(text), expected, "Text does not match expected bold string!") 21 | 22 | def test_link(self): 23 | text = "name" 24 | link = "www.example.com" 25 | 26 | self.assertEqual(formatter.link(link, text), "name") 27 | 28 | def test_price_signed(self): 29 | price_pos = 1.752221 30 | price_neg = -1.82111 31 | 32 | self.assertEqual(formatter.price(price_pos), "+1.75 €", msg="Positive price not displayed correctly") 33 | self.assertEqual(formatter.price(price_neg), "-1.82 €", msg="Negative price not displayed correctly") 34 | 35 | def test_price_unsigned(self): 36 | price_pos = 1.752221 37 | price_neg = -1.82111 38 | 39 | self.assertEqual(formatter.price(price_pos, False), "1.75 €", msg="Positive price not displayed correctly") 40 | self.assertEqual(formatter.price(price_neg, False), "-1.82 €", msg="Negative price not displayed correctly") 41 | --------------------------------------------------------------------------------