diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..c586011
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,2 @@
+README-en.md linguist-language=English
+README.md linguist-language=Chinese
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..a81c8ee
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,138 @@
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+# Usually these files are written by a python script from a template
+# before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.py,cover
+.hypothesis/
+.pytest_cache/
+cover/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+.pybuilder/
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+# For a library or package, you might want to ignore these files since the code is
+# intended to run in multiple environments; otherwise, check them in:
+# .python-version
+
+# pipenv
+# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+# However, in case of collaboration, if having platform-specific dependencies or dependencies
+# having no cross-platform support, pipenv may install dependencies that don't work, or not
+# install all needed dependencies.
+#Pipfile.lock
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow
+__pypackages__/
+
+# Celery stuff
+celerybeat-schedule
+celerybeat.pid
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# pytype static type analyzer
+.pytype/
+
+# Cython debug symbols
+cython_debug/
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..97ec565
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023 Shiki@Z
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README-en.md b/README-en.md
new file mode 100644
index 0000000..2cf8b0c
--- /dev/null
+++ b/README-en.md
@@ -0,0 +1,188 @@
+# InspectionLite
+InspectionLite is a Python project used to detect updates on specified web pages. It can automatically detect updates on the specified page based on the user-defined task list and push update notifications to specified users.
+
+# Features
+- **Supports multiple notification methods**: This program supports multiple notification methods, such as QQ bot based on Mirai HTTP, SMTP emails, etc., to meet the different needs of users.
+- **Customizable tasks**: InspectionLite supports reading the task list from the configuration file. Users can add new detection tasks according to their needs.
+- **Easy to extend**: The program adopts a modular design, and users can easily write new notification methods and register them in the system.
+- **High degree of customization**: Users can configure parameters according to their own needs, such as modifying database connection information, task lists, notification methods, etc.
+- **High reliability**: The program runs stably and reliably, and can promptly send emails to administrators in case of abnormal situations.
+
+# Installation
+1. Clone the repository to your local machine.
+2. Enter the project directory.
+3. Install dependencies: `pip install -r requirements.txt`
+4. Configure program parameters: edit the configuration file in the `/conf` directory and modify parameter values as needed.
+5. Run the program: `python main.py`.
+
+# Usage Examples
+
+ 1. Modify the conf/push.py file
+
+```python
+push_setting = {
+ # The administrator's email address. If there are any notification errors during runtime, they will be sent to this email address.
+ 'admin': {
+ 'email': 'admin@example.com'
+ },
+ # Fill in the parameters for the Mirai notification protocol here.
+ # If you don't need Mirai notification, leave it blank.
+ # Note: This project defaults to using Mirai HTTP protocol for notification.
+ 'mirai': {
+ 'host': 'mirai.host.com:port',
+ 'verify_key': 'Mirai HTTP verify key',
+ # Fill in the sender's QQ number here. The sender must have logged in to Mirai Console.
+ 'sender': 10000
+ },
+ # Fill in the parameters for the SMTP (email) notification protocol here.
+ # This is required because messages will be sent via email in case of notification errors.
+ 'smtp': {
+ 'host': 'smtp.example.com',
+ 'port': 465,
+ 'username': 'inspectionlite@example.com',
+ 'password': 'example password'
+ }
+}
+```
+
+
+
+
+ 2. Modify the conf/sql.py file
+
+```python
+# The project uses a MySQL database, so fill in the login parameters for the database here.
+# Note: The database user must have read/write permission for the database.
+sql_setting = {
+ 'host': 'database address',
+ 'user': 'database username',
+ 'password': 'database password',
+ 'db': 'database to use'
+}
+```
+
+
+
+
+ 3. Modify the conf/subscribers.py file
+
+```python
+subscribers = [
+ # User 1
+ {
+ # If subscribing to all notifications, set range to True;
+ # If subscribing to some notifications, write them as a list here;
+ # If subscribing to no notifications, set range to False (or delete this user).
+ 'range': True,
+ 'mirai': {
+ # Enable Mirai notification by setting enable to True.
+ # If not using Mirai notification, ignore id and is_group.
+ 'enable': True,
+ 'id': 10001,
+ 'is_group': False,
+ },
+ 'smtp': {
+ # Enable SMTP (email) notification by setting enable to True.
+ # If not using SMTP notification, ignore email.
+ 'enable': True,
+ 'email': 'user@example.com'
+ }
+ },
+ # User 2
+ {
+ 'range': ['CSP notifications'],
+ 'mirai': {
+ 'enable': True,
+ 'id': 20001,
+ 'is_group': True,
+ },
+ 'smtp': {
+ 'enable': False
+ }
+ }
+]
+```
+
+
+
+
+ 4. Modify the conf/tasks.py file
+
+```python
+task_list = [
+ 'CSP notifications',
+ 'NJ Tech Academic Affairs Office',
+# Add your tasks here.
+# The following two functions are the main process of the task. Please read the examples carefully and fill in the same format.
+]
+
+
+def get_task_url(task_name: str) -> str:
+ """
+ Get the URL of the specified task.
+
+ :param task_name: Task name
+ :return: URL
+ """
+ if task_name == task_list[0]:
+ jump_request = task_url_parse('https://www.cspro.org/cms/show.action?code=jumpchanneltemplate')
+ return urljoin('https://www.cspro.org', findall('\".*\"', jump_request.script.text)[1].strip('\"'))
+ elif task_name == task_list[1]:
+ return 'http://jwc.njtech.edu.cn/'
+
+
+def get_task_css(task_name: str) -> str:
+ """
+ Get the CSS selector of the specified task.
+
+ :param task_name: Task name
+ :return: CSS selector
+ """
+ if task_name == task_list[0]:
+ return 'body > div.l_mainouter > div > div:nth-child(1) > div.l_newsmsg.clearfix > div.l_overflowhidden > ' \
+ 'span > a '
+ elif task_name == task_list[1]:
+ return '#notice > div.ct > ul > li > p > a'
+```
+
+
+
+
+ (Optional) 5. Add your own notification method under the push directory
+
+```python
+# To implement your own notification method, you must inherit the push.base.PushBase class.
+# Then, you only need to implement the _send_update method.
+from push.base import PushBase
+
+
+class MyPush(PushBase):
+ def _send_update(self,
+ task: str,
+ title: str,
+ url: str,
+ subscriber: dict) -> bool:
+ # Implement your notification method here.
+ # Suppose you use mypush.py as your file name here:
+ # 1. Put the global parameters you need to use in push_setting['mypush'] in conf/push.py (if necessary);
+ # 2. Put individual parameters for each user in subscribers[index]['mypush'] in conf/subscribers.py.
+ pass
+```
+
+
+
+# Technical Details
+### The InspectionLite project mainly consists of the following modules:
+
+- `main.py`: Program entry point, responsible for creating database connections and task units.
+- `/conf` directory: Stores the program's configuration files, such as MySQL connection information, notification methods, and task lists.
+- `/core` directory: Stores the core code of the program, such as task detection, data storage, and notification function implementations.
+- `/push` directory: Stores specific implementations of different notification methods.
+
+### The workflow of InspectionLite is as follows:
+
+- Obtain the Web page URL and CSS selector to be detected based on the task list.
+- Detect the update status of the Web page, and if an update occurs, save the update record to the MySQL database.
+- Use multiple notification methods to send update notifications to specified users.
+- If a notification failure occurs, retry and email the administrator with details if the retry fails.
+- The entire program runs on the main program and can automatically perform task detection and notification operations.
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..3bf6ea0
--- /dev/null
+++ b/README.md
@@ -0,0 +1,192 @@
+# InspectionLite
+InspectionLite 是一个用于检测指定 Web 页面更新的 Python 项目。它能够根据用户定义的任务列表,自动地检测指定页面的更新情况,并将更新信息推送给指定的用户。
+
+[*English Version*](https://github.com/kressety/InspectionLite/blob/main/README-en.md)
+
+# 特点
+- **支持多种推送方式**:该程序支持多种推送方式,如 ~~企业微信、钉钉、~~ (待实现) 基于Mirai HTTP的QQ机器人和SMTP邮件等,能够满足不同用户的推送需求。
+- **可自定义任务**:InspectionLite支持从配置文件中读取任务列表,用户可以根据自己的需求添加新的检测任务。
+- **易于扩展**:程序采用模块化设计,用户可以方便地编写新的推送方法并注册到系统中。
+- **定制化程度高**:用户可以根据自己的需求进行相应的配置,如修改数据库连接信息、任务列表、推送方式等。
+- **可靠性强**:程序运行稳定可靠,能够在出现异常情况时及时发送邮件告知管理员。
+
+# 安装
+1. 克隆仓库到本地
+2. 进入项目目录
+3. 安装依赖:`pip install -r requirements.txt`
+4. 配置程序参数:编辑`/conf`目录下的配置文件,根据需要修改参数值。
+5. 运行程序:`python main.py`
+
+# 使用示例
+
+ 1. 修改 conf/push.py 文件
+
+```python
+push_setting = {
+ # 管理员邮箱,如果运行中出现推送错误则会发送到这个邮箱。
+ 'admin': {
+ 'email': 'admin@example.com'
+ },
+ # Mirai推送协议参数在这里填写,
+ # 如果不需要Mirai推送则不需要填写。
+ # 注意:本项目默认通过Mirai HTTP协议推送。
+ 'mirai': {
+ 'host': 'mirai.host.com:port',
+ 'verify_key': 'Mirai HTTP verify key',
+ # 这里填写消息发送者的QQ号,必须已经在Mirai Console上登录
+ 'sender': 10000
+ },
+ # SMTP(电子邮箱)推送协议参数在这里填写。
+ # 这里必须填写,因为出现推送错误会通过邮箱发送消息。
+ 'smtp': {
+ 'host': 'smtp.example.com',
+ 'port': 465,
+ 'username': 'inspectionlite@example.com',
+ 'password': 'example password'
+ }
+}
+```
+
+
+
+
+ 2. 修改 conf/sql.py 文件
+
+```python
+# 项目使用MySQL数据库,因此在这里填写数据库的登录参数。
+# 注意:数据库用户必须拥有该数据库的读/写权限。
+sql_setting = {
+ 'host': '数据库地址',
+ 'user': '数据库用户名',
+ 'password': '数据库密码',
+ 'db': '要使用的数据库'
+}
+```
+
+
+
+
+ 3. 修改 conf/subscribers.py 文件
+
+```python
+subscribers = [
+ # 这里是 1 号用户
+ {
+ # 订阅的推送,如果全部订阅则将 range 设置为 True;
+ # 如果部分订阅,则将订阅的内容以 list 的形式写在这里;
+ # 如果不订阅任何内容,则将 range 置为 False (或者你可以直接删除这个用户)。
+ 'range': True,
+ 'mirai': {
+ # 启用则将 enable 置为 True。
+ 'enable': True,
+ # 如果不启用 Mirai 推送,则 id 和 is_group 的值都会被忽略。
+ 'id': 10001,
+ # 如果推送目标是一个QQ群,则将 is_group 置为 True;
+ # 如果推送给单个用户,则此项为 False。
+ 'is_group': False,
+ },
+ 'smtp': {
+ # 同上,启用则置为 True。
+ 'enable': True,
+ # 如果启用,则填写 email,否则此项会被忽略,不需要填写。
+ 'email': 'user@example.com'
+ }
+ },
+ # 这里是 2 号用户
+ {
+ 'range': ['CSP通知'],
+ 'mirai': {
+ 'enable': True,
+ 'id': 20001,
+ 'is_group': True,
+ },
+ 'smtp': {
+ 'enable': False
+ }
+ }
+]
+```
+
+
+
+
+ 4. 修改 conf/tasks.py 文件
+
+```python
+task_list = [
+ 'CSP通知',
+ '工大教务处',
+# 将你的任务添加在这里。
+# 下面两个函数是任务的主要流程,请仔细阅读两个示例,并按照相同格式填写。
+]
+
+
+def get_task_url(task_name: str) -> str:
+ """
+ 获取指定任务的URL。
+
+ :param task_name: 任务名
+ :return: URL
+ """
+ if task_name == task_list[0]:
+ jump_request = task_url_parse('https://www.cspro.org/cms/show.action?code=jumpchanneltemplate')
+ return urljoin('https://www.cspro.org', findall('\".*\"', jump_request.script.text)[1].strip('\"'))
+ elif task_name == task_list[1]:
+ return 'http://jwc.njtech.edu.cn/'
+
+
+def get_task_css(task_name: str) -> str:
+ """
+ 获取指定任务的CSS选择器。
+
+ :param task_name: 任务名
+ :return: CSS选择器
+ """
+ if task_name == task_list[0]:
+ return 'body > div.l_mainouter > div > div:nth-child(1) > div.l_newsmsg.clearfix > div.l_overflowhidden > ' \
+ 'span > a '
+ elif task_name == task_list[1]:
+ return '#notice > div.ct > ul > li > p > a'
+```
+
+
+
+
+ (可选) 5. 在 push 目录下添加自己的推送方式
+
+```python
+# 要实现自己的推送方式,必须继承 push.base.PushBase 类。
+# 然后,你只需要实现 _send_update 方法即可。
+from push.base import PushBase
+
+
+class MyPush(PushBase):
+ def _send_update(self,
+ task: str,
+ title: str,
+ url: str,
+ subscriber: dict) -> bool:
+ # 在这里实现你的推送方法。
+ # 假设在这里你使用了mypush.py作为你的文件名,那么:
+ # 1. 在conf/push.py的push_setting['mypush']中放置你需要使用的全局参数(如有必要);
+ # 2. 在conf/subscribers.py的subscribers[index]['mypush']中放置每个用户的个人参数。
+ pass
+```
+
+
+
+# 技术细节
+### InspectionLite 项目主要包含以下几个模块:
+
+- `main.py`:程序入口,负责创建数据库连接和任务单元。
+- `/conf`目录:存放程序的配置文件,如MySQL连接信息、推送方式和任务列表等。
+- `/core`目录:存放程序的核心代码,如任务检测、数据存储和推送功能的实现。
+- `/push`目录:存放不同的推送方式的具体实现。
+
+### InspectionLite 的工作流程如下:
+
+- 根据任务列表获取需要检测的 Web 页面 URL 和 CSS 选择器。
+- 检测 Web 页面更新情况,若发生更新则将更新记录保存到 MySQL 数据库中。
+- 使用多种推送方式向指定用户发送更新通知。
+- 如果出现推送失败的情况,则进行重试,并在重试失败时向管理员发送邮件告知具体情况。
+- 整个程序运行在主程序上,可自动执行任务检测和推送操作。
\ No newline at end of file
diff --git a/conf/push.py b/conf/push.py
new file mode 100644
index 0000000..4138fe4
--- /dev/null
+++ b/conf/push.py
@@ -0,0 +1,23 @@
+push_setting = {
+ # 管理员邮箱,如果运行中出现推送错误则会发送到这个邮箱。
+ 'admin': {
+ 'email': 'admin@example.com'
+ },
+ # Mirai推送协议参数在这里填写,
+ # 如果不需要Mirai推送则不需要填写。
+ # 注意:本项目默认通过Mirai HTTP协议推送。
+ 'mirai': {
+ 'host': 'mirai.host.com:port',
+ 'verify_key': 'Mirai HTTP verify key',
+ # 这里填写消息发送者的QQ号,必须已经在Mirai Console上登录
+ 'sender': 10000
+ },
+ # SMTP(电子邮箱)推送协议参数在这里填写。
+ # 这里必须填写,因为出现推送错误会通过邮箱发送消息。
+ 'smtp': {
+ 'host': 'smtp.example.com',
+ 'port': 465,
+ 'username': 'inspectionlite@example.com',
+ 'password': 'example password'
+ }
+}
\ No newline at end of file
diff --git a/conf/sql.py b/conf/sql.py
new file mode 100644
index 0000000..be9911f
--- /dev/null
+++ b/conf/sql.py
@@ -0,0 +1,8 @@
+# 项目使用MySQL数据库,因此在这里填写数据库的登录参数。
+# 注意:数据库用户必须拥有该数据库的读/写权限。
+sql_setting = {
+ 'host': '数据库地址',
+ 'user': '数据库用户名',
+ 'password': '数据库密码',
+ 'db': '要使用的数据库'
+}
\ No newline at end of file
diff --git a/conf/subscribers.py b/conf/subscribers.py
new file mode 100644
index 0000000..c0a2a7d
--- /dev/null
+++ b/conf/subscribers.py
@@ -0,0 +1,36 @@
+subscribers = [
+ # 这里是 1 号用户
+ {
+ # 订阅的推送,如果全部订阅则将 range 设置为 True;
+ # 如果部分订阅,则将订阅的内容以 list 的形式写在这里;
+ # 如果不订阅任何内容,则将 range 置为 False (或者你可以直接删除这个用户)。
+ 'range': True,
+ 'mirai': {
+ # 启用则将 enable 置为 True。
+ 'enable': True,
+ # 如果不启用 Mirai 推送,则 id 和 is_group 的值都会被忽略。
+ 'id': 10001,
+ # 如果推送目标是一个QQ群,则将 is_group 置为 True;
+ # 如果推送给单个用户,则此项为 False。
+ 'is_group': False,
+ },
+ 'smtp': {
+ # 同上,启用则置为 True。
+ 'enable': True,
+ # 如果启用,则填写 email,否则此项会被忽略,不需要填写。
+ 'email': 'user@example.com'
+ }
+ },
+ # 这里是 2 号用户
+ {
+ 'range': ['CSP通知'],
+ 'mirai': {
+ 'enable': True,
+ 'id': 20001,
+ 'is_group': True,
+ },
+ 'smtp': {
+ 'enable': False
+ }
+ }
+]
\ No newline at end of file
diff --git a/conf/tasks.py b/conf/tasks.py
new file mode 100644
index 0000000..3a63554
--- /dev/null
+++ b/conf/tasks.py
@@ -0,0 +1,39 @@
+from urllib.parse import urljoin
+
+from core.parse import task_url_parse
+from re import findall
+
+task_list = [
+ 'CSP通知',
+ '工大教务处',
+# 将你的任务添加在这里。
+# 下面两个函数是任务的主要流程,请仔细阅读两个示例,并按照相同格式填写。
+]
+
+
+def get_task_url(task_name: str) -> str:
+ """
+ 获取指定任务的URL。
+
+ :param task_name: 任务名
+ :return: URL
+ """
+ if task_name == task_list[0]:
+ jump_request = task_url_parse('https://www.cspro.org/cms/show.action?code=jumpchanneltemplate')
+ return urljoin('https://www.cspro.org', findall('\".*\"', jump_request.script.text)[1].strip('\"'))
+ elif task_name == task_list[1]:
+ return 'http://jwc.njtech.edu.cn/'
+
+
+def get_task_css(task_name: str) -> str:
+ """
+ 获取指定任务的CSS选择器。
+
+ :param task_name: 任务名
+ :return: CSS选择器
+ """
+ if task_name == task_list[0]:
+ return 'body > div.l_mainouter > div > div:nth-child(1) > div.l_newsmsg.clearfix > div.l_overflowhidden > ' \
+ 'span > a '
+ elif task_name == task_list[1]:
+ return '#notice > div.ct > ul > li > p > a'
diff --git a/core/parse.py b/core/parse.py
new file mode 100644
index 0000000..722ba87
--- /dev/null
+++ b/core/parse.py
@@ -0,0 +1,27 @@
+from typing import Union
+
+from bs4 import BeautifulSoup
+from requests import get, RequestException
+
+
+def task_url_parse(url: str) -> Union[BeautifulSoup, bool]:
+ """
+ 解析任务URL,并返回BeautifulSoup对象。
+
+ :param url: 任务URL
+ :return: 如果完成解析,返回BS对象,否则返回False
+ """
+ header = {
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) '
+ 'Chrome/103.0.5060.134 Safari/537.36 Edg/103.0.1264.77 '
+ }
+ try:
+ request = get(url, headers=header)
+ request.encoding = 'UTF-8'
+ if request.status_code == 200:
+ html_data = BeautifulSoup(request.text, features='html.parser')
+ return html_data
+ else:
+ return False
+ except RequestException:
+ return False
diff --git a/core/sql.py b/core/sql.py
new file mode 100644
index 0000000..085003e
--- /dev/null
+++ b/core/sql.py
@@ -0,0 +1,90 @@
+from pymysql import connect, Error
+
+
+def set_task_sql_connect(
+ host: str,
+ user: str,
+ password: str,
+ db: str) -> connect:
+ """
+ 建立数据库连接。
+
+ :param host: 数据库地址
+ :param user: 数据库用户名
+ :param password: 数据库密码
+ :param db: 数据库名
+ :return: 数据库连接
+ """
+ return connect(host=host, user=user, password=password, db=db)
+
+
+def task_table_initialization(
+ database: connect,
+ table_name: str) -> bool:
+ """
+ 初始化数据表,如果不存在则新建
+
+ :param database: 数据库连接
+ :param table_name: 数据表名
+ :return: True:初始化成功 | False:初始化失败
+ """
+ try:
+ with database.cursor() as database_cursor:
+ database_cursor.execute(f"""
+ create table if not exists {table_name}(
+ title text not null,
+ url text not null
+ )""")
+ database.commit()
+ database_cursor.close()
+ return True
+ except Error:
+ return False
+
+
+def task_table_check(
+ database: connect,
+ table_name: str,
+ title: str,
+ url: str) -> bool:
+ """
+ 检查表中是否已存在指定行。
+
+ :param database: 数据库连接
+ :param table_name: 数据表名
+ :param title: 标题
+ :param url: 链接
+ :return: True:已存在或出现错误 | False:不存在
+ """
+ try:
+ with database.cursor() as database_cursor:
+ result = database_cursor.execute(f"select * from {table_name} where title='{title}' and url='{url}'")
+ database_cursor.close()
+ if result > 0:
+ return True
+ else:
+ return False
+ except Error:
+ return True
+
+
+def task_table_insert(
+ database: connect,
+ table_name: str,
+ title: str,
+ url: str):
+ """
+ 向数据表中插入指定行。
+
+ :param database: 数据库连接
+ :param table_name: 数据表名
+ :param title: 标题
+ :param url: 链接
+ """
+ try:
+ with database.cursor() as database_cursor:
+ database_cursor.execute(f"insert into {table_name} (title, url) VALUES ('{title}', '{url}')")
+ database.commit()
+ database_cursor.close()
+ except Error:
+ pass
diff --git a/core/task.py b/core/task.py
new file mode 100644
index 0000000..387b159
--- /dev/null
+++ b/core/task.py
@@ -0,0 +1,156 @@
+from email.mime.text import MIMEText
+from email.utils import make_msgid, formataddr, parseaddr
+from importlib import import_module
+from json import dumps
+from os import listdir
+from smtplib import SMTPException, SMTP_SSL
+from time import sleep
+from urllib.parse import urljoin, urlparse
+
+from pymysql import connect
+from pypinyin import slug
+
+from conf.push import push_setting
+from conf.tasks import get_task_url, get_task_css
+from core.parse import task_url_parse
+from core.sql import task_table_initialization, task_table_check, task_table_insert
+
+
+def _push_register() -> list:
+ """
+ 注册推送方法。
+
+ :return: 推送方法列表
+ """
+ push_methods = [
+ push_method_file[: push_method_file.rfind('.')]
+ for push_method_file in listdir('push')
+ if (push_method_file.endswith('.py')) and (push_method_file != 'base.py')
+ ]
+ for push_method_file_index in range(len(push_methods)):
+ push_method_module = import_module(f'push.{push_methods[push_method_file_index]}')
+ push_methods[push_method_file_index] = eval(f'push_method_module.{push_method_module.__dir__()[-1]}')
+ return push_methods
+
+
+def _failure_process(failure_list: list):
+ try:
+ mail_server = SMTP_SSL(push_setting['smtp']['host'])
+ mail_server.connect(
+ push_setting['smtp']['host'],
+ push_setting['smtp']['port']
+ )
+ mail_server.login(
+ push_setting['smtp']['username'],
+ push_setting['smtp']['password']
+ )
+
+ message = MIMEText(
+ dumps(
+ failure_list,
+ indent=4,
+ ensure_ascii=False
+ ),
+ 'plain',
+ 'utf-8'
+ )
+ message['From'] = formataddr(
+ parseaddr(f"Inspection自动提醒 <{push_setting['smtp']['username']}>"),
+ 'utf-8'
+ )
+ message['To'] = formataddr(
+ parseaddr(push_setting['admin']['email']),
+ 'utf-8'
+ )
+ message['Subject'] = 'Inspection自动提醒:推送失败'
+ message['Message-ID'] = make_msgid()
+
+ mail_server.sendmail(
+ push_setting['smtp']['username'],
+ push_setting['admin']['email'],
+ message.as_string()
+ )
+ sleep(5)
+ mail_server.close()
+ return True
+ except SMTPException:
+ return False
+
+
+class Task:
+ def __init__(self,
+ task: str,
+ subscribers: list,
+ sql_connect: connect):
+ """
+ Inspection任务单元。
+
+ :param task: 任务名
+ :param subscribers: 用户列表
+ :param sql_connect: 数据库连接
+ """
+ self.task = task
+ self.url = get_task_url(task)
+ self.css_selector = get_task_css(task)
+ self.subscribers = subscribers
+ self.sql_connect = sql_connect
+ self.push_methods = _push_register()
+
+ self.table_name = slug(task, separator='')
+ if task_table_initialization(
+ self.sql_connect,
+ self.table_name):
+ self._update()
+
+ def _update(self):
+ retry_list = []
+ failure_list = []
+
+ task_request = task_url_parse(self.url)
+ if task_request:
+ for item in task_request.select(self.css_selector):
+ write_title = item.text.strip()
+ write_url = urljoin(
+ f'{urlparse(self.url).scheme}://{urlparse(self.url).hostname}',
+ item.attrs['href']
+ )
+
+ if not task_table_check(
+ self.sql_connect,
+ self.table_name,
+ write_title,
+ write_url):
+ for push_method in self.push_methods:
+ push_session = push_method(
+ self.task,
+ write_title,
+ write_url,
+ self.subscribers
+ )
+ failed_push = push_session.get_failed_push()
+ if failed_push['subscribers']:
+ failed_push['class'] = push_method
+ retry_list.append(failed_push)
+
+ task_table_insert(
+ self.sql_connect,
+ self.table_name,
+ write_title,
+ write_url
+ )
+
+ if retry_list:
+ for retry_push in retry_list:
+ retry_session = retry_push['class'](
+ retry_push['task'],
+ retry_push['title'],
+ retry_push['url'],
+ retry_push['subscribers']
+ )
+ failed_push = retry_session.get_failed_push()
+ if failed_push['subscribers']:
+ failed_push['class'] = retry_push['class']
+ failure_list.append(failed_push)
+
+ if failure_list:
+ _failure_process(failure_list)
diff --git a/main.py b/main.py
new file mode 100644
index 0000000..7a8839e
--- /dev/null
+++ b/main.py
@@ -0,0 +1,23 @@
+from conf.sql import sql_setting
+from conf.subscribers import subscribers
+from conf.tasks import task_list
+from core.sql import set_task_sql_connect
+from core.task import Task
+
+
+def main():
+ sql_connect = set_task_sql_connect(
+ sql_setting['host'],
+ sql_setting['user'],
+ sql_setting['password'],
+ sql_setting['db']
+ )
+
+ for task in task_list:
+ Task(task, subscribers, sql_connect)
+
+ sql_connect.close()
+
+
+if __name__ == '__main__':
+ main()
diff --git a/push/base.py b/push/base.py
new file mode 100644
index 0000000..b51f625
--- /dev/null
+++ b/push/base.py
@@ -0,0 +1,62 @@
+from abc import ABC, abstractmethod
+from inspect import getfile
+from os.path import basename
+
+
+class PushBase(ABC):
+ def __init__(self,
+ task: str,
+ title: str,
+ url: str,
+ subscribers: list):
+ """
+ 更新推送的抽象类,继承此类并实现_send_update方法即可注册为一个有效的推送方法。
+ 注意:实现方法后请前往conf/subscribers.py文件中添加用户参数(必须包含enable参数),
+ 前往conf/push.py添加该种推送方法所需(如有)的参数。
+
+ :param task: 任务名
+ :param title: 消息标题
+ :param url: 消息链接
+ :param subscribers: 用户列表
+ """
+ push_name = basename(getfile(self.__class__))[: basename(getfile(self.__class__)).rfind('.')]
+ enabled_subscribers = [
+ subscriber[push_name]
+ for subscriber in subscribers
+ if (subscriber[push_name]['enable']) and ((subscriber['range'] is True) or (task in subscriber['range']))
+ ]
+ self._failed_push = {
+ 'task': task,
+ 'title': title,
+ 'url': url,
+ 'subscribers': []
+ }
+ for subscriber in enabled_subscribers:
+ if not self._send_update(task, title, url, subscriber):
+ self._failed_push['subscribers'].append(subscriber)
+
+ def get_failed_push(self):
+ """
+ 当推送失败时,使用此方法获取未能成功送达的用户列表。
+
+ :return: 推送未能送达的用户列表及对应的消息内容
+ """
+ return self._failed_push
+
+ @abstractmethod
+ def _send_update(self,
+ task: str,
+ title: str,
+ url: str,
+ subscriber: dict) -> bool:
+ """
+ 推送的消息发送方式,推送类的最关键方法,必须在子类中实现才能使用。
+ 请自行捕获异常,如果推送发送失败返回False,成功返回True。
+
+ :param task: 任务名
+ :param title: 消息标题
+ :param url: 消息链接
+ :param subscriber: 用户
+ :return: True:成功 | False:失败
+ """
+ pass
diff --git a/push/mirai.py b/push/mirai.py
new file mode 100644
index 0000000..d0b6ee7
--- /dev/null
+++ b/push/mirai.py
@@ -0,0 +1,86 @@
+from typing import Union
+
+from requests import post
+
+from conf.push import push_setting
+from push.base import PushBase
+
+
+class Mirai(PushBase):
+ def _send_update(self,
+ task: str,
+ title: str,
+ url: str,
+ subscriber: dict) -> bool:
+ def _mirai_http_auth() -> Union[str, bool]:
+ """
+ Mirai HTTP接口身份验证。
+
+ :return: 如果完成身份验证,返回会话密钥,否则返回False
+ """
+ try:
+ auth_string = {
+ 'verifyKey': push_setting['mirai']['verify_key']
+ }
+ result = post(f'http://{push_setting["mirai"]["host"]}/verify', json=auth_string).json()
+ if int(result['code']) == 0:
+ bind_string = {
+ 'sessionKey': result['session'],
+ 'qq': push_setting['mirai']['sender']
+ }
+ result = post(f'http://{push_setting["mirai"]["host"]}/bind', json=bind_string).json()
+ if int(result['code']) == 0:
+ return bind_string['sessionKey']
+ else:
+ _mirai_http_release(bind_string['sessionKey'])
+ return False
+ else:
+ return False
+ except Exception:
+ return False
+
+ def _mirai_http_release(
+ session_key: str) -> bool:
+ """
+ 释放本次Mirai HTTP会话。
+
+ :param session_key: 待释放的会话密钥
+ :return: True:成功 | False:失败
+ """
+ release_string = {
+ 'sessionKey': session_key,
+ 'qq': push_setting['mirai']['sender']
+ }
+ try:
+ if int(post(f'http://{push_setting["mirai"]["host"]}/release', json=release_string).json()[
+ 'code']) == 0:
+ return True
+ else:
+ return False
+ except Exception:
+ return False
+
+ auth_result = _mirai_http_auth()
+ push_result = False
+ if auth_result:
+ headers = {
+ 'Content-Type': 'application/json'
+ }
+ message_string = {
+ 'sessionKey': auth_result,
+ 'target': subscriber['id'],
+ 'messageChain': [
+ {'type': 'Plain', 'text': f'{task}发现更新:\n'},
+ {'type': 'Plain', 'text': f'{title}\n'},
+ {'type': 'Plain', 'text': url}
+ ]
+ }
+ request_format = 'sendGroupMessage' if subscriber['is_group'] else 'sendFriendMessage'
+ try:
+ if int(post(f'http://{push_setting["mirai"]["host"]}/{request_format}', json=message_string,
+ headers=headers).json()['code']) == 0:
+ push_result = True
+ except Exception:
+ pass
+ _mirai_http_release(auth_result)
+ return push_result
diff --git a/push/smtp.py b/push/smtp.py
new file mode 100644
index 0000000..32bbb2c
--- /dev/null
+++ b/push/smtp.py
@@ -0,0 +1,52 @@
+from email.mime.text import MIMEText
+from email.utils import make_msgid, parseaddr, formataddr
+from smtplib import SMTPException, SMTP_SSL
+from time import sleep
+
+from conf.push import push_setting
+from push.base import PushBase
+
+
+class SMTP(PushBase):
+ def _send_update(self,
+ task: str,
+ title: str,
+ url: str,
+ subscriber: dict) -> bool:
+ try:
+ mail_server = SMTP_SSL(push_setting['smtp']['host'])
+ mail_server.connect(
+ push_setting['smtp']['host'],
+ push_setting['smtp']['port']
+ )
+ mail_server.login(
+ push_setting['smtp']['username'],
+ push_setting['smtp']['password']
+ )
+
+ message = MIMEText(
+ title + '\n' + url,
+ 'plain',
+ 'utf-8'
+ )
+ message['From'] = formataddr(
+ parseaddr(f"Inspection自动提醒 <{push_setting['smtp']['username']}>"),
+ 'utf-8'
+ )
+ message['To'] = formataddr(
+ parseaddr(subscriber['email']),
+ 'utf-8'
+ )
+ message['Subject'] = f'Inspection自动提醒:{task}'
+ message['Message-ID'] = make_msgid()
+
+ mail_server.sendmail(
+ push_setting['smtp']['username'],
+ subscriber['email'],
+ message.as_string()
+ )
+ sleep(5)
+ mail_server.close()
+ return True
+ except SMTPException:
+ return False
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..804b518
Binary files /dev/null and b/requirements.txt differ