├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.yml │ └── bug_report.yml ├── workflows │ ├── python-publish.yml │ └── main.yml └── renovate.json ├── docs ├── img │ ├── 05-23-22-110541.png │ ├── 06-23-22-110601.png │ └── 06-23-22-110636.png ├── 常见问题.md ├── 1.0 使用教程.md ├── 更新日志.md ├── 迁移到 go-cqhttp.md ├── 部署教程.md └── 2.0 使用教程.md ├── src └── plugins │ └── ELF_RSS2 │ ├── command │ ├── __init__.py │ ├── upload_group_file.py │ ├── add_cookies.py │ ├── add_dy.py │ ├── show_dy.py │ ├── show_all.py │ ├── del_dy.py │ ├── rsshub_add.py │ └── change_dy.py │ ├── parsing │ ├── routes │ │ ├── __init__.py │ │ ├── yande_re.py │ │ ├── youtube.py │ │ ├── bilibili.py │ │ ├── south_plus.py │ │ ├── twitter.py │ │ ├── weibo.py │ │ ├── danbooru.py │ │ └── pixiv.py │ ├── utils.py │ ├── check_update.py │ ├── download_torrent.py │ ├── handle_translation.py │ ├── cache_manage.py │ ├── handle_html_tag.py │ ├── parsing_rss.py │ ├── send_message.py │ ├── __init__.py │ └── handle_images.py │ ├── __init__.py │ ├── config.py │ ├── my_trigger.py │ ├── pikpak_offline.py │ ├── rss_parsing.py │ ├── utils.py │ ├── qbittorrent_download.py │ └── rss_class.py ├── Dockerfile ├── bot.py ├── .pre-commit-config.yaml ├── requirements.txt ├── docker-compose.yml ├── pyproject.toml ├── .env.dev ├── README.md └── .gitignore /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /docs/img/05-23-22-110541.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Quan666/ELF_RSS/HEAD/docs/img/05-23-22-110541.png -------------------------------------------------------------------------------- /docs/img/06-23-22-110601.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Quan666/ELF_RSS/HEAD/docs/img/06-23-22-110601.png -------------------------------------------------------------------------------- /docs/img/06-23-22-110636.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Quan666/ELF_RSS/HEAD/docs/img/06-23-22-110636.png -------------------------------------------------------------------------------- /src/plugins/ELF_RSS2/command/__init__.py: -------------------------------------------------------------------------------- 1 | from . import ( 2 | add_cookies, 3 | add_dy, 4 | change_dy, 5 | del_dy, 6 | rsshub_add, 7 | show_all, 8 | show_dy, 9 | upload_group_file, 10 | ) 11 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM tiangolo/uvicorn-gunicorn-fastapi:python3.10 2 | 3 | RUN python3 -m pip config set global.index-url https://mirrors.aliyun.com/pypi/simple 4 | 5 | COPY . /app/ 6 | 7 | RUN python3 -m pip install -r requirements.txt 8 | 9 | CMD nb run 10 | -------------------------------------------------------------------------------- /bot.py: -------------------------------------------------------------------------------- 1 | import nonebot 2 | from nonebot.adapters.onebot.v11 import Adapter as OneBotV11Adapter 3 | 4 | nonebot.init() 5 | app = nonebot.get_asgi() 6 | driver = nonebot.get_driver() 7 | driver.register_adapter(OneBotV11Adapter) 8 | nonebot.load_plugins("src/plugins") 9 | 10 | if __name__ == "__main__": 11 | nonebot.run(app="__mp_main__:app") 12 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | minimum_pre_commit_version: "3.2.0" 2 | files: ^.*\.py$ 3 | repos: 4 | - repo: https://github.com/charliermarsh/ruff-pre-commit 5 | rev: 'v0.0.257' 6 | hooks: 7 | - id: ruff 8 | args: 9 | - --fix 10 | - repo: https://github.com/psf/black 11 | rev: 23.1.0 12 | hooks: 13 | - id: black -------------------------------------------------------------------------------- /src/plugins/ELF_RSS2/parsing/routes/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | 4 | def __list_all_modules() -> List[str]: 5 | from pathlib import Path 6 | 7 | module_paths = list(Path(__file__).parent.glob("*.py")) 8 | return [module.name[:-3] for module in module_paths if module.name != "__init__.py"] 9 | 10 | 11 | ALL_MODULES = sorted(__list_all_modules()) 12 | __all__ = ALL_MODULES + ["ALL_MODULES"] 13 | -------------------------------------------------------------------------------- /docs/常见问题.md: -------------------------------------------------------------------------------- 1 | # 常见问题 2 | 3 | 1. 安装依赖时提示 `ERROR: Invalid requirement: '' (from line x of requirements.txt)` 或者其他提示 xxx 模块安装失败时 4 | 5 | > 出现该提示后请打开 `requirements.txt` 文件,并将文件中对应模块那一行(不明白是哪一行就全部)“==”或“~=”前面的模块复制出来 使用 `pip3 install xxx` 安装(库名替代xxx) 6 | 7 | 2. pixiv 图片下载失败,可能是 pixiv.cat 访问不稳定或者频繁拉取导致IP被关小黑屋? 8 | 9 | > 注:v2.2.9 开始会自动处理,配置项 `CLOSE_PIXIV_CAT` 失效。 10 | > 出现该情况,将配置文件中配置项 `CLOSE_PIXIV_CAT` 的值设置为 `true` 。或者换个IP? 11 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp[speedups]~=3.13.2 2 | arrow~=1.4.0 3 | async-timeout~=4.0.3 4 | bbcode~=1.1.0 5 | cachetools~=5.5.2 6 | emoji~=2.15.0 7 | feedparser~=6.0.12 8 | deep-translator~=1.11.4 9 | ImageHash~=4.3.2 10 | magneturi~=1.3 11 | nb-cli~=1.6.0 12 | nonebot-adapter-onebot~=2.4.6 13 | nonebot-plugin-apscheduler~=0.5.0 14 | nonebot2[fastapi]~=2.4.4 15 | pikpakapi~=0.1.11 16 | Pillow~=10.4.0 17 | pydantic>=1.10.26,<3.0.0,!=2.5.0,!=2.5.1 18 | pyquery~=2.0.1 19 | python-qbittorrent~=0.4.3 20 | tenacity~=8.5.0 21 | tinydb~=4.8.2 22 | yarl~=1.22.0 -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: 功能需求 2 | description: 希望添加一个功能 3 | labels: [ enhancement ] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | 提新功能需求之前,请确保你使用的是最新的 ELF_RSS ,并仔细阅读文档,避免发生“提出的需求是已经实现了的”这种令人哭笑不得的情况。 9 | 10 | - type: textarea 11 | id: feature 12 | attributes: 13 | label: 功能需求 14 | placeholder: 希望添加的功能是什么? 15 | validations: 16 | required: true 17 | 18 | - type: textarea 19 | id: reason 20 | attributes: 21 | label: 理由 22 | placeholder: 为什么要添加这个功能? 23 | validations: 24 | required: true 25 | -------------------------------------------------------------------------------- /src/plugins/ELF_RSS2/parsing/routes/yande_re.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Any, Dict 3 | 4 | from .. import ParsingBase, check_update 5 | 6 | 7 | # 检查更新 8 | @ParsingBase.append_before_handler(rex=r"https\:\/\/yande\.re\/post\/piclens\?tags\=") 9 | async def handle_check_update(state: Dict[str, Any]) -> Dict[str, Any]: 10 | db = state["tinydb"] 11 | change_data = check_update(db, state["new_data"]) 12 | for i in change_data: 13 | if i.get("media_content"): 14 | i["summary"] = re.sub( 15 | r'https://[^"]+', i["media_content"][0]["url"], i["summary"] 16 | ) 17 | return {"change_data": change_data} 18 | -------------------------------------------------------------------------------- /src/plugins/ELF_RSS2/parsing/utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Any, Dict, Optional 3 | 4 | from ..config import config 5 | 6 | 7 | # 代理 8 | def get_proxy(open_proxy: bool = True) -> Optional[str]: 9 | if not open_proxy or not config.rss_proxy: 10 | return None 11 | return f"http://{config.rss_proxy}" 12 | 13 | 14 | # 获取正文 15 | def get_summary(item: Dict[str, Any]) -> str: 16 | summary: str = ( 17 | item["content"][0]["value"] if item.get("content") else item["summary"] 18 | ) 19 | return f"
标签后增加俩个换行
147 | for i in ["p", "pre"]:
148 | rss_str = re.sub(f"{i}>", f"{i}>\n\n", rss_str)
149 |
150 | # 直接去掉标签,留下内部文本信息
151 | for i in html_tags:
152 | rss_str = re.sub(f"<{i} [^>]+>", "", rss_str)
153 | rss_str = re.sub(f"?{i}>", "", rss_str)
154 |
155 | rss_str = re.sub(r"<(br|hr)\s?/?>|<(br|hr) [^>]+>", "\n", rss_str)
156 | rss_str = re.sub(r"]+>", "\n", rss_str)
157 | rss_str = re.sub(r"?h\d>", "\n", rss_str)
158 |
159 | # 删除图片、视频标签
160 | rss_str = re.sub(
161 | r")?|
]+>", "", rss_str, flags=re.DOTALL
162 | )
163 |
164 | # 去掉多余换行
165 | while "\n\n\n" in rss_str:
166 | rss_str = rss_str.replace("\n\n\n", "\n\n")
167 | rss_str = rss_str.strip()
168 |
169 | if 0 < config.max_length < len(rss_str):
170 | rss_str = f"{rss_str[: config.max_length]}..."
171 |
172 | return rss_str
173 |
--------------------------------------------------------------------------------
/src/plugins/ELF_RSS2/rss_parsing.py:
--------------------------------------------------------------------------------
1 | from typing import Any, Dict, Optional, Tuple
2 |
3 | import aiohttp
4 | import feedparser
5 | from nonebot.adapters.onebot.v11 import Bot
6 | from nonebot.log import logger
7 | from tinydb import TinyDB
8 | from yarl import URL
9 |
10 | from . import my_trigger as tr
11 | from .config import DATA_PATH, config
12 | from .parsing import get_proxy
13 | from .parsing.cache_manage import cache_filter
14 | from .parsing.check_update import dict_hash
15 | from .parsing.parsing_rss import ParsingRss
16 | from .rss_class import Rss
17 | from .utils import (
18 | filter_valid_group_id_list,
19 | filter_valid_guild_channel_id_list,
20 | filter_valid_user_id_list,
21 | get_bot,
22 | get_http_caching_headers,
23 | send_message_to_admin,
24 | )
25 |
26 | HEADERS = {
27 | "Accept": "application/xhtml+xml,application/xml,*/*",
28 | "Accept-Language": "en-US,en;q=0.9",
29 | "Cache-Control": "max-age=0",
30 | "User-Agent": (
31 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
32 | "AppleWebKit/537.36 (KHTML, like Gecko) "
33 | "Chrome/111.0.0.0 Safari/537.36"
34 | ),
35 | "Connection": "keep-alive",
36 | "Content-Type": "application/xml; charset=utf-8",
37 | }
38 |
39 |
40 | async def filter_and_validate_rss(rss: Rss, bot: Bot) -> Rss:
41 | if rss.user_id:
42 | rss.user_id = await filter_valid_user_id_list(bot, rss.user_id)
43 | if rss.group_id:
44 | rss.group_id = await filter_valid_group_id_list(bot, rss.group_id)
45 | if rss.guild_channel_id:
46 | rss.guild_channel_id = await filter_valid_guild_channel_id_list(
47 | bot, rss.guild_channel_id
48 | )
49 | return rss
50 |
51 |
52 | async def save_first_time_fetch(rss: Rss, new_rss: Dict[str, Any]) -> None:
53 | _file = DATA_PATH / f"{Rss.handle_name(rss.name)}.json"
54 | result = [cache_filter(entry) for entry in new_rss["entries"]]
55 | for r in result:
56 | r["hash"] = dict_hash(r)
57 |
58 | with TinyDB(
59 | _file,
60 | encoding="utf-8",
61 | sort_keys=True,
62 | indent=4,
63 | ensure_ascii=False,
64 | ) as db:
65 | db.insert_multiple(result)
66 |
67 | logger.info(f"{rss.name} 第一次抓取成功!")
68 |
69 |
70 | # 抓取 feed,读取缓存,检查更新,对更新进行处理
71 | async def start(rss: Rss) -> None:
72 | bot: Bot = await get_bot() # type: ignore
73 | if bot is None:
74 | return
75 |
76 | # 先检查订阅者是否合法
77 | rss = await filter_and_validate_rss(rss, bot)
78 | if not any([rss.user_id, rss.group_id, rss.guild_channel_id]):
79 | await auto_stop_and_notify_admin(rss, bot)
80 | return
81 |
82 | new_rss, cached = await fetch_rss(rss)
83 | # 检查是否存在rss记录
84 | _file = DATA_PATH / f"{Rss.handle_name(rss.name)}.json"
85 | first_time_fetch = not _file.exists()
86 |
87 | if cached:
88 | logger.info(f"{rss.name} 没有新信息")
89 | return
90 |
91 | if not new_rss or not new_rss.get("feed"):
92 | rss.error_count += 1
93 | logger.warning(f"{rss.name} 抓取失败!")
94 |
95 | if first_time_fetch:
96 | if config.rss_proxy and not rss.img_proxy:
97 | rss.img_proxy = True
98 | logger.info(f"{rss.name} 第一次抓取失败,自动使用代理抓取")
99 | await start(rss)
100 | else:
101 | await auto_stop_and_notify_admin(rss, bot)
102 |
103 | if rss.error_count >= 100:
104 | await auto_stop_and_notify_admin(rss, bot)
105 | return
106 |
107 | if new_rss.get("feed") and rss.error_count > 0:
108 | rss.error_count = 0
109 |
110 | if first_time_fetch:
111 | await save_first_time_fetch(rss, new_rss)
112 | return
113 |
114 | pr = ParsingRss(rss=rss)
115 | await pr.start(rss_name=rss.name, new_rss=new_rss)
116 |
117 |
118 | async def auto_stop_and_notify_admin(rss: Rss, bot: Bot) -> None:
119 | rss.stop = True
120 | rss.upsert()
121 | tr.delete_job(rss)
122 | cookies_str = "及 cookies " if rss.cookies else ""
123 | if not any([rss.user_id, rss.group_id, rss.guild_channel_id]):
124 | msg = f"{rss.name}[{rss.get_url()}]无人订阅!已自动停止更新!"
125 | elif rss.error_count >= 100:
126 | msg = f"{rss.name}[{rss.get_url()}]已经连续抓取失败超过 100 次!已自动停止更新!请检查订阅地址{cookies_str}!"
127 | else:
128 | msg = f"{rss.name}[{rss.get_url()}]第一次抓取失败!已自动停止更新!请检查订阅地址{cookies_str}!"
129 | await send_message_to_admin(msg, bot)
130 |
131 |
132 | async def fetch_rss_backup(
133 | rss: Rss, session: aiohttp.ClientSession, proxy: Optional[str]
134 | ) -> Dict[str, Any]:
135 | d = {}
136 | for rsshub_url in config.rsshub_backup:
137 | rss_url = rss.get_url(rsshub=str(rsshub_url))
138 | try:
139 | resp = await session.get(rss_url, proxy=proxy)
140 | d = feedparser.parse(await resp.text())
141 | if d.get("feed"):
142 | logger.info(f"[{rss_url}]抓取成功!")
143 | break
144 | except Exception:
145 | logger.debug(f"[{rss_url}]访问失败!将使用备用 RSSHub 地址!")
146 | continue
147 | return d
148 |
149 |
150 | # 获取 RSS 并解析为 json
151 | async def fetch_rss(rss: Rss) -> Tuple[Dict[str, Any], bool]:
152 | rss_url = rss.get_url()
153 | # 对本机部署的 RSSHub 不使用代理
154 | local_host = ["localhost", "127.0.0.1"]
155 | proxy = get_proxy(rss.img_proxy) if URL(rss_url).host not in local_host else None
156 | cookies = rss.cookies or None
157 | headers = HEADERS.copy()
158 | if cookies:
159 | headers["cookie"] = cookies
160 | d = {}
161 | cached = False
162 |
163 | if not config.rsshub_backup and not config.debug:
164 | if rss.etag:
165 | headers["If-None-Match"] = rss.etag
166 | if rss.last_modified:
167 | headers["If-Modified-Since"] = rss.last_modified
168 |
169 | async with aiohttp.ClientSession(
170 | headers=headers,
171 | raise_for_status=True,
172 | timeout=aiohttp.ClientTimeout(10),
173 | ) as session:
174 | try:
175 | resp = await session.get(rss_url, proxy=proxy)
176 | if not config.rsshub_backup:
177 | http_caching_headers = get_http_caching_headers(resp.headers)
178 | rss.etag = http_caching_headers["ETag"]
179 | rss.last_modified = http_caching_headers["Last-Modified"]
180 | rss.upsert()
181 | if (
182 | resp.status == 200 and int(resp.headers.get("Content-Length", "1")) == 0
183 | ) or resp.status == 304:
184 | cached = True
185 | d = feedparser.parse(await resp.text())
186 | except Exception:
187 | if not URL(rss.url).scheme and config.rsshub_backup:
188 | logger.debug(f"[{rss_url}]访问失败!将使用备用 RSSHub 地址!")
189 | d = await fetch_rss_backup(rss, session, proxy)
190 | else:
191 | logger.error(f"[{rss_url}]访问失败!")
192 | return d, cached
193 |
--------------------------------------------------------------------------------
/docs/部署教程.md:
--------------------------------------------------------------------------------
1 | # 2.0 部署教程
2 |
3 | ## 手动部署
4 |
5 | ### 配置 QQ 协议端
6 |
7 | 目前支持的协议有:
8 |
9 | - [OneBot(CQHTTP)](https://github.com/howmanybots/onebot/blob/master/README.md)
10 |
11 | QQ 协议端举例:
12 |
13 | - [go-cqhttp](https://github.com/Mrs4s/go-cqhttp)(基于 [MiraiGo](https://github.com/Mrs4s/MiraiGo))
14 | - [cqhttp-mirai-embedded](https://github.com/yyuueexxiinngg/cqhttp-mirai/tree/embedded)
15 | - [Mirai](https://github.com/mamoe/mirai)+ [cqhttp-mirai](https://github.com/yyuueexxiinngg/cqhttp-mirai)
16 | - [Mirai](https://github.com/mamoe/mirai)+ [Mirai Native](https://github.com/iTXTech/mirai-native)+ [CQHTTP](https://github.com/richardchien/coolq-http-api)
17 | - [OICQ-http-api](https://github.com/takayama-lily/onebot)(基于 [OICQ](https://github.com/takayama-lily/oicq))
18 |
19 | **本插件主要以 [`go-cqhttp`](https://github.com/Mrs4s/go-cqhttp) 为主要适配对象,其他协议端存在兼容性问题,不保证可用性!**
20 |
21 | 1. 下载 `go-cqhttp` 对应平台的 release 文件,建议使用 `v1.0.0-beta8-fix2` [点此前往](https://github.com/Mrs4s/go-cqhttp/releases)
22 |
23 | 2. 运行 `go-cqhttp.exe` 或者使用 `./go-cqhttp` 启动
24 |
25 | 3. 选择 `反向 WS` 方式连接,生成配置文件 `config.yml` 并按修改以下几行内容
26 |
27 | ```yaml
28 | account: # 账号相关
29 | uin: 1233456 # 修改为 QQ 账号
30 |
31 | # 连接服务列表
32 | servers:
33 | # 反向WS设置
34 | - ws-reverse:
35 | # 反向WS Universal 地址
36 | universal: ws://127.0.0.1:8080/onebot/v11/ws/
37 | ```
38 |
39 | 其中 `ws://127.0.0.1:8080/onebot/v11/ws/` 中的 `127.0.0.1` 和 `8080` 应分别对应 nonebot2 配置文件的 `HOST` 和 `PORT`
40 |
41 | 4. 运行 `go-cqhttp.exe` 或者使用 `./go-cqhttp` 启动,扫码登陆
42 |
43 | ### 配置 `ELF_RSS`
44 |
45 | 注意:推荐 Python 3.8.3+ 版本
46 |
47 | Windows版安装包下载地址:[https://www.python.org/ftp/python/3.8.3/python-3.8.3-amd64.exe](https://www.python.org/ftp/python/3.8.3/python-3.8.3-amd64.exe)
48 |
49 | #### 第一次部署
50 |
51 | ##### 不使用脚手架
52 |
53 | 1. 下载代码到本地
54 |
55 | 2. 运行 `pip install -r requirements.txt` 或者 运行 `pip install .`
56 |
57 | 3. 复制 `.env.dev` 文件,并改名为 `.env.prod`,按照注释修改配置
58 |
59 | ```bash
60 | cp .env.dev .env.prod
61 | ```
62 |
63 | 注意配置
64 | ```dotenv
65 | COMMAND_START=["","/"] # 配置命令起始字符
66 | ```
67 |
68 | "" 和 "/" 分别对应指令前不需要前缀和前缀是 "/" ,也就是 "add" 和 "/add" 的区别。 [#382](https://github.com/Quan666/ELF_RSS/issues/382)
69 |
70 | 4. 运行 `nb run`
71 |
72 | 5. 收到机器人发送的启动成功消息
73 |
74 | ##### 使用脚手架
75 |
76 | 1. 安装 `nb-cli`
77 |
78 | ```bash
79 | pip install nb-cli
80 | ```
81 |
82 | 2. 使用 `nb-cli` 新建工程
83 |
84 | ```bash
85 | nb create
86 | ```
87 |
88 | 3. 选择 `OneBot V11` 适配器创建工程
89 |
90 | 4. 切换到工程目录,安装 `ELF-RSS`
91 |
92 | ```bash
93 | nb plugin install ELF-RSS
94 | ```
95 |
96 | 5. 复制本仓库中 `.env.dev` 文件内容到工程目录下的 `.env.prod` 文件,并根据注释修改
97 |
98 | #### 已经部署过其它 Nonebot2 机器人
99 |
100 | 1. 下载 `src/plugins/ELF_RSS2` 文件夹 到你部署好了的机器人 `plugins` 目录
101 |
102 | 2. 下载 `requirements.txt` 文件,并运行 `pip install -r requirements.txt`
103 |
104 | 3. 同 `第一次部署` 一样,修改配置文件
105 |
106 | 4. 运行 `nb run`
107 |
108 | 5. 收到机器人发送的启动成功消息
109 |
110 | #### 从 Nonebot1 到 NoneBot2
111 |
112 | > 注意:go-cqhttp 的配置需要有所变动!,**`config.yml`务必按照下方样式修改!**
113 | > ```yaml
114 | > # yml 注意缩进!!!
115 | > - ws-reverse:
116 | > # 是否禁用当前反向WS服务
117 | > disabled: false
118 | > # 反向WS Universal 地址
119 | > universal: ws://127.0.0.1:8080/onebot/v11/ws/
120 | > ```
121 |
122 | 1. 卸载 nonebot1
123 |
124 | ```bash
125 | pip uninstall nonebot
126 | ```
127 |
128 | 2. 运行
129 |
130 | ```bash
131 | pip install -r requirements.txt
132 | ```
133 |
134 | 3. 参照 `第一次部署`
135 |
136 | ---
137 |
138 | > ### RSS 源中 torrent 自动下载并上传至订阅群 相关设置
139 | >
140 | > #### 1. 配置 qbittorrent
141 | >
142 | > ##### - Windows安装配置
143 | >
144 | > 1. 下载并安装 [qbittorrent](https://www.qbittorrent.org/download.php)
145 | >
146 | > 2. 设置 qbittorrent
147 | >
148 | > 
149 | >
150 | > ##### - Linux安装配置
151 | >
152 | > 1. 说明:由于Linux各发行版本软件包管理器差异较大,这里以ubuntu2004和centos7.9为例,其他发行版方法大同小异。如想体验最新版qbittorrent或找不到软件源,可以参考[官方教程](https://github.com/qbittorrent/qBittorrent/wiki/Running-qBittorrent-without-X-server-(WebUI-only))进行编译安装
153 | >
154 | > 2. 对于centos,可以使用epel软件源安装qbittorrent-nox
155 | >
156 | > ```bash
157 | > yum -y install epel-release
158 | > yum -y install qbittorrent-nox.x86_64
159 | > ```
160 | >
161 | > 3. 对于ubuntu,建议使用qbittorrent官方ppa安装qbittorrent-nox
162 | >
163 | > ```bash
164 | > sudo add-apt-repository ppa:qbittorrent-team/qbittorrent-stable
165 | > sudo apt -y install qbittorrent-nox
166 | > ```
167 | >
168 | > 4. 设置qbittorrent
169 | >
170 | > 安装完成后,运行
171 | >
172 | > ```bash
173 | > qbittorrent-nox
174 | > ```
175 | >
176 | > 此时 qbittorrent-nox 会显示“Legal Notice”(法律通告),告诉你使用 qbittorrent 会上传数据,需要自己承担责任。
177 | >
178 | > 输入y表示接受
179 | >
180 | > 接下来的会显示一段信息:
181 | >
182 | > ```text
183 | > ******** Information ********
184 | > To control qBittorrent, access the Web UI at http://localhost:8080
185 | > The Web UI administrator user name is: admin
186 | > The Web UI administrator password is still the default one: adminadmin
187 | > This is a security risk, please consider changing your password from program preferences.
188 | > ```
189 | >
190 | > 此时qBittorrent Web UI就已经在8080端口运行了
191 | >
192 | > 访问面板,打开Tools>Options
193 | >
194 | > 
195 | >
196 | > 选择Web UI,在Port里修改为8081
197 | >
198 | > 
199 | >
200 | > 下滑,修改用户名和密码(可选),勾选Bypass authentication for localhost
201 | >
202 | > 
203 | >
204 | > 下滑,点击save保存,设置完成
205 | >
206 | > 5. qbittorrent-nox默认没有开机启动,建议通过systemctl配置开机启动
207 | >
208 | > #### 2. 设置API超时时间
209 | >
210 | > 在配置文件中新增 以下配置
211 | >
212 | > ```dotenv
213 | > API_TIMEOUT=3600 # 超时,单位 s,建议根据你上传带宽灵活配置
214 | > ```
215 | >
216 | > **注意:**
217 | >
218 | > **如果是容器部署qbittorrent,请将其下载路径挂载到宿主机,以及确保go-cqhttp能访问到下载的文件**
219 | >
220 | > **要么保证挂载路径与容器内路径一致,要么配置 qb_down_path 配置项为挂载路径**
221 |
222 | ---
223 |
224 | ## 1.x 部署教程
225 |
226 | ### 要求
227 |
228 | 1. python3.8+
229 |
230 | ### 开始安装
231 |
232 | 1. 安装有关依赖
233 |
234 | ```bash
235 | # 国内
236 | pip3 install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
237 |
238 | # 国外服务器
239 | # pip3 install -r requirements.txt
240 |
241 | # 如果pip3安装不了,将pip换成pip再全部重新安装
242 | # 建议使用pip3
243 | ```
244 |
245 | 2. 下载插件文件
246 | [ELF_RSS 项目地址](https://github.com/Quan666/ELF_RSS "ELF_RSS 项目地址")
247 |
248 | 3. 修改配置文件
249 | 解压打开后修改`config.py` 文件,以记事本打开就行
250 |
251 | ```python
252 | from nonebot.default_config import *
253 |
254 | HOST = '0.0.0.0'
255 | PORT = 8080
256 |
257 | NICKNAME = {'ELF', 'elf'}
258 |
259 | COMMAND_START = {'', '/', '!', '/', '!'}
260 |
261 | SUPERUSERS = {123456789} # 管理员(你)的QQ号
262 |
263 | API_ROOT = 'http://127.0.0.1:5700' #
264 | RSS_PROXY = '127.0.0.1:7890' # 代理地址
265 | ROOTUSER=[123456] # 管理员qq,支持多管理员,逗号分隔 如 [1,2,3] 注意,启动消息只发送给第一个管理员
266 | DEBUG = False
267 | RSSHUB='https://rsshub.app' # rsshub订阅地址
268 | DELCACHE=3 #缓存删除间隔 天
269 | ```
270 |
271 | > **修改完后记得保存**
272 |
273 | ### 机器人相关配置
274 |
275 | 移步 [迁移到 go-cqhttp](迁移到%20go-cqhttp.md)
276 |
277 | ### 运行插件
278 |
279 | shift+右键打开powershell或命令行输入
280 |
281 | ```bash
282 | hypercorn run:app -b 0.0.0.0:8080
283 |
284 | # 或者使用(不推荐) python bot.py
285 | # 或者 python3 bot.py
286 | ```
287 |
288 | 运行后qq会收到消息
289 |
290 | > **第一次运行要先添加订阅**
291 | > **不要关闭窗口**
292 | > **CTRL+C可以结束运行**
293 | > **如果某次运行无法使用命令,请多次按CTRL+C**
294 |
295 | ### 更新
296 |
297 | `git pull`
298 | 或者下载代码覆盖
299 |
--------------------------------------------------------------------------------
/docs/2.0 使用教程.md:
--------------------------------------------------------------------------------
1 | # 2.0 使用教程
2 |
3 | > 注意:
4 | >
5 | > 1. 所有命令均分群组、子频道和私聊三种情况,执行结果也会不同
6 | > 2. [] 包起来的参数表示可选,但某些情况下为必须参数
7 | > 3. 所有订阅命令群管都可使用(但是有一定限制)
8 | > 4. 私聊直接发送命令即可,群聊和子频道需在消息首部或尾部添加 **机器人昵称** 或者 **@机器人**
9 | > 5. 群聊中也可以回复机器人发的消息执行命令,子频道暂不支持
10 | > 6. 所有参数之间均用空格分割,符号为英文标点
11 |
12 | ## 添加订阅
13 |
14 | > 命令:add (添加订阅、sub)
15 | >
16 | > 参数:订阅名 [RSS 地址]
17 | >
18 | > 示例: `add test twitter/user/huagequan`
19 | >
20 | > 使用技巧:先快速添加订阅,之后再通过 `change` 命令修改
21 | >
22 | > 命令解释:
23 | >
24 | > 必需 `订阅名` 及 `RSS地址(RSSHub订阅源可以省略域名,其余需要完整的URL地址)` 两个参数,
25 | > 订阅到当前 `群组` 、 `频道` 或 `QQ`。
26 |
27 | ## 添加 RSSHub 订阅
28 |
29 | > 命令:rsshub_add
30 | >
31 | > 参数:[RSSHub 路由名] [订阅名]
32 | >
33 | > 示例: `rsshub_add github`
34 | >
35 | > 命令解释:
36 | >
37 | > 发送命令后,按照提示依次输入 RSSHub 路由、订阅名和路由参数
38 |
39 | ## 删除订阅
40 |
41 | > 命令:deldy (删除订阅、drop、unsub)
42 | >
43 | > 参数:订阅名 [订阅名 ...](支持批量操作)
44 | >
45 | > 示例: `deldy test` `deldy test1 test2`
46 | >
47 | > 命令解释:
48 | >
49 | > 1. 在超级管理员私聊使用该命令时,可完全删除订阅
50 | > 2. 在群组使用该命令时,将该群组从订阅群组中删除
51 | > 3. 在子频道使用该命令时,将该子频道从订阅子频道中删除
52 |
53 | ## 所有订阅
54 |
55 | > 命令:show_all(showall,select_all,selectall,所有订阅)
56 | >
57 | > 参数:[关键词](支持正则,过滤生效范围:订阅名、订阅地址、QQ 号、群号)
58 | >
59 | > 示例: `showall test` `showall 123`
60 | >
61 | > 命令解释:
62 | >
63 | > 1. 携带 `关键词` 参数时,展示该群组或子频道或所有订阅中含有关键词的订阅
64 | > 2. 不携带 `关键词` 参数时,展示该群组或子频道或所有订阅
65 | > 3. 当 `关键词` 参数为整数时候,只对超级管理员用户额外展示所有订阅中 `QQ号` 或 `群号` 含有关键词的订阅
66 |
67 | ## 查看订阅
68 |
69 | > 命令:show(查看订阅)
70 | >
71 | > 参数:[订阅名]
72 | >
73 | > 示例: `show test`
74 | >
75 | > 命令解释:
76 | >
77 | > 1. 携带 `订阅名` 参数时,展示该订阅的详细信息
78 | > 2. 不携带 `订阅名` 参数时,展示该群组或子频道或 QQ 的订阅详情
79 |
80 | ## 修改订阅
81 |
82 | > 命令:change(修改订阅,moddy)
83 | >
84 | > 参数:订阅名[, 订阅名,...] 属性=值[ 属性=值 ...]
85 | >
86 | > 示例: `change test1[,test2,...] qq=,123,234 qun=-1`
87 | >
88 | > 使用技巧:可以先只发送 `change` ,机器人会返回提示信息,无需记住复杂的参数列表
89 | >
90 | > 对应参数:
91 | >
92 | > | 修改项 | 参数名 | 值范围 | 备注 |
93 | > |-----------------|-----------|--------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------|
94 | > | 订阅名 | -name | 无空格字符串 | 禁止将多个订阅批量改名,会因为名称相同起冲突 |
95 | > | 订阅链接 | -url | 无空格字符串 | RSSHub 订阅源可以省略域名,其余需要完整的 URL 地址 |
96 | > | QQ 号 | -qq | 正整数 / -1 | 需要先加该对象好友;前加英文逗号表示追加;-1 设为空 |
97 | > | QQ 群 | -qun | 正整数 / -1 | 需要先加入该群组;前加英文逗号表示追加;-1 设为空 |
98 | > | 更新频率 | -time | 正整数 / crontab 字符串 | 值为整数时表示每 x 分钟进行一次检查更新,且必须大于等于 1
值为 crontab 字符串时,详见表格下方的补充说明 |
99 | > | 代理 | -proxy | 1 / 0 | 是否启用代理 |
100 | > | 翻译 | -tl | 1 / 0 | 是否翻译正文内容 |
101 | > | 仅标题 | -ot | 1 / 0 | 是否仅发送标题 |
102 | > | 仅图片 | -op | 1 / 0 | 是否仅发送图片 (正文中只保留图片) |
103 | > | 仅含有图片 | -ohp | 1 / 0 | 仅含有图片不同于仅图片,除了图片还会发送正文中的其他文本信息 |
104 | > | 下载种子 | -downopen | 1 / 0 | 是否进行 BT 下载 (需要配置 qBittorrent,参考:[第一次部署](部署教程.md#第一次部署)) |
105 | > | 白名单关键词 | -wkey | 无空格字符串 / 空 | 支持正则表达式,匹配时推送消息及下载
设为空 (wkey=) 时不生效
前面加 +/- 表示追加/去除,详见表格下方的补充说明 |
106 | > | 黑名单关键词 | -bkey | 无空格字符串 / 空 | 同白名单关键词,但匹配时不推送,可在避免冲突的情况下组合使用 |
107 | > | 种子上传到群 | -upgroup | 1 / 0 | 是否将 BT 下载完成的文件上传到群 (需要配置 qBittorrent,参考:[第一次部署](部署教程.md#第一次部署)) |
108 | > | 去重模式 | -mode | link / title / image / or / -1 | 分为按链接 (link)、标题 (title)、图片 (image) 判断
其中 image 模式,出于性能考虑以及避免误伤情况发生,生效对象限定为只带 1 张图片的消息
此外,如果属性中带有 or 说明判断逻辑是任一匹配即去重,默认为全匹配
-1 设为禁用 |
109 | > | 图片数量限制 | -img_num | 正整数 | 只发送限定数量的图片,防止刷屏 |
110 | > | 正文待移除内容 | -rm_list | 无空格字符串 / -1 | 从正文中要移除的指定内容,支持正则表达式
因为参数解析的缘故,格式必须如:`rm_list='a'` 或 `rm_list='a','b'`
该处理过程是在解析 html 标签后进行的
要将该参数设为空,使用 `rm_list='-1'` |
111 | > | 停止更新 | -stop | 1 / 0 | 对订阅停止、恢复检查更新 |
112 | > | PikPak 离线下载 | -pikpak | 1 / 0 | 将磁力链接离线到 PikPak 网盘,方便追番 |
113 | > | PikPak 离线下载路径匹配 | -ppk | 无空格字符串 | 匹配正文中的关键字作为目录 |
114 | > | 发送合并消息 | -forward | 1 / 0 | 当一次更新多条消息时,尝试发送合并消息 |
115 | >
116 | > **注:**
117 | >
118 | > 各个属性之间使用**空格**分割
119 | >
120 | > wkey / bkey 前面加 +/- 表示追加/去除,最终处理为格式如 `a` 、 `a|b` 、 `a|b|c` ……
121 | >
122 | > 如要使用,请在修改后检查处理后的正则表达式是否正确
123 | >
124 | > time 属性兼容 Linux crontab 格式,**但不同的是,crontab 中的空格应该替换为 `_` 即下划线**
125 | >
126 | > 可以参考 [Linux crontab 命令](https://www.runoob.com/linux/linux-comm-crontab.html) 务必理解!但实际有少许不同,主要是设置第 5 个字段时,即每周有不同。
127 | >
128 | > 时间格式如下:
129 | >
130 | > ```text
131 | > f1_f2_f3_f4_f5
132 | > ```
133 | >
134 | > - 其中 f1 是表示分钟,f2 表示小时,f3 表示一个月份中的第几日,f4 表示月份,f5 表示一个星期中的第几天。program 表示要执行的程序。
135 | > - 当 f1 为 *时表示每分钟都要执行 program,f2 为* 时表示每小时都要执行程序,其馀类推
136 | > - 当 f1 为 a-b 时表示从第 a 分钟到第 b 分钟这段时间内要执行,f2 为 a-b 时表示从第 a 到第 b 小时都要执行,其馀类推
137 | > - 当 f1 为 */n 时表示每 n 分钟个时间间隔执行一次,f2 为*/n 表示每 n 小时个时间间隔执行一次,其馀类推
138 | > - 当 f1 为 a, b, c, ... 时表示第 a, b, c, ... 分钟要执行,f2 为 a, b, c, ... 时表示第 a, b, c... 个小时要执行,其馀类推
139 | >
140 | > ```text
141 | > * * * * *
142 | > - - - - -
143 | > | | | | |
144 | > | | | | +----- 星期中星期几 (0 - 6) (星期一为 0,星期天为 6) (int|str) – number or name of weekday (0-6 or mon,tue,wed,thu,fri,sat,sun)
145 | > | | | +---------- 月份 (1 - 12)
146 | > | | +--------------- 一个月中的第几天 (1 - 31)
147 | > | +-------------------- 小时 (0 - 23)
148 | > +------------------------- 分钟 (0 - 59)
149 | > ```
150 | >
151 | > 以下是一些示例:
152 | >
153 | > ``` text
154 | > 1 # 每分钟执行一次(普通)
155 | > 1_ # 每小时的第一分钟运行(cron)
156 | > */1 # 每分钟执行一次
157 | > *_*/1 # 每小时执行一次(注意,均在整点运行)
158 | > *_*_*_*_0, 1, 2, 6 # 每周 1、2、3、日运行,周日为 6
159 | > 0_6-12/3_*_12_* #在 12 月内, 每天的早上 6 点到 12 点,每隔 3 个小时 0 分钟执行一次
160 | > *_12_* # 每天 12 点运行
161 | > # 如果不生效请查看控制台输出
162 | > ```
163 |
--------------------------------------------------------------------------------
/src/plugins/ELF_RSS2/parsing/parsing_rss.py:
--------------------------------------------------------------------------------
1 | import re
2 | from inspect import signature
3 | from typing import Any, Callable, Dict, List, Optional, Tuple
4 |
5 | from tinydb import TinyDB
6 |
7 | from ..config import DATA_PATH
8 | from ..rss_class import Rss
9 | from ..utils import partition_list
10 |
11 |
12 | # 订阅器启动的时候将解析器注册到rss实例类?,避免每次推送时再匹配
13 | class ParsingItem:
14 | def __init__(
15 | self,
16 | func: Callable[..., Any],
17 | rex: str = "(.*)",
18 | priority: int = 10,
19 | block: bool = False,
20 | ):
21 | # 解析函数
22 | self.func: Callable[..., Any] = func
23 | # 匹配的订阅地址正则,"(.*)" 是全都匹配
24 | self.rex: str = rex
25 | # 优先级,数字越小优先级越高。优先级相同时,会抛弃默认处理方式,即抛弃 rex="(.*)"
26 | self.priority: int = priority
27 | # 是否阻止执行之后的处理,默认不阻止。抛弃默认处理方式,只需要 block==True and priority<10
28 | self.block: bool = block
29 |
30 |
31 | # 解析器排序
32 | def _sort(_list: List[ParsingItem]) -> List[ParsingItem]:
33 | _list.sort(key=lambda x: x.priority)
34 | return _list
35 |
36 |
37 | # rss 解析类 ,需要将特殊处理的订阅注册到该类
38 | class ParsingBase:
39 | """
40 | - **类型**: ``List[ParsingItem]``
41 | - **说明**: 最先执行的解析器,定义了检查更新等前置步骤
42 | """
43 |
44 | before_handler: List[ParsingItem] = []
45 |
46 | """
47 | - **类型**: ``Dict[str, List[ParsingItem]]``
48 | - **说明**: 解析器
49 | """
50 | handler: Dict[str, List[ParsingItem]] = {
51 | "title": [],
52 | "summary": [],
53 | "picture": [],
54 | "source": [],
55 | "date": [],
56 | "torrent": [],
57 | "after": [], # item的最后处理,此处调用消息截取、发送
58 | }
59 |
60 | """
61 | - **类型**: ``List[ParsingItem]``
62 | - **说明**: 最后执行的解析器,在消息发送后,也可以多条消息合并发送
63 | """
64 | after_handler: List[ParsingItem] = []
65 |
66 | # 增加解析器
67 | @classmethod
68 | def append_handler(
69 | cls,
70 | parsing_type: str,
71 | rex: str = "(.*)",
72 | priority: int = 10,
73 | block: bool = False,
74 | ) -> Callable[..., Any]:
75 | def _decorator(func: Callable[..., Any]) -> Callable[..., Any]:
76 | cls.handler[parsing_type].append(ParsingItem(func, rex, priority, block))
77 | cls.handler.update({parsing_type: _sort(cls.handler[parsing_type])})
78 | return func
79 |
80 | return _decorator
81 |
82 | @classmethod
83 | def append_before_handler(
84 | cls, rex: str = "(.*)", priority: int = 10, block: bool = False
85 | ) -> Callable[..., Any]:
86 | """
87 | 装饰一个方法,作为将其一个前置处理器
88 | 参数:
89 | rex: 用于正则匹配目标订阅地址,匹配成功后执行器将适用
90 | priority: 执行器优先级,自定义执行器会覆盖掉相同优先级的默认执行器
91 | block: 是否要阻断后续执行器进行
92 | """
93 |
94 | def _decorator(func: Callable[..., Any]) -> Callable[..., Any]:
95 | cls.before_handler.append(ParsingItem(func, rex, priority, block))
96 | cls.before_handler = _sort(cls.before_handler)
97 | return func
98 |
99 | return _decorator
100 |
101 | @classmethod
102 | def append_after_handler(
103 | cls, rex: str = "(.*)", priority: int = 10, block: bool = False
104 | ) -> Callable[..., Any]:
105 | """
106 | 装饰一个方法,作为将其一个后置处理器
107 | 参数:
108 | rex: 用于正则匹配目标订阅地址,匹配成功后执行器将适用
109 | priority: 执行器优先级,自定义执行器会覆盖掉相同优先级的默认执行器
110 | block: 是否要阻断后续执行器进行
111 | """
112 |
113 | def _decorator(func: Callable[..., Any]) -> Callable[..., Any]:
114 | cls.after_handler.append(ParsingItem(func, rex, priority, block))
115 | cls.after_handler = _sort(cls.after_handler)
116 | return func
117 |
118 | return _decorator
119 |
120 |
121 | # 对处理器进行过滤
122 | def _handler_filter(_handler_list: List[ParsingItem], _url: str) -> List[ParsingItem]:
123 | _result = [h for h in _handler_list if re.search(h.rex, _url)]
124 | # 删除优先级相同时默认的处理器
125 | _delete = [
126 | (h.func.__name__, "(.*)", h.priority) for h in _result if h.rex != "(.*)"
127 | ]
128 | _result = [
129 | h for h in _result if (h.func.__name__, h.rex, h.priority) not in _delete
130 | ]
131 | return _result
132 |
133 |
134 | async def _run_handlers(
135 | handlers: List[ParsingItem],
136 | rss: Rss,
137 | state: Dict[str, Any],
138 | item: Optional[Dict[str, Any]] = None,
139 | item_msg: Optional[str] = None,
140 | tmp: Optional[str] = None,
141 | tmp_state: Optional[Dict[str, Any]] = None,
142 | ) -> Tuple[Dict[str, Any], str]:
143 | for handler in handlers:
144 | kwargs = {
145 | "rss": rss,
146 | "state": state,
147 | "item": item,
148 | "item_msg": item_msg,
149 | "tmp": tmp,
150 | "tmp_state": tmp_state,
151 | }
152 | handler_params = signature(handler.func).parameters
153 | handler_kwargs = {k: v for k, v in kwargs.items() if k in handler_params}
154 |
155 | if any((item, item_msg, tmp, tmp_state)):
156 | tmp = await handler.func(**handler_kwargs)
157 | else:
158 | state.update(await handler.func(**handler_kwargs))
159 | if handler.block or (tmp_state is not None and not tmp_state["continue"]):
160 | break
161 | return state, tmp or ""
162 |
163 |
164 | # 解析实例
165 | class ParsingRss:
166 | # 初始化解析实例
167 | def __init__(self, rss: Rss):
168 | self.state: Dict[str, Any] = {} # 用于存储实例处理中上下文数据
169 | self.rss: Rss = rss
170 |
171 | # 对处理器进行过滤
172 | self.before_handler: List[ParsingItem] = _handler_filter(
173 | ParsingBase.before_handler, self.rss.get_url()
174 | )
175 | self.handler: Dict[str, List[ParsingItem]] = {}
176 | for k, v in ParsingBase.handler.items():
177 | self.handler[k] = _handler_filter(v, self.rss.get_url())
178 | self.after_handler: List[ParsingItem] = _handler_filter(
179 | ParsingBase.after_handler, self.rss.get_url()
180 | )
181 |
182 | # 开始解析
183 | async def start(self, rss_name: str, new_rss: Dict[str, Any]) -> None:
184 | # new_data 是完整的 rss 解析后的 dict
185 | # 前置处理
186 | rss_title = new_rss["feed"]["title"]
187 | new_data = new_rss["entries"]
188 | _file = DATA_PATH / f"{Rss.handle_name(rss_name)}.json"
189 | db = TinyDB(
190 | _file,
191 | encoding="utf-8",
192 | sort_keys=True,
193 | indent=4,
194 | ensure_ascii=False,
195 | )
196 | self.state.update(
197 | {
198 | "rss_title": rss_title,
199 | "new_data": new_data,
200 | "change_data": [], # 更新的消息列表
201 | "conn": None, # 数据库连接
202 | "tinydb": db, # 缓存 json
203 | "error_count": 0, # 消息发送失败计数
204 | }
205 | )
206 | self.state, _ = await _run_handlers(self.before_handler, self.rss, self.state)
207 |
208 | # 分条处理
209 | self.state.update(
210 | {
211 | "header_message": f"【{rss_title}】更新了!",
212 | "messages": [],
213 | "items": [],
214 | }
215 | )
216 | if change_data := self.state["change_data"]:
217 | for parted_item_list in partition_list(change_data, 10):
218 | for item in parted_item_list:
219 | item_msg = ""
220 | for handler_list in self.handler.values():
221 | # 用于保存上一次处理结果
222 | tmp = ""
223 | tmp_state = {"continue": True} # 是否继续执行后续处理
224 |
225 | # 某一个内容的处理如正文,传入原文与上一次处理结果,此次处理完后覆盖
226 | _, tmp = await _run_handlers(
227 | handler_list,
228 | self.rss,
229 | self.state,
230 | item=item,
231 | item_msg=item_msg,
232 | tmp=tmp,
233 | tmp_state=tmp_state,
234 | )
235 | item_msg += tmp
236 | self.state["messages"].append(item_msg)
237 | self.state["items"].append(item)
238 |
239 | _, _ = await _run_handlers(self.after_handler, self.rss, self.state)
240 | self.state["messages"] = self.state["items"] = []
241 | else:
242 | _, _ = await _run_handlers(self.after_handler, self.rss, self.state)
243 |
--------------------------------------------------------------------------------
/src/plugins/ELF_RSS2/utils.py:
--------------------------------------------------------------------------------
1 | import base64
2 | import functools
3 | import math
4 | import re
5 | from contextlib import suppress
6 | from typing import Any, Dict, Generator, List, Mapping, Optional
7 |
8 | from aiohttp import ClientSession
9 | from cachetools import TTLCache
10 | from cachetools.keys import hashkey
11 | from nonebot import get_bot as nonebot_get_bot
12 | from nonebot.adapters.onebot.v11 import Bot
13 | from nonebot.log import logger
14 |
15 | from .config import config
16 | from .parsing.utils import get_proxy
17 |
18 | bot_offline = False
19 |
20 |
21 | def get_http_caching_headers(
22 | headers: Optional[Mapping[str, Any]],
23 | ) -> Dict[str, Optional[str]]:
24 | return (
25 | {
26 | "Last-Modified": headers.get("Last-Modified") or headers.get("Date"),
27 | "ETag": headers.get("ETag"),
28 | }
29 | if headers
30 | else {"Last-Modified": None, "ETag": None}
31 | )
32 |
33 |
34 | def convert_size(size_bytes: int) -> str:
35 | if size_bytes == 0:
36 | return "0 B"
37 | size_name = ("B", "KB", "MB", "GB", "TB")
38 | i = int(math.floor(math.log(size_bytes, 1024)))
39 | p = math.pow(1024, i)
40 | s = round(size_bytes / p, 2)
41 | return f"{s} {size_name[i]}"
42 |
43 |
44 | def cached_async(cache, key=hashkey): # type: ignore
45 | """
46 | https://github.com/tkem/cachetools/commit/3f073633ed4f36f05b57838a3e5655e14d3e3524
47 | """
48 |
49 | def decorator(func): # type: ignore
50 | if cache is None:
51 |
52 | async def wrapper(*args, **kwargs): # type: ignore
53 | return await func(*args, **kwargs)
54 |
55 | else:
56 |
57 | async def wrapper(*args, **kwargs): # type: ignore
58 | k = key(*args, **kwargs)
59 | with suppress(KeyError): # key not found
60 | return cache[k]
61 | v = await func(*args, **kwargs)
62 | with suppress(ValueError): # value too large
63 | cache[k] = v
64 | return v
65 |
66 | return functools.update_wrapper(wrapper, func)
67 |
68 | return decorator
69 |
70 |
71 | @cached_async(TTLCache(maxsize=1, ttl=300)) # type: ignore
72 | async def get_bot_friend_list(bot: Bot) -> List[int]:
73 | friend_list = await bot.get_friend_list()
74 | return [i["user_id"] for i in friend_list]
75 |
76 |
77 | @cached_async(TTLCache(maxsize=1, ttl=300)) # type: ignore
78 | async def get_bot_group_list(bot: Bot) -> List[int]:
79 | group_list = await bot.get_group_list()
80 | return [i["group_id"] for i in group_list]
81 |
82 |
83 | @cached_async(TTLCache(maxsize=1, ttl=300)) # type: ignore
84 | async def get_bot_guild_channel_list(
85 | bot: Bot, guild_id: Optional[str] = None
86 | ) -> List[str]:
87 | guild_list = await bot.get_guild_list()
88 | if guild_id is None:
89 | return [i["guild_id"] for i in guild_list]
90 | elif guild_id in [i["guild_id"] for i in guild_list]:
91 | channel_list = await bot.get_guild_channel_list(guild_id=guild_id)
92 | return [i["channel_id"] for i in channel_list]
93 | return []
94 |
95 |
96 | def get_torrent_b16_hash(content: bytes) -> str:
97 | import magneturi
98 |
99 | # mangetlink = magneturi.from_torrent_file(torrentname)
100 | manget_link = magneturi.from_torrent_data(content)
101 | # print(mangetlink)
102 | ch = ""
103 | n = 20
104 | b32_hash = n * ch + manget_link[20:52]
105 | # print(b32Hash)
106 | b16_hash = base64.b16encode(base64.b32decode(b32_hash))
107 | b16_hash = b16_hash.lower()
108 | # print("40位info hash值:" + '\n' + b16Hash)
109 | # print("磁力链:" + '\n' + "magnet:?xt=urn:btih:" + b16Hash)
110 | return str(b16_hash, "utf-8")
111 |
112 |
113 | async def send_message_to_admin(message: str, bot: Optional[Bot] = None) -> None:
114 | if bot is None:
115 | bot = await get_bot()
116 | if bot is None:
117 | return
118 | try:
119 | await bot.send_private_msg(
120 | user_id=int(list(config.superusers)[0]), message=message
121 | )
122 | except Exception as e:
123 | logger.error(f"管理员消息推送失败:{e}")
124 | logger.error(f"消息内容:{message}")
125 |
126 |
127 | async def send_msg(
128 | msg: str,
129 | user_ids: Optional[List[str]] = None,
130 | group_ids: Optional[List[str]] = None,
131 | ) -> List[Dict[str, Any]]:
132 | """
133 | msg: str
134 | user: List[str]
135 | group: List[str]
136 |
137 | 发送消息到私聊或群聊
138 | """
139 | bot: Bot = await get_bot() # type: ignore
140 | if bot is None:
141 | raise ValueError("There are not bots to get.")
142 | msg_id = []
143 | if group_ids:
144 | for group_id in group_ids:
145 | msg_id.append(await bot.send_group_msg(group_id=int(group_id), message=msg))
146 | if user_ids:
147 | for user_id in user_ids:
148 | msg_id.append(await bot.send_private_msg(user_id=int(user_id), message=msg))
149 | return msg_id
150 |
151 |
152 | # 校验正则表达式合法性
153 | def regex_validate(regex: str) -> bool:
154 | try:
155 | re.compile(regex)
156 | return True
157 | except re.error:
158 | return False
159 |
160 |
161 | # 过滤合法好友
162 | async def filter_valid_user_id_list(bot: Bot, user_id_list: List[str]) -> List[str]:
163 | friend_list = await get_bot_friend_list(bot)
164 | valid_user_id_list = [
165 | user_id for user_id in user_id_list if int(user_id) in friend_list
166 | ]
167 | if invalid_user_id_list := [
168 | user_id for user_id in user_id_list if user_id not in valid_user_id_list
169 | ]:
170 | logger.warning(
171 | f"QQ号[{','.join(invalid_user_id_list)}]不是Bot[{bot.self_id}]的好友"
172 | )
173 | return valid_user_id_list
174 |
175 |
176 | # 过滤合法群组
177 | async def filter_valid_group_id_list(bot: Bot, group_id_list: List[str]) -> List[str]:
178 | group_list = await get_bot_group_list(bot)
179 | valid_group_id_list = [
180 | group_id for group_id in group_id_list if int(group_id) in group_list
181 | ]
182 | if invalid_group_id_list := [
183 | group_id for group_id in group_id_list if group_id not in valid_group_id_list
184 | ]:
185 | logger.warning(
186 | f"Bot[{bot.self_id}]未加入群组[{','.join(invalid_group_id_list)}]"
187 | )
188 | return valid_group_id_list
189 |
190 |
191 | # 过滤合法频道
192 | async def filter_valid_guild_channel_id_list(
193 | bot: Bot, guild_channel_id_list: List[str]
194 | ) -> List[str]:
195 | valid_guild_channel_id_list = []
196 | for guild_channel_id in guild_channel_id_list:
197 | guild_id, channel_id = guild_channel_id.split("@")
198 | guild_list = await get_bot_guild_channel_list(bot)
199 | if guild_id not in guild_list:
200 | guild_name = (await bot.get_guild_meta_by_guest(guild_id=guild_id))[
201 | "guild_name"
202 | ]
203 | logger.warning(f"Bot[{bot.self_id}]未加入频道 {guild_name}[{guild_id}]")
204 | continue
205 |
206 | channel_list = await get_bot_guild_channel_list(bot, guild_id=guild_id)
207 | if channel_id not in channel_list:
208 | guild_name = (await bot.get_guild_meta_by_guest(guild_id=guild_id))[
209 | "guild_name"
210 | ]
211 | logger.warning(
212 | f"Bot[{bot.self_id}]未加入频道 {guild_name}[{guild_id}]的子频道[{channel_id}]"
213 | )
214 | continue
215 | valid_guild_channel_id_list.append(guild_channel_id)
216 | return valid_guild_channel_id_list
217 |
218 |
219 | def partition_list(
220 | input_list: List[Any], partition_size: int
221 | ) -> Generator[List[Any], None, None]:
222 | for i in range(0, len(input_list), partition_size):
223 | yield input_list[i : i + partition_size]
224 |
225 |
226 | async def send_message_to_telegram_admin(message: str) -> None:
227 | try:
228 | async with ClientSession(raise_for_status=True) as session:
229 | await session.post(
230 | f"https://api.telegram.org/bot{config.telegram_bot_token}/sendMessage",
231 | json={
232 | "chat_id": config.telegram_admin_ids[0],
233 | "text": message,
234 | },
235 | proxy=get_proxy(),
236 | )
237 | except Exception as e:
238 | logger.error(f"发送到 Telegram 失败:\n {e}")
239 |
240 |
241 | async def get_bot() -> Optional[Bot]:
242 | global bot_offline
243 | bot: Optional[Bot] = None
244 | try:
245 | bot = nonebot_get_bot() # type: ignore
246 | bot_offline = False
247 | except ValueError:
248 | if not bot_offline and config.telegram_admin_ids and config.telegram_bot_token:
249 | await send_message_to_telegram_admin("QQ Bot 已离线!")
250 | logger.warning("Bot 已离线!")
251 | bot_offline = True
252 | return bot
253 |
--------------------------------------------------------------------------------
/src/plugins/ELF_RSS2/qbittorrent_download.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import base64
3 | import re
4 | from pathlib import Path
5 | from typing import Any, Dict, List, Optional
6 |
7 | import aiohttp
8 | import arrow
9 | from apscheduler.triggers.interval import IntervalTrigger
10 | from nonebot.adapters.onebot.v11 import ActionFailed, Bot, NetworkError
11 | from nonebot.log import logger
12 | from nonebot_plugin_apscheduler import scheduler
13 | from qbittorrent import Client
14 |
15 | from .config import config
16 | from .utils import (
17 | convert_size,
18 | get_bot,
19 | get_bot_group_list,
20 | get_torrent_b16_hash,
21 | send_message_to_admin,
22 | )
23 |
24 | # 计划
25 | # 创建一个全局定时器用来检测种子下载情况
26 | # 群文件上传成功回调
27 | # 文件三种状态1.下载中2。上传中3.上传完成
28 | # 文件信息持久化存储
29 | # 关键词正则表达式
30 | # 下载开关
31 |
32 | DOWN_STATUS_DOWNING = 1 # 下载中
33 | DOWN_STATUS_UPLOADING = 2 # 上传中
34 | DOWN_STATUS_UPLOAD_OK = 3 # 上传完成
35 | down_info: Dict[str, Dict[str, Any]] = {}
36 |
37 | # 示例
38 | # {
39 | # "hash值": {
40 | # "status":DOWN_STATUS_DOWNING,
41 | # "start_time":None, # 下载开始时间
42 | # "downing_tips_msg_id":[] # 下载中通知群上一条通知的信息,用于撤回,防止刷屏
43 | # }
44 | # }
45 |
46 |
47 | # 发送通知
48 | async def send_msg(
49 | bot: Bot, msg: str, notice_group: Optional[List[str]] = None
50 | ) -> List[Dict[str, Any]]:
51 | logger.info(msg)
52 | msg_id = []
53 | group_list = await get_bot_group_list(bot)
54 | if down_status_msg_group := (notice_group or config.down_status_msg_group):
55 | for group_id in down_status_msg_group:
56 | if int(group_id) not in group_list:
57 | logger.error(f"Bot[{bot.self_id}]未加入群组[{group_id}]")
58 | continue
59 | msg_id.append(await bot.send_group_msg(group_id=int(group_id), message=msg))
60 | return msg_id
61 |
62 |
63 | async def get_qb_client() -> Optional[Client]:
64 | try:
65 | qb = Client(config.qb_web_url)
66 | if config.qb_username and config.qb_password:
67 | qb.login(config.qb_username, config.qb_password)
68 | else:
69 | qb.login()
70 | except Exception:
71 | msg = (
72 | "❌ 无法连接到 qbittorrent ,请检查:\n"
73 | "1. 是否启动程序\n"
74 | "2. 是否勾选了“Web用户界面(远程控制)”\n"
75 | "3. 连接地址、端口是否正确"
76 | )
77 | logger.exception(msg)
78 | await send_message_to_admin(msg)
79 | return None
80 | try:
81 | qb.get_default_save_path()
82 | except Exception:
83 | msg = "❌ 无法连登录到 qbittorrent ,请检查相关配置是否正确"
84 | logger.exception(msg)
85 | await send_message_to_admin(msg)
86 | return None
87 | return qb
88 |
89 |
90 | async def get_torrent_info_from_hash(
91 | bot: Bot, qb: Client, url: str, proxy: Optional[str]
92 | ) -> Dict[str, str]:
93 | info = None
94 | if re.search(r"magnet:\?xt=urn:btih:", url):
95 | qb.download_from_link(link=url)
96 | if _hash_str := re.search(r"[A-F\d]{40}", url, flags=re.I):
97 | hash_str = _hash_str[0].lower()
98 | else:
99 | hash_str = (
100 | base64.b16encode(
101 | base64.b32decode(re.search(r"[2-7A-Z]{32}", url, flags=re.I)[0]) # type: ignore
102 | )
103 | .decode("utf-8")
104 | .lower()
105 | )
106 |
107 | else:
108 | async with aiohttp.ClientSession(
109 | timeout=aiohttp.ClientTimeout(total=100)
110 | ) as session:
111 | try:
112 | resp = await session.get(url, proxy=proxy)
113 | content = await resp.read()
114 | qb.download_from_file(content)
115 | hash_str = get_torrent_b16_hash(content)
116 | except Exception as e:
117 | await send_msg(bot, f"下载种子失败,可能需要代理\n{e}")
118 | return {}
119 |
120 | while not info:
121 | for tmp_torrent in qb.torrents():
122 | if tmp_torrent["hash"] == hash_str and tmp_torrent["size"]:
123 | info = {
124 | "hash": tmp_torrent["hash"],
125 | "filename": tmp_torrent["name"],
126 | "size": convert_size(tmp_torrent["size"]),
127 | }
128 | await asyncio.sleep(1)
129 | return info
130 |
131 |
132 | # 种子地址,种子下载路径,群文件上传 群列表,订阅名称
133 | async def start_down(
134 | bot: Bot, url: str, group_ids: List[str], name: str, proxy: Optional[str]
135 | ) -> str:
136 | qb = await get_qb_client()
137 | if not qb:
138 | return ""
139 | # 获取种子 hash
140 | info = await get_torrent_info_from_hash(bot=bot, qb=qb, url=url, proxy=proxy)
141 | await rss_trigger(
142 | bot,
143 | hash_str=info["hash"],
144 | group_ids=group_ids,
145 | name=f"订阅:{name}\n{info['filename']}\n文件大小:{info['size']}",
146 | )
147 | down_info[info["hash"]] = {
148 | "status": DOWN_STATUS_DOWNING,
149 | "start_time": arrow.now(), # 下载开始时间
150 | "downing_tips_msg_id": [], # 下载中通知群上一条通知的信息,用于撤回,防止刷屏
151 | }
152 | return info["hash"]
153 |
154 |
155 | async def update_down_status_message(
156 | bot: Bot, hash_str: str, info: Dict[str, Any], name: str
157 | ) -> List[Dict[str, Any]]:
158 | return await send_msg(
159 | bot,
160 | f"{name}\n"
161 | f"Hash:{hash_str}\n"
162 | f"下载了 {round(info['total_downloaded'] / info['total_size'] * 100, 2)}%\n"
163 | f"平均下载速度: {round(info['dl_speed_avg'] / 1024, 2)} KB/s",
164 | )
165 |
166 |
167 | async def upload_files_to_groups(
168 | bot: Bot,
169 | group_ids: List[str],
170 | info: Dict[str, Any],
171 | files: List[Dict[str, Any]],
172 | name: str,
173 | hash_str: str,
174 | ) -> None:
175 | for group_id in group_ids:
176 | for tmp in files:
177 | path = Path(info.get("save_path", "")) / tmp["name"]
178 | if config.qb_down_path:
179 | if (_path := Path(config.qb_down_path)).is_dir():
180 | path = _path / tmp["name"]
181 |
182 | await send_msg(bot, f"{name}\nHash:{hash_str}\n开始上传到群:{group_id}")
183 |
184 | try:
185 | await bot.call_api(
186 | "upload_group_file",
187 | group_id=group_id,
188 | file=str(path),
189 | name=tmp["name"],
190 | )
191 | except ActionFailed:
192 | msg = (
193 | f"{name}\nHash:{hash_str}\n上传到群:{group_id}失败!请手动上传!"
194 | )
195 | await send_msg(bot, msg, [group_id])
196 | logger.exception(msg)
197 | except (NetworkError, TimeoutError) as e:
198 | logger.warning(e)
199 |
200 |
201 | # 检查下载状态
202 | async def check_down_status(
203 | bot: Bot, hash_str: str, group_ids: List[str], name: str
204 | ) -> None:
205 | qb = await get_qb_client()
206 | if not qb:
207 | return
208 | # 防止中途删掉任务,无限执行
209 | try:
210 | info = qb.get_torrent(hash_str)
211 | files = qb.get_torrent_files(hash_str)
212 | except Exception as e:
213 | logger.exception(e)
214 | scheduler.remove_job(hash_str)
215 | return
216 |
217 | if info["total_downloaded"] - info["total_size"] >= 0.000000:
218 | all_time = arrow.now() - down_info[hash_str]["start_time"]
219 | await send_msg(
220 | bot,
221 | f"👏 {name}\n"
222 | f"Hash:{hash_str}\n"
223 | f"下载完成!耗时:{str(all_time).split('.', 2)[0]}",
224 | )
225 | down_info[hash_str]["status"] = DOWN_STATUS_UPLOADING
226 |
227 | await upload_files_to_groups(bot, group_ids, info, files, name, hash_str)
228 |
229 | scheduler.remove_job(hash_str)
230 | down_info[hash_str]["status"] = DOWN_STATUS_UPLOAD_OK
231 | else:
232 | await delete_msg(bot, down_info[hash_str]["downing_tips_msg_id"])
233 | msg_id = await update_down_status_message(bot, hash_str, info, name)
234 | down_info[hash_str]["downing_tips_msg_id"] = msg_id
235 |
236 |
237 | # 撤回消息
238 | async def delete_msg(bot: Bot, msg_ids: List[Dict[str, Any]]) -> None:
239 | for msg_id in msg_ids:
240 | await bot.delete_msg(message_id=msg_id["message_id"])
241 |
242 |
243 | async def rss_trigger(bot: Bot, hash_str: str, group_ids: List[str], name: str) -> None:
244 | # 制作一个频率为“ n 秒 / 次”的触发器
245 | trigger = IntervalTrigger(seconds=int(config.down_status_msg_date), jitter=10)
246 | job_defaults = {"max_instances": 1}
247 | # 添加任务
248 | scheduler.add_job(
249 | func=check_down_status, # 要添加任务的函数,不要带参数
250 | trigger=trigger, # 触发器
251 | args=(
252 | bot,
253 | hash_str,
254 | group_ids,
255 | name,
256 | ), # 函数的参数列表,注意:只有一个值时,不能省略末尾的逗号
257 | id=hash_str,
258 | misfire_grace_time=60, # 允许的误差时间,建议不要省略
259 | job_defaults=job_defaults,
260 | )
261 | bot: Bot = await get_bot() # type: ignore
262 | if bot is None:
263 | return
264 | await send_msg(bot, f"👏 {name}\nHash:{hash_str}\n下载任务添加成功!", group_ids)
265 |
--------------------------------------------------------------------------------
/src/plugins/ELF_RSS2/parsing/send_message.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | from collections import defaultdict
3 | from contextlib import suppress
4 | from typing import Any, Callable, Coroutine, DefaultDict, Dict, List, Tuple, Union
5 |
6 | import arrow
7 | from nonebot.adapters.onebot.v11 import Bot, Message, MessageSegment
8 | from nonebot.adapters.onebot.v11.exception import NetworkError
9 | from nonebot.log import logger
10 |
11 | from ..config import config
12 | from ..rss_class import Rss
13 | from ..utils import get_bot
14 | from .cache_manage import insert_into_cache_db, write_item
15 |
16 | sending_lock: DefaultDict[Tuple[Union[int, str], str], asyncio.Lock] = defaultdict(
17 | asyncio.Lock
18 | )
19 |
20 |
21 | # 发送消息
22 | async def send_msg(
23 | rss: Rss, messages: List[str], items: List[Dict[str, Any]], header_message: str
24 | ) -> bool:
25 | bot: Bot = await get_bot() # type: ignore
26 | if bot is None:
27 | return False
28 | if not messages:
29 | return False
30 | flag = False
31 | if rss.user_id:
32 | flag = any(
33 | await asyncio.gather(
34 | *[
35 | send_private_msg(
36 | bot,
37 | messages,
38 | int(user_id),
39 | items,
40 | header_message,
41 | rss.send_forward_msg,
42 | )
43 | for user_id in rss.user_id
44 | ]
45 | )
46 | )
47 | if rss.group_id:
48 | flag = (
49 | any(
50 | await asyncio.gather(
51 | *[
52 | send_group_msg(
53 | bot,
54 | messages,
55 | int(group_id),
56 | items,
57 | header_message,
58 | rss.send_forward_msg,
59 | )
60 | for group_id in rss.group_id
61 | ]
62 | )
63 | )
64 | or flag
65 | )
66 | if rss.guild_channel_id:
67 | flag = (
68 | any(
69 | await asyncio.gather(
70 | *[
71 | send_guild_channel_msg(
72 | bot, messages, guild_channel_id, items, header_message
73 | )
74 | for guild_channel_id in rss.guild_channel_id
75 | ]
76 | )
77 | )
78 | or flag
79 | )
80 | return flag
81 |
82 |
83 | # 发送私聊消息
84 | async def send_private_msg(
85 | bot: Bot,
86 | message: List[str],
87 | user_id: int,
88 | items: List[Dict[str, Any]],
89 | header_message: str,
90 | send_forward_msg: bool,
91 | ) -> bool:
92 | return await send_msgs_with_lock(
93 | bot=bot,
94 | messages=message,
95 | target_id=user_id,
96 | target_type="private",
97 | items=items,
98 | header_message=header_message,
99 | send_func=lambda user_id, message: bot.send_private_msg(
100 | user_id=user_id, message=message # type: ignore
101 | ),
102 | send_forward_msg=send_forward_msg,
103 | )
104 |
105 |
106 | # 发送群聊消息
107 | async def send_group_msg(
108 | bot: Bot,
109 | message: List[str],
110 | group_id: int,
111 | items: List[Dict[str, Any]],
112 | header_message: str,
113 | send_forward_msg: bool,
114 | ) -> bool:
115 | return await send_msgs_with_lock(
116 | bot=bot,
117 | messages=message,
118 | target_id=group_id,
119 | target_type="group",
120 | items=items,
121 | header_message=header_message,
122 | send_func=lambda group_id, message: bot.send_group_msg(
123 | group_id=group_id, message=message # type: ignore
124 | ),
125 | send_forward_msg=send_forward_msg,
126 | )
127 |
128 |
129 | # 发送频道消息
130 | async def send_guild_channel_msg(
131 | bot: Bot,
132 | message: List[str],
133 | guild_channel_id: str,
134 | items: List[Dict[str, Any]],
135 | header_message: str,
136 | ) -> bool:
137 | guild_id, channel_id = guild_channel_id.split("@")
138 | return await send_msgs_with_lock(
139 | bot=bot,
140 | messages=message,
141 | target_id=guild_channel_id,
142 | target_type="guild_channel",
143 | items=items,
144 | header_message=header_message,
145 | send_func=lambda guild_channel_id, message: bot.send_guild_channel_msg(
146 | message=message, guild_id=guild_id, channel_id=channel_id
147 | ),
148 | )
149 |
150 |
151 | async def send_single_msg(
152 | message: str,
153 | target_id: Union[int, str],
154 | item: Dict[str, Any],
155 | header_message: str,
156 | send_func: Callable[[Union[int, str], str], Coroutine[Any, Any, Dict[str, Any]]],
157 | ) -> bool:
158 | flag = False
159 | try:
160 | await send_func(
161 | target_id, f"{header_message}\n----------------------\n{message}"
162 | )
163 | flag = True
164 | except Exception as e:
165 | error_msg = f"E: {repr(e)}\n消息发送失败!\n链接:[{item.get('link')}]"
166 | logger.error(error_msg)
167 | if item.get("to_send"):
168 | flag = True
169 | with suppress(Exception):
170 | await send_func(target_id, error_msg)
171 | return flag
172 |
173 |
174 | async def send_multiple_msgs(
175 | messages: List[str],
176 | target_id: Union[int, str],
177 | items: List[Dict[str, Any]],
178 | header_message: str,
179 | send_func: Callable[[Union[int, str], str], Coroutine[Any, Any, Dict[str, Any]]],
180 | ) -> bool:
181 | flag = False
182 | for message, item in zip(messages, items):
183 | flag = (
184 | await send_single_msg(message, target_id, item, header_message, send_func)
185 | or flag
186 | )
187 | return flag
188 |
189 |
190 | async def send_msgs_with_lock(
191 | bot: Bot,
192 | messages: List[str],
193 | target_id: Union[int, str],
194 | target_type: str,
195 | items: List[Dict[str, Any]],
196 | header_message: str,
197 | send_func: Callable[[Union[int, str], str], Coroutine[Any, Any, Dict[str, Any]]],
198 | send_forward_msg: bool = False,
199 | ) -> bool:
200 | start_time = arrow.now()
201 | async with sending_lock[(target_id, target_type)]:
202 | if len(messages) == 1:
203 | flag = await send_single_msg(
204 | messages[0], target_id, items[0], header_message, send_func
205 | )
206 | elif send_forward_msg and target_type != "guild_channel":
207 | flag = await try_sending_forward_msg(
208 | bot, messages, target_id, target_type, items, header_message, send_func
209 | )
210 | else:
211 | flag = await send_multiple_msgs(
212 | messages, target_id, items, header_message, send_func
213 | )
214 | await asyncio.sleep(max(1 - (arrow.now() - start_time).total_seconds(), 0))
215 | return flag
216 |
217 |
218 | async def try_sending_forward_msg(
219 | bot: Bot,
220 | messages: List[str],
221 | target_id: Union[int, str],
222 | target_type: str,
223 | items: List[Dict[str, Any]],
224 | header_message: str,
225 | send_func: Callable[[Union[int, str], str], Coroutine[Any, Any, Dict[str, Any]]],
226 | ) -> bool:
227 | forward_messages = handle_forward_message(bot, [header_message] + messages)
228 | try:
229 | if target_type == "private":
230 | await bot.send_private_forward_msg(
231 | user_id=target_id, messages=forward_messages
232 | )
233 | elif target_type == "group":
234 | await bot.send_group_forward_msg(
235 | group_id=target_id, messages=forward_messages
236 | )
237 | flag = True
238 | except NetworkError:
239 | # 如果图片体积过大或数量过多,很可能触发这个错误,但实际上发送成功,不过高概率吞图,只警告不处理
240 | logger.warning("图片过大或数量过多,可能发送失败!")
241 | flag = True
242 | except Exception as e:
243 | logger.warning(f"E: {repr(e)}\n合并消息发送失败!将尝试逐条发送!")
244 | flag = await send_multiple_msgs(
245 | messages, target_id, items, header_message, send_func
246 | )
247 | return flag
248 |
249 |
250 | def handle_forward_message(bot: Bot, messages: List[str]) -> Message:
251 | return Message(
252 | [
253 | MessageSegment.node_custom(
254 | user_id=int(bot.self_id),
255 | nickname=list(config.nickname)[0] if config.nickname else "\u200b",
256 | content=message,
257 | )
258 | for message in messages
259 | ]
260 | )
261 |
262 |
263 | # 发送消息并写入文件
264 | async def handle_send_msgs(
265 | rss: Rss, messages: List[str], items: List[Dict[str, Any]], state: Dict[str, Any]
266 | ) -> None:
267 | db = state["tinydb"]
268 | header_message = state["header_message"]
269 |
270 | if await send_msg(rss, messages, items, header_message):
271 | if rss.duplicate_filter_mode:
272 | for item in items:
273 | insert_into_cache_db(
274 | conn=state["conn"], item=item, image_hash=item["image_hash"]
275 | )
276 |
277 | for item in items:
278 | if item.get("to_send"):
279 | item.pop("to_send")
280 |
281 | else:
282 | for item in items:
283 | item["to_send"] = True
284 |
285 | state["error_count"] += len(messages)
286 |
287 | for item in items:
288 | write_item(db, item)
289 |
--------------------------------------------------------------------------------
/src/plugins/ELF_RSS2/rss_class.py:
--------------------------------------------------------------------------------
1 | import re
2 | from copy import deepcopy
3 | from typing import Any, Dict, List, Optional
4 |
5 | from tinydb import Query, TinyDB
6 | from tinydb.operations import set as tinydb_set
7 | from yarl import URL
8 |
9 | from .config import DATA_PATH, JSON_PATH, config
10 |
11 |
12 | class Rss:
13 | def __init__(self, data: Optional[Dict[str, Any]] = None):
14 | self.name: str = "" # 订阅名
15 | self.url: str = "" # 订阅地址
16 | self.user_id: List[str] = [] # 订阅用户(qq)
17 | self.group_id: List[str] = [] # 订阅群组
18 | self.guild_channel_id: List[str] = [] # 订阅子频道
19 | self.img_proxy: bool = False
20 | self.time: str = "5" # 更新频率 分钟/次
21 | self.translation: bool = False # 翻译
22 | self.only_title: bool = False # 仅标题
23 | self.only_pic: bool = False # 仅图片
24 | self.only_has_pic: bool = False # 仅含有图片
25 | self.download_pic: bool = False # 是否要下载图片
26 | self.cookies: str = ""
27 | self.down_torrent: bool = False # 是否下载种子
28 | self.down_torrent_keyword: str = "" # 过滤关键字,支持正则
29 | self.black_keyword: str = "" # 黑名单关键词
30 | self.is_open_upload_group: bool = True # 默认开启上传到群
31 | self.duplicate_filter_mode: List[str] = [] # 去重模式
32 | self.max_image_number: int = 0 # 图片数量限制,防止消息太长刷屏
33 | self.content_to_remove: Optional[str] = None # 正文待移除内容,支持正则
34 | self.etag: Optional[str] = None
35 | self.last_modified: Optional[str] = None # 上次更新时间
36 | self.error_count: int = 0 # 连续抓取失败的次数,超过 100 就停止更新
37 | self.stop: bool = False # 停止更新
38 | self.pikpak_offline: bool = False # 是否PikPak离线
39 | self.pikpak_path_key: str = (
40 | "" # PikPak 离线下载路径匹配正则表达式,用于自动归档文件 例如 r"(?:\[.*?\][\s\S])([\s\S]*)[\s\S]-"
41 | )
42 | self.send_forward_msg: bool = (
43 | False # 当一次更新多条消息时,是否尝试发送合并消息
44 | )
45 | if data:
46 | self.__dict__.update(data)
47 |
48 | # 返回订阅链接
49 | def get_url(self, rsshub: str = str(config.rsshub)) -> str:
50 | if URL(self.url).scheme in ["http", "https"]:
51 | return self.url
52 | # 去除 rsshub地址末尾的斜杠 和 订阅地址开头的斜杠
53 | return f"{rsshub.rstrip('/')}/{self.url.lstrip('/')}"
54 |
55 | # 读取记录
56 | @staticmethod
57 | def read_rss() -> List["Rss"]:
58 | # 如果文件不存在
59 | if not JSON_PATH.exists():
60 | return []
61 | with TinyDB(
62 | JSON_PATH,
63 | encoding="utf-8",
64 | sort_keys=True,
65 | indent=4,
66 | ensure_ascii=False,
67 | ) as db:
68 | rss_list = [Rss(rss) for rss in db.all()]
69 | return rss_list
70 |
71 | # 过滤订阅名中的特殊字符
72 | @staticmethod
73 | def handle_name(name: str) -> str:
74 | name = re.sub(r'[?*:"<>\\/|]', "_", name)
75 | if name == "rss":
76 | name = "rss_"
77 | return name
78 |
79 | # 查找是否存在当前订阅名 rss 要转换为 rss_
80 | @staticmethod
81 | def get_one_by_name(name: str) -> Optional["Rss"]:
82 | feed_list = Rss.read_rss()
83 | return next((feed for feed in feed_list if feed.name == name), None)
84 |
85 | # 添加订阅
86 | def add_user_or_group_or_channel(
87 | self,
88 | user: Optional[str] = None,
89 | group: Optional[str] = None,
90 | guild_channel: Optional[str] = None,
91 | ) -> None:
92 | if user:
93 | if user in self.user_id:
94 | return
95 | self.user_id.append(user)
96 | elif group:
97 | if group in self.group_id:
98 | return
99 | self.group_id.append(group)
100 | elif guild_channel:
101 | if guild_channel in self.guild_channel_id:
102 | return
103 | self.guild_channel_id.append(guild_channel)
104 | self.upsert()
105 |
106 | # 删除订阅 群组
107 | def delete_group(self, group: str) -> bool:
108 | if group not in self.group_id:
109 | return False
110 | self.group_id.remove(group)
111 | with TinyDB(
112 | JSON_PATH,
113 | encoding="utf-8",
114 | sort_keys=True,
115 | indent=4,
116 | ensure_ascii=False,
117 | ) as db:
118 | db.update(tinydb_set("group_id", self.group_id), Query().name == self.name) # type: ignore
119 | return True
120 |
121 | # 删除订阅 子频道
122 | def delete_guild_channel(self, guild_channel: str) -> bool:
123 | if guild_channel not in self.guild_channel_id:
124 | return False
125 | self.guild_channel_id.remove(guild_channel)
126 | with TinyDB(
127 | JSON_PATH,
128 | encoding="utf-8",
129 | sort_keys=True,
130 | indent=4,
131 | ensure_ascii=False,
132 | ) as db:
133 | db.update(
134 | tinydb_set("guild_channel_id", self.guild_channel_id), Query().name == self.name # type: ignore
135 | )
136 | return True
137 |
138 | # 删除整个订阅
139 | def delete_rss(self) -> None:
140 | with TinyDB(
141 | JSON_PATH,
142 | encoding="utf-8",
143 | sort_keys=True,
144 | indent=4,
145 | ensure_ascii=False,
146 | ) as db:
147 | db.remove(Query().name == self.name)
148 | self.delete_file()
149 |
150 | # 重命名订阅缓存 json 文件
151 | def rename_file(self, target: str) -> None:
152 | source = DATA_PATH / f"{Rss.handle_name(self.name)}.json"
153 | if source.exists():
154 | source.rename(target)
155 |
156 | # 删除订阅缓存 json 文件
157 | def delete_file(self) -> None:
158 | (DATA_PATH / f"{Rss.handle_name(self.name)}.json").unlink(missing_ok=True)
159 |
160 | # 隐私考虑,不展示除当前群组或频道外的群组、频道和QQ
161 | def hide_some_infos(
162 | self, group_id: Optional[int] = None, guild_channel_id: Optional[str] = None
163 | ) -> "Rss":
164 | if not group_id and not guild_channel_id:
165 | return self
166 | rss_tmp = deepcopy(self)
167 | rss_tmp.guild_channel_id = [guild_channel_id, "*"] if guild_channel_id else []
168 | rss_tmp.group_id = [str(group_id), "*"] if group_id else []
169 | rss_tmp.user_id = ["*"] if rss_tmp.user_id else []
170 | return rss_tmp
171 |
172 | @staticmethod
173 | def get_by_guild_channel(guild_channel_id: str) -> List["Rss"]:
174 | rss_old = Rss.read_rss()
175 | return [
176 | rss.hide_some_infos(guild_channel_id=guild_channel_id)
177 | for rss in rss_old
178 | if guild_channel_id in rss.guild_channel_id
179 | ]
180 |
181 | @staticmethod
182 | def get_by_group(group_id: int) -> List["Rss"]:
183 | rss_old = Rss.read_rss()
184 | return [
185 | rss.hide_some_infos(group_id=group_id)
186 | for rss in rss_old
187 | if str(group_id) in rss.group_id
188 | ]
189 |
190 | @staticmethod
191 | def get_by_user(user: str) -> List["Rss"]:
192 | rss_old = Rss.read_rss()
193 | return [rss for rss in rss_old if user in rss.user_id]
194 |
195 | def set_cookies(self, cookies: str) -> None:
196 | self.cookies = cookies
197 | with TinyDB(
198 | JSON_PATH,
199 | encoding="utf-8",
200 | sort_keys=True,
201 | indent=4,
202 | ensure_ascii=False,
203 | ) as db:
204 | db.update(tinydb_set("cookies", cookies), Query().name == self.name) # type: ignore
205 |
206 | def upsert(self, old_name: Optional[str] = None) -> None:
207 | with TinyDB(
208 | JSON_PATH,
209 | encoding="utf-8",
210 | sort_keys=True,
211 | indent=4,
212 | ensure_ascii=False,
213 | ) as db:
214 | if old_name:
215 | db.update(self.__dict__, Query().name == old_name)
216 | else:
217 | db.upsert(self.__dict__, Query().name == str(self.name))
218 |
219 | def __str__(self) -> str:
220 | def _generate_feature_string(feature: str, value: Any) -> str:
221 | return f"{feature}:{value}" if value else ""
222 |
223 | if self.duplicate_filter_mode:
224 | delimiter = " 或 " if "or" in self.duplicate_filter_mode else "、"
225 | mode_name = {"link": "链接", "title": "标题", "image": "图片"}
226 | mode_msg = (
227 | "已启用去重模式,"
228 | f"{delimiter.join(mode_name[i] for i in self.duplicate_filter_mode if i != 'or')} 相同时去重"
229 | )
230 | else:
231 | mode_msg = ""
232 |
233 | ret_list = [
234 | f"名称:{self.name}",
235 | f"订阅地址:{self.url}",
236 | _generate_feature_string("订阅QQ", self.user_id),
237 | _generate_feature_string("订阅群", self.group_id),
238 | _generate_feature_string("订阅子频道", self.guild_channel_id),
239 | f"更新时间:{self.time}",
240 | _generate_feature_string("代理", self.img_proxy),
241 | _generate_feature_string("翻译", self.translation),
242 | _generate_feature_string("仅标题", self.only_title),
243 | _generate_feature_string("仅图片", self.only_pic),
244 | _generate_feature_string("下载图片", self.download_pic),
245 | _generate_feature_string("仅含有图片", self.only_has_pic),
246 | _generate_feature_string("白名单关键词", self.down_torrent_keyword),
247 | _generate_feature_string("黑名单关键词", self.black_keyword),
248 | _generate_feature_string("cookies", self.cookies),
249 | "种子自动下载功能已启用" if self.down_torrent else "",
250 | (
251 | ""
252 | if self.is_open_upload_group
253 | else f"是否上传到群:{self.is_open_upload_group}"
254 | ),
255 | mode_msg,
256 | _generate_feature_string("图片数量限制", self.max_image_number),
257 | _generate_feature_string("正文待移除内容", self.content_to_remove),
258 | _generate_feature_string("连续抓取失败的次数", self.error_count),
259 | _generate_feature_string("停止更新", self.stop),
260 | _generate_feature_string("PikPak离线", self.pikpak_offline),
261 | _generate_feature_string("PikPak离线路径匹配", self.pikpak_path_key),
262 | ]
263 | return "\n".join([i for i in ret_list if i != ""])
264 |
--------------------------------------------------------------------------------
/src/plugins/ELF_RSS2/parsing/__init__.py:
--------------------------------------------------------------------------------
1 | import re
2 | import sqlite3
3 | from difflib import SequenceMatcher
4 | from importlib import import_module
5 | from typing import Any, Dict, List
6 |
7 | import arrow
8 | import emoji
9 | from nonebot.log import logger
10 | from pyquery import PyQuery as Pq
11 |
12 | from ..config import DATA_PATH, config
13 | from ..rss_class import Rss
14 | from .cache_manage import (
15 | cache_db_manage,
16 | cache_json_manage,
17 | duplicate_exists,
18 | write_item,
19 | )
20 | from .check_update import check_update, get_item_date
21 | from .download_torrent import down_torrent, pikpak_offline
22 | from .handle_html_tag import handle_html_tag
23 | from .handle_images import handle_img
24 | from .handle_translation import handle_translation
25 | from .parsing_rss import ParsingBase
26 | from .routes import ALL_MODULES
27 | from .send_message import handle_send_msgs
28 | from .utils import get_proxy, get_summary
29 |
30 | for module in ALL_MODULES:
31 | import_module(f".routes.{module}", package=__name__)
32 |
33 |
34 | # 检查更新
35 | @ParsingBase.append_before_handler()
36 | async def handle_check_update(state: Dict[str, Any]):
37 | db = state.get("tinydb")
38 | change_data = check_update(db, state.get("new_data"))
39 | return {"change_data": change_data}
40 |
41 |
42 | # 判断是否满足推送条件
43 | @ParsingBase.append_before_handler(priority=11) # type: ignore
44 | async def handle_check_update(rss: Rss, state: Dict[str, Any]):
45 | change_data = state.get("change_data")
46 | db = state.get("tinydb")
47 | for item in change_data.copy():
48 | summary = get_summary(item)
49 | # 检查是否包含屏蔽词
50 | if config.black_word and re.findall("|".join(config.black_word), summary):
51 | logger.info("内含屏蔽词,已经取消推送该消息")
52 | write_item(db, item)
53 | change_data.remove(item)
54 | continue
55 | # 检查是否匹配关键词 使用 down_torrent_keyword 字段,命名是历史遗留导致,实际应该是白名单关键字
56 | if rss.down_torrent_keyword and not re.search(
57 | rss.down_torrent_keyword, summary
58 | ):
59 | write_item(db, item)
60 | change_data.remove(item)
61 | continue
62 | # 检查是否匹配黑名单关键词 使用 black_keyword 字段
63 | if rss.black_keyword and (
64 | re.search(rss.black_keyword, item["title"])
65 | or re.search(rss.black_keyword, summary)
66 | ):
67 | write_item(db, item)
68 | change_data.remove(item)
69 | continue
70 | # 检查是否只推送有图片的消息
71 | if (rss.only_pic or rss.only_has_pic) and not re.search(
72 | r"
]+>|\[img]", summary
73 | ):
74 | logger.info(f"{rss.name} 已开启仅图片/仅含有图片,该消息没有图片,将跳过")
75 | write_item(db, item)
76 | change_data.remove(item)
77 |
78 | return {"change_data": change_data}
79 |
80 |
81 | # 如果启用了去重模式,对推送列表进行过滤
82 | @ParsingBase.append_before_handler(priority=12) # type: ignore
83 | async def handle_check_update(rss: Rss, state: Dict[str, Any]):
84 | change_data = state.get("change_data")
85 | conn = state.get("conn")
86 | db = state.get("tinydb")
87 |
88 | # 检查是否启用去重 使用 duplicate_filter_mode 字段
89 | if not rss.duplicate_filter_mode:
90 | return {"change_data": change_data}
91 |
92 | if not conn:
93 | conn = sqlite3.connect(str(DATA_PATH / "cache.db"))
94 | conn.set_trace_callback(logger.debug)
95 |
96 | cache_db_manage(conn)
97 |
98 | delete = []
99 | for index, item in enumerate(change_data):
100 | is_duplicate, image_hash = await duplicate_exists(
101 | rss=rss,
102 | conn=conn,
103 | item=item,
104 | summary=get_summary(item),
105 | )
106 | if is_duplicate:
107 | write_item(db, item)
108 | delete.append(index)
109 | else:
110 | change_data[index]["image_hash"] = str(image_hash)
111 |
112 | change_data = [
113 | item for index, item in enumerate(change_data) if index not in delete
114 | ]
115 |
116 | return {
117 | "change_data": change_data,
118 | "conn": conn,
119 | }
120 |
121 |
122 | # 处理标题
123 | @ParsingBase.append_handler(parsing_type="title")
124 | async def handle_title(rss: Rss, item: Dict[str, Any]) -> str:
125 | # 判断是否开启了只推送图片
126 | if rss.only_pic:
127 | return ""
128 |
129 | title = item["title"]
130 |
131 | if not config.blockquote:
132 | title = re.sub(r" - 转发 .*", "", title)
133 |
134 | res = f"标题:{title}\n"
135 | # 隔开标题和正文
136 | if not rss.only_title:
137 | res += "\n"
138 | if rss.translation:
139 | res += await handle_translation(content=title)
140 |
141 | # 如果开启了只推送标题,跳过下面判断标题与正文相似度的处理
142 | if rss.only_title:
143 | return emoji.emojize(res, language="alias")
144 |
145 | # 判断标题与正文相似度,避免标题正文一样,或者是标题为正文前N字等情况
146 | try:
147 | summary_html = Pq(get_summary(item))
148 | if not config.blockquote:
149 | summary_html.remove("blockquote")
150 | similarity = SequenceMatcher(None, summary_html.text()[: len(title)], title)
151 | # 标题正文相似度
152 | if similarity.ratio() > 0.6:
153 | res = ""
154 | except Exception as e:
155 | logger.warning(f"{rss.name} 没有正文内容!{e}")
156 |
157 | return emoji.emojize(res, language="alias")
158 |
159 |
160 | # 处理正文 判断是否是仅推送标题 、是否仅推送图片
161 | @ParsingBase.append_handler(parsing_type="summary", priority=1)
162 | async def handle_summary(rss: Rss, tmp_state: Dict[str, Any]) -> str:
163 | if rss.only_title or rss.only_pic:
164 | tmp_state["continue"] = False
165 | return ""
166 |
167 |
168 | # 处理正文 处理网页 tag
169 | @ParsingBase.append_handler(parsing_type="summary") # type: ignore
170 | async def handle_summary(rss: Rss, item: Dict[str, Any], tmp: str) -> str:
171 | try:
172 | tmp += handle_html_tag(html=Pq(get_summary(item)))
173 | except Exception as e:
174 | logger.warning(f"{rss.name} 没有正文内容!{e}")
175 | return tmp
176 |
177 |
178 | # 处理正文 移除指定内容
179 | @ParsingBase.append_handler(parsing_type="summary", priority=11) # type: ignore
180 | async def handle_summary(rss: Rss, tmp: str) -> str:
181 | # 移除指定内容
182 | if rss.content_to_remove:
183 | for pattern in rss.content_to_remove:
184 | tmp = re.sub(pattern, "", tmp)
185 | # 去除多余换行
186 | while "\n\n\n" in tmp:
187 | tmp = tmp.replace("\n\n\n", "\n\n")
188 | tmp = tmp.strip()
189 | return emoji.emojize(tmp, language="alias")
190 |
191 |
192 | # 处理正文 翻译
193 | @ParsingBase.append_handler(parsing_type="summary", priority=12) # type: ignore
194 | async def handle_summary(rss: Rss, tmp: str) -> str:
195 | if rss.translation:
196 | tmp += await handle_translation(tmp)
197 | return tmp
198 |
199 |
200 | # 处理图片
201 | @ParsingBase.append_handler(parsing_type="picture")
202 | async def handle_picture(rss: Rss, item: Dict[str, Any], tmp: str) -> str:
203 | # 判断是否开启了只推送标题
204 | if rss.only_title:
205 | return ""
206 |
207 | res = ""
208 | try:
209 | res += await handle_img(
210 | item=item,
211 | img_proxy=rss.img_proxy,
212 | img_num=rss.max_image_number,
213 | )
214 | except Exception as e:
215 | logger.warning(f"{rss.name} 没有正文内容!{e}")
216 |
217 | # 判断是否开启了只推送图片
218 | return f"{res}\n" if rss.only_pic else f"{tmp + res}\n"
219 |
220 |
221 | # 处理来源
222 | @ParsingBase.append_handler(parsing_type="source")
223 | async def handle_source(item: Dict[str, Any]) -> str:
224 | return f"链接:{item['link']}\n"
225 |
226 |
227 | # 处理种子
228 | @ParsingBase.append_handler(parsing_type="torrent")
229 | async def handle_torrent(rss: Rss, item: Dict[str, Any]) -> str:
230 | res: List[str] = []
231 | if not rss.is_open_upload_group:
232 | rss.group_id = []
233 | if rss.down_torrent:
234 | # 处理种子
235 | try:
236 | hash_list = await down_torrent(
237 | rss=rss, item=item, proxy=get_proxy(rss.img_proxy)
238 | )
239 | if hash_list and hash_list[0] is not None:
240 | res.append("\n磁力:")
241 | res.extend([f"magnet:?xt=urn:btih:{h}" for h in hash_list])
242 | except Exception:
243 | logger.exception("下载种子时出错")
244 | if rss.pikpak_offline:
245 | try:
246 | result = await pikpak_offline(
247 | rss=rss, item=item, proxy=get_proxy(rss.img_proxy)
248 | )
249 | if result:
250 | res.append("\nPikPak 离线成功")
251 | res.extend(
252 | [
253 | f"{r.get('name')}\n{r.get('file_size')} - {r.get('path')}"
254 | for r in result
255 | ]
256 | )
257 | except Exception:
258 | logger.exception("PikPak 离线时出错")
259 | return "\n".join(res)
260 |
261 |
262 | # 处理日期
263 | @ParsingBase.append_handler(parsing_type="date")
264 | async def handle_date(item: Dict[str, Any]) -> str:
265 | date = get_item_date(item)
266 | date = date.replace(tzinfo="local") if date > arrow.now() else date.to("local")
267 | return f"日期:{date.format('YYYY年MM月DD日 HH:mm:ss')}"
268 |
269 |
270 | # 发送消息
271 | @ParsingBase.append_handler(parsing_type="after")
272 | async def handle_message(
273 | rss: Rss,
274 | state: Dict[str, Any],
275 | item: Dict[str, Any],
276 | item_msg: str,
277 | ) -> str:
278 | if rss.send_forward_msg:
279 | return ""
280 |
281 | # 发送消息并写入文件
282 | await handle_send_msgs(rss=rss, messages=[item_msg], items=[item], state=state)
283 | return ""
284 |
285 |
286 | @ParsingBase.append_after_handler()
287 | async def after_handler(rss: Rss, state: Dict[str, Any]) -> Dict[str, Any]:
288 | if rss.send_forward_msg:
289 | # 发送消息并写入文件
290 | await handle_send_msgs(
291 | rss=rss, messages=state["messages"], items=state["items"], state=state
292 | )
293 |
294 | db = state["tinydb"]
295 | new_data_length = len(state["new_data"])
296 | cache_json_manage(db, new_data_length)
297 |
298 | message_count = len(state["change_data"])
299 | success_count = message_count - state["error_count"]
300 |
301 | if message_count > 10 and len(state["messages"]) == 10:
302 | return {}
303 |
304 | if success_count > 0:
305 | logger.info(f"{rss.name} 新消息推送完毕,共计:{success_count}/{message_count}")
306 | elif message_count > 0:
307 | logger.error(f"{rss.name} 新消息推送失败,共计:{message_count}")
308 | else:
309 | logger.info(f"{rss.name} 没有新信息")
310 |
311 | if conn := state["conn"]:
312 | conn.close()
313 |
314 | db.close()
315 |
316 | return {}
317 |
--------------------------------------------------------------------------------
/src/plugins/ELF_RSS2/parsing/handle_images.py:
--------------------------------------------------------------------------------
1 | import base64
2 | import random
3 | import re
4 | from io import BytesIO
5 | from typing import Any, Dict, Optional, Tuple, Union
6 |
7 | import aiohttp
8 | from nonebot.log import logger
9 | from PIL import Image, UnidentifiedImageError
10 | from pyquery import PyQuery as Pq
11 | from tenacity import RetryError, retry, stop_after_attempt, stop_after_delay
12 | from yarl import URL
13 |
14 | from ..config import Path, config
15 | from ..rss_class import Rss
16 | from .utils import get_proxy, get_summary
17 |
18 |
19 | # 通过 ezgif 压缩 GIF
20 | @retry(stop=(stop_after_attempt(5) | stop_after_delay(30)))
21 | async def resize_gif(url: str, resize_ratio: int = 2) -> Optional[bytes]:
22 | async with aiohttp.ClientSession() as session:
23 | resp = await session.post(
24 | "https://s3.ezgif.com/resize",
25 | data={"new-image-url": url},
26 | )
27 | d = Pq(await resp.text())
28 | next_url = d("form").attr("action")
29 | _file = d("form > input[type=hidden]:nth-child(1)").attr("value")
30 | token = d("form > input[type=hidden]:nth-child(2)").attr("value")
31 | old_width = d("form > input[type=hidden]:nth-child(3)").attr("value")
32 | old_height = d("form > input[type=hidden]:nth-child(4)").attr("value")
33 | data = {
34 | "file": _file,
35 | "token": token,
36 | "old_width": old_width,
37 | "old_height": old_height,
38 | "width": str(int(old_width) // resize_ratio),
39 | "method": "gifsicle",
40 | "ar": "force",
41 | }
42 | resp = await session.post(next_url, params="ajax=true", data=data)
43 | d = Pq(await resp.text())
44 | output_img_url = "https:" + d("img:nth-child(1)").attr("src")
45 | return await download_image(output_img_url)
46 |
47 |
48 | # 通过 ezgif 把视频中间 4 秒转 GIF 作为预览
49 | @retry(stop=(stop_after_attempt(5) | stop_after_delay(30)))
50 | async def get_preview_gif_from_video(url: str) -> str:
51 | async with aiohttp.ClientSession() as session:
52 | resp = await session.post(
53 | "https://s3.ezgif.com/video-to-gif",
54 | data={"new-image-url": url},
55 | )
56 | d = Pq(await resp.text())
57 | video_length = re.search(
58 | r"\d\d:\d\d:\d\d", str(d("#main > p.filestats > strong"))
59 | ).group() # type: ignore
60 | hours = int(video_length.split(":")[0])
61 | minutes = int(video_length.split(":")[1])
62 | seconds = int(video_length.split(":")[2])
63 | video_length_median = (hours * 60 * 60 + minutes * 60 + seconds) // 2
64 | next_url = d("form").attr("action")
65 | _file = d("form > input[type=hidden]:nth-child(1)").attr("value")
66 | token = d("form > input[type=hidden]:nth-child(2)").attr("value")
67 | default_end = d("#end").attr("value")
68 | if float(default_end) >= 4:
69 | start = video_length_median - 2
70 | end = video_length_median + 2
71 | else:
72 | start = 0
73 | end = default_end
74 | data = {
75 | "file": _file,
76 | "token": token,
77 | "start": start,
78 | "end": end,
79 | "size": 320,
80 | "fps": 25,
81 | "method": "ffmpeg",
82 | }
83 | resp = await session.post(next_url, params="ajax=true", data=data)
84 | d = Pq(await resp.text())
85 | return f'https:{d("img:nth-child(1)").attr("src")}'
86 |
87 |
88 | # 图片压缩
89 | async def zip_pic(url: str, content: bytes) -> Union[Image.Image, bytes, None]:
90 | # 打开一个 JPEG/PNG/GIF/WEBP 图像文件
91 | try:
92 | im = Image.open(BytesIO(content))
93 | except UnidentifiedImageError:
94 | logger.error(f"无法识别图像文件 链接:[{url}]")
95 | return None
96 | if im.format != "GIF":
97 | # 先把 WEBP 图像转为 PNG
98 | if im.format == "WEBP":
99 | with BytesIO() as output:
100 | im.save(output, "PNG")
101 | im = Image.open(output)
102 | # 对图像文件进行缩小处理
103 | im.thumbnail((config.zip_size, config.zip_size))
104 | width, height = im.size
105 | logger.debug(f"Resize image to: {width} x {height}")
106 | # 和谐
107 | points = [(0, 0), (0, height - 1), (width - 1, 0), (width - 1, height - 1)]
108 | for x, y in points:
109 | im.putpixel((x, y), random.randint(0, 255))
110 | return im
111 | else:
112 | if len(content) > config.gif_zip_size * 1024:
113 | try:
114 | return await resize_gif(url)
115 | except RetryError:
116 | logger.error(f"GIF 图片[{url}]压缩失败,将发送原图")
117 | return content
118 |
119 |
120 | # 将图片转化为 base64
121 | def get_pic_base64(content: Union[Image.Image, bytes, None]) -> str:
122 | if not content:
123 | return ""
124 | if isinstance(content, Image.Image):
125 | with BytesIO() as output:
126 | content.save(output, format=content.format)
127 | content = output.getvalue()
128 | if isinstance(content, bytes):
129 | return str(base64.b64encode(content).decode())
130 | return ""
131 |
132 |
133 | # 去你的 pixiv.cat
134 | async def fuck_pixiv_cat(url: str) -> str:
135 | img_id = re.sub("https://pixiv.cat/", "", url)
136 | img_id = img_id[:-4]
137 | info_list = img_id.split("-")
138 | async with aiohttp.ClientSession() as session:
139 | try:
140 | resp = await session.get(
141 | f"https://api.obfs.dev/api/pixiv/illust?id={info_list[0]}"
142 | )
143 | resp_json = await resp.json()
144 | if len(info_list) >= 2:
145 | return str(
146 | resp_json["illust"]["meta_pages"][int(info_list[1]) - 1][
147 | "image_urls"
148 | ]["original"]
149 | )
150 | else:
151 | return str(
152 | resp_json["illust"]["meta_single_page"]["original_image_url"]
153 | )
154 | except Exception as e:
155 | logger.error(f"处理pixiv.cat链接时出现问题 :{e} 链接:[{url}]")
156 | return url
157 |
158 |
159 | @retry(stop=(stop_after_attempt(5) | stop_after_delay(30)))
160 | async def download_image_detail(url: str, proxy: bool) -> Optional[bytes]:
161 | async with aiohttp.ClientSession(raise_for_status=True) as session:
162 | referer = f"{URL(url).scheme}://{URL(url).host}/"
163 | headers = {"referer": referer}
164 | try:
165 | resp = await session.get(
166 | url, headers=headers, proxy=get_proxy(open_proxy=proxy)
167 | )
168 | # 如果图片无法获取到,直接返回
169 | if len(await resp.read()) == 0:
170 | if "pixiv.cat" in url:
171 | url = await fuck_pixiv_cat(url=url)
172 | return await download_image(url, proxy)
173 | logger.error(
174 | f"图片[{url}]下载失败! Content-Type: {resp.headers['Content-Type']} status: {resp.status}"
175 | )
176 | return None
177 | # 如果图片格式为 SVG ,先转换为 PNG
178 | if resp.headers["Content-Type"].startswith("image/svg+xml"):
179 | next_url = str(
180 | URL("https://images.weserv.nl/").with_query(f"url={url}&output=png")
181 | )
182 | return await download_image(next_url, proxy)
183 | return await resp.read()
184 | except Exception as e:
185 | logger.warning(f"图片[{url}]下载失败!将重试最多 5 次!\n{e}")
186 | raise
187 |
188 |
189 | async def download_image(url: str, proxy: bool = False) -> Optional[bytes]:
190 | try:
191 | return await download_image_detail(url=url, proxy=proxy)
192 | except RetryError:
193 | logger.error(f"图片[{url}]下载失败!已达最大重试次数!有可能需要开启代理!")
194 | return None
195 |
196 |
197 | async def handle_img_combo(url: str, img_proxy: bool, rss: Optional[Rss] = None) -> str:
198 | """'
199 | 下载图片并返回可用的CQ码
200 |
201 | 参数:
202 | url: 需要下载的图片地址
203 | img_proxy: 是否使用代理下载图片
204 | rss: Rss对象
205 | 返回值:
206 | 返回当前图片的CQ码,以base64格式编码发送
207 | 如获取图片失败将会提示图片走丢了
208 | """
209 | if content := await download_image(url, img_proxy):
210 | if rss is not None and rss.download_pic:
211 | _url = URL(url)
212 | logger.debug(f"正在保存图片: {url}")
213 | try:
214 | save_image(content=content, file_url=_url, rss=rss)
215 | except Exception as e:
216 | logger.warning(f"在保存图片到本地时出现错误\nE:{repr(e)}")
217 | if resize_content := await zip_pic(url, content):
218 | if img_base64 := get_pic_base64(resize_content):
219 | return f"[CQ:image,file=base64://{img_base64}]"
220 | return f"\n图片走丢啦 链接:[{url}]\n"
221 |
222 |
223 | async def handle_img_combo_with_content(url: str, content: bytes) -> str:
224 | if resize_content := await zip_pic(url, content):
225 | if img_base64 := get_pic_base64(resize_content):
226 | return f"[CQ:image,file=base64://{img_base64}]"
227 | return f"\n图片走丢啦 链接:[{url}]\n" if url else "\n图片走丢啦\n"
228 |
229 |
230 | # 处理图片、视频
231 | async def handle_img(item: Dict[str, Any], img_proxy: bool, img_num: int) -> str:
232 | if item.get("image_content"):
233 | return await handle_img_combo_with_content(
234 | item.get("gif_url", ""), item["image_content"]
235 | )
236 | html = Pq(get_summary(item))
237 | img_str = ""
238 | # 处理图片
239 | doc_img = list(html("img").items())
240 | # 只发送限定数量的图片,防止刷屏
241 | if 0 < img_num < len(doc_img):
242 | img_str += f"\n因启用图片数量限制,目前只有 {img_num} 张图片:"
243 | doc_img = doc_img[:img_num]
244 | for img in doc_img:
245 | url = img.attr("src")
246 | img_str += await handle_img_combo(url, img_proxy)
247 |
248 | # 处理视频
249 | if doc_video := html("video"):
250 | img_str += "\n视频封面:"
251 | for video in doc_video.items():
252 | url = video.attr("poster")
253 | img_str += await handle_img_combo(url, img_proxy)
254 |
255 | return img_str
256 |
257 |
258 | # 处理 bbcode 图片
259 | async def handle_bbcode_img(html: Pq, img_proxy: bool, img_num: int) -> str:
260 | img_str = ""
261 | # 处理图片
262 | img_list = re.findall(r"\[img[^]]*](.+)\[/img]", str(html), flags=re.I)
263 | # 只发送限定数量的图片,防止刷屏
264 | if 0 < img_num < len(img_list):
265 | img_str += f"\n因启用图片数量限制,目前只有 {img_num} 张图片:"
266 | img_list = img_list[:img_num]
267 | for img_tmp in img_list:
268 | img_str += await handle_img_combo(img_tmp, img_proxy)
269 |
270 | return img_str
271 |
272 |
273 | def file_name_format(file_url: URL, rss: Rss) -> Tuple[Path, str]:
274 | """
275 | 可以根据用户设置的规则来格式化文件名
276 | """
277 | format_rule = config.img_format or ""
278 | down_path = config.img_down_path or ""
279 | rules = { # 替换格式化字符串
280 | "{subs}": rss.name,
281 | "{name}": (
282 | file_url.name if "{ext}" not in format_rule else Path(file_url.name).stem
283 | ),
284 | "{ext}": file_url.suffix if "{ext}" in format_rule else "",
285 | }
286 | for k, v in rules.items():
287 | format_rule = format_rule.replace(k, v)
288 | if down_path == "": # 如果没设置保存路径的话,就保存到默认目录下
289 | save_path = Path().cwd() / "data" / "image"
290 | elif down_path[0] == ".":
291 | save_path = Path().cwd() / Path(down_path)
292 | else:
293 | save_path = Path(down_path)
294 | full_path = save_path / format_rule
295 | save_path = full_path.parents[0]
296 | save_name = full_path.name
297 | return save_path, save_name
298 |
299 |
300 | def save_image(content: bytes, file_url: URL, rss: Rss) -> None:
301 | """
302 | 将压缩之前的原图保存到本地的电脑上
303 | """
304 | save_path, save_name = file_name_format(file_url=file_url, rss=rss)
305 |
306 | full_save_path = save_path / save_name
307 | try:
308 | full_save_path.write_bytes(content)
309 | except FileNotFoundError:
310 | # 初次写入时文件夹不存在,需要创建一下
311 | save_path.mkdir(parents=True)
312 | full_save_path.write_bytes(content)
313 |
--------------------------------------------------------------------------------
/src/plugins/ELF_RSS2/command/change_dy.py:
--------------------------------------------------------------------------------
1 | import re
2 | from contextlib import suppress
3 | from copy import deepcopy
4 | from typing import Any, List, Match, Optional
5 |
6 | from nonebot import on_command
7 | from nonebot.adapters.onebot.v11 import GroupMessageEvent, Message, MessageEvent
8 | from nonebot.adapters.onebot.v11.permission import GROUP_ADMIN, GROUP_OWNER
9 | from nonebot.log import logger
10 | from nonebot.matcher import Matcher
11 | from nonebot.params import ArgPlainText, CommandArg
12 | from nonebot.permission import SUPERUSER
13 | from nonebot.rule import to_me
14 |
15 | from .. import my_trigger as tr
16 | from ..config import DATA_PATH
17 | from ..rss_class import Rss
18 | from ..utils import regex_validate
19 |
20 | RSS_CHANGE = on_command(
21 | "change",
22 | aliases={"修改订阅", "modify"},
23 | rule=to_me(),
24 | priority=5,
25 | permission=GROUP_ADMIN | GROUP_OWNER | SUPERUSER,
26 | )
27 |
28 |
29 | @RSS_CHANGE.handle()
30 | async def handle_first_receive(matcher: Matcher, args: Message = CommandArg()) -> None:
31 | if args.extract_plain_text():
32 | matcher.set_arg("RSS_CHANGE", args)
33 |
34 |
35 | # 处理带多个值的订阅参数
36 | def handle_property(value: str, property_list: List[Any]) -> List[Any]:
37 | # 清空
38 | if value == "-1":
39 | return []
40 | value_list = value.split(",")
41 | # 追加
42 | if value_list[0] == "":
43 | value_list.pop(0)
44 | return property_list + [i for i in value_list if i not in property_list]
45 | # 防止用户输入重复参数,去重并保持原来的顺序
46 | return list(dict.fromkeys(value_list))
47 |
48 |
49 | # 处理类型为正则表达式的订阅参数
50 | def handle_regex_property(value: str, old_value: str) -> Optional[str]:
51 | result = None
52 | if not value:
53 | result = None
54 | elif value.startswith("+"):
55 | result = f"{old_value}|{value.lstrip('+')}" if old_value else value.lstrip("+")
56 | elif value.startswith("-"):
57 | if regex_list := old_value.split("|"):
58 | with suppress(ValueError):
59 | regex_list.remove(value.lstrip("-"))
60 | result = "|".join(regex_list) if regex_list else None
61 | else:
62 | result = value
63 | if isinstance(result, str) and not regex_validate(result):
64 | result = None
65 | return result
66 |
67 |
68 | attribute_dict = {
69 | "name": "name",
70 | "url": "url",
71 | "qq": "user_id",
72 | "qun": "group_id",
73 | "channel": "guild_channel_id",
74 | "time": "time",
75 | "proxy": "img_proxy",
76 | "tl": "translation",
77 | "ot": "only_title",
78 | "op": "only_pic",
79 | "ohp": "only_has_pic",
80 | "downpic": "download_pic",
81 | "upgroup": "is_open_upload_group",
82 | "downopen": "down_torrent",
83 | "downkey": "down_torrent_keyword",
84 | "wkey": "down_torrent_keyword",
85 | "blackkey": "black_keyword",
86 | "bkey": "black_keyword",
87 | "mode": "duplicate_filter_mode",
88 | "img_num": "max_image_number",
89 | "stop": "stop",
90 | "pikpak": "pikpak_offline",
91 | "ppk": "pikpak_path_key",
92 | "forward": "send_forward_msg",
93 | }
94 |
95 |
96 | def handle_name_change(rss: Rss, value_to_change: str) -> None:
97 | tr.delete_job(rss)
98 | rss.rename_file(str(DATA_PATH / f"{Rss.handle_name(value_to_change)}.json"))
99 |
100 |
101 | def handle_time_change(value_to_change: str) -> str:
102 | if not re.search(r"[_*/,-]", value_to_change):
103 | if int(float(value_to_change)) < 1:
104 | return "1"
105 | else:
106 | return str(int(float(value_to_change)))
107 | return value_to_change
108 |
109 |
110 | # 处理要修改的订阅参数
111 | def handle_change_list(
112 | rss: Rss,
113 | key_to_change: str,
114 | value_to_change: str,
115 | group_id: Optional[int],
116 | guild_channel_id: Optional[str],
117 | ) -> None:
118 | if key_to_change == "name":
119 | handle_name_change(rss, value_to_change)
120 | elif (
121 | key_to_change in {"qq", "qun", "channel"}
122 | and not group_id
123 | and not guild_channel_id
124 | ) or key_to_change == "mode":
125 | value_to_change = handle_property(
126 | value_to_change, getattr(rss, attribute_dict[key_to_change])
127 | ) # type:ignore
128 | elif key_to_change == "time":
129 | value_to_change = handle_time_change(value_to_change)
130 | elif key_to_change in {
131 | "proxy",
132 | "tl",
133 | "ot",
134 | "op",
135 | "ohp",
136 | "downpic",
137 | "upgroup",
138 | "downopen",
139 | "stop",
140 | "pikpak",
141 | "forward",
142 | }:
143 | value_to_change = bool(int(value_to_change)) # type:ignore
144 | if key_to_change == "stop" and not value_to_change and rss.error_count > 0:
145 | rss.error_count = 0
146 | elif key_to_change in {"downkey", "wkey", "blackkey", "bkey"}:
147 | value_to_change = handle_regex_property(
148 | value_to_change, getattr(rss, attribute_dict[key_to_change])
149 | ) # type:ignore
150 | elif key_to_change == "ppk" and not value_to_change:
151 | value_to_change = None # type:ignore
152 | elif key_to_change == "img_num":
153 | value_to_change = int(value_to_change) # type:ignore
154 | setattr(rss, attribute_dict.get(key_to_change), value_to_change) # type:ignore
155 |
156 |
157 | prompt = """\
158 | 请输入要修改的订阅
159 | 订阅名[,订阅名,...] 属性=值[ 属性=值 ...]
160 | 如:
161 | test1[,test2,...] qq=,123,234 qun=-1
162 | 对应参数:
163 | 订阅名(-name): 禁止将多个订阅批量改名,名称相同会冲突
164 | 订阅链接(-url)
165 | QQ(-qq)
166 | 群(-qun)
167 | 更新频率(-time)
168 | 代理(-proxy)
169 | 翻译(-tl)
170 | 仅Title(ot)
171 | 仅图片(-op)
172 | 仅含图片(-ohp)
173 | 下载图片(-downpic): 下载图片到本地硬盘,仅pixiv有效
174 | 下载种子(-downopen)
175 | 白名单关键词(-wkey)
176 | 黑名单关键词(-bkey)
177 | 种子上传到群(-upgroup)
178 | 去重模式(-mode)
179 | 图片数量限制(-img_num): 只发送限定数量的图片,防止刷屏
180 | 正文移除内容(-rm_list): 从正文中移除指定内容,支持正则
181 | 停止更新(-stop): 停止更新订阅
182 | PikPak离线(-pikpak): 开启PikPak离线下载
183 | PikPak离线路径匹配(-ppk): 匹配离线下载的文件夹,设置该值后生效
184 | 发送合并消息(-forward): 当一次更新多条消息时,尝试发送合并消息
185 | 注:
186 | 1. 仅含有图片不同于仅图片,除了图片还会发送正文中的其他文本信息
187 | 2. proxy/tl/ot/op/ohp/downopen/upgroup/stop/pikpak 值为 1/0
188 | 3. 去重模式分为按链接(link)、标题(title)、图片(image)判断,其中 image 模式生效对象限定为只带 1 张图片的消息。如果属性中带有 or 说明判断逻辑是任一匹配即去重,默认为全匹配
189 | 4. 白名单关键词支持正则表达式,匹配时推送消息及下载,设为空(wkey=)时不生效
190 | 5. 黑名单关键词同白名单相似,匹配时不推送,两者可以一起用
191 | 6. 正文待移除内容格式必须如:rm_list='a' 或 rm_list='a','b'。该处理过程在解析 html 标签后进行,设为空使用 rm_list='-1'"
192 | 7. QQ、群号、去重模式前加英文逗号表示追加,-1设为空
193 | 8. 各个属性使用空格分割
194 | 9. downpic保存的文件位于程序根目录下 "data/image/订阅名/图片名"
195 | 详细用法请查阅文档。\
196 | """
197 |
198 |
199 | async def filter_rss_by_permissions(
200 | rss_list: List[Rss],
201 | change_info: str,
202 | group_id: Optional[int],
203 | guild_channel_id: Optional[str],
204 | ) -> List[Rss]:
205 | if group_id:
206 | if re.search(" (qq|qun|channel)=", change_info):
207 | await RSS_CHANGE.finish(
208 | "❌ 禁止在群组中修改订阅账号!如要取消订阅请使用 deldy 命令!"
209 | )
210 | rss_list = [
211 | rss
212 | for rss in rss_list
213 | if rss.group_id == [str(group_id)]
214 | and not rss.user_id
215 | and not rss.guild_channel_id
216 | ]
217 |
218 | if guild_channel_id:
219 | if re.search(" (qq|qun|channel)=", change_info):
220 | await RSS_CHANGE.finish(
221 | "❌ 禁止在子频道中修改订阅账号!如要取消订阅请使用 deldy 命令!"
222 | )
223 | rss_list = [
224 | rss
225 | for rss in rss_list
226 | if rss.guild_channel_id == [str(guild_channel_id)]
227 | and not rss.user_id
228 | and not rss.group_id
229 | ]
230 |
231 | if not rss_list:
232 | await RSS_CHANGE.finish(
233 | "❌ 请检查是否存在以下问题:\n1.要修改的订阅名不存在对应的记录\n2.当前群组或频道无权操作"
234 | )
235 |
236 | return rss_list
237 |
238 |
239 | @RSS_CHANGE.got("RSS_CHANGE", prompt=prompt)
240 | async def handle_rss_change(
241 | event: MessageEvent, change_info: str = ArgPlainText("RSS_CHANGE")
242 | ) -> None:
243 | group_id = event.group_id if isinstance(event, GroupMessageEvent) else None
244 | guild_channel_id = None
245 | name_list = change_info.split(" ")[0].split(",")
246 | rss_list: List[Rss] = []
247 | for name in name_list:
248 | if rss_tmp := Rss.get_one_by_name(name=name):
249 | rss_list.append(rss_tmp)
250 |
251 | # 出于公平考虑,限制订阅者只有当前群组或频道时才能修改订阅,否则只有超级管理员能修改
252 | rss_list = await filter_rss_by_permissions(
253 | rss_list, change_info, group_id, guild_channel_id
254 | )
255 |
256 | if len(rss_list) > 1 and " name=" in change_info:
257 | await RSS_CHANGE.finish("❌ 禁止将多个订阅批量改名!会因为名称相同起冲突!")
258 |
259 | # 参数特殊处理:正文待移除内容
260 | rm_list_exist = re.search("rm_list='.+'", change_info)
261 | change_list = handle_rm_list(rss_list, change_info, rm_list_exist)
262 |
263 | changed_rss_list = await batch_change_rss(
264 | change_list, group_id, guild_channel_id, rss_list, rm_list_exist
265 | )
266 | # 隐私考虑,不展示除当前群组或频道外的群组、频道和QQ
267 | rss_msg_list = [
268 | str(rss.hide_some_infos(group_id, guild_channel_id)) for rss in changed_rss_list
269 | ]
270 | result_msg = f"👏 修改了 {len(rss_msg_list)} 条订阅"
271 | if rss_msg_list:
272 | separator = "\n----------------------\n"
273 | result_msg += separator + separator.join(rss_msg_list)
274 | await RSS_CHANGE.finish(result_msg)
275 |
276 |
277 | async def validate_rss_change(key_to_change: str, value_to_change: str) -> None:
278 | # 对用户输入的去重模式参数进行校验
279 | mode_property_set = {"", "-1", "link", "title", "image", "or"}
280 | if key_to_change == "mode" and (
281 | set(value_to_change.split(",")) - mode_property_set or value_to_change == "or"
282 | ):
283 | await RSS_CHANGE.finish(
284 | f"❌ 去重模式参数错误!\n{key_to_change}={value_to_change}"
285 | )
286 | elif key_to_change in {
287 | "downkey",
288 | "wkey",
289 | "blackkey",
290 | "bkey",
291 | } and not regex_validate(value_to_change.lstrip("+-")):
292 | await RSS_CHANGE.finish(
293 | f"❌ 正则表达式错误!\n{key_to_change}={value_to_change}"
294 | )
295 | elif key_to_change == "ppk" and not regex_validate(value_to_change):
296 | await RSS_CHANGE.finish(
297 | f"❌ 正则表达式错误!\n{key_to_change}={value_to_change}"
298 | )
299 |
300 |
301 | async def batch_change_rss(
302 | change_list: List[str],
303 | group_id: Optional[int],
304 | guild_channel_id: Optional[str],
305 | rss_list: List[Rss],
306 | rm_list_exist: Optional[Match[str]] = None,
307 | ) -> List[Rss]:
308 | changed_rss_list = []
309 |
310 | for rss in rss_list:
311 | new_rss = deepcopy(rss)
312 | rss_name = rss.name
313 |
314 | for change_dict in change_list:
315 | key_to_change, value_to_change = change_dict.split("=", 1)
316 |
317 | if key_to_change in attribute_dict.keys():
318 | await validate_rss_change(key_to_change, value_to_change)
319 | handle_change_list(
320 | new_rss, key_to_change, value_to_change, group_id, guild_channel_id
321 | )
322 | else:
323 | await RSS_CHANGE.finish(f"❌ 参数错误!\n{change_dict}")
324 |
325 | if new_rss.__dict__ == rss.__dict__ and not rm_list_exist:
326 | continue
327 | changed_rss_list.append(new_rss)
328 |
329 | # 参数解析完毕,写入
330 | new_rss.upsert(rss_name)
331 |
332 | # 加入定时任务
333 | if not new_rss.stop:
334 | await tr.add_job(new_rss)
335 | elif not rss.stop:
336 | tr.delete_job(new_rss)
337 | logger.info(f"{rss_name} 已停止更新")
338 |
339 | return changed_rss_list
340 |
341 |
342 | # 参数特殊处理:正文待移除内容
343 | def handle_rm_list(
344 | rss_list: List[Rss], change_info: str, rm_list_exist: Optional[Match[str]] = None
345 | ) -> List[str]:
346 | rm_list = None
347 |
348 | if rm_list_exist:
349 | rm_list_str = rm_list_exist[0].lstrip().replace("rm_list=", "")
350 | rm_list = [i.strip("'") for i in rm_list_str.split("','")]
351 | change_info = change_info.replace(rm_list_exist[0], "").strip()
352 |
353 | if rm_list:
354 | for rss in rss_list:
355 | if len(rm_list) == 1 and rm_list[0] == "-1":
356 | setattr(rss, "content_to_remove", None)
357 | elif valid_rm_list := [i for i in rm_list if regex_validate(i)]:
358 | setattr(rss, "content_to_remove", valid_rm_list)
359 |
360 | change_list = [i.strip() for i in change_info.split(" ")]
361 | # 去掉订阅名
362 | change_list.pop(0)
363 |
364 | return change_list
365 |
--------------------------------------------------------------------------------