Full Code of nfe-w/aio-dynamic-push for AI

master 68363d41c542 cached
43 files
117.4 KB
32.0k tokens
136 symbols
1 requests
Download .txt
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次/天<br/>👉https://sct.ftqq.com                                                       |
| Server酱_3         | serverChan_3     |    ✅    | 🤔需要安装app<br/>👉https://sc3.ft07.com/                                                                       |
| 企业微信自建应用          | wecom_apps       |    ✅    | 😢新用户不再推荐,2022年6月20日之后新创建的应用,需要配置可信IP<br/>👉https://work.weixin.qq.com/wework_admin/frame#apps/createApiApp |
| 企业微信消息推送(原"群机器人") | wecom_bot        |    ✅    | 🥳推荐,新建群聊添加自定义消息推送即可<br/>👉https://developer.work.weixin.qq.com/document/path/99110                         |
| 钉钉群聊机器人           | dingtalk_bot     |    ✅    | 🥳推荐,新建群聊添加自定义机器人即可,自定义关键词使用"【"<br/>👉https://open.dingtalk.com/document/robots/custom-robot-access         |
| 飞书自建应用            | feishu_apps      |    ✅    | 🤔可以使用个人版,创建应用,授予其机器人权限<br/>👉https://open.feishu.cn/app?lang=zh-CN                                         |
| 飞书群聊机器人           | feishu_bot       | ❌(暂不支持) | 🤩推荐,新建群聊添加自定义机器人即可,自定义关键词使用"【"<br/>👉https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot     |
| Telegram机器人       | telegram_bot     |    ✅    | 🪜需要自备网络环境<br/>👉https://core.telegram.org/bots                                                             |
| QQ频道机器人           | qq_bot           |    ✅    | 😢需要自行创建机器人,并启用机器人在频道内发言的权限<br/>👉https://q.qq.com/#/app/create-bot                                         |
| NapCatQQ          | napcat_qq        |    ✅    | 🐧好用,但需要自行部署 NapCatQQ<br/>👉https://github.com/NapNeko/NapCatQQ                                             |
| Bark              | bark             |    ❌    | 🍎适合苹果系用户,十分轻量,但没法推送图片<br/>👉https://apps.apple.com/cn/app/id1403753865                                     |
| Gotify            | gotify           |    ❌    | 🖥️适合自建服务器<br/>👉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<YOUR_API_TOKEN>/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=<apptoken>
    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}<br><a href='{jump_url}'>点击查看详情</a>"
        if pic_url is not None:
            body += f"<br><img src='{pic_url}'>"
        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
Download .txt
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
Download .txt
SYMBOL INDEX (136 symbols across 32 files)

FILE: common/cache.py
  function set_cached_value (line 6) | def set_cached_value(key, value):
  function get_cached_value (line 11) | def get_cached_value(key, need_log=False):

FILE: common/config.py
  class ConfigReaderForYml (line 8) | class ConfigReaderForYml(object):
    method __init__ (line 9) | def __init__(self, config_file_name="config.yml"):
    method get_common_config (line 16) | def get_common_config(self) -> dict:
    method get_query_task_config (line 21) | def get_query_task_config(self) -> list:
    method get_push_channel_config (line 26) | def get_push_channel_config(self) -> list:

FILE: common/logger.py
  function set_logger (line 6) | def set_logger():

FILE: common/proxy.py
  class Proxy (line 7) | class Proxy(object):
    method __init__ (line 13) | def __init__(self):
    method get_proxy (line 25) | def get_proxy(self, proxy_check_url="https://www.baidu.com", timeout=2...
    method _delete_proxy (line 67) | def _delete_proxy(self, proxy_ip):

FILE: common/util.py
  function _get_random_useragent (line 14) | def _get_random_useragent():
  function requests_get (line 18) | def requests_get(url, module_name="未指定", headers=None, params=None, use_...
  function requests_post (line 33) | def requests_post(url, module_name="未指定", headers=None, params=None, dat...
  function _get_proxy (line 48) | def _get_proxy():
  function check_response_is_ok (line 58) | def check_response_is_ok(response=None):

FILE: main.py
  function init_push_channel (line 11) | def init_push_channel(push_channel_config_list: list):
  function init_push_channel_test (line 22) | def init_push_channel_test(common_config: dict):
  function init_query_task (line 35) | def init_query_task(query_task_config_list: list):
  function main (line 50) | def main():

FILE: push_channel/__init__.py
  function get_push_channel (line 39) | def get_push_channel(config) -> PushChannel:

FILE: push_channel/_push_channel.py
  class PushChannel (line 4) | class PushChannel(ABC):
    method __init__ (line 5) | def __init__(self, config):
    method push (line 11) | def push(self, title, content, jump_url=None, pic_url=None, extend_dat...

FILE: push_channel/bark.py
  class Bark (line 8) | class Bark(PushChannel):
    method __init__ (line 9) | def __init__(self, config):
    method push (line 16) | def push(self, title, content, jump_url=None, pic_url=None, extend_dat...

FILE: push_channel/demo.py
  class Demo (line 5) | class Demo(PushChannel):
    method __init__ (line 6) | def __init__(self, config):
    method push (line 13) | def push(self, title, content, jump_url=None, pic_url=None, extend_dat...

FILE: push_channel/dingtalk_bot.py
  class DingtalkBot (line 8) | class DingtalkBot(PushChannel):
    method __init__ (line 9) | def __init__(self, config):
    method push (line 15) | def push(self, title, content, jump_url=None, pic_url=None, extend_dat...

FILE: push_channel/email.py
  class Email (line 9) | class Email(PushChannel):
    method __init__ (line 10) | def __init__(self, config):
    method push (line 22) | def push(self, title, content, jump_url=None, pic_url=None, extend_dat...

FILE: push_channel/feishu_apps.py
  class FeishuApps (line 14) | class FeishuApps(PushChannel):
    method __init__ (line 15) | def __init__(self, config):
    method push (line 24) | def push(self, title, content, jump_url=None, pic_url=None, extend_dat...
    method _get_img_key (line 86) | def _get_img_key(self, pic_url: str):
    method _get_tenant_access_token (line 138) | def _get_tenant_access_token(self):

FILE: push_channel/feishu_bot.py
  class FeishuBot (line 8) | class FeishuBot(PushChannel):
    method __init__ (line 9) | def __init__(self, config):
    method push (line 15) | def push(self, title, content, jump_url=None, pic_url=None, extend_dat...
    method _get_img_key (line 72) | def _get_img_key(self, pic_url: str) -> str:

FILE: push_channel/gotify.py
  class Gotify (line 8) | class Gotify(PushChannel):
    method __init__ (line 9) | def __init__(self, config):
    method push (line 15) | def push(self, title, content, jump_url=None, pic_url=None, extend_dat...

FILE: push_channel/napcat_qq.py
  class NapCatQQ (line 8) | class NapCatQQ(PushChannel):
    method __init__ (line 14) | def __init__(self, config):
    method push (line 29) | def push(self, title, content, jump_url=None, pic_url=None, extend_dat...

FILE: push_channel/qq_bot.py
  class QQBot (line 8) | class QQBot(PushChannel):
    method __init__ (line 9) | def __init__(self, config):
    method push (line 25) | def push(self, title, content, jump_url=None, pic_url=None, extend_dat...
    method get_headers (line 44) | def get_headers(self):
    method init_guild_id_name_dict (line 60) | def init_guild_id_name_dict(self) -> dict:
    method init_channels (line 73) | def init_channels(self, guild_id, guild_name):

FILE: push_channel/server_chan_3.py
  class ServerChan3 (line 6) | class ServerChan3(PushChannel):
    method __init__ (line 7) | def __init__(self, config):
    method push (line 15) | def push(self, title, content, jump_url=None, pic_url=None, extend_dat...

FILE: push_channel/server_chan_turbo.py
  class ServerChanTurbo (line 6) | class ServerChanTurbo(PushChannel):
    method __init__ (line 7) | def __init__(self, config):
    method push (line 13) | def push(self, title, content, jump_url=None, pic_url=None, extend_dat...

FILE: push_channel/telegram_bot.py
  class TelegramBot (line 8) | class TelegramBot(PushChannel):
    method __init__ (line 9) | def __init__(self, config):
    method push (line 16) | def push(self, title, content, jump_url=None, pic_url=None, extend_dat...

FILE: push_channel/webhook.py
  class Webhook (line 6) | class Webhook(PushChannel):
    method __init__ (line 7) | def __init__(self, config):
    method push (line 14) | def push(self, title, content, jump_url=None, pic_url=None, extend_dat...

FILE: push_channel/wecom_apps.py
  class WeComApps (line 8) | class WeComApps(PushChannel):
    method __init__ (line 9) | def __init__(self, config):
    method push (line 17) | def push(self, title, content, jump_url=None, pic_url=None, extend_dat...
    method _get_wechat_access_token (line 57) | def _get_wechat_access_token(self):

FILE: push_channel/wecom_bot.py
  class WeComBot (line 8) | class WeComBot(PushChannel):
    method __init__ (line 9) | def __init__(self, config):
    method push (line 15) | def push(self, title, content, jump_url=None, pic_url=None, extend_dat...

FILE: query_task/__init__.py
  function get_query_task (line 21) | def get_query_task(config) -> QueryTask:

FILE: query_task/_query_task.py
  class QueryTask (line 8) | class QueryTask(ABC):
    method __init__ (line 9) | def __init__(self, config):
    method query (line 25) | def query(self):
    method handle_for_result_null (line 28) | def handle_for_result_null(self, null_id="-1", dict_key=None, module_n...
    method push (line 54) | def push(self, title, content, jump_url=None, pic_url=None, extend_dat...

FILE: query_task/query_bilibili.py
  class QueryBilibili (line 12) | class QueryBilibili(QueryTask):
    method __init__ (line 13) | def __init__(self, config):
    method query (line 21) | def query(self):
    method init_buvid3 (line 38) | def init_buvid3(self, get_from_cache=True):
    method get_new_buvid3 (line 47) | def get_new_buvid3(self):
    method generate_buvid3 (line 68) | def generate_buvid3(self):
    method query_dynamic_v2 (line 83) | def query_dynamic_v2(self, uid=None, is_retry_by_buvid3=False):
    method query_dynamic (line 220) | def query_dynamic(self, uid=None):
    method query_live_status_batch (line 304) | def query_live_status_batch(self, uid_list=None):
    method get_headers (line 356) | def get_headers(uid):
    method push_for_bili_dynamic (line 370) | def push_for_bili_dynamic(self, uname=None, dynamic_id=None, content=N...
    method push_for_bili_live (line 397) | def push_for_bili_live(self, uname=None, room_id=None, room_title=None...

FILE: query_task/query_demo.py
  class QueryDemo (line 8) | class QueryDemo(QueryTask):
    method __init__ (line 9) | def __init__(self, config):
    method query (line 14) | def query(self):
    method query_dynamic (line 27) | def query_dynamic(self, uid=None):
    method push_for_xxx (line 33) | def push_for_xxx(self, username=None, dynamic_id=None, content=None, p...

FILE: query_task/query_douyin.py
  class QueryDouyin (line 16) | class QueryDouyin(QueryTask):
    method __init__ (line 17) | def __init__(self, config):
    method query (line 25) | def query(self):
    method get_signature (line 42) | def get_signature(self):
    method init_ttwid (line 50) | def init_ttwid(self, get_from_cache=True):
    method generate_ttwid (line 59) | def generate_ttwid(self):
    method query_dynamic (line 85) | def query_dynamic(self, nickname=None, sec_uid=None):
    method query_live_status_v2 (line 130) | def query_live_status_v2(self, user_account=None):
    method query_live_status_v3 (line 192) | def query_live_status_v3(self, user_account=None, is_retry_by_ttwid=Fa...
    method get_headers (line 267) | def get_headers():
    method get_headers_for_live (line 281) | def get_headers_for_live():
    method push_for_douyin_dynamic (line 294) | def push_for_douyin_dynamic(self, nickname=None, aweme_id=None, conten...
    method push_for_douyin_live (line 311) | def push_for_douyin_live(self, nickname=None, jump_url=None, room_titl...

FILE: query_task/query_douyu.py
  class QueryDouyu (line 10) | class QueryDouyu(QueryTask):
    method __init__ (line 11) | def __init__(self, config):
    method query (line 15) | def query(self):
    method query_live_status (line 28) | def query_live_status(self, room_id=None):
    method push_for_douyu_live (line 76) | def push_for_douyu_live(self, username=None, room_title=None, jump_url...

FILE: query_task/query_huya.py
  class QueryHuya (line 12) | class QueryHuya(QueryTask):
    method __init__ (line 13) | def __init__(self, config):
    method query (line 17) | def query(self):
    method query_live_status (line 30) | def query_live_status(self, room_id=None):
    method push_for_huya_live (line 86) | def push_for_huya_live(self, username=None, room_title=None, jump_url=...

FILE: query_task/query_weibo.py
  class QueryWeibo (line 13) | class QueryWeibo(QueryTask):
    method __init__ (line 14) | def __init__(self, config):
    method query (line 19) | def query(self):
    method query_dynamic (line 32) | def query_dynamic(self, uid=None):
    method get_headers (line 115) | def get_headers(uid):
    method push_for_weibo_dynamic (line 129) | def push_for_weibo_dynamic(self, username=None, mblog_id=None, content...

FILE: query_task/query_xhs.py
  class QueryXhs (line 13) | class QueryXhs(QueryTask):
    method __init__ (line 14) | def __init__(self, config):
    method query (line 19) | def query(self):
    method query_dynamic (line 32) | def query_dynamic(self, profile_id=None):
    method get_note_detail (line 110) | def get_note_detail(self, note_id=None):
    method get_headers (line 139) | def get_headers():
    method push_for_xhs_dynamic (line 155) | def push_for_xhs_dynamic(self, username=None, note_title=None, content...
Condensed preview — 43 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (140K chars).
[
  {
    "path": ".dockerignore",
    "chars": 53,
    "preview": ".git/\n.github/\n.idea/\n.venv/\ndocs/\nLICENSE\nREADME.md\n"
  },
  {
    "path": ".github/workflows/docker-image.yml",
    "chars": 874,
    "preview": "name: Build and Push Docker Image\n\non:\n  push:\n    branches:\n      - master\n  pull_request:\n    branches:\n      - master"
  },
  {
    "path": ".gitignore",
    "chars": 2103,
    "preview": "# Created by .ignore support plugin (hsz.mobi)\n### Python template\n# Byte-compiled / optimized / DLL files\n__pycache__/\n"
  },
  {
    "path": ".python-version",
    "chars": 4,
    "preview": "3.9\n"
  },
  {
    "path": "Dockerfile",
    "chars": 330,
    "preview": "FROM python:3.9-alpine AS builder\nCOPY --from=ghcr.io/astral-sh/uv:0.7.6 /uv /uvx /bin/\nWORKDIR /app\nCOPY . /app/\nRUN uv"
  },
  {
    "path": "LICENSE",
    "chars": 1062,
    "preview": "MIT License\n\nCopyright (c) 2021 EGG_W\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof t"
  },
  {
    "path": "README.md",
    "chars": 6263,
    "preview": "# All-in-one Dynamic Push - 多合一动态检测与推送\n\n[![Docker Image](https://img.shields.io/badge/DockerHub-nfew/aio--dynamic--push-"
  },
  {
    "path": "common/cache.py",
    "chars": 334,
    "preview": "from common.logger import log\n\nlocal_cache = {}\n\n\ndef set_cached_value(key, value):\n    log.info(f\"[本地缓存]将: {key} -> {va"
  },
  {
    "path": "common/config.py",
    "chars": 985,
    "preview": "import os\n\nimport yaml\n\nfrom common.logger import log\n\n\nclass ConfigReaderForYml(object):\n    def __init__(self, config_"
  },
  {
    "path": "common/logger.py",
    "chars": 378,
    "preview": "import logging\n\nlog = logging.getLogger()\n\n\ndef set_logger():\n    log.setLevel(logging.INFO)\n    formatter = logging.For"
  },
  {
    "path": "common/proxy.py",
    "chars": 2353,
    "preview": "import requests\n\nfrom common.config import global_config\nfrom common.logger import log\n\n\nclass Proxy(object):\n    curren"
  },
  {
    "path": "common/util.py",
    "chars": 1921,
    "preview": "import requests\nimport urllib3\nfrom fake_useragent import UserAgent\n\nfrom common.logger import log\nfrom common.proxy imp"
  },
  {
    "path": "config.yml",
    "chars": 7170,
    "preview": "common:\n  # ip 代理池\n  proxy_pool:\n    # 是否启用 true/false\n    enable: false\n    # ip池地址,参考 https://github.com/jhao104/proxy"
  },
  {
    "path": "entrypoint.sh",
    "chars": 242,
    "preview": "#!/bin/sh\nset -e\n\nif [ ! -f /mnt/config.yml ]; then\n  echo 'Error: /mnt/config.yml file not found. Please mount the /mnt"
  },
  {
    "path": "main.py",
    "chars": 2277,
    "preview": "import time\n\nimport schedule\n\nimport push_channel\nimport query_task\nfrom common.config import global_config\nfrom common."
  },
  {
    "path": "push_channel/__init__.py",
    "chars": 1283,
    "preview": "from ._push_channel import PushChannel\nfrom .bark import Bark\nfrom .demo import Demo\nfrom .dingtalk_bot import DingtalkB"
  },
  {
    "path": "push_channel/_push_channel.py",
    "chars": 583,
    "preview": "from abc import ABC, abstractmethod\n\n\nclass PushChannel(ABC):\n    def __init__(self, config):\n        self.name = config"
  },
  {
    "path": "push_channel/bark.py",
    "chars": 1262,
    "preview": "from urllib.parse import urlencode\n\nfrom common import util\nfrom common.logger import log\nfrom . import PushChannel\n\n\ncl"
  },
  {
    "path": "push_channel/demo.py",
    "chars": 531,
    "preview": "from common.logger import log\nfrom . import PushChannel\n\n\nclass Demo(PushChannel):\n    def __init__(self, config):\n     "
  },
  {
    "path": "push_channel/dingtalk_bot.py",
    "chars": 1164,
    "preview": "import json\n\nfrom common import util\nfrom common.logger import log\nfrom . import PushChannel\n\n\nclass DingtalkBot(PushCha"
  },
  {
    "path": "push_channel/email.py",
    "chars": 1921,
    "preview": "import smtplib\nfrom email.mime.multipart import MIMEMultipart\nfrom email.mime.text import MIMEText\n\nfrom common.logger i"
  },
  {
    "path": "push_channel/feishu_apps.py",
    "chars": 5467,
    "preview": "import json\nimport mimetypes\nimport os\nimport uuid\n\nimport requests\nfrom requests_toolbelt import MultipartEncoder\n\nfrom"
  },
  {
    "path": "push_channel/feishu_bot.py",
    "chars": 2292,
    "preview": "import json\n\nfrom common import util\nfrom common.logger import log\nfrom . import PushChannel\n\n\nclass FeishuBot(PushChann"
  },
  {
    "path": "push_channel/gotify.py",
    "chars": 865,
    "preview": "import json\n\nfrom common import util\nfrom common.logger import log\nfrom . import PushChannel\n\n\nclass Gotify(PushChannel)"
  },
  {
    "path": "push_channel/napcat_qq.py",
    "chars": 3097,
    "preview": "import json\n\nfrom common import util\nfrom common.logger import log\nfrom . import PushChannel\n\n\nclass NapCatQQ(PushChanne"
  },
  {
    "path": "push_channel/qq_bot.py",
    "chars": 3757,
    "preview": "import json\n\nfrom common import util\nfrom common.logger import log\nfrom . import PushChannel\n\n\nclass QQBot(PushChannel):"
  },
  {
    "path": "push_channel/server_chan_3.py",
    "chars": 1037,
    "preview": "from common import util\nfrom common.logger import log\nfrom . import PushChannel\n\n\nclass ServerChan3(PushChannel):\n    de"
  },
  {
    "path": "push_channel/server_chan_turbo.py",
    "chars": 859,
    "preview": "from common import util\nfrom common.logger import log\nfrom . import PushChannel\n\n\nclass ServerChanTurbo(PushChannel):\n  "
  },
  {
    "path": "push_channel/telegram_bot.py",
    "chars": 1205,
    "preview": "import json\n\nfrom common import util\nfrom common.logger import log\nfrom . import PushChannel\n\n\nclass TelegramBot(PushCha"
  },
  {
    "path": "push_channel/webhook.py",
    "chars": 1160,
    "preview": "from common import util\nfrom common.logger import log\nfrom . import PushChannel\n\n\nclass Webhook(PushChannel):\n    def __"
  },
  {
    "path": "push_channel/wecom_apps.py",
    "chars": 2302,
    "preview": "import json\n\nfrom common import util\nfrom common.logger import log\nfrom . import PushChannel\n\n\nclass WeComApps(PushChann"
  },
  {
    "path": "push_channel/wecom_bot.py",
    "chars": 1260,
    "preview": "import json\n\nfrom common import util\nfrom common.logger import log\nfrom . import PushChannel\n\n\nclass WeComBot(PushChanne"
  },
  {
    "path": "pyproject.toml",
    "chars": 443,
    "preview": "[project]\nname = \"aio-dynamic-push\"\nversion = \"0.1.0\"\ndescription = \"All-in-one Dynamic Push\"\nreadme = \"README.md\"\nrequi"
  },
  {
    "path": "query_task/__init__.py",
    "chars": 741,
    "preview": "from ._query_task import QueryTask\nfrom .query_bilibili import QueryBilibili\nfrom .query_demo import QueryDemo\nfrom .que"
  },
  {
    "path": "query_task/_query_task.py",
    "chars": 3674,
    "preview": "from abc import ABC, abstractmethod\nfrom collections import deque\n\nimport push_channel\nfrom common.logger import log\n\n\nc"
  },
  {
    "path": "query_task/query_bilibili.py",
    "chars": 19303,
    "preview": "import json\nimport time\nfrom collections import deque\n\nfrom common import util\nfrom common.cache import set_cached_value"
  },
  {
    "path": "query_task/query_demo.py",
    "chars": 1976,
    "preview": "import time\n\nfrom common.logger import log\nfrom common.proxy import my_proxy\nfrom query_task import QueryTask\n\n\nclass Qu"
  },
  {
    "path": "query_task/query_douyin.py",
    "chars": 14663,
    "preview": "import json\nimport time\nfrom collections import deque\nfrom urllib import parse\n\nimport requests\nfrom bs4 import Beautifu"
  },
  {
    "path": "query_task/query_douyu.py",
    "chars": 3481,
    "preview": "import json\nimport time\n\nfrom common import util\nfrom common.logger import log\nfrom common.proxy import my_proxy\nfrom qu"
  },
  {
    "path": "query_task/query_huya.py",
    "chars": 4070,
    "preview": "import json\nimport time\n\nfrom bs4 import BeautifulSoup\n\nfrom common import util\nfrom common.logger import log\nfrom commo"
  },
  {
    "path": "query_task/query_weibo.py",
    "chars": 6467,
    "preview": "import datetime\nimport json\nimport re\nimport time\nfrom collections import deque\n\nfrom common import util\nfrom common.log"
  },
  {
    "path": "query_task/query_xhs.py",
    "chars": 8112,
    "preview": "import json\nimport time\nfrom collections import deque\n\nfrom bs4 import BeautifulSoup\n\nfrom common import util\nfrom commo"
  },
  {
    "path": "requirements.txt",
    "chars": 866,
    "preview": "# This file was autogenerated by uv via the following command:\n#    uv pip compile pyproject.toml -o requirements.txt\nbe"
  }
]

About this extraction

This page contains the full source code of the nfe-w/aio-dynamic-push GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 43 files (117.4 KB), approximately 32.0k tokens, and a symbol index with 136 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!