Repository: nfe-w/aio-dynamic-push Branch: master Commit: 68363d41c542 Files: 43 Total size: 117.4 KB Directory structure: gitextract_r3tg9153/ ├── .dockerignore ├── .github/ │ └── workflows/ │ └── docker-image.yml ├── .gitignore ├── .python-version ├── Dockerfile ├── LICENSE ├── README.md ├── common/ │ ├── cache.py │ ├── config.py │ ├── logger.py │ ├── proxy.py │ └── util.py ├── config.yml ├── entrypoint.sh ├── main.py ├── push_channel/ │ ├── __init__.py │ ├── _push_channel.py │ ├── bark.py │ ├── demo.py │ ├── dingtalk_bot.py │ ├── email.py │ ├── feishu_apps.py │ ├── feishu_bot.py │ ├── gotify.py │ ├── napcat_qq.py │ ├── qq_bot.py │ ├── server_chan_3.py │ ├── server_chan_turbo.py │ ├── telegram_bot.py │ ├── webhook.py │ ├── wecom_apps.py │ └── wecom_bot.py ├── pyproject.toml ├── query_task/ │ ├── __init__.py │ ├── _query_task.py │ ├── query_bilibili.py │ ├── query_demo.py │ ├── query_douyin.py │ ├── query_douyu.py │ ├── query_huya.py │ ├── query_weibo.py │ └── query_xhs.py └── requirements.txt ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ .git/ .github/ .idea/ .venv/ docs/ LICENSE README.md ================================================ FILE: .github/workflows/docker-image.yml ================================================ name: Build and Push Docker Image on: push: branches: - master pull_request: branches: - master workflow_dispatch: env: IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/${{ github.event.repository.name }} jobs: build-and-push: runs-on: ubuntu-latest steps: - name: Checkout Repository uses: actions/checkout@v3 - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Setup Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to Docker Hub uses: docker/login-action@v3.0.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_PASSWORD }} - name: Build and Push Docker Image run: | docker buildx build --push --platform linux/amd64,linux/arm64 -t ${{ env.IMAGE_NAME }}:latest . ================================================ FILE: .gitignore ================================================ # Created by .ignore support plugin (hsz.mobi) ### Python template # 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/ ================================================ FILE: .python-version ================================================ 3.9 ================================================ FILE: Dockerfile ================================================ FROM python:3.9-alpine AS builder COPY --from=ghcr.io/astral-sh/uv:0.7.6 /uv /uvx /bin/ WORKDIR /app COPY . /app/ RUN uv sync --locked FROM python:3.9-alpine # 设置容器的时区为中国北京时间 ENV TZ=Asia/Shanghai WORKDIR /app COPY . /app/ COPY --from=builder /app/.venv /app/.venv RUN chmod +x entrypoint.sh ENTRYPOINT ["./entrypoint.sh"] ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2021 EGG_W 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. ================================================ FILE: README.md ================================================ # All-in-one Dynamic Push - 多合一动态检测与推送 [![Docker Image](https://img.shields.io/badge/DockerHub-nfew/aio--dynamic--push-367AC7?logo=Docker&logoColor=white)](https://hub.docker.com/r/nfew/aio-dynamic-push) [![Docker Pulls](https://img.shields.io/docker/pulls/nfew/aio-dynamic-push?logo=Docker&logoColor=white)](https://hub.docker.com/r/nfew/aio-dynamic-push) [![Docker Image Size](https://img.shields.io/docker/image-size/nfew/aio-dynamic-push/latest?logo=Docker&logoColor=white)](https://hub.docker.com/r/nfew/aio-dynamic-push) [![Python Version](https://img.shields.io/badge/python-3.9-blue?logo=Python&logoColor=white)](https://www.python.org/downloads) [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv) [![Actions Status](https://img.shields.io/github/actions/workflow/status/nfe-w/aio-dynamic-push/docker-image.yml?logo=Github)](https://github.com/nfe-w/aio-dynamic-push/actions) [![GitHub License](https://img.shields.io/github/license/nfe-w/aio-dynamic-push?logo=Github&logoColor=white)](https://github.com/nfe-w/aio-dynamic-push/blob/master/LICENSE) ![GitHub Repo stars](https://img.shields.io/github/stars/nfe-w/aio-dynamic-push) ## 简介 一款整合多平台 `动态/直播开播提醒` 检测与推送的小工具,目前支持以下平台: - [x] B站 - [x] 微博 - [x] 小红书 - [x] 抖音 - [x] 斗鱼 - [x] 虎牙 ## 工作流程 ![](docs/image/aio-dynamic-push.png) ## Docker(推荐的部署方式) [![](https://img.shields.io/badge/DockerHub-nfew/aio--dynamic--push-367AC7?style=flat-square&logo=Docker&logoColor=white)](https://hub.docker.com/r/nfew/aio-dynamic-push) ```sh # 下载并修改配置文件 config.yml # 启动 docker run -d -v [配置文件的绝对路径]/config.yml:/mnt/config.yml nfew/aio-dynamic-push:latest ``` ## 配置文件 [config.yml](./config.yml) 说明 (1)`common`下的参数 - 项目的一些公共参数 (2)`query_task`下的参数 - 支持配置多项不同的任务,并为不同的任务配置不同的推送通道 | 任务类型 | type | 动态检测 | 开播检测 | |------|----------|:----:|:----:| | B站 | bilibili | ✅ | ✅ | | 微博 | weibo | ✅ | ❌ | | 小红书 | xhs | ✅ | ❌ | | 抖音 | douyin | ❌ | ✅ | | 斗鱼 | douyu | ❌ | ✅ | | 虎牙 | huya | ❌ | ✅ | (3)`push_channel`下的参数 - 支持配置多种推送通道 | 通道类型 | type | 推送附带图片 | 说明 | |-------------------|------------------|:-------:|-------------------------------------------------------------------------------------------------------------| | Server酱_Turbo | serverChan_turbo | ✅ | 🙅‍♀️不推荐,不用安装app,但免费用户5次/天
👉https://sct.ftqq.com | | Server酱_3 | serverChan_3 | ✅ | 🤔需要安装app
👉https://sc3.ft07.com/ | | 企业微信自建应用 | wecom_apps | ✅ | 😢新用户不再推荐,2022年6月20日之后新创建的应用,需要配置可信IP
👉https://work.weixin.qq.com/wework_admin/frame#apps/createApiApp | | 企业微信消息推送(原"群机器人") | wecom_bot | ✅ | 🥳推荐,新建群聊添加自定义消息推送即可
👉https://developer.work.weixin.qq.com/document/path/99110 | | 钉钉群聊机器人 | dingtalk_bot | ✅ | 🥳推荐,新建群聊添加自定义机器人即可,自定义关键词使用"【"
👉https://open.dingtalk.com/document/robots/custom-robot-access | | 飞书自建应用 | feishu_apps | ✅ | 🤔可以使用个人版,创建应用,授予其机器人权限
👉https://open.feishu.cn/app?lang=zh-CN | | 飞书群聊机器人 | feishu_bot | ❌(暂不支持) | 🤩推荐,新建群聊添加自定义机器人即可,自定义关键词使用"【"
👉https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot | | Telegram机器人 | telegram_bot | ✅ | 🪜需要自备网络环境
👉https://core.telegram.org/bots | | QQ频道机器人 | qq_bot | ✅ | 😢需要自行创建机器人,并启用机器人在频道内发言的权限
👉https://q.qq.com/#/app/create-bot | | NapCatQQ | napcat_qq | ✅ | 🐧好用,但需要自行部署 NapCatQQ
👉https://github.com/NapNeko/NapCatQQ | | Bark | bark | ❌ | 🍎适合苹果系用户,十分轻量,但没法推送图片
👉https://apps.apple.com/cn/app/id1403753865 | | Gotify | gotify | ❌ | 🖥️适合自建服务器
👉https://gotify.net | | Webhook | webhook | ✅(POST) | ⚡️通用的方式,请求格式详见附录 | | 电子邮件 | email | ✅ | 📧通用的方式 | ## 本地运行方式 1. (可选,推荐)安装 [uv](https://github.com/astral-sh/uv) ```sh # macOS / Linux curl -LsSf https://astral.sh/uv/install.sh | sh # Windows powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" ``` 2. 安装依赖 ```sh # 使用 uv 安装依赖 uv sync --locked # 或:使用 pip 安装依赖 pip3 install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple ``` 3. 修改配置文件 [config.yml](./config.yml) 4. 启动项目 ```sh nohup python3 -u main.py >& aio-dynamic-push.log & ``` ## 开发说明 推荐使用 [uv](https://github.com/astral-sh/uv) 运行 - 新增查询任务:详见 `query_task/query_demo.py` - 新增推送通道:详见 `push_channel/demo.py` ## 附录 ### Webhook 支持的请求格式 #### GET 请求 ```http request GET https://xxx.api.com?title={{title}}&content={{content}} ``` #### POST 请求 ```http request POST https://xxx.api.com Content-Type: application/json { "query_task_config": { "name": "任务名称", "enable": true, "type": "bilibili/weibo/xhs/douyin", "intervals_second": 600, "begin_time": "00:00", "end_time": "23:59", "target_push_name_list": [ "推送通道名称" ], "enable_living_check": false, "enable_dynamic_check": true }, "dynamic_raw_data": { "key1": "value1", "key2": "value2" } } ``` ## 声明 - 本仓库发布的`aio-dynamic-push`项目中涉及的任何脚本,仅用于测试和学习研究,禁止用于商业用途 - `nfe-w` 对任何脚本问题概不负责,包括但不限于由任何脚本错误导致的任何损失或损害 - 以任何方式查看此项目的人或直接或间接使用`aio-dynamic-push`项目的任何脚本的使用者都应仔细阅读此声明 - `nfe-w` 保留随时更改或补充此免责声明的权利。一旦使用并复制了任何相关脚本或`aio-dynamic-push`项目,则视为已接受此免责声明 - 本项目遵循`MIT LICENSE`协议,如果本声明与`MIT LICENSE`协议有冲突之处,以本声明为准 ================================================ FILE: common/cache.py ================================================ from common.logger import log local_cache = {} def set_cached_value(key, value): log.info(f"[本地缓存]将: {key} -> {value} 存入缓存中") local_cache[key] = value def get_cached_value(key, need_log=False): value = local_cache.get(key) if need_log is True: log.info(f"[本地缓存]从缓存中获取: {key} -> {value}") return value ================================================ FILE: common/config.py ================================================ import os import yaml from common.logger import log class ConfigReaderForYml(object): def __init__(self, config_file_name="config.yml"): config_file_path = os.path.join(os.getcwd(), config_file_name) if not os.path.exists(config_file_path): raise FileNotFoundError(f"No such file: {config_file_name}") with open(config_file_path, "r", encoding="utf-8") as file: self._config = yaml.safe_load(file) def get_common_config(self) -> dict: result = self._config.get("common", {}) log.info(f"加载配置common: {result}") return result def get_query_task_config(self) -> list: result = self._config.get("query_task", []) log.info(f"加载配置query_task: {result}") return result def get_push_channel_config(self) -> list: result = self._config.get("push_channel", []) log.info(f"加载配置push_channel: {result}") return result global_config = ConfigReaderForYml() ================================================ FILE: common/logger.py ================================================ import logging log = logging.getLogger() def set_logger(): log.setLevel(logging.INFO) formatter = logging.Formatter("%(asctime)s - %(process)d-%(threadName)s - %(filename)20s[line:%(lineno)3d] - %(levelname)5s: %(message)s") console_handler = logging.StreamHandler() console_handler.setFormatter(formatter) log.addHandler(console_handler) set_logger() ================================================ FILE: common/proxy.py ================================================ import requests from common.config import global_config from common.logger import log class Proxy(object): current_proxy_ip = None _enable = False _proxy_pool_url = None def __init__(self): common_config = global_config.get_common_config() proxy_pool = common_config.get("proxy_pool", None) if proxy_pool is not None: self._enable = proxy_pool.get("enable", False) self._proxy_pool_url = proxy_pool.get("proxy_pool_url", None) if self._enable and self._proxy_pool_url is None: self._enable = False log.error("【ip池】未配置ip池地址") if self._enable: log.info(f"【ip池】已启用,地址: {self._proxy_pool_url}") def get_proxy(self, proxy_check_url="https://www.baidu.com", timeout=2, retry_count=10): """ 获取一个有效的代理ip :param proxy_check_url: 检验代理ip有效性的url :param timeout: 超时时间 :param retry_count: 重试次数 :return: 有效的代理ip """ if not self._enable: return None while retry_count > 0: # noinspection PyBroadException try: ip_pool_response = requests.get(self._proxy_pool_url + "/get") except Exception: log.error("【ip池】连接失败") return None proxy_ip = ip_pool_response.json().get("proxy", None) if proxy_ip is None: log.info("【ip池】当前为空池") return None proxies = { "http": f"http://{proxy_ip}", "https": f"http://{proxy_ip}", } # noinspection PyBroadException try: response = requests.get(proxy_check_url, proxies=proxies, timeout=timeout) if response.status_code == requests.codes.OK: log.info(f"【ip池】获取ip成功: {proxy_ip}") return proxy_ip except ConnectionRefusedError: retry_count -= 1 self._delete_proxy(proxy_ip) except Exception: retry_count -= 1 log.info(f"【ip池】尝试多次均未获取到有效ip") return None def _delete_proxy(self, proxy_ip): requests.get(self._proxy_pool_url + f"/delete/?proxy={proxy_ip}") log.info(f"【ip池】移除ip: {proxy_ip}") my_proxy = Proxy() ================================================ FILE: common/util.py ================================================ import requests import urllib3 from fake_useragent import UserAgent from common.logger import log from common.proxy import my_proxy # 关闭ssl警告 urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) ua = UserAgent(os=["macos"], min_version=120.0) def _get_random_useragent(): return ua.chrome def requests_get(url, module_name="未指定", headers=None, params=None, use_proxy=False): if headers is None: headers = {} random_ua = _get_random_useragent() headers.setdefault('User-Agent', random_ua) headers.setdefault('user-agent', random_ua) proxies = _get_proxy() if use_proxy else None try: response = requests.get(url, headers=headers, params=params, proxies=proxies, timeout=10, verify=False) except Exception as e: log.error(f"【{module_name}】:{e}", exc_info=True) return None return response def requests_post(url, module_name="未指定", headers=None, params=None, data=None, json=None, use_proxy=False): if headers is None: headers = {} random_ua = _get_random_useragent() headers.setdefault('User-Agent', random_ua) headers.setdefault('user-agent', random_ua) proxies = _get_proxy() if use_proxy else None try: response = requests.post(url, headers=headers, params=params, data=data, json=json, proxies=proxies, timeout=10, verify=False) except Exception as e: log.error(f"【{module_name}】:{e}", exc_info=True) return None return response def _get_proxy(): proxy_ip = my_proxy.current_proxy_ip if proxy_ip is None: return None else: return { "http": f"http://{proxy_ip}" } def check_response_is_ok(response=None): if response is None: return False if response.status_code != requests.codes.OK: log.error(f"status: {response.status_code}, url: {response.url}") return False return True ================================================ FILE: config.yml ================================================ common: # ip 代理池 proxy_pool: # 是否启用 true/false enable: false # ip池地址,参考 https://github.com/jhao104/proxy_pool proxy_pool_url: http://ip:port # 推送通道 push_channel: # 是否在项目启动时,给各通道发送一条测试消息 true/false send_test_msg_when_start: false query_task: # 任务名称,可自定义 - name: 任务_bilibili # 是否启用 true/false enable: false # 任务类型,bilibili/weibo/xhs/douyin type: bilibili # 查询间隔,单位秒,不建议设置太频繁 intervals_second: 60 # 查询开始时间,格式 HH:mm begin_time: "00:00" # 查询结束时间,格式 HH:mm end_time: "23:59" # 推送通道名称,对应 push_channel 中的 name target_push_name_list: - 推送通道_企业微信应用 - 推送通道_钉钉机器人 # 是否启用动态检测 true/false enable_dynamic_check: true # 是否启用直播检测 true/false enable_living_check: true # 用户 uid 列表,参考 https://space.bilibili.com/1795147802 uid_list: - 1795147802 - 1669777785 - 1778026586 - 1875044092 - 1811071010 - 2018113152 # 是否跳过提醒"转发"类型的动态 true/false skip_forward: true # 如果请求提示 -352, 可以填入下面两项之一, 建议使用 payload 自动获取buvid3 # cookie,可选,配置后可以一定程度防ban,建议用小号的,貌似无痕窗口不登录时拿到的cookie也能用,优先级高于自动获取buvid3 cookie: "" # 浏览器指纹,取自于 /x/internal/gaia-gateway/ExClimbWuzhi 接口的body,需要填入的是payload参数的value,用于自动获取并激活buvid3 payload: "" - name: 任务_weibo enable: false type: weibo intervals_second: 60 begin_time: "00:00" end_time: "23:59" target_push_name_list: - 推送通道_企业微信应用 - 推送通道_钉钉机器人 enable_dynamic_check: true # 用户 uid 列表,参考 https://weibo.com/u/7198559139 uid_list: - 7198559139 - 1765893783 # 登录用户的cookie,用于查询"仅粉丝可见",如果不查这类微博暂时无需配置。浏览器打开 https://m.weibo.cn 登录后,按F12刷新,在 Network 中找到任意请求,把请求头中的cookie复制过来即可 cookie: "" - name: 任务_xhs enable: false type: xhs intervals_second: 300 begin_time: "00:00" end_time: "23:59" target_push_name_list: - 推送通道_企业微信应用 - 推送通道_钉钉机器人 enable_dynamic_check: true # 用户 profile_id 列表,参考 https://www.xiaohongshu.com/user/profile/52d8c541b4c4d60e6c867480 profile_id_list: - 52d8c541b4c4d60e6c867480 - 5bf64788aa4b0a000114b879 # cookie,可选,配置后可以一定程度防ban,建议用小号的 cookie: "" - name: 任务_douyin enable: false type: douyin intervals_second: 30 begin_time: "00:00" end_time: "23:59" target_push_name_list: - 推送通道_企业微信应用 - 推送通道_钉钉机器人 enable_dynamic_check: false # 抖音动态监测目前坏了,还没修好 # 签名服务器url,参考 https://github.com/coder-fly/douyin-signature signature_server_url: http://ip:port # 用户名列表,必填(接口现在不返回作者信息了,重新获取还挺麻烦的,自己填上凑合用先) username_list: - 嘉然今天吃什么 - 七海Nana7mi # 作者sec_uid列表,参考 https://www.douyin.com/user/MS4wLjABAAAA5ZrIrbgva_HMeHuNn64goOD2XYnk4ItSypgRHlbSh1c sec_uid_list: - MS4wLjABAAAA5ZrIrbgva_HMeHuNn64goOD2XYnk4ItSypgRHlbSh1c - MS4wLjABAAAAGeiluJjizroSmPhcNdlsS0b7M0rxi5ygfrtqdByE0FCYi__j0fS_E52uGaF7ujpn enable_living_check: true # 抖音号列表,用于检测开播状态,如果不开启直播检测可以不填,现已支持纯数字的抖音号 douyin_id_list: - ASOULjiaran - Nana7mi0715 - name: 任务_douyu enable: false type: douyu intervals_second: 300 begin_time: "00:00" end_time: "23:59" target_push_name_list: - 推送通道_企业微信应用 - 推送通道_钉钉机器人 enable_living_check: true # 直播间号列表,用于检测开播状态,如果不开启直播检测可以不填 room_id_list: - 307876 - 12306 - name: 任务_huya enable: false type: huya intervals_second: 300 begin_time: "00:00" end_time: "23:59" target_push_name_list: - 推送通道_企业微信应用 - 推送通道_钉钉机器人 enable_living_check: true # 直播间号列表,用于检测开播状态,如果不开启直播检测可以不填 room_id_list: - "lpl" - "880201" push_channel: # 通道名称,唯一,可自定义不要纯数字,对应 query_task 中的 target_push_name_list - name: 推送通道_Server酱_Turbo # 是否启用 true/false enable: false # 通道类型,详见 README.md type: serverChan_turbo # Server酱_Turbo推送服务,如果启用该推送,必须填入,获取参考 https://sct.ftqq.com send_key: - name: 推送通道_Server酱_3 enable: false type: serverChan_3 # Server酱_3的参数 ,如果启用该推送,必须填入,获取参考 https://sc3.ft07.com send_key: uid: # 消息标签,非必填,多个标签使用竖线分隔,例如:标签1|标签2 tags: - name: 推送通道_企业微信应用 enable: false type: wecom_apps # 企业id,如果启动该推送则必填 corp_id: # 应用id,如果启动该推送则必填 agent_id: # 应用Secret,如果启动该推送则必填 corp_secret: - name: 推送通道_企业微信消息推送(原"群机器人") enable: false type: wecom_bot # Webhook 地址中的 key,如果启动该推送则必填,即:https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx 中的 xxx key: - name: 推送通道_钉钉机器人 enable: false type: dingtalk_bot # 机器人秘钥,如果启动该推送则必填 access_token: - name: 推送通道_飞书自建应用 enable: false type: feishu_apps # 飞书自建应用的app_id,如果启动该推送则必填 app_id: # 飞书自建应用的app_secret,如果启动该推送则必填 app_secret: # 消息接收者id类型 open_id/user_id/union_id/email/chat_id,详见 https://open.feishu.cn/document/server-docs/im-v1/message/create receive_id_type: # 消息接收者的ID,ID类型应与查询参数receive_id_type 对应 receive_id: - name: 推送通道_飞书机器人 enable: false type: feishu_bot # 飞书机器人webhook key,如果启动该推送则必填 webhook_key: - name: 推送通道_Telegram机器人 enable: false type: telegram_bot # Telegram 机器人的token,如果启动该推送则必填 api_token: # Telegram 机器人的chat_id,如果启动该推送则必填,详见 https://api.telegram.org/bot/getUpdates chat_id: - name: 推送通道_QQ机器人 enable: false type: qq_bot # 接口域名 https://bot.q.qq.com/wiki/develop/api/#%E6%8E%A5%E5%8F%A3%E5%9F%9F%E5%90%8D base_url: https://api.sgroup.qq.com # 机器人ID,如果启动该推送则必填,https://q.qq.com/qqbot/#/developer/developer-setting app_id: # 机器人密钥,如果启动该推送则必填 app_secret: # 推送频道及子频道设置(支持多组) push_target_list: # 频道名称 - guild_name: "频道1" # 子频道名称 channel_name_list: - "子频道11" - "子频道12" - guild_name: "频道2" channel_name_list: - "子频道21" - "子频道22" - name: 推送通道_NapCatQQ enable: false type: napcat_qq # NapCatQQ 的 HTTP 服务器地址 api_url: http://localhost:3000 # 接口Token token: # user_id 与 group_id 二选一,user_id 为用户 QQ 号,group_id 为群号 user_id: group_id: # 需要 @ 的 QQ 号,仅能填写 1 个,"all" 表示@全体成员 at_qq: "all" - name: 推送通道_Bark enable: false type: bark # iOS 设备专用,Bark 服务器地址,如果启动该推送则必填,不要携带最后的"/",默认值:https://api.day.app server_url: https://api.day.app # iOS 设备专用,Bark app 的key,如果启动该推送则必填 key: - name: 推送通道_Gotify enable: false type: gotify # Gotify 服务器地址,如果启动该推送则必填,例如 https://push.example.com/message?token= web_server_url: - name: 推送通道_Webhook enable: false type: webhook # Webhook 地址,如果启动该推送则必填,标题和内容使用 {{title}} 和 {{content}} 占位符 webhook_url: https://xxx.com?title={{title}}&content={{content}} # 请求方法,GET/POST request_method: GET - name: 推送通道_Email enable: false type: email # SMTP 服务器地址,如果启动该推送则必填,例如 smtp.qq.com smtp_host: # SMTP 服务器端口,如果启动该推送则必填,例如 465 smtp_port: # 是否启用SSL,默认true smtp_ssl: true # 是否启用TSL,默认false smtp_tls: false # 发件人邮箱,如果启动该推送则必填 sender_email: # 发件人密码,如果启动该推送则必填,QQ邮箱需要使用授权码,参考 https://service.mail.qq.com/detail/0/75 sender_password: # 收件人地址,如果启动该推送则必填,多个地址用英文逗号分隔 receiver_email: ================================================ FILE: entrypoint.sh ================================================ #!/bin/sh set -e if [ ! -f /mnt/config.yml ]; then echo 'Error: /mnt/config.yml file not found. Please mount the /mnt/config.yml file and try again.' exit 1 fi cp -f /mnt/config.yml /app/config.yml exec /app/.venv/bin/python -u main.py ================================================ FILE: main.py ================================================ import time import schedule import push_channel import query_task from common.config import global_config from common.logger import log def init_push_channel(push_channel_config_list: list): log.info("开始初始化推送通道") for config in push_channel_config_list: if config.get('enable', False): if push_channel.push_channel_dict.get(config.get('name', '')) is not None: raise ValueError(f"推送通道名称重复: {config.get('name', '')}") log.info(f"初始化推送通道: {config.get('name', '')},通道类型: {config.get('type', None)}") push_channel.push_channel_dict[config.get('name', '')] = push_channel.get_push_channel(config) def init_push_channel_test(common_config: dict): push_channel_config: dict = common_config.get("push_channel", {}) send_test_msg_when_start = push_channel_config.get("send_test_msg_when_start", False) if send_test_msg_when_start: for channel_name, channel in push_channel.push_channel_dict.items(): log.info(f"推送通道【{channel_name}】发送测试消息") channel.push(title=f"【{channel_name}】通道测试", content=f"可正常使用🎉", jump_url="https://www.baidu.com", pic_url="https://www.baidu.com/img/flexible/logo/pc/result.png", extend_data={}) def init_query_task(query_task_config_list: list): log.info("初始化查询任务") for config in query_task_config_list: if config.get('enable', False): current_query = query_task.get_query_task(config).query schedule.every(config.get("intervals_second", 60)).seconds.do(current_query) log.info(f"初始化查询任务: {config.get('name', '')},任务类型: {config.get('type', None)}") # 先执行一次 current_query() while True: schedule.run_pending() time.sleep(1) def main(): common_config = global_config.get_common_config() query_task_config_list = global_config.get_query_task_config() push_channel_config_list = global_config.get_push_channel_config() # 初始化推送通道 init_push_channel(push_channel_config_list) # 初始化推送通道测试 init_push_channel_test(common_config) # 初始化查询任务 init_query_task(query_task_config_list) if __name__ == '__main__': main() ================================================ FILE: push_channel/__init__.py ================================================ from ._push_channel import PushChannel from .bark import Bark from .demo import Demo from .dingtalk_bot import DingtalkBot from .email import Email from .feishu_apps import FeishuApps from .feishu_bot import FeishuBot from .gotify import Gotify from .napcat_qq import NapCatQQ from .qq_bot import QQBot from .server_chan_3 import ServerChan3 from .server_chan_turbo import ServerChanTurbo from .telegram_bot import TelegramBot from .webhook import Webhook from .wecom_apps import WeComApps from .wecom_bot import WeComBot push_channel_dict: dict[str, PushChannel] = {} _channel_type_to_class = { "serverChan_turbo": ServerChanTurbo, "serverChan_3": ServerChan3, "wecom_apps": WeComApps, "wecom_bot": WeComBot, "dingtalk_bot": DingtalkBot, "feishu_apps": FeishuApps, "feishu_bot": FeishuBot, "telegram_bot": TelegramBot, "qq_bot": QQBot, "napcat_qq": NapCatQQ, "bark": Bark, "gotify": Gotify, "webhook": Webhook, "email": Email, "demo": Demo, } def get_push_channel(config) -> PushChannel: channel_type = config.get("type", None) if channel_type is None or channel_type not in _channel_type_to_class: raise ValueError(f"不支持的通道类型: {channel_type}") return _channel_type_to_class[channel_type](config) ================================================ FILE: push_channel/_push_channel.py ================================================ from abc import ABC, abstractmethod class PushChannel(ABC): def __init__(self, config): self.name = config.get("name", "") self.enable = config.get("enable", False) self.type = config.get("type", "") @abstractmethod def push(self, title, content, jump_url=None, pic_url=None, extend_data=None): """ :param title: 标题 :param content: 内容 :param jump_url: 跳转url :param pic_url: 图片url :param extend_data: 扩展数据 """ raise NotImplementedError("Subclasses must implement the push method") ================================================ FILE: push_channel/bark.py ================================================ from urllib.parse import urlencode from common import util from common.logger import log from . import PushChannel class Bark(PushChannel): def __init__(self, config): super().__init__(config) self.server_url = str(config.get("server_url", "https://api.day.app")) self.key = str(config.get("key", "")) if self.key == "": log.error(f"【推送_{self.name}】配置不完整,推送功能将无法正常使用") def push(self, title, content, jump_url=None, pic_url=None, extend_data=None): push_url = f"{self.server_url}/{self.key}/{title}/{content}" params = {} if jump_url: params["url"] = jump_url if extend_data: query_task_config = extend_data.get("query_task_config") if query_task_config and "name" in query_task_config: params["group"] = query_task_config["name"] avatar_url = extend_data.get("avatar_url") if avatar_url: params["icon"] = avatar_url push_url = f"{push_url}?{urlencode(params)}" if params else push_url response = util.requests_post(push_url, self.name) push_result = "成功" if util.check_response_is_ok(response) else "失败" log.info(f"【推送_{self.name}】{push_result}") ================================================ FILE: push_channel/demo.py ================================================ from common.logger import log from . import PushChannel class Demo(PushChannel): def __init__(self, config): super().__init__(config) # 在这里初始化通道需要的参数 self.param = str(config.get("param", "")) if self.param == "": log.error(f"【推送_{self.name}】配置不完整,推送功能将无法正常使用") def push(self, title, content, jump_url=None, pic_url=None, extend_data=None): # 在这里实现推送逻辑,记得要在 push_channel/__init__.py 中注册推送通道 push_result = "成功" log.info(f"【推送_{self.name}】{push_result}") ================================================ FILE: push_channel/dingtalk_bot.py ================================================ import json from common import util from common.logger import log from . import PushChannel class DingtalkBot(PushChannel): def __init__(self, config): super().__init__(config) self.access_token = str(config.get("access_token", "")) if self.access_token == "": log.error(f"【推送_{self.name}】配置不完整,推送功能将无法正常使用") def push(self, title, content, jump_url=None, pic_url=None, extend_data=None): push_url = "https://oapi.dingtalk.com/robot/send" headers = { "Content-Type": "application/json" } params = { "access_token": self.access_token } body = { "msgtype": "link", "link": { "title": title, "text": content, "messageUrl": jump_url } } if pic_url is not None: body["link"]["picUrl"] = pic_url response = util.requests_post(push_url, self.name, headers=headers, params=params, data=json.dumps(body)) push_result = "成功" if util.check_response_is_ok(response) else "失败" log.info(f"【推送_{self.name}】{push_result}") ================================================ FILE: push_channel/email.py ================================================ import smtplib from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from common.logger import log from . import PushChannel class Email(PushChannel): def __init__(self, config): super().__init__(config) self.smtp_host = str(config.get("smtp_host", "")) self.smtp_port = str(config.get("smtp_port", "")) self.smtp_ssl = config.get("smtp_ssl", True) self.smtp_tls = config.get("smtp_tls", False) self.sender_email = str(config.get("sender_email", "")) self.sender_password = str(config.get("sender_password", "")) self.receiver_email = str(config.get("receiver_email", "")) if self.smtp_host == "" or self.smtp_port == "" or self.sender_email == "" or self.sender_password == "" or self.receiver_email == "": log.error(f"【推送_{self.name}】配置不完整,推送功能将无法正常使用") def push(self, title, content, jump_url=None, pic_url=None, extend_data=None): message = MIMEMultipart() message["Subject"] = title message["From"] = self.sender_email message["To"] = self.receiver_email body = f"{content}
点击查看详情" if pic_url is not None: body += f"
" message.attach(MIMEText(body, "html")) try: func = smtplib.SMTP_SSL if self.smtp_ssl else smtplib.SMTP with func(self.smtp_host, int(self.smtp_port)) as server: if self.smtp_tls: server.starttls() server.login(self.sender_email, self.sender_password) server.sendmail(self.sender_email, self.receiver_email, message.as_string()) push_result = "成功" except smtplib.SMTPException as e: log.error(f"【推送_{self.name}】{e}", exc_info=True) push_result = "失败" log.info(f"【推送_{self.name}】{push_result}") ================================================ FILE: push_channel/feishu_apps.py ================================================ import json import mimetypes import os import uuid import requests from requests_toolbelt import MultipartEncoder from common import util from common.logger import log from . import PushChannel class FeishuApps(PushChannel): def __init__(self, config): super().__init__(config) self.app_id = str(config.get("app_id", "")) self.app_secret = str(config.get("app_secret", "")) self.receive_id_type = str(config.get("receive_id_type", "")) self.receive_id = str(config.get("receive_id", "")) if self.app_id == "" or self.app_secret == "" or self.receive_id_type == "" or self.receive_id == "": log.error(f"【推送_{self.name}】配置不完整,推送功能将无法正常使用") def push(self, title, content, jump_url=None, pic_url=None, extend_data=None): tenant_access_token = self._get_tenant_access_token() if tenant_access_token is None: return None url = f"https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type={self.receive_id_type}" headers = { "Content-Type": "application/json", "Authorization": f"Bearer {tenant_access_token}" } card_elements = [ { "tag": "markdown", "content": content } ] if pic_url is not None: img_key = self._get_img_key(pic_url) if img_key is not None: card_elements.append({ "alt": { "content": "", "tag": "plain_text" }, "img_key": img_key, "tag": "img" }) card_elements.append({ "tag": "action", "actions": [ { "tag": "button", "text": { "tag": "plain_text", "content": "点我跳转" }, "type": "primary", "url": jump_url } ] }) content = { "config": { "wide_screen_mode": True }, "header": { "template": "blue", "title": { "content": title, "tag": "plain_text" } }, "elements": card_elements } body = { "receive_id": self.receive_id, "msg_type": "interactive", "content": json.dumps(content) } response = util.requests_post(url, self.name, headers=headers, data=json.dumps(body)) push_result = "成功" if util.check_response_is_ok(response) else "失败" log.info(f"【推送_{self.name}】{push_result}") def _get_img_key(self, pic_url: str): # 下载图片 log.info(f"【推送_{self.name}】开始下载图片:{pic_url}") response = requests.get(pic_url, verify=False) if not util.check_response_is_ok(response): log.error(f"【推送_{self.name}】下载图片{pic_url}失败") return None content_type = response.headers['Content-Type'] extension = mimetypes.guess_extension(content_type) # 如果无法从内容类型推断扩展名,则默认使用.jpg if not extension: extension = '.jpg' file_path = str(uuid.uuid4()) + extension with open(file_path, 'wb') as file: file.write(response.content) log.info(f"【推送_{self.name}】下载图片{pic_url}成功") # 上传图片 tenant_access_token = self._get_tenant_access_token() if tenant_access_token is None: # 删除本地文件 os.remove(file_path) return None url = "https://open.feishu.cn/open-apis/im/v1/images" response = None try: # 使用上下文管理器确保文件在请求完成后被关闭 with open(file_path, 'rb') as f: multi_form = MultipartEncoder({ 'image_type': 'message', # 同时传递文件名与内容类型,提升兼容性 'image': (f"image{extension}", f, content_type if content_type else 'application/octet-stream') }) headers = { 'Authorization': f"Bearer {tenant_access_token}", 'Content-Type': multi_form.content_type, } response = util.requests_post(url, self.name, headers=headers, data=multi_form) finally: # 无论上传是否成功都尝试删除临时文件,避免文件句柄占用导致 WinError 32 try: os.remove(file_path) except Exception as e: log.warning(f"【推送_{self.name}】删除临时图片失败:{file_path},原因:{e}") if util.check_response_is_ok(response): return response.json()["data"]["image_key"] else: log.error(f"【推送_{self.name}】上传图片失败") return None def _get_tenant_access_token(self): url = f"https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal/" headers = { "Content-Type": "application/json; charset=utf-8" } body = { "app_id": self.app_id, "app_secret": self.app_secret } response = util.requests_post(url, self.name, headers=headers, data=json.dumps(body)) if util.check_response_is_ok(response): return response.json()["tenant_access_token"] else: log.error(f"【推送_{self.name}】获取tenant_access_token失败") return None ================================================ FILE: push_channel/feishu_bot.py ================================================ import json from common import util from common.logger import log from . import PushChannel class FeishuBot(PushChannel): def __init__(self, config): super().__init__(config) self.webhook_key = str(config.get("webhook_key", "")) if self.webhook_key == "": log.error(f"【推送_{self.name}】配置不完整,推送功能将无法正常使用") def push(self, title, content, jump_url=None, pic_url=None, extend_data=None): push_url = f"https://open.feishu.cn/open-apis/bot/v2/hook/{self.webhook_key}" headers = { "Content-Type": "application/json" } card_elements = [ { "tag": "markdown", "content": content } ] if pic_url is not None: img_key = self._get_img_key(pic_url) if img_key is not None: card_elements.append({ "alt": { "content": "", "tag": "plain_text" }, "img_key": img_key, "tag": "img" }) card_elements.append({ "tag": "action", "actions": [ { "tag": "button", "text": { "tag": "plain_text", "content": "点我跳转" }, "type": "primary", "url": jump_url } ] }) body = { "msg_type": "interactive", "card": { "config": { "wide_screen_mode": True }, "header": { "template": "blue", "title": { "content": title, "tag": "plain_text" } }, "elements": card_elements } } response = util.requests_post(push_url, self.name, headers=headers, data=json.dumps(body)) push_result = "成功" if util.check_response_is_ok(response) else "失败" log.info(f"【推送_{self.name}】{push_result}") def _get_img_key(self, pic_url: str) -> str: # todo: 上传图片到飞书 return None ================================================ FILE: push_channel/gotify.py ================================================ import json from common import util from common.logger import log from . import PushChannel class Gotify(PushChannel): def __init__(self, config): super().__init__(config) self.web_server_url = str(config.get("web_server_url", "")) if self.web_server_url == "": log.error(f"【推送_{self.name}】配置不完整,推送功能将无法正常使用") def push(self, title, content, jump_url=None, pic_url=None, extend_data=None): headers = { "Content-Type": "application/json", } body = { "title": title, "message": content, "priority": 5 } response = util.requests_post(self.web_server_url, self.name, headers=headers, data=json.dumps(body)) push_result = "成功" if util.check_response_is_ok(response) else "失败" log.info(f"【推送_{self.name}】{push_result}") ================================================ FILE: push_channel/napcat_qq.py ================================================ import json from common import util from common.logger import log from . import PushChannel class NapCatQQ(PushChannel): """ Author: https://github.com/YingChengxi See: https://github.com/nfe-w/aio-dynamic-push/issues/50 """ def __init__(self, config): super().__init__(config) self.api_url = str(config.get("api_url", "")) self.token = str(config.get("token", "")) _user_id = config.get("user_id", None) self.user_id = str(_user_id) if _user_id else None _group_id = config.get("group_id", None) self.group_id = str(_group_id) if _group_id else None _at_qq = config.get("at_qq", None) self.at_qq = str(_at_qq) if _at_qq else None if not self.api_url or (not self.user_id and not self.group_id): log.error(f"【推送_{self.name}】配置不完整,推送功能将无法正常使用") if self.user_id and self.group_id: log.error(f"【推送_{self.name}】配置错误,不能同时设置 user_id 和 group_id") def push(self, title, content, jump_url=None, pic_url=None, extend_data=None): message = [{ "type": "text", "data": {"text": f"{title}\n\n{content}"} }] if pic_url: message.append({ "type": "text", "data": {"text": "\n\n"} }) message.append({ "type": "image", "data": {"file": pic_url} }) if jump_url: message.append({ "type": "text", "data": {"text": f"\n\n原文: {jump_url}"} }) if self.at_qq: message.append({ "type": "text", "data": {"text": "\n\n"} }) message.append({ "type": "at", "data": { "qq": f"{self.at_qq}", } }) payload = { "user_id": self.user_id, "group_id": self.group_id, "message": message } headers = {"Content-Type": "application/json"} if self.token: headers["Authorization"] = f"Bearer {self.token}" api_endpoint = f"{self.api_url.rstrip('/')}/send_msg" try: response = util.requests_post( api_endpoint, self.name, headers=headers, data=json.dumps(payload) ) if util.check_response_is_ok(response): resp_data = response.json() if resp_data.get("status") == "ok" and resp_data.get("retcode") == 0: log.info(f"【推送_{self.name}】消息发送成功") return True else: error_msg = resp_data.get("message", "未知错误") log.error(f"【推送_{self.name}】API返回错误: {error_msg}") else: log.error(f"【推送_{self.name}】请求失败,状态码: {response.status_code}") except Exception as e: log.error(f"【推送_{self.name}】发送消息时出现异常: {str(e)}") return False return False ================================================ FILE: push_channel/qq_bot.py ================================================ import json from common import util from common.logger import log from . import PushChannel class QQBot(PushChannel): def __init__(self, config): super().__init__(config) self.base_url = str(config.get("base_url", "")) self.app_id = str(config.get("app_id", "")) self.app_secret = str(config.get("app_secret", "")) self.push_target_list = config.get("push_target_list", []) if self.base_url == "" or self.app_id == "" or self.app_secret == "" or len(self.push_target_list) == 0: log.error(f"【推送_{self.name}】配置不完整,推送功能将无法正常使用") return # 初始化目标频道 self.channel_id_name_dict = {} for guild_id, guild_name in self.init_guild_id_name_dict().items(): self.init_channels(guild_id, guild_name) if len(self.channel_id_name_dict) == 0: log.error(f"【推送_{self.name}】未找到推送目标频道,推送功能将无法正常使用") def push(self, title, content, jump_url=None, pic_url=None, extend_data=None): for channel_id, channel_name in self.channel_id_name_dict.items(): push_url = f'/channels/{channel_id}/messages' headers = { "Content-Type": "application/json", } headers.update(self.get_headers()) body = { "content": title + "\n\n" + content, } if pic_url is not None: body["content"] += '\n\n' body["image"] = pic_url response = util.requests_post(self.base_url + push_url, self.name, headers=headers, data=json.dumps(body)) push_result = "成功" if util.check_response_is_ok(response) else "失败" log.info(f"【推送_{self.name}】【{channel_name}】{push_result}") def get_headers(self): response = util.requests_post("https://bots.qq.com/app/getAppAccessToken", f"{self.name}_获取accessToken", headers={ "Content-Type": "application/json", }, data=json.dumps({ "appId": self.app_id, "clientSecret": self.app_secret })) if util.check_response_is_ok(response): result = json.loads(str(response.content, "utf-8")) return { "Authorization": f"QQBot {result['access_token']}" } return {} # region 初始化参数 def init_guild_id_name_dict(self) -> dict: guild_name_list = [str(item['guild_name']) for item in self.push_target_list] guild_id_name_dict = {} url = '/users/@me/guilds' response = util.requests_get(self.base_url + url, f"{self.name}_获取频道列表", headers=self.get_headers()) if util.check_response_is_ok(response): result = json.loads(str(response.content, "utf-8")) for item in result: if item['name'] in guild_name_list: guild_id_name_dict[item['id']] = item['name'] return guild_id_name_dict def init_channels(self, guild_id, guild_name): channel_name_list = [ str(channel) for item in self.push_target_list if item['guild_name'] == guild_name for channel in item['channel_name_list'] ] url = f'/guilds/{guild_id}/channels' response = util.requests_get(self.base_url + url, f"{self.name}_获取子频道列表", headers=self.get_headers()) if util.check_response_is_ok(response): result = json.loads(str(response.content, "utf-8")) for item in result: if item['name'] in channel_name_list and item['type'] == 0: # 只获取文字子频道 https://bot.q.qq.com/wiki/develop/api/openapi/channel/model.html#channeltype self.channel_id_name_dict[item['id']] = f'{guild_name}->{item["name"]}' # endregion ================================================ FILE: push_channel/server_chan_3.py ================================================ from common import util from common.logger import log from . import PushChannel class ServerChan3(PushChannel): def __init__(self, config): super().__init__(config) self.send_key = str(config.get("send_key", "")) self.uid = str(config.get("uid", "")) self.tags = config.get("tags", None) if self.send_key == "" or self.uid == "": log.error(f"【推送_{self.name}】配置不完整,推送功能将无法正常使用") def push(self, title, content, jump_url=None, pic_url=None, extend_data=None): push_url = f"https://{self.uid}.push.ft07.com/send/{self.send_key}.send" data = { "title": title, "desp": f"{content}\n\n[点我直达]({jump_url})" } if pic_url: data["desp"] += f"\n\n![]({pic_url})" if self.tags: data["tags"] = self.tags response = util.requests_post(push_url, self.name, data=data) push_result = "成功" if util.check_response_is_ok(response) else "失败" log.info(f"【推送_{self.name}】{push_result}") ================================================ FILE: push_channel/server_chan_turbo.py ================================================ from common import util from common.logger import log from . import PushChannel class ServerChanTurbo(PushChannel): def __init__(self, config): super().__init__(config) self.send_key = str(config.get("send_key", "")) if self.send_key == "": log.error(f"【推送_{self.name}】配置不完整,推送功能将无法正常使用") def push(self, title, content, jump_url=None, pic_url=None, extend_data=None): push_url = f"https://sctapi.ftqq.com/{self.send_key}.send" data = { "title": title, "desp": f"{content}\n\n[点我直达]({jump_url})" } if pic_url: data["desp"] += f"\n\n![]({pic_url})" response = util.requests_post(push_url, self.name, data=data) push_result = "成功" if util.check_response_is_ok(response) else "失败" log.info(f"【推送_{self.name}】{push_result}") ================================================ FILE: push_channel/telegram_bot.py ================================================ import json from common import util from common.logger import log from . import PushChannel class TelegramBot(PushChannel): def __init__(self, config): super().__init__(config) self.api_token = str(config.get("api_token", "")) self.chat_id = str(config.get("chat_id", "")) if self.api_token == "" or self.chat_id == "": log.error(f"【推送_{self.name}】配置不完整,推送功能将无法正常使用") def push(self, title, content, jump_url=None, pic_url=None, extend_data=None): push_url = f"https://api.telegram.org/bot{self.api_token}/sendMessage" headers = { "Content-Type": "application/json" } body = { "chat_id": self.chat_id, "text": f"[{title}]({jump_url})\n`{content}`", "parse_mode": "Markdown" } if pic_url is not None: body["link_preview_options"] = { "is_disabled": False, "url": pic_url } response = util.requests_post(push_url, self.name, headers=headers, data=json.dumps(body)) push_result = "成功" if util.check_response_is_ok(response) else "失败" log.info(f"【推送_{self.name}】{push_result}") ================================================ FILE: push_channel/webhook.py ================================================ from common import util from common.logger import log from . import PushChannel class Webhook(PushChannel): def __init__(self, config): super().__init__(config) self.webhook_url = str(config.get("webhook_url", "")) self.request_method = str(config.get("request_method", "GET")).upper() if self.webhook_url == "": log.error(f"【推送_{self.name}】配置不完整,推送功能将无法正常使用") def push(self, title, content, jump_url=None, pic_url=None, extend_data=None): if not self.webhook_url: log.warning(f"【推送_{self.name}】推送地址为空,跳过推送") return push_url = self.webhook_url.replace("{{title}}", title).replace("{{content}}", content) if self.request_method == "GET": response = util.requests_get(push_url, self.name) elif self.request_method == "POST": response = util.requests_post(push_url, self.name, json=extend_data) else: log.error(f"【推送_{self.name}】不支持的请求方法:{self.request_method}") return push_result = "成功" if util.check_response_is_ok(response) else "失败" log.info(f"【推送_{self.name}】{push_result}") ================================================ FILE: push_channel/wecom_apps.py ================================================ import json from common import util from common.logger import log from . import PushChannel class WeComApps(PushChannel): def __init__(self, config): super().__init__(config) self.corp_id = str(config.get("corp_id", "")) self.agent_id = str(config.get("agent_id", "")) self.corp_secret = str(config.get("corp_secret", "")) if self.corp_id == "" or self.agent_id == "" or self.corp_secret == "": log.error(f"【推送_{self.name}】配置不完整,推送功能将无法正常使用") def push(self, title, content, jump_url=None, pic_url=None, extend_data=None): access_token = self._get_wechat_access_token() push_url = "https://qyapi.weixin.qq.com/cgi-bin/message/send" params = { "access_token": access_token } body = { "touser": "@all", "agentid": self.agent_id, "safe": 0, "enable_id_trans": 0, "enable_duplicate_check": 0, "duplicate_check_interval": 1800 } if pic_url is None: body["msgtype"] = "textcard" body["textcard"] = { "title": title, "description": content, "url": jump_url, "btntxt": "打开详情" } else: body["msgtype"] = "news" body["news"] = { "articles": [ { "title": title, "description": content, "url": jump_url, "picurl": pic_url } ] } response = util.requests_post(push_url, self.name, params=params, data=json.dumps(body)) push_result = "成功" if util.check_response_is_ok(response) else "失败" log.info(f"【推送_{self.name}】{push_result}") def _get_wechat_access_token(self): access_token = None url = f"https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={self.corp_id}&corpsecret={self.corp_secret}" response = util.requests_get(url, f"{self.name}_获取access_token") if util.check_response_is_ok(response): result = json.loads(str(response.content, "utf-8")) access_token = result["access_token"] return access_token ================================================ FILE: push_channel/wecom_bot.py ================================================ import json from common import util from common.logger import log from . import PushChannel class WeComBot(PushChannel): def __init__(self, config): super().__init__(config) self.key = str(config.get("key", "")) if self.key == "": log.error(f"【推送_{self.name}】配置不完整,推送功能将无法正常使用") def push(self, title, content, jump_url=None, pic_url=None, extend_data=None): push_url = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send" headers = { "Content-Type": "application/json" } params = { "key": self.key } body = { "msgtype": "news", "news": { "articles": [ { "title": title, "description": content, "url": jump_url, } ] } } if pic_url is not None: body["news"]["articles"][0]["picurl"] = pic_url response = util.requests_post(push_url, self.name, headers=headers, params=params, data=json.dumps(body)) push_result = "成功" if util.check_response_is_ok(response) else "失败" log.info(f"【推送_{self.name}】{push_result}") ================================================ FILE: pyproject.toml ================================================ [project] name = "aio-dynamic-push" version = "0.1.0" description = "All-in-one Dynamic Push" readme = "README.md" requires-python = ">=3.9" dependencies = [ "beautifulsoup4==4.12.3", "fake-useragent==1.5.1", "pyyaml==6.0.1", "requests==2.31.0", "requests-toolbelt==1.0.0", "schedule==1.2.1", "urllib3==1.26.9", ] [[tool.uv.index]] name = "tsinghua" url = "https://pypi.tuna.tsinghua.edu.cn/simple" default = true ================================================ FILE: query_task/__init__.py ================================================ from ._query_task import QueryTask from .query_bilibili import QueryBilibili from .query_demo import QueryDemo from .query_douyin import QueryDouyin from .query_douyu import QueryDouyu from .query_huya import QueryHuya from .query_weibo import QueryWeibo from .query_xhs import QueryXhs _task_type_to_class = { "bilibili": QueryBilibili, "weibo": QueryWeibo, "xhs": QueryXhs, "douyin": QueryDouyin, "douyu": QueryDouyu, "huya": QueryHuya, "demo": QueryDemo, } def get_query_task(config) -> QueryTask: task_type = config.get("type", None) if task_type is None or task_type not in _task_type_to_class: raise ValueError(f"不支持的查询任务: {task_type}") return _task_type_to_class[task_type](config) ================================================ FILE: query_task/_query_task.py ================================================ from abc import ABC, abstractmethod from collections import deque import push_channel from common.logger import log class QueryTask(ABC): def __init__(self, config): self.name = config.get("name", "") self.enable = config.get("enable", False) self.type = config.get("type", "") self.intervals_second = config.get("intervals_second", 60) self.begin_time = config.get("begin_time", "00:00") self.end_time = config.get("end_time", "23:59") self.target_push_name_list = config.get("target_push_name_list", []) self.enable_dynamic_check = config.get("enable_dynamic_check", False) self.enable_living_check = config.get("enable_living_check", False) self.len_of_deque = 100 self.dynamic_dict = {} self.living_status_dict = {} @abstractmethod def query(self): raise NotImplementedError("Subclasses must implement the query method") def handle_for_result_null(self, null_id="-1", dict_key=None, module_name="未指定", user_name=None): """ 对动态状态请求返回为空时进行特殊处理 :param null_id: 用于占位的id :param dict_key: dynamic_dict的key,通常为用户id :param module_name: 用于日志输出的模块名 :param user_name: 用于日志输出的用户名或id """ if dict_key is None: log.error(f"{module_name},handle_for_result_null,参数dynamic_dict_key不能为空") if user_name is None: user_name = dict_key if self.dynamic_dict.get(dict_key, None) is None: # 当 dynamic_dict 中无 dict_key 时,证明是第一次请求且用户没发布过动态,进行初始化 self.dynamic_dict[dict_key] = deque(maxlen=self.len_of_deque) self.dynamic_dict[dict_key].append(null_id) log.info(f"【{module_name}-查询动态状态-{self.name}】【{user_name}】动态初始化:{self.dynamic_dict[dict_key]}") else: # 当 dynamic_dict 中有 dict_key 时,检测 deque 的第一个元素是不是 null_id previous_id = self.dynamic_dict[dict_key].pop() self.dynamic_dict[dict_key].append(previous_id) if previous_id != null_id: # 当第一个元素不是 null_id 时,代表用户已经发布过动态了,此时可能是请求被拦截;否则代表用户持续没有发布动态 log.error(f"【{module_name}-查询动态状态-{self.name}】【{user_name}】动态列表为空") def push(self, title, content, jump_url=None, pic_url=None, extend_data=None): for item in self.target_push_name_list: target_push_channel = push_channel.push_channel_dict.get(item, None) if target_push_channel is None: log.error(f"【{self.name}】推送通道【{item}】不存在") else: try: if extend_data is None: extend_data = {} extend_data = { **extend_data, 'query_task_config': { 'name': self.name, 'enable': self.enable, 'type': self.type, 'intervals_second': self.intervals_second, 'begin_time': self.begin_time, 'end_time': self.end_time, 'target_push_name_list': self.target_push_name_list, 'enable_dynamic_check': self.enable_dynamic_check, 'enable_living_check': self.enable_living_check, }, } if pic_url == '': pic_url = None target_push_channel.push(title, content, jump_url, pic_url, extend_data) except Exception as e: log.error(f"【{self.name}】推送通道【{item}】出错:{e}", exc_info=True) ================================================ FILE: query_task/query_bilibili.py ================================================ import json import time from collections import deque from common import util from common.cache import set_cached_value, get_cached_value from common.logger import log from common.proxy import my_proxy from query_task import QueryTask class QueryBilibili(QueryTask): def __init__(self, config): super().__init__(config) self.uid_list = config.get("uid_list", []) self.skip_forward = config.get("skip_forward", True) self.cookie = config.get("cookie", "") self.payload = config.get("payload", "") self.buvid3 = None def query(self): if not self.enable: return try: self.init_buvid3() current_time = time.strftime("%H:%M", time.localtime(time.time())) if self.begin_time <= current_time <= self.end_time: my_proxy.current_proxy_ip = my_proxy.get_proxy(proxy_check_url="http://api.bilibili.com/x/space/acc/info") if self.enable_dynamic_check: for uid in self.uid_list: self.query_dynamic_v2(uid) time.sleep(1) if self.enable_living_check: self.query_live_status_batch(self.uid_list) except Exception as e: log.error(f"【B站-查询任务-{self.name}】出错:{e}", exc_info=True) def init_buvid3(self, get_from_cache=True): buvid3 = None if get_from_cache: buvid3 = get_cached_value("buvid3") if buvid3 is None: buvid3 = self.get_new_buvid3() set_cached_value("buvid3", buvid3) self.buvid3 = buvid3 def get_new_buvid3(self): buvid3 = self.generate_buvid3() url = "https://api.bilibili.com/x/internal/gaia-gateway/ExClimbWuzhi" headers = { 'content-type': 'application/json;charset=UTF-8', 'cookie': f'buvid3={buvid3};' } payload = json.dumps({"payload": self.payload}) response = util.requests_post(url, f"B站-查询动态状态-激活buvid3-{self.name}", headers=headers, data=payload, use_proxy=True) if util.check_response_is_ok(response): data = response.json() code = data.get("code", -1) message = data.get("message", "") if code == 0: log.info(f"【B站-查询动态状态-激活buvid3-{self.name}】激活成功") else: log.error(f"【B站-查询动态状态-激活buvid3-{self.name}】激活失败, code:{code}, message: {message}") else: log.error(f"【B站-查询动态状态-激活buvid3-{self.name}】激活失败") return buvid3 def generate_buvid3(self): url = "https://api.bilibili.com/x/frontend/finger/spi" headers = {} response = util.requests_get(url, f"B站-查询动态状态-spi-{self.name}", headers=headers, use_proxy=True) if util.check_response_is_ok(response): try: result = json.loads(str(response.content, "utf-8")) except UnicodeDecodeError: log.error(f"【B站-查询动态状态-请求buvid3-{self.name}】解析content出错") return data = result.get("data") buvid3 = data.get("b_3") return buvid3 return None def query_dynamic_v2(self, uid=None, is_retry_by_buvid3=False): if uid is None: return uid = str(uid) query_url = (f"https://api.bilibili.com/x/polymer/web-dynamic/v1/feed/space" f"?host_mid={uid}&offset=&my_ts={int(time.time())}&features=itemOpusStyle") headers = self.get_headers(uid) if self.buvid3 is not None: headers['cookie'] = f"buvid3={self.buvid3};" if self.cookie != "": headers["cookie"] = self.cookie response = util.requests_get(query_url, f"B站-查询动态状态-{self.name}", headers=headers, use_proxy=True) if util.check_response_is_ok(response): try: result = json.loads(str(response.content, "utf-8")) except UnicodeDecodeError: log.error(f"【B站-查询动态状态-{self.name}】【{uid}】解析content出错") return if result["code"] != 0: log.error(f"【B站-查询动态状态-{self.name}】请求返回数据code错误:{result['code']}") if result["code"] == -352: if is_retry_by_buvid3 is True: log.error(f"【B站-查询动态状态-{self.name}】已经重试获取了【{uid}】,但依然失败") return self.init_buvid3(get_from_cache=False) log.info(f"【B站-查询动态状态-{self.name}】重新获取到了buvid3:{self.buvid3}") log.info(f"【B站-查询动态状态-{self.name}】重试获取【{uid}】的动态") self.query_dynamic_v2(uid, is_retry_by_buvid3=True) else: data = result["data"] if "items" not in data or data["items"] is None or len(data["items"]) == 0: super().handle_for_result_null(-1, uid, "B站", uid) return items = data["items"] # 循环遍历 items ,剔除不满足要求的数据 items = [item for item in items if (item["modules"].get("module_tag", None) is None or item["modules"].get("module_tag").get("text", None) != "置顶") # 跳过置顶 ] # 跳过置顶后再判断一下,防止越界 if len(data["items"]) == 0: super().handle_for_result_null(-1, uid, "B站", uid) return item = items[0] dynamic_id = item["id_str"] try: uname = item["modules"]["module_author"]["name"] except KeyError: log.error(f"【B站-查询动态状态-{self.name}】【{uid}】获取不到uname") return avatar_url = None try: avatar_url = item["modules"]["module_author"]["face"] except Exception: log.error(f"【B站-查询动态状态-{self.name}】头像获取发生错误,uid:{uid}") if self.dynamic_dict.get(uid, None) is None: self.dynamic_dict[uid] = deque(maxlen=self.len_of_deque) for index in range(self.len_of_deque): if index < len(items): self.dynamic_dict[uid].appendleft(items[index]["id_str"]) log.info(f"【B站-查询动态状态-{self.name}】【{uname}】动态初始化:{self.dynamic_dict[uid]}") return if dynamic_id not in self.dynamic_dict[uid]: previous_dynamic_id = self.dynamic_dict[uid].pop() self.dynamic_dict[uid].append(previous_dynamic_id) log.info(f"【B站-查询动态状态-{self.name}】【{uname}】上一条动态id[{previous_dynamic_id}],本条动态id[{dynamic_id}]") self.dynamic_dict[uid].append(dynamic_id) log.debug(self.dynamic_dict[uid]) dynamic_type = item["type"] allow_type_list = ["DYNAMIC_TYPE_DRAW", # 带图/图文动态,纯文本、大封面图文、九宫格图文 "DYNAMIC_TYPE_WORD", # 纯文字动态,疑似废弃 "DYNAMIC_TYPE_AV", # 投稿视频 "DYNAMIC_TYPE_ARTICLE", # 投稿专栏 "DYNAMIC_TYPE_COMMON_SQUARE" # 装扮 ] if self.skip_forward is False: allow_type_list.append("DYNAMIC_TYPE_FORWARD") # 动态转发 if dynamic_type not in allow_type_list: log.info(f"【B站-查询动态状态-{self.name}】【{uname}】动态有更新,但不在需要推送的动态类型列表中,dynamic_type->{dynamic_type}") return timestamp = int(item["modules"]["module_author"]["pub_ts"]) dynamic_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(timestamp)) module_dynamic = item["modules"]["module_dynamic"] content = None pic_url = None title_msg = "发动态了" if dynamic_type == "DYNAMIC_TYPE_FORWARD": # 转发动态 content = module_dynamic["desc"]["text"] title_msg = "转发了动态" elif dynamic_type == "DYNAMIC_TYPE_DRAW": if module_dynamic["major"]["type"] == "MAJOR_TYPE_OPUS": # 带图/图文动态,纯文本、大封面图文、九宫格图文 content = module_dynamic["major"]["opus"]["summary"]["text"] try: title = module_dynamic["major"]["opus"]["title"] content = f"「{title}」{content}" if title else content except Exception: pass try: pic_url = module_dynamic["major"]["opus"]["pics"][0]["url"] except Exception: pass else: # 未知 content = module_dynamic["desc"]["text"] pic_url = module_dynamic["major"]["draw"]["items"][0]["src"] elif dynamic_type == "DYNAMIC_TYPE_WORD": # 纯文字动态,疑似废弃 content = module_dynamic["desc"]["text"] elif dynamic_type == "DYNAMIC_TYPE_AV": # 投稿视频 content = module_dynamic["major"]["archive"]["title"] pic_url = module_dynamic["major"]["archive"]["cover"] title_msg = "投稿了" elif dynamic_type == "DYNAMIC_TYPE_ARTICLE": # 投稿专栏 content = module_dynamic["major"]["opus"]["title"] try: pic_url = module_dynamic["major"]["opus"]["pics"][0]["url"] except Exception: pass elif dynamic_type == "DYNAMIC_TYPE_COMMON_SQUARE": # 装扮 content = module_dynamic["desc"]["text"] log.info(f"【B站-查询动态状态-{self.name}】【{uname}】动态有更新,准备推送:{content[:30]}") self.push_for_bili_dynamic(uname, dynamic_id, content, pic_url, dynamic_type, dynamic_time, title_msg, dynamic_raw_data=item, avatar_url=avatar_url) @DeprecationWarning def query_dynamic(self, uid=None): if uid is None: return uid = str(uid) query_url = (f"http://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/space_history" f"?host_uid={uid}&offset_dynamic_id=0&need_top=0&platform=web&my_ts={int(time.time())}") headers = self.get_headers(uid) if self.cookie != "": headers["cookie"] = self.cookie response = util.requests_get(query_url, f"B站-查询动态状态-{self.name}", headers=headers, use_proxy=True) if util.check_response_is_ok(response): try: result = json.loads(str(response.content, "utf-8")) except UnicodeDecodeError: log.error(f"【B站-查询动态状态-{self.name}】【{uid}】解析content出错") return if result["code"] != 0: log.error(f"【B站-查询动态状态-{self.name}】请求返回数据code错误:{result['code']}") else: data = result["data"] if 'cards' not in data or data["cards"] is None or len(data["cards"]) == 0: super().handle_for_result_null(-1, uid, "B站", uid) return item = data["cards"][0] dynamic_id = item["desc"]["dynamic_id"] try: uname = item["desc"]["user_profile"]["info"]["uname"] except KeyError: log.error(f"【B站-查询动态状态-{self.name}】【{uid}】获取不到uname") return if self.dynamic_dict.get(uid, None) is None: self.dynamic_dict[uid] = deque(maxlen=self.len_of_deque) cards = data["cards"] for index in range(self.len_of_deque): if index < len(cards): self.dynamic_dict[uid].appendleft(cards[index]["desc"]["dynamic_id"]) log.info(f"【B站-查询动态状态-{self.name}】【{uname}】动态初始化:{self.dynamic_dict[uid]}") return if dynamic_id not in self.dynamic_dict[uid]: previous_dynamic_id = self.dynamic_dict[uid].pop() self.dynamic_dict[uid].append(previous_dynamic_id) log.info(f"【B站-查询动态状态-{self.name}】【{uname}】上一条动态id[{previous_dynamic_id}],本条动态id[{dynamic_id}]") self.dynamic_dict[uid].append(dynamic_id) log.debug(self.dynamic_dict[uid]) dynamic_type = item["desc"]["type"] if dynamic_type not in [2, 4, 8, 64]: log.info(f"【B站-查询动态状态-{self.name}】【{uname}】动态有更新,但不在需要推送的动态类型列表中") return timestamp = int(item["desc"]["timestamp"]) dynamic_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(timestamp)) card_str = item["card"] card = json.loads(card_str) content = None pic_url = None title_msg = "发动态了" if dynamic_type == 1: # 转发动态 content = card["item"]["content"] title_msg = "转发了动态" elif dynamic_type == 2: # 图文动态 content = card["item"]["description"] pic_url = card["item"]["pictures"][0]["img_src"] elif dynamic_type == 4: # 文字动态 content = card["item"]["content"] elif dynamic_type == 8: # 投稿动态 content = card["title"] pic_url = card["pic"] title_msg = "投稿了" elif dynamic_type == 64: # 专栏动态 content = card["title"] pic_url = card["image_urls"][0] log.info(f"【B站-查询动态状态-{self.name}】【{uname}】动态有更新,准备推送:{content[:30]}") self.push_for_bili_dynamic(uname, dynamic_id, content, pic_url, dynamic_type, dynamic_time, title_msg, dynamic_raw_data=item) def query_live_status_batch(self, uid_list=None): if uid_list is None: uid_list = [] if len(uid_list) == 0: return query_url = "https://api.live.bilibili.com/room/v1/Room/get_status_info_by_uids" headers = self.get_headers(uid_list[0]) if self.cookie != "": headers["cookie"] = self.cookie data = json.dumps({ "uids": list(map(int, uid_list)) }) response = util.requests_post(query_url, "B站-查询直播状态", headers=headers, data=data, use_proxy=True) if util.check_response_is_ok(response): result = json.loads(str(response.content, "utf-8")) if result["code"] != 0: log.error(f"【B站-查询直播状态-{self.name}】请求返回数据code错误:{result['code']}") else: live_status_list = result["data"] if len(live_status_list) == 0: return for uid, item_info in live_status_list.items(): try: uname = item_info["uname"] live_status = item_info["live_status"] except (KeyError, TypeError): log.error(f"【B站-查询直播状态-{self.name}】【{uid}】获取不到live_status") continue avatar_url = None try: avatar_url = item_info["face"] except Exception: log.error(f"【B站-查询动态状态-{self.name}】头像获取发生错误,uid:{uid}") if self.living_status_dict.get(uid, None) is None: self.living_status_dict[uid] = live_status log.info(f"【B站-查询直播状态-{self.name}】【{uname}】初始化") continue if self.living_status_dict.get(uid, None) != live_status: self.living_status_dict[uid] = live_status room_id = item_info["room_id"] room_title = item_info["title"] room_cover_url = item_info["cover_from_user"] if live_status == 1: log.info(f"【B站-查询直播状态-{self.name}】【{uname}】开播了,准备推送:{room_title}") self.push_for_bili_live(uname, room_id, room_title, room_cover_url, avatar_url=avatar_url) @staticmethod def get_headers(uid): return { "accept": "application/json, text/plain, */*", "accept-encoding": "gzip, deflate", "accept-language": "zh-CN,zh;q=0.9", "cache-control": "no-cache", "cookie": "l=v;", "origin": "https://space.bilibili.com", "pragma": "no-cache", "referer": f"https://space.bilibili.com/{uid}/dynamic", "sec-fetch-mode": "cors", "sec-fetch-site": "same-site", } def push_for_bili_dynamic(self, uname=None, dynamic_id=None, content=None, pic_url=None, dynamic_type=None, dynamic_time=None, title_msg='发动态了', dynamic_raw_data=None, avatar_url=None): """ B站动态提醒推送 :param uname: up主名字 :param dynamic_id: 动态id :param content: 动态内容 :param pic_url: 动态图片 :param dynamic_type: 动态类型 :param dynamic_time: 动态发送时间 :param title_msg: 推送标题 :param dynamic_raw_data: 动态原始数据 :param avatar_url: 头像url """ if uname is None or dynamic_id is None or content is None: log.error(f"【B站-动态提醒推送-{self.name}】缺少参数,uname:[{uname}],dynamic_id:[{dynamic_id}],content:[{content[:30]}]") return title = f"【B站】【{uname}】{title_msg}" content = f"{content[:100] + (content[100:] and '...')}[{dynamic_time}]" dynamic_url = f"https://www.bilibili.com/opus/{dynamic_id}" extend_data = { 'dynamic_raw_data': dynamic_raw_data, 'avatar_url': avatar_url, } super().push(title, content, dynamic_url, pic_url, extend_data=extend_data) def push_for_bili_live(self, uname=None, room_id=None, room_title=None, room_cover_url=None, avatar_url=None): """ B站直播提醒推送 :param uname: up主名字 :param room_id: 直播间id :param room_title: 直播间标题 :param room_cover_url: 直播间封面 :param avatar_url: 头像url """ title = f"【B站】【{uname}】开播了" live_url = f"https://live.bilibili.com/{room_id}" extend_data = { 'avatar_url': avatar_url, } super().push(title, room_title, live_url, room_cover_url, extend_data=extend_data) ================================================ FILE: query_task/query_demo.py ================================================ import time from common.logger import log from common.proxy import my_proxy from query_task import QueryTask class QueryDemo(QueryTask): def __init__(self, config): super().__init__(config) # 在这里初始化任务需要的参数 self.uid_list = config.get("uid_list", []) def query(self): if not self.enable: return try: current_time = time.strftime("%H:%M", time.localtime(time.time())) if self.begin_time <= current_time <= self.end_time: my_proxy.current_proxy_ip = my_proxy.get_proxy(proxy_check_url="用于检测代理是否可用的url") if self.enable_dynamic_check: for uid in self.uid_list: self.query_dynamic(uid) except Exception as e: log.error(f"【XXX-查询任务-{self.name}】出错:{e}", exc_info=True) def query_dynamic(self, uid=None): if uid is None: return # 在这里实现检测逻辑,记得要在 query_task/__init__.py 中注册任务类型 self.push_for_xxx(username="用户名", dynamic_id="动态id", content="动态内容", pic_url="图片地址", jump_url="跳转地址", dynamic_time="动态发送时间") def push_for_xxx(self, username=None, dynamic_id=None, content=None, pic_url=None, jump_url=None, dynamic_time=None, dynamic_raw_data=None): """ XXX动态提醒推送 :param username: 用户名 :param dynamic_id: 动态id :param content: 动态内容 :param pic_url: 图片地址 :param jump_url: 跳转地址 :param dynamic_time: 动态发送时间 :param dynamic_raw_data: 动态原始数据 """ if username is None or dynamic_id is None or content is None: log.error(f"【XXX-动态提醒推送-{self.name}】缺少参数,username:[{username}],dynamic_id:[{dynamic_id}],content:[{content[:30]}]") return title = f"【XXX】【{username}】发动态了" content = f"{content[:100] + (content[100:] and '...')}[{dynamic_time}]" super().push(title, content, jump_url, pic_url, extend_data={'dynamic_raw_data': dynamic_raw_data}) ================================================ FILE: query_task/query_douyin.py ================================================ import json import time from collections import deque from urllib import parse import requests from bs4 import BeautifulSoup from requests.utils import dict_from_cookiejar from common import util from common.cache import set_cached_value, get_cached_value from common.logger import log from common.proxy import my_proxy from query_task import QueryTask class QueryDouyin(QueryTask): def __init__(self, config): super().__init__(config) self.signature_server_url = config.get("signature_server_url", "") self.username_list = config.get("username_list", []) self.sec_uid_list = config.get("sec_uid_list", []) self.douyin_id_list = config.get("douyin_id_list", []) self.ttwid = None def query(self): if not self.enable: return try: self.init_ttwid() current_time = time.strftime("%H:%M", time.localtime(time.time())) if self.begin_time <= current_time <= self.end_time: my_proxy.current_proxy_ip = my_proxy.get_proxy(proxy_check_url="https://www.iesdouyin.com/web/api/v2/aweme/post") if self.enable_dynamic_check: for i in range(len(self.username_list)): self.query_dynamic(self.username_list[i], self.sec_uid_list[i]) if self.enable_living_check: for douyin_id in self.douyin_id_list: self.query_live_status_v3(douyin_id) except Exception as e: log.error(f"【抖音-查询任务-{self.name}】出错:{e}", exc_info=True) def get_signature(self): # noinspection PyBroadException try: return requests.get(self.signature_server_url).text except Exception: log.error("【抖音-获取签名】连接失败") return "" def init_ttwid(self, get_from_cache=True): ttwid = None if get_from_cache: ttwid = get_cached_value("ttwid") if ttwid is None: ttwid = self.generate_ttwid() set_cached_value("ttwid", ttwid) self.ttwid = ttwid def generate_ttwid(self): url = "https://ttwid.bytedance.com/ttwid/union/register/" headers = self.get_headers() # 固定的内容 body_data = { "region": "cn", "aid": 1768, "needFid": False, "service": "www.ixigua.com", "migrate_info": { "ticket": "", "source": "node" }, "cbUrlProtocol": "https", "union": True } response = util.requests_post(url, f"抖音-生成ttwid-{self.name}", headers=headers, json=body_data) if util.check_response_is_ok(response): cookie_dict = dict_from_cookiejar(response.cookies) ttwid = cookie_dict.get('ttwid') log.info(f"【抖音-生成ttwid-{self.name}】成功:{ttwid}") return ttwid else: log.error(f"【抖音-生成ttwid-{self.name}】请求返回数据code错误:{response.status_code}") return None def query_dynamic(self, nickname=None, sec_uid=None): if nickname is None or sec_uid is None: return query_url = f"http://www.iesdouyin.com/web/api/v2/aweme/post?sec_uid={sec_uid}&count=21&max_cursor=0&aid=1128&_signature={self.get_signature()}" headers = self.get_headers() response = util.requests_get(query_url, f"抖音-查询动态状态-{self.name}", headers=headers, use_proxy=True) if util.check_response_is_ok(response): result = json.loads(str(response.content, "utf-8")) if result["status_code"] != 0: log.error(f"【抖音-查询动态状态-{self.name}】请求返回数据code错误:{result['status_code']}") else: aweme_list = result["aweme_list"] if len(aweme_list) == 0: super().handle_for_result_null("-1", sec_uid, "抖音", nickname) return aweme = aweme_list[0] aweme_id = aweme["aweme_id"] if self.dynamic_dict.get(sec_uid, None) is None: self.dynamic_dict[sec_uid] = deque(maxlen=self.len_of_deque) for index in range(self.len_of_deque): if index < len(aweme_list): self.dynamic_dict[sec_uid].appendleft(aweme_list[index]["aweme_id"]) log.info(f"【抖音-查询动态状态-{self.name}】【{nickname}】动态初始化:{self.dynamic_dict[sec_uid]}") return if aweme_id not in self.dynamic_dict[sec_uid]: previous_aweme_id = self.dynamic_dict[sec_uid].pop() self.dynamic_dict[sec_uid].append(previous_aweme_id) log.info(f"【抖音-查询动态状态-{self.name}】【{nickname}】上一条动态id[{previous_aweme_id}],本条动态id[{aweme_id}]") self.dynamic_dict[sec_uid].append(aweme_id) log.debug(self.dynamic_dict[sec_uid]) try: content = aweme["desc"] pic_url = aweme["video"]["cover"]["url_list"][0] video_url = f"https://www.douyin.com/video/{aweme_id}" log.info(f"【抖音-查询动态状态-{self.name}】【{nickname}】动态有更新,准备推送:{content[:30]}") self.push_for_douyin_dynamic(nickname, aweme_id, content, pic_url, video_url, dynamic_raw_data=aweme) except AttributeError: log.error(f"【抖音-查询动态状态-{self.name}】dict取值错误,nickname:{nickname}") return @DeprecationWarning def query_live_status_v2(self, user_account=None): if user_account is None: return query_url = f"https://live.douyin.com/{user_account}?my_ts={int(time.time())}" headers = self.get_headers_for_live() response = util.requests_get(query_url, f"抖音-查询直播状态-{self.name}", headers=headers, use_proxy=True) if util.check_response_is_ok(response): html_text = response.text soup = BeautifulSoup(html_text, "html.parser") scripts = soup.findAll("script") result = None for script in scripts: if "nickname" in script.text: script_string = script.string unquote_string = parse.unquote(script_string) # 截取最外层{}内的内容 json_string_with_escape = unquote_string[unquote_string.find("{"):unquote_string.rfind("}") + 1] # 多层转义的去转义 json_string = json_string_with_escape.replace("\\\\", "\\").replace("\\\"", '"') try: result = json.loads(json_string) except TypeError: log.error(f"【抖音-查询直播状态-{self.name}】json解析错误,user_account:{user_account}") return None break if result is None: log.error(f"【抖音-查询直播状态-{self.name}】请求返回数据为空,user_account:{user_account}") else: try: room_info = result["state"]["roomStore"]["roomInfo"] room = room_info.get("room") anchor = room_info.get("anchor") nickname = anchor["nickname"] except AttributeError: log.error(f"【抖音-查询直播状态-{self.name}】dict取值错误,user_account:{user_account}") return if room is None: if self.living_status_dict.get(user_account, None) is None: self.living_status_dict[user_account] = "init" log.info(f"【抖音-查询直播状态-{self.name}】【{nickname}】初始化") return if room is not None: live_status = room.get("status") if self.living_status_dict.get(user_account, None) is None: self.living_status_dict[user_account] = live_status log.info(f"【抖音-查询直播状态-{self.name}】【{nickname}】初始化") return if self.living_status_dict.get(user_account, None) != live_status: self.living_status_dict[user_account] = live_status if live_status == 2: name = nickname room_title = room["title"] room_cover_url = room["cover"]["url_list"][0] log.info(f"【抖音-查询直播状态-{self.name}】【{nickname}】开播了,准备推送:{room_title}") self.push_for_douyin_live(name, query_url, room_title, room_cover_url) def query_live_status_v3(self, user_account=None, is_retry_by_ttwid=False): if user_account is None: return query_url = "https://live.douyin.com/webcast/room/web/enter/" headers = self.get_headers_for_live() if self.ttwid is not None: headers['cookie'] = f"ttwid={self.ttwid}" params = { "aid": "6383", "device_platform": "web", "enter_from": "web_live", "cookie_enabled": "true", "browser_language": "zh-CN", "browser_platform": "Win32", "browser_name": "Chrome", "browser_version": "109.0.0.0", "web_rid": f"{user_account}", } response = util.requests_get(query_url, f"抖音-查询直播状态-{self.name}", headers=headers, params=params, use_proxy=True) if util.check_response_is_ok(response): if len(response.content) == 0: log.error(f"【抖音-查询直播状态-{self.name}】请求返回数据为空,user_account:{user_account}") if is_retry_by_ttwid is True: log.error(f"【抖音-查询直播状态-{self.name}】已经重试获取了ttwid【{user_account}】,但依然失败") return self.init_ttwid(get_from_cache=False) log.info(f"【抖音-查询直播状态-{self.name}】重新获取到了ttwid:{self.ttwid}") log.info(f"【抖音-查询直播状态-{self.name}】重试获取【{user_account}】的动态") self.query_live_status_v3(user_account, is_retry_by_ttwid=True) return result = json.loads(str(response.content, "utf-8")) if result["status_code"] != 0: log.error(f"【抖音-查询直播状态-{self.name}】请求返回数据code错误:{result['status_code']},user_account:{user_account}") else: data = result["data"] if data is None: log.error(f"【抖音-查询直播状态-{self.name}】请求返回数据为空,user_account:{user_account}") return room_datas = data.get('data') if room_datas is None or len(room_datas) == 0: log.error(f"【抖音-查询直播状态-{self.name}】【{user_account}】疑似未开通直播间,跳过本次检测") return try: room_data = room_datas[0] room_status = data.get('room_status') nickname = data.get('user').get('nickname') except AttributeError: log.error(f"【抖音-查询直播状态-{self.name}】dict取值错误,user_account:{user_account}") return avatar_url = None try: avatar_url = data.get('user').get('avatar_thumb').get('url_list')[0] except Exception: log.error(f"【抖音-查询直播状态-{self.name}】头像获取发生错误,user_account:{user_account}") if self.living_status_dict.get(user_account, None) is None: self.living_status_dict[user_account] = room_status log.info(f"【抖音-查询直播状态-{self.name}】【{nickname}】初始化") return if self.living_status_dict.get(user_account, None) != room_status: self.living_status_dict[user_account] = room_status if room_status == 0: room_title = room_data["title"] room_cover_url = room_data["cover"]["url_list"][0] jump_url = f'https://live.douyin.com/{user_account}' log.info(f"【抖音-查询直播状态-{self.name}】【{nickname}】开播了,准备推送:{room_title}") self.push_for_douyin_live(nickname, jump_url, room_title, room_cover_url, avatar_url=avatar_url) @staticmethod def get_headers(): return { "accept": "application/json", "accept-encoding": "gzip, deflate", "accept-language": "zh-CN,zh;q=0.9", "cache-control": "no-cache", "pragma": "no-cache", "referer": "https://www.iesdouyin.com/", "sec-fetch-mode": "cors", "sec-fetch-site": "same-site", "x-requested-with": "XMLHttpRequest", } @staticmethod def get_headers_for_live(): return { "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3", "accept-encoding": "gzip, deflate", "accept-language": "zh-CN,zh;q=0.9", "cache-control": "no-cache", "pragma": "no-cache", "sec-fetch-mode": "navigate", "sec-fetch-site": "same-site", "sec-fetch-user": "?1", "upgrade-insecure-requests": "1", } def push_for_douyin_dynamic(self, nickname=None, aweme_id=None, content=None, pic_url=None, video_url=None, dynamic_raw_data=None): """ 抖音动态提醒推送 :param nickname: 作者名 :param aweme_id: 动态id :param content: 动态内容 :param pic_url: 封面图片 :param video_url: 视频地址 :param dynamic_raw_data: 动态原始数据 """ if nickname is None or aweme_id is None or content is None: log.error(f"【抖音-动态提醒推送-{self.name}】缺少参数,nickname:[{nickname}],aweme_id:[{aweme_id}],content:[{content[:30]}]") return title = f"【抖音】【{nickname}】发视频了" content = content[:100] + (content[100:] and "...") super().push(title, content, video_url, pic_url, extend_data={'dynamic_raw_data': dynamic_raw_data}) def push_for_douyin_live(self, nickname=None, jump_url=None, room_title=None, room_cover_url=None, avatar_url=None): """ 抖音直播提醒推送 :param nickname: 作者名 :param jump_url: 跳转地址 :param room_title: 直播间标题 :param room_cover_url: 直播间封面 :param avatar_url: 头像url """ title = f"【抖音】【{nickname}】开播了" extend_data = { 'avatar_url': avatar_url, } super().push(title, room_title, jump_url, room_cover_url, extend_data=extend_data) ================================================ FILE: query_task/query_douyu.py ================================================ import json import time from common import util from common.logger import log from common.proxy import my_proxy from query_task import QueryTask class QueryDouyu(QueryTask): def __init__(self, config): super().__init__(config) self.room_id_list = config.get("room_id_list", []) def query(self): if not self.enable: return try: current_time = time.strftime("%H:%M", time.localtime(time.time())) if self.begin_time <= current_time <= self.end_time: my_proxy.current_proxy_ip = my_proxy.get_proxy(proxy_check_url="https://www.douyu.com") if self.enable_living_check: for room_id in self.room_id_list: self.query_live_status(room_id) except Exception as e: log.error(f"【斗鱼-查询任务-{self.name}】出错:{e}", exc_info=True) def query_live_status(self, room_id=None): if room_id is None: return query_url = f'https://www.douyu.com/betard/{room_id}' response = util.requests_get(query_url, f"斗鱼-查询直播状态-{self.name}", use_proxy=True) if util.check_response_is_ok(response): try: result = json.loads(str(response.content, "utf-8")) except UnicodeDecodeError: log.error(f"【斗鱼-查询直播状态-{self.name}】【{room_id}】解析content出错") return if result is None: log.error(f"【斗鱼-查询直播状态-{self.name}】【{room_id}】请求返回数据为空") return room_info = result.get('room') if room_info is None: log.error(f"【斗鱼-查询直播状态-{self.name}】【{room_id}】请求返回数据room为空") try: username = room_info.get('nickname') except AttributeError: log.error(f"【斗鱼-查询直播状态-{self.name}】dict取值错误,room_id:{room_id}") return avatar_url = None try: avatar_url = room_info.get('avatar').get('small') except Exception: log.error(f"【斗鱼-查询直播状态-{self.name}】头像获取发生错误,room_id:{room_id}") show_status = room_info['show_status'] if self.living_status_dict.get(room_id, None) is None: self.living_status_dict[room_id] = show_status log.info(f"【斗鱼-查询直播状态-{self.name}】【{username}】初始化") return if self.living_status_dict.get(room_id, None) != show_status: self.living_status_dict[room_id] = show_status if show_status == 1: room_name = room_info.get('room_name') room_pic = room_info.get('room_pic') log.info(f"【斗鱼-查询直播状态-{self.name}】【{username}】开播了,准备推送:{room_name}") self.push_for_douyu_live(username=username, room_title=room_name, jump_url=f'https://www.douyu.com/{room_id}', room_cover_url=room_pic, avatar_url=avatar_url) def push_for_douyu_live(self, username=None, room_title=None, jump_url=None, room_cover_url=None, avatar_url=None): """ 斗鱼直播提醒推送 :param username: 主播名称 :param room_title: 直播间标题 :param jump_url: 跳转地址 :param room_cover_url: 直播间封面 :param avatar_url: 头像url """ title = f"【斗鱼】【{username}】开播了" extend_data = { 'avatar_url': avatar_url, } super().push(title, room_title, jump_url, room_cover_url, extend_data=extend_data) ================================================ FILE: query_task/query_huya.py ================================================ import json import time from bs4 import BeautifulSoup from common import util from common.logger import log from common.proxy import my_proxy from query_task import QueryTask class QueryHuya(QueryTask): def __init__(self, config): super().__init__(config) self.room_id_list = config.get("room_id_list", []) def query(self): if not self.enable: return try: current_time = time.strftime("%H:%M", time.localtime(time.time())) if self.begin_time <= current_time <= self.end_time: my_proxy.current_proxy_ip = my_proxy.get_proxy(proxy_check_url="https://www.huya.com") if self.enable_living_check: for room_id in self.room_id_list: self.query_live_status(room_id) except Exception as e: log.error(f"【虎牙-查询任务-{self.name}】出错:{e}", exc_info=True) def query_live_status(self, room_id=None): if room_id is None: return query_url = f'https://www.huya.com/{room_id}' response = util.requests_get(query_url, f"虎牙-查询直播状态-{self.name}", use_proxy=True) if util.check_response_is_ok(response): if len(response.text) == 0: log.error(f"【虎牙-查询直播状态-{self.name}】请求返回数据为空,room_id:{room_id}") return html_text = response.text soup = BeautifulSoup(html_text, "html.parser") scripts = soup.findAll("script") result = None for script in scripts: if script.string is not None and "stream: " in script.string: try: result = json.loads(script.string.split('stream: ')[1].split('};')[0].replace("undefined", "null")) except TypeError: log.error(f"【虎牙-查询直播状态-{self.name}】json解析错误,room_id:{room_id}") return None break if result is None: log.error(f"【虎牙-查询直播状态-{self.name}】请求返回数据为空,room_id:{room_id}") return try: game_stream_info_list = result.get('data')[0].get('gameStreamInfoList') game_live_info = result.get('data')[0].get('gameLiveInfo') username = game_live_info.get('nick') except AttributeError: log.error(f"【虎牙-查询直播状态-{self.name}】dict取值错误,room_id:{room_id}") return avatar_url = None try: avatar_url = game_live_info.get('avatar180') except Exception: log.error(f"【虎牙-查询直播状态-{self.name}】头像获取发生错误,room_id:{room_id}") live_status = len(game_stream_info_list) > 0 if self.living_status_dict.get(room_id, None) is None: self.living_status_dict[room_id] = live_status log.info(f"【虎牙-查询直播状态-{self.name}】【{username}】初始化") return if self.living_status_dict.get(room_id, None) != live_status: self.living_status_dict[room_id] = live_status if live_status is True: room_name = game_live_info.get('roomName', '') screenshot = game_live_info.get('screenshot', '').split('?')[0] log.info(f"【虎牙-查询直播状态-{self.name}】【{username}】开播了,准备推送:{room_name}") self.push_for_huya_live(username=username, room_title=room_name, jump_url=query_url, room_cover_url=screenshot, avatar_url=avatar_url) def push_for_huya_live(self, username=None, room_title=None, jump_url=None, room_cover_url=None, avatar_url=None): """ 虎牙直播提醒推送 :param username: 主播名称 :param room_title: 直播间标题 :param jump_url: 跳转地址 :param room_cover_url: 直播间封面 :param avatar_url: 头像url """ title = f"【虎牙】【{username}】开播了" extend_data = { 'avatar_url': avatar_url, } super().push(title, room_title, jump_url, room_cover_url, extend_data=extend_data) ================================================ FILE: query_task/query_weibo.py ================================================ import datetime import json import re import time from collections import deque from common import util from common.logger import log from common.proxy import my_proxy from query_task import QueryTask class QueryWeibo(QueryTask): def __init__(self, config): super().__init__(config) self.uid_list = config.get("uid_list", []) self.cookie = config.get("cookie", "") def query(self): if not self.enable: return try: current_time = time.strftime("%H:%M", time.localtime(time.time())) if self.begin_time <= current_time <= self.end_time: my_proxy.current_proxy_ip = my_proxy.get_proxy(proxy_check_url="https://m.weibo.com") if self.enable_dynamic_check: for uid in self.uid_list: self.query_dynamic(uid) except Exception as e: log.error(f"【微博-查询任务-{self.name}】出错:{e}", exc_info=True) def query_dynamic(self, uid=None): if uid is None: return uid = str(uid) query_url = f"https://m.weibo.cn/api/container/getIndex?type=uid&value={uid}&containerid=107603{uid}&count=25" headers = self.get_headers(uid) if self.cookie != "": headers["cookie"] = self.cookie response = util.requests_get(query_url, f"微博-查询动态状态-{self.name}", headers=headers, use_proxy=True) if util.check_response_is_ok(response): result = json.loads(str(response.content, "utf-8")) cards = result["data"]["cards"] if len(cards) == 0: super().handle_for_result_null("-1", uid, "微博", uid) return # 循环遍历 cards ,剔除不满足要求的数据 cards = [card for card in cards if card.get("mblog") is not None # 跳过不包含 mblog 的数据 and card["mblog"].get("isTop", None) != 1 # 跳过置顶 and card["mblog"].get("mblogtype", None) != 2 # 跳过置顶 ] # 跳过置顶后再判断一下,防止越界 if len(cards) == 0: super().handle_for_result_null("-1", uid, "微博", uid) return card = cards[0] mblog = card["mblog"] mblog_id = mblog["id"] user = mblog["user"] screen_name = user["screen_name"] avatar_url = None try: avatar_url = user["avatar_hd"] except Exception: log.error(f"【微博-查询动态状态-{self.name}】头像获取发生错误,uid:{uid}") if self.dynamic_dict.get(uid, None) is None: self.dynamic_dict[uid] = deque(maxlen=self.len_of_deque) for index in range(self.len_of_deque): if index < len(cards): self.dynamic_dict[uid].appendleft(cards[index]["mblog"]["id"]) log.info(f"【微博-查询动态状态-{self.name}】【{screen_name}】动态初始化:{self.dynamic_dict[uid]}") return if mblog_id not in self.dynamic_dict[uid]: previous_mblog_id = self.dynamic_dict[uid].pop() self.dynamic_dict[uid].append(previous_mblog_id) log.info(f"【微博-查询动态状态-{self.name}】【{screen_name}】上一条动态id[{previous_mblog_id}],本条动态id[{mblog_id}]") self.dynamic_dict[uid].append(mblog_id) log.debug(self.dynamic_dict[uid]) card_type = card["card_type"] if card_type not in [9]: log.info(f"【微博-查询动态状态-{self.name}】【{screen_name}】动态有更新,但不在需要推送的动态类型列表中") return # 如果动态发送日期早于昨天,则跳过(既能避免因api返回历史内容导致的误推送,也可以兼顾到前一天停止检测后产生的动态) created_at = time.strptime(mblog["created_at"], "%a %b %d %H:%M:%S %z %Y") created_at_ts = time.mktime(created_at) yesterday = (datetime.datetime.now() + datetime.timedelta(days=-1)).strftime("%Y-%m-%d") yesterday_ts = time.mktime(time.strptime(yesterday, "%Y-%m-%d")) if created_at_ts < yesterday_ts: log.info(f"【微博-查询动态状态-{self.name}】【{screen_name}】动态有更新,但动态发送时间早于今天,可能是历史动态,不予推送") return dynamic_time = time.strftime("%Y-%m-%d %H:%M:%S", created_at) content = None pic_url = None jump_url = None if card_type == 9: text = mblog["text"] text = re.sub(r"<[^>]+>", "", text) content = mblog["raw_text"] if mblog.get("raw_text", None) is not None else text pic_url = mblog.get("original_pic", None) jump_url = card["scheme"] log.info(f"【微博-查询动态状态-{self.name}】【{screen_name}】动态有更新,准备推送:{content[:30]}") self.push_for_weibo_dynamic(screen_name, mblog_id, content, pic_url, jump_url, dynamic_time, dynamic_raw_data=card, avatar_url=avatar_url) @staticmethod def get_headers(uid): return { "accept": "application/json, text/plain, */*", "accept-encoding": "gzip, deflate", "accept-language": "zh-CN,zh;q=0.9", "cache-control": "no-cache", "pragma": "no-cache", "mweibo-pwa": "1", "referer": f"https://m.weibo.cn/u/{uid}", "sec-fetch-mode": "cors", "sec-fetch-site": "same-site", "x-requested-with": "XMLHttpRequest", } def push_for_weibo_dynamic(self, username=None, mblog_id=None, content=None, pic_url=None, jump_url=None, dynamic_time=None, dynamic_raw_data=None, avatar_url=None): """ 微博动态提醒推送 :param username: 博主名 :param mblog_id: 动态id :param content: 动态内容 :param pic_url: 图片地址 :param jump_url: 跳转地址 :param dynamic_time: 动态发送时间 :param dynamic_raw_data: 动态原始数据 :param avatar_url: 头像url """ if username is None or mblog_id is None or content is None: log.error(f"【微博-动态提醒推送-{self.name}】缺少参数,username:[{username}],mblog_id:[{mblog_id}],content:[{content[:30]}]") return title = f"【微博】【{username}】发微博了" content = f"{content[:100] + (content[100:] and '...')}[{dynamic_time}]" extend_data = { 'dynamic_raw_data': dynamic_raw_data, 'avatar_url': avatar_url, } super().push(title, content, jump_url, pic_url, extend_data=extend_data) ================================================ FILE: query_task/query_xhs.py ================================================ import json import time from collections import deque from bs4 import BeautifulSoup from common import util from common.logger import log from common.proxy import my_proxy from query_task import QueryTask class QueryXhs(QueryTask): def __init__(self, config): super().__init__(config) self.profile_id_list = config.get("profile_id_list", []) self.cookie = config.get("cookie", "") def query(self): if not self.enable: return try: current_time = time.strftime("%H:%M", time.localtime(time.time())) if self.begin_time <= current_time <= self.end_time: my_proxy.current_proxy_ip = my_proxy.get_proxy(proxy_check_url="https://www.xiaohongshu.com/") if self.enable_dynamic_check: for profile_id in self.profile_id_list: self.query_dynamic(profile_id) except Exception as e: log.error(f"【小红书-查询任务-{self.name}】出错:{e}", exc_info=True) def query_dynamic(self, profile_id=None): if profile_id is None: return query_url = f"https://www.xiaohongshu.com/user/profile/{profile_id}" headers = self.get_headers() if self.cookie != "": headers["cookie"] = self.cookie response = util.requests_get(query_url, f"小红书-查询动态状态-{self.name}", headers=headers, use_proxy=True) if util.check_response_is_ok(response): html_text = response.text soup = BeautifulSoup(html_text, "html.parser") scripts = soup.findAll("script") result = None for script in scripts: if script.string is not None and "window.__INITIAL_STATE__=" in script.string: try: result = json.loads(script.string.replace("window.__INITIAL_STATE__=", "").replace("undefined", "null")) except TypeError: log.error(f"【小红书-查询动态状态-{self.name}】json解析错误,profile_id:{profile_id}") return None break if result is None: log.error(f"【小红书-查询动态状态-{self.name}】请求返回数据为空,profile_id:{profile_id}") else: user_name = result["user"]["userPageData"]["basicInfo"]["nickname"] avatar_url = None try: avatar_url = result["user"]["userPageData"]["basicInfo"]["images"] except Exception: log.error(f"【小红书-查询动态状态-{self.name}】头像获取发生错误,profile_id:{profile_id}") notes = result["user"]["notes"][0] if len(notes) == 0: super().handle_for_result_null("-1", profile_id, "小红书", user_name) return # 循环遍历 notes ,剔除不满足要求的数据 notes = [note for note in notes if note.get("noteCard") is not None # 跳过不包含 noteCard 的数据 and (note["noteCard"].get("interactInfo") is None or note["noteCard"]["interactInfo"].get("sticky") is not True) # 跳过置顶 ] # 跳过置顶后再判断一下,防止越界 if len(notes) == 0: super().handle_for_result_null("-1", profile_id, "小红书", user_name) return note = notes[0] note_title = note["noteCard"]["displayTitle"] if self.dynamic_dict.get(profile_id, None) is None: self.dynamic_dict[profile_id] = deque(maxlen=self.len_of_deque) for index in range(self.len_of_deque): if index < len(notes): self.dynamic_dict[profile_id].appendleft(notes[index]["noteCard"]["displayTitle"]) log.info(f"【小红书-查询动态状态-{self.name}】【{user_name}】动态初始化:{self.dynamic_dict[profile_id]}") return if note_title not in self.dynamic_dict[profile_id]: previous_note_title = self.dynamic_dict[profile_id].pop() self.dynamic_dict[profile_id].append(previous_note_title) log.info(f"【小红书-查询动态状态-{self.name}】【{user_name}】上一条动态标题[{previous_note_title}],本条动态标题[{note_title}]") self.dynamic_dict[profile_id].append(note_title) log.debug(self.dynamic_dict[profile_id]) note_title = note_title note_desc = '' dynamic_time = '无法获取内容,请打开小红书查看详情' note_card = note["noteCard"] content = f"【{note_title}】{note_desc}" pic_url = note_card["cover"]["infoList"][-1]["url"] jump_url = f"https://www.xiaohongshu.com/user/profile/{profile_id}" log.info(f"【小红书-查询动态状态-{self.name}】【{user_name}】动态有更新,准备推送:{content[:30]}") self.push_for_xhs_dynamic(user_name, note_title, content, pic_url, jump_url, dynamic_time, dynamic_raw_data=note, avatar_url=avatar_url) def get_note_detail(self, note_id=None): if note_id is None: return None query_url = f"https://www.xiaohongshu.com/explore/{note_id}" headers = self.get_headers() response = util.requests_get(query_url, f"小红书-查询动态明细-{self.name}", headers=headers, use_proxy=True) if util.check_response_is_ok(response): html_text = response.text soup = BeautifulSoup(html_text, "html.parser") scripts = soup.findAll("script") result = None for script in scripts: if script.string is not None and "window.__INITIAL_STATE__=" in script.string: try: result = json.loads(script.string.replace("window.__INITIAL_STATE__=", "").replace("undefined", "null")) except TypeError: log.error(f"【小红书-查询动态明细-{self.name}】json解析错误,note_id:{note_id}") return None break if result is None: log.error(f"【小红书-查询动态明细-{self.name}】请求返回数据为空,note_id:{note_id}") else: note = result["note"] if note is None: return None return note["noteDetailMap"][note["firstNoteId"]]["note"] @staticmethod def get_headers(): return { "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "accept-language": "zh-CN,zh;q=0.9", "cache-control": "no-cache", "pragma": "no-cache", "sec-ch-ua": "'Google Chrome';v='119', 'Chromium';v='119', 'Not?A_Brand';v='24'", "sec-ch-ua-mobile": "?0", "sec-ch-ua-platform": "'macOS'", "sec-fetch-dest": "document", "sec-fetch-mode": "navigate", "sec-fetch-site": "none", "sec-fetch-user": "?1", "upgrade-insecure-requests": "1" } def push_for_xhs_dynamic(self, username=None, note_title=None, content=None, pic_url=None, jump_url=None, dynamic_time=None, dynamic_raw_data=None, avatar_url=None): """ 小红书动态提醒推送 :param username: 博主名 :param note_title: 笔记标题 :param content: 动态内容 :param pic_url: 图片地址 :param jump_url: 跳转地址 :param dynamic_time: 动态发送时间 :param dynamic_raw_data: 动态原始数据 :param avatar_url: 头像url """ if username is None or note_title is None or content is None: log.error(f"【小红书-动态提醒推送-{self.name}】缺少参数,username:[{username}],note_title:[{note_title}],content:[{content[:30]}]") return title = f"【小红书】【{username}】发动态了" content = f"{content[:100] + (content[100:] and '...')}[{dynamic_time}]" extend_data = { 'dynamic_raw_data': dynamic_raw_data, 'avatar_url': avatar_url, } super().push(title, content, jump_url, pic_url, extend_data=extend_data) ================================================ FILE: requirements.txt ================================================ # This file was autogenerated by uv via the following command: # uv pip compile pyproject.toml -o requirements.txt beautifulsoup4==4.12.3 # via aio-dynamic-push (pyproject.toml) certifi==2025.4.26 # via requests charset-normalizer==3.4.2 # via requests fake-useragent==1.5.1 # via aio-dynamic-push (pyproject.toml) idna==3.10 # via requests importlib-resources==6.5.2 # via fake-useragent pyyaml==6.0.1 # via aio-dynamic-push (pyproject.toml) requests==2.31.0 # via # aio-dynamic-push (pyproject.toml) # requests-toolbelt requests-toolbelt==1.0.0 # via aio-dynamic-push (pyproject.toml) schedule==1.2.1 # via aio-dynamic-push (pyproject.toml) soupsieve==2.7 # via beautifulsoup4 urllib3==1.26.9 # via # aio-dynamic-push (pyproject.toml) # requests zipp==3.21.0 # via importlib-resources