├── README.md ├── custom ├── analysis_bilibili.py ├── atall.py ├── cat.py ├── dog.py ├── fileLink.py ├── fox.py ├── manage_group.py ├── nbnhhsh.py ├── ping.py ├── processing_request.py ├── requirements.txt ├── scan_qrcode.py ├── status_info.py └── wantwords.py ├── custom_reply ├── __init__.py ├── custom_reply.py ├── manage_content.py └── requirements.txt └── rss2 ├── __init__.py ├── command ├── __init__.py ├── add_cookies.py ├── add_dy.py ├── change_dy.py ├── del_dy.py ├── rsshub_add.py ├── show_all.py ├── show_dy.py └── upload_group_file.py ├── config.py ├── my_trigger.py ├── parsing ├── __init__.py ├── cache_manage.py ├── check_update.py ├── download_torrent.py ├── handle_html_tag.py ├── handle_images.py ├── handle_translation.py ├── parsing_rss.py ├── routes │ ├── __init__.py │ ├── danbooru.py │ ├── nga.py │ ├── pixiv.py │ ├── south_plus.py │ ├── twitter.py │ ├── weibo.py │ ├── yande_re.py │ └── youtube.py ├── send_message.py └── utils.py ├── permission.py ├── pikpak_offline.py ├── qbittorrent_download.py ├── requirements.txt ├── rss_class.py ├── rss_parsing.py └── utils.py /README.md: -------------------------------------------------------------------------------- 1 | # 使用说明 2 | 3 | ### 注意更新 Hoshino 本体,以防 CQ 码注入.jpg
4 | 5 | ### requirements.txt 是所需第三方模块,使用前需安装模块
6 | 7 | ### 在所要使用插件的目录下执行 `pip install -r requirements.txt` 安装依赖库
8 | 9 | ### `pip install -r requirements.txt --upgrade` 更新依赖库
10 | 11 | 需配合[Hoshino(v2)](https://github.com/Ice-Cirno/HoshinoBot)使用
12 | 具体使用看[WIKI](https://github.com/mengshouer/HoshinoBot-Plugins/wiki)
13 | 14 | ## 其他插件 15 | 16 | [FFXIV 相关插件](https://github.com/mengshouer/HoshinoBot-Plugins/tree/ffxiv) 17 | 18 | [搜图插件](https://github.com/mengshouer/HoshinoBot-Plugins/tree/picsearch) 19 | -------------------------------------------------------------------------------- /custom/analysis_bilibili.py: -------------------------------------------------------------------------------- 1 | import re 2 | import urllib.parse 3 | import json 4 | import nonebot 5 | from typing import Optional, Union 6 | from time import localtime, strftime 7 | from aiohttp import ClientSession 8 | 9 | from hoshino import Service, logger 10 | from nonebot import Message, MessageSegment 11 | 12 | 13 | headers = { 14 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36 Edg/112.0.1722.58" 15 | } 16 | analysis_stat = {} # group_id : last_vurl 17 | config = nonebot.get_bot().config 18 | blacklist = getattr(config, "analysis_blacklist", []) 19 | analysis_display_image = getattr(config, "analysis_display_image", False) 20 | analysis_display_image_list = getattr(config, "analysis_display_image_list", []) 21 | trust_env = getattr(config, "analysis_trust_env", False) 22 | 23 | 24 | sv2 = Service("search_bilibili_video") 25 | # 手动搜视频标题 26 | @sv2.on_prefix("搜视频") 27 | async def search_bilibili_video_by_title(bot, ev): 28 | title = ev.message.extract_plain_text() 29 | group_id = ev.group_id if ev.group_id else ev.get("channel_id", None) 30 | 31 | async with ClientSession(trust_env=trust_env, headers=headers) as session: 32 | vurl = await search_bili_by_title(title, session) 33 | msg = await bili_keyword(group_id, vurl, session) 34 | try: 35 | await bot.send(ev, msg) 36 | except: 37 | # 避免简介有风控内容无法发送 38 | logger.warning(f"{msg}\n此次解析可能被风控,尝试去除简介后发送!") 39 | msg = re.sub(r"简介.*", "", msg) 40 | await bot.send(ev, msg) 41 | 42 | 43 | sv = Service("analysis_bilibili") 44 | # on_rex判断不到小程序信息 45 | @sv.on_message() 46 | async def rex_bilibili(bot, ev): 47 | text = str(ev.message).strip() 48 | if blacklist and ev.user_id in blacklist: 49 | return 50 | if re.search(r"(b23.tv)|(bili(22|23|33|2233).cn)", text, re.I): 51 | # 提前处理短链接,避免解析到其他的 52 | text = await b23_extract(text) 53 | patterns = r"(\.bilibili\.com)|(^(av|cv)(\d+))|(^BV([a-zA-Z0-9]{10})+)|(\[\[QQ小程序\]哔哩哔哩\])|(QQ小程序&#93;哔哩哔哩)|(QQ小程序]哔哩哔哩)" 54 | match = re.compile(patterns, re.I).search(text) 55 | if match: 56 | group_id = ev.group_id if ev.group_id else ev.get("channel_id", None) 57 | async with ClientSession(trust_env=trust_env, headers=headers) as session: 58 | msg = await bili_keyword(group_id, text, session) 59 | if msg: 60 | try: 61 | await bot.send(ev, msg) 62 | except: 63 | # 避免简介有风控内容无法发送 64 | logger.warning(f"{msg}\n此次解析可能被风控,尝试去除简介后发送!") 65 | msg = re.sub(r"简介.*", "", msg) 66 | await bot.send(ev, msg) 67 | 68 | 69 | async def bili_keyword( 70 | group_id: Optional[int], text: str, session: ClientSession 71 | ) -> Union[Message, str]: 72 | try: 73 | # 提取url 74 | url, page, time_location = extract(text) 75 | # 如果是小程序就去搜索标题 76 | if not url: 77 | if title := re.search(r'"desc":("[^"哔哩]+")', text): 78 | vurl = await search_bili_by_title(title[1], session) 79 | if vurl: 80 | url, page, time_location = extract(vurl) 81 | 82 | # 获取视频详细信息 83 | msg, vurl = "", "" 84 | if "view?" in url: 85 | msg, vurl = await video_detail( 86 | url, page=page, time_location=time_location, session=session 87 | ) 88 | elif "bangumi" in url: 89 | msg, vurl = await bangumi_detail(url, time_location, session) 90 | elif "xlive" in url: 91 | msg, vurl = await live_detail(url, session) 92 | elif "article" in url: 93 | msg, vurl = await article_detail(url, page, session) 94 | elif "dynamic" in url: 95 | msg, vurl = await dynamic_detail(url, session) 96 | 97 | # 避免多个机器人解析重复推送 98 | if group_id: 99 | if group_id in analysis_stat and analysis_stat[group_id] == vurl: 100 | return "" 101 | analysis_stat[group_id] = vurl 102 | except Exception as e: 103 | msg = "bili_keyword Error: {}".format(type(e)) 104 | return msg 105 | 106 | 107 | async def b23_extract(text): 108 | b23 = re.compile(r"b23.tv/(\w+)|(bili(22|23|33|2233).cn)/(\w+)", re.I).search( 109 | text.replace("\\", "") 110 | ) 111 | url = f"https://{b23[0]}" 112 | # 考虑到是在 on_message 内进行操作,避免无用的创建 session,所以分开写 113 | async with ClientSession(trust_env=trust_env) as session: 114 | async with session.get(url) as resp: 115 | return str(resp.url) 116 | 117 | 118 | def extract(text: str): 119 | try: 120 | url = "" 121 | # 视频分p 122 | page = re.compile(r"([?&]|&)p=\d+").search(text) 123 | # 视频播放定位时间 124 | time = re.compile(r"([?&]|&)t=\d+").search(text) 125 | # 主站视频 av 号 126 | aid = re.compile(r"av\d+", re.I).search(text) 127 | # 主站视频 bv 号 128 | bvid = re.compile(r"BV([A-Za-z0-9]{10})+", re.I).search(text) 129 | # 番剧视频页 130 | epid = re.compile(r"ep\d+", re.I).search(text) 131 | # 番剧剧集ssid(season_id) 132 | ssid = re.compile(r"ss\d+", re.I).search(text) 133 | # 番剧详细页 134 | mdid = re.compile(r"md\d+", re.I).search(text) 135 | # 直播间 136 | room_id = re.compile(r"live.bilibili.com/(blanc/|h5/)?(\d+)", re.I).search(text) 137 | # 文章 138 | cvid = re.compile( 139 | r"(/read/(cv|mobile|native)(/|\?id=)?|^cv)(\d+)", re.I 140 | ).search(text) 141 | # 动态 142 | dynamic_id_type2 = re.compile( 143 | r"(t|m).bilibili.com/(\d+)\?(.*?)(&|&)type=2", re.I 144 | ).search(text) 145 | # 动态 146 | dynamic_id = re.compile(r"(t|m).bilibili.com/(\d+)", re.I).search(text) 147 | if bvid: 148 | url = f"https://api.bilibili.com/x/web-interface/view?bvid={bvid[0]}" 149 | elif aid: 150 | url = f"https://api.bilibili.com/x/web-interface/view?aid={aid[0][2:]}" 151 | elif epid: 152 | url = ( 153 | f"https://bangumi.bilibili.com/view/web_api/season?ep_id={epid[0][2:]}" 154 | ) 155 | elif ssid: 156 | url = f"https://bangumi.bilibili.com/view/web_api/season?season_id={ssid[0][2:]}" 157 | elif mdid: 158 | url = f"https://bangumi.bilibili.com/view/web_api/season?media_id={mdid[0][2:]}" 159 | elif room_id: 160 | url = f"https://api.live.bilibili.com/xlive/web-room/v1/index/getInfoByRoom?room_id={room_id[2]}" 161 | elif cvid: 162 | page = cvid[4] 163 | url = f"https://api.bilibili.com/x/article/viewinfo?id={page}&mobi_app=pc&from=web" 164 | elif dynamic_id_type2: 165 | url = f"https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/get_dynamic_detail?rid={dynamic_id_type2[2]}&type=2" 166 | elif dynamic_id: 167 | url = f"https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/get_dynamic_detail?dynamic_id={dynamic_id[2]}" 168 | return url, page, time 169 | except Exception: 170 | return "", None, None 171 | 172 | 173 | async def search_bili_by_title(title: str, session: ClientSession) -> str: 174 | mainsite_url = "https://www.bilibili.com" 175 | search_url = f"https://api.bilibili.com/x/web-interface/wbi/search/all/v2?keyword={urllib.parse.quote(title)}" 176 | 177 | # set headers 178 | async with session.get(mainsite_url) as resp: 179 | assert resp.status == 200 180 | 181 | async with session.get(search_url) as resp: 182 | result = (await resp.json())["data"]["result"] 183 | 184 | for i in result: 185 | if i.get("result_type") != "video": 186 | continue 187 | # 只返回第一个结果 188 | return i["data"][0].get("arcurl") 189 | 190 | 191 | # 处理超过一万的数字 192 | def handle_num(num: int): 193 | if num > 10000: 194 | num = f"{num / 10000:.2f}万" 195 | return num 196 | 197 | 198 | async def video_detail(url: str, session: ClientSession, **kwargs): 199 | try: 200 | async with session.get(url) as resp: 201 | res = (await resp.json()).get("data") 202 | if not res: 203 | return "解析到视频被删了/稿件不可见或审核中/权限不足", url 204 | vurl = f"https://www.bilibili.com/video/av{res['aid']}" 205 | title = f"\n标题:{res['title']}\n" 206 | cover = ( 207 | MessageSegment.image(res["pic"]) 208 | if analysis_display_image or "video" in analysis_display_image_list 209 | else MessageSegment.text("") 210 | ) 211 | if page := kwargs.get("page"): 212 | page = page[0].replace("&", "&") 213 | p = int(page[3:]) 214 | if p <= len(res["pages"]): 215 | vurl += f"?p={p}" 216 | part = res["pages"][p - 1]["part"] 217 | if part != res["title"]: 218 | title += f"小标题:{part}\n" 219 | if time_location := kwargs.get("time_location"): 220 | time_location = time_location[0].replace("&", "&")[3:] 221 | if page: 222 | vurl += f"&t={time_location}" 223 | else: 224 | vurl += f"?t={time_location}" 225 | pubdate = strftime("%Y-%m-%d %H:%M:%S", localtime(res["pubdate"])) 226 | tname = f"类型:{res['tname']} | UP:{res['owner']['name']} | 日期:{pubdate}\n" 227 | stat = f"播放:{handle_num(res['stat']['view'])} | 弹幕:{handle_num(res['stat']['danmaku'])} | 收藏:{handle_num(res['stat']['favorite'])}\n" 228 | stat += f"点赞:{handle_num(res['stat']['like'])} | 硬币:{handle_num(res['stat']['coin'])} | 评论:{handle_num(res['stat']['reply'])}\n" 229 | desc = f"简介:{res['desc']}" 230 | desc_list = desc.split("\n") 231 | desc = "".join(i + "\n" for i in desc_list if i) 232 | desc_list = desc.split("\n") 233 | if len(desc_list) > 4: 234 | desc = desc_list[0] + "\n" + desc_list[1] + "\n" + desc_list[2] + "……" 235 | mstext = MessageSegment.text("".join([vurl, title, tname, stat, desc])) 236 | msg = Message([cover, mstext]) 237 | return msg, vurl 238 | except Exception as e: 239 | msg = "视频解析出错--Error: {}".format(type(e)) 240 | return msg, None 241 | 242 | 243 | async def bangumi_detail(url: str, time_location: str, session: ClientSession): 244 | try: 245 | async with session.get(url) as resp: 246 | res = (await resp.json()).get("result") 247 | if not res: 248 | return None, None 249 | cover = ( 250 | MessageSegment.image(res["cover"]) 251 | if analysis_display_image or "bangumi" in analysis_display_image_list 252 | else MessageSegment.text("") 253 | ) 254 | title = f"番剧:{res['title']}\n" 255 | desc = f"{res['newest_ep']['desc']}\n" 256 | index_title = "" 257 | style = "".join(f"{i}," for i in res["style"]) 258 | style = f"类型:{style[:-1]}\n" 259 | evaluate = f"简介:{res['evaluate']}\n" 260 | if "season_id" in url: 261 | vurl = f"https://www.bilibili.com/bangumi/play/ss{res['season_id']}" 262 | elif "media_id" in url: 263 | vurl = f"https://www.bilibili.com/bangumi/media/md{res['media_id']}" 264 | else: 265 | epid = re.compile(r"ep_id=\d+").search(url)[0][len("ep_id=") :] 266 | for i in res["episodes"]: 267 | if str(i["ep_id"]) == epid: 268 | index_title = f"标题:{i['index_title']}\n" 269 | break 270 | vurl = f"https://www.bilibili.com/bangumi/play/ep{epid}" 271 | if time_location: 272 | time_location = time_location[0].replace("&", "&")[3:] 273 | vurl += f"?t={time_location}" 274 | mstext = MessageSegment.text( 275 | "".join([f"{vurl}\n", title, index_title, desc, style, evaluate]) 276 | ) 277 | msg = Message([cover, mstext]) 278 | return msg, vurl 279 | except Exception as e: 280 | msg = "番剧解析出错--Error: {}".format(type(e)) 281 | msg += f"\n{url}" 282 | return msg, None 283 | 284 | 285 | async def live_detail(url: str, session: ClientSession): 286 | try: 287 | async with session.get(url) as resp: 288 | res = await resp.json() 289 | if res["code"] != 0: 290 | return None, None 291 | res = res["data"] 292 | uname = res["anchor_info"]["base_info"]["uname"] 293 | room_id = res["room_info"]["room_id"] 294 | title = res["room_info"]["title"] 295 | cover = ( 296 | MessageSegment.image(res["room_info"]["cover"]) 297 | if analysis_display_image or "live" in analysis_display_image_list 298 | else MessageSegment.text("") 299 | ) 300 | live_status = res["room_info"]["live_status"] 301 | lock_status = res["room_info"]["lock_status"] 302 | parent_area_name = res["room_info"]["parent_area_name"] 303 | area_name = res["room_info"]["area_name"] 304 | online = res["room_info"]["online"] 305 | tags = res["room_info"]["tags"] 306 | watched_show = res["watched_show"]["text_large"] 307 | vurl = f"https://live.bilibili.com/{room_id}\n" 308 | if lock_status: 309 | lock_time = res["room_info"]["lock_time"] 310 | lock_time = strftime("%Y-%m-%d %H:%M:%S", localtime(lock_time)) 311 | title = f"[已封禁]直播间封禁至:{lock_time}\n" 312 | elif live_status == 1: 313 | title = f"[直播中]标题:{title}\n" 314 | elif live_status == 2: 315 | title = f"[轮播中]标题:{title}\n" 316 | else: 317 | title = f"[未开播]标题:{title}\n" 318 | up = f"主播:{uname} 当前分区:{parent_area_name}-{area_name}\n" 319 | watch = f"观看:{watched_show} 直播时的人气上一次刷新值:{handle_num(online)}\n" 320 | if tags: 321 | tags = f"标签:{tags}\n" 322 | if live_status: 323 | player = f"独立播放器:https://www.bilibili.com/blackboard/live/live-activity-player.html?enterTheRoom=0&cid={room_id}" 324 | else: 325 | player = "" 326 | mstext = MessageSegment.text("".join([vurl, title, up, watch, tags, player])) 327 | msg = Message([cover, mstext]) 328 | return msg, vurl 329 | except Exception as e: 330 | msg = "直播间解析出错--Error: {}".format(type(e)) 331 | return msg, None 332 | 333 | 334 | async def article_detail(url: str, cvid: str, session: ClientSession): 335 | try: 336 | async with session.get(url) as resp: 337 | res = (await resp.json()).get("data") 338 | if not res: 339 | return None, None 340 | images = ( 341 | [MessageSegment.image(i) for i in res["origin_image_urls"]] 342 | if analysis_display_image or "article" in analysis_display_image_list 343 | else [] 344 | ) 345 | vurl = f"https://www.bilibili.com/read/cv{cvid}" 346 | title = f"标题:{res['title']}\n" 347 | up = f"作者:{res['author_name']} (https://space.bilibili.com/{res['mid']})\n" 348 | view = f"阅读数:{handle_num(res['stats']['view'])} " 349 | favorite = f"收藏数:{handle_num(res['stats']['favorite'])} " 350 | coin = f"硬币数:{handle_num(res['stats']['coin'])}" 351 | share = f"分享数:{handle_num(res['stats']['share'])} " 352 | like = f"点赞数:{handle_num(res['stats']['like'])} " 353 | dislike = f"不喜欢数:{handle_num(res['stats']['dislike'])}" 354 | desc = view + favorite + coin + "\n" + share + like + dislike + "\n" 355 | mstext = MessageSegment.text("".join([title, up, desc, vurl])) 356 | msg = Message(images) 357 | msg.append(mstext) 358 | return msg, vurl 359 | except Exception as e: 360 | msg = "专栏解析出错--Error: {}".format(type(e)) 361 | return msg, None 362 | 363 | 364 | async def dynamic_detail(url: str, session: ClientSession): 365 | try: 366 | async with session.get(url) as resp: 367 | res = (await resp.json())["data"].get("card") 368 | if not res: 369 | return None, None 370 | card = json.loads(res["card"]) 371 | dynamic_id = res["desc"]["dynamic_id"] 372 | vurl = f"https://t.bilibili.com/{dynamic_id}\n" 373 | if not (item := card.get("item")): 374 | return "动态不存在文字内容", vurl 375 | if not (content := item.get("description")): 376 | content = item.get("content") 377 | content = content.replace("\r", "\n") 378 | if len(content) > 250: 379 | content = content[:250] + "......" 380 | images = ( 381 | item.get("pictures", []) 382 | if analysis_display_image or "dynamic" in analysis_display_image_list 383 | else [] 384 | ) 385 | if images: 386 | images = [MessageSegment.image(i.get("img_src")) for i in images] 387 | else: 388 | pics = item.get("pictures_count") 389 | if pics: 390 | content += f"\nPS:动态中包含{pics}张图片" 391 | if origin := card.get("origin"): 392 | jorigin = json.loads(origin) 393 | short_link = jorigin.get("short_link") 394 | if short_link: 395 | content += f"\n动态包含转发视频{short_link}" 396 | else: 397 | content += f"\n动态包含转发其他动态" 398 | msg = Message(content) 399 | msg.extend(images) 400 | msg.append(MessageSegment.text(f"\n{vurl}")) 401 | return msg, vurl 402 | except Exception as e: 403 | msg = "动态解析出错--Error: {}".format(type(e)) 404 | return msg, None 405 | -------------------------------------------------------------------------------- /custom/atall.py: -------------------------------------------------------------------------------- 1 | from hoshino import Service 2 | from nonebot import MessageSegment 3 | 4 | sv_help = """ 5 | 让群员使用bot来@全体成员,前提bot得有管理员(叫人用 6 | 只要前缀为"@全员"就触发,默认关闭 7 | """.strip() 8 | sv = Service("atall", enable_on_default=False) 9 | 10 | 11 | @sv.on_prefix("@全员") 12 | async def atall(bot, ev): 13 | try: 14 | msg = ev.message.extract_plain_text() 15 | msg = f"{MessageSegment.at('all')} {msg}" 16 | await bot.send(ev, msg) 17 | # 一个一个群员进行@,慎用 18 | # try: 19 | # await bot.send(ev, msg) 20 | # except: 21 | # try: 22 | # m = await bot.get_group_member_list(group_id=ev.group_id) 23 | # msg = "" 24 | # for i in range(0, len(m)): 25 | # u = m[i]["user_id"] 26 | # if u != ev.self_id: 27 | # msg += f"{MessageSegment.at(u)} " 28 | # msg += ev.message.extract_plain_text() 29 | # await bot.send(ev, msg) 30 | # except: 31 | # await bot.send(ev, "at all send fail!!!") 32 | except: 33 | # 可能发送内容被风控,只发送@全体成员 34 | await bot.send(ev, MessageSegment.at("all")) 35 | -------------------------------------------------------------------------------- /custom/cat.py: -------------------------------------------------------------------------------- 1 | import httpx 2 | from nonebot import on_command, CommandSession, MessageSegment 3 | 4 | 5 | @on_command("!cat", only_to_me=False) 6 | async def cat(session: CommandSession): 7 | url = "https://api.thecatapi.com/v1/images/search" 8 | with httpx.Client(proxies={}) as client: 9 | r = client.get(url, timeout=5) 10 | picurl = r.json()[0]["url"] 11 | await session.send(MessageSegment.image(picurl)) 12 | -------------------------------------------------------------------------------- /custom/dog.py: -------------------------------------------------------------------------------- 1 | import httpx 2 | from nonebot import on_command, CommandSession, MessageSegment 3 | 4 | 5 | @on_command("/dog", aliases=("!dog", "\\dog"), only_to_me=False) 6 | async def dog(session: CommandSession): 7 | try: 8 | try: 9 | api_url = "https://api.thedogapi.com/v1/images/search" 10 | with httpx.Client(proxies={}) as client: 11 | r = client.get(api_url, timeout=5) 12 | img_url = r.json()[0]["url"] 13 | except Exception as e: 14 | api_url = "https://dog.ceo/api/breeds/image/random" 15 | with httpx.Client(proxies={}) as client: 16 | r = client.get(api_url, timeout=5) 17 | img_url = r.json()["message"] 18 | msg = MessageSegment.image(img_url) 19 | except Exception as e: 20 | msg = "Error: {}".format(type(e)) 21 | await session.send(msg) 22 | -------------------------------------------------------------------------------- /custom/fileLink.py: -------------------------------------------------------------------------------- 1 | import re 2 | import urllib 3 | 4 | try: 5 | from hoshino import Service 6 | from nonebot import MessageSegment 7 | 8 | _sv = Service("groupFileLink") 9 | sv = _sv.on_notice 10 | except: 11 | from nonebot import on_notice, MessageSegment 12 | 13 | sv = on_notice 14 | 15 | 16 | @sv("group_upload") 17 | async def groupFileLink(session): 18 | link = session.ctx["file"]["url"] 19 | file_name = session.ctx["file"]["name"] 20 | size = session.ctx["file"]["size"] 21 | link = re.sub(r"fname=.*", f"fname={urllib.parse.quote(file_name)}", link) 22 | if ( 23 | link[-4:].lower() in [".jpg", ".png", ".gif", ".bmp", "jfif", "webp"] 24 | and size < 31457280 25 | ): 26 | await session.send(MessageSegment.image(link)) 27 | elif ( 28 | link[-4:].lower() 29 | in [".mp4", ".avi", ".mkv", ".rmvb", ".flv", ".wmv", ".mpg", ".mpeg"] 30 | and size < 104857600 31 | ): 32 | await session.send(MessageSegment.video(link)) 33 | elif ( 34 | link[-4:].lower() in [".mp3", ".wav", ".wma", ".ogg", ".ape", ".flac"] 35 | and size < 31457280 36 | ): 37 | await session.send(MessageSegment.record(link)) 38 | else: 39 | await session.send(f"文件:{file_name}\n直链:{link}") 40 | -------------------------------------------------------------------------------- /custom/fox.py: -------------------------------------------------------------------------------- 1 | import httpx 2 | from nonebot import on_command, CommandSession, MessageSegment 3 | 4 | 5 | @on_command("/fox", aliases=("!fox", "\\fox"), only_to_me=False) 6 | async def fox(session: CommandSession): 7 | try: 8 | api_url = "https://randomfox.ca/floof/" 9 | with httpx.Client(proxies={}) as client: 10 | r = client.get(api_url, timeout=5) 11 | img_url = r.json()["image"] 12 | msg = MessageSegment.image(img_url) 13 | except Exception as e: 14 | msg = "Error: {}".format(type(e)) 15 | await session.send(msg) 16 | -------------------------------------------------------------------------------- /custom/manage_group.py: -------------------------------------------------------------------------------- 1 | import nonebot 2 | from nonebot.argparse import ArgumentParser 3 | from nonebot import on_command, CommandSession 4 | from hoshino.typing import NoticeSession 5 | from nonebot.permission import SUPERUSER 6 | 7 | USAGE = r""" 8 | USAGE: group [OPTIONS] 9 | 10 | OPTIONS: 11 | -h, --help 显示本使用帮助 12 | -ls, --list 显示群列表 13 | -l , --leave 退出某个群聊 14 | """.strip() 15 | 16 | 17 | @on_command("group", permission=SUPERUSER, only_to_me=False, shell_like=True) 18 | async def set_group(session: CommandSession): 19 | parser = ArgumentParser(session=session, usage=USAGE) 20 | parser.add_argument("-ls", "--list", action="store_true") 21 | parser.add_argument("-l", "--leave", type=int, default=0) 22 | args = parser.parse_args(session.argv) 23 | if args.list: 24 | await ls_group(session) 25 | elif args.leave: 26 | gid = args.leave 27 | await leave_group(session, gid) 28 | else: 29 | await session.finish(USAGE) 30 | 31 | 32 | async def ls_group(session: CommandSession): 33 | bot = session.bot 34 | self_ids = bot._wsr_api_clients.keys() 35 | for sid in self_ids: 36 | gl = await bot.get_group_list(self_id=sid) 37 | msg = ["{group_id} {group_name}".format_map(g) for g in gl] 38 | msg = "\n".join(msg) 39 | msg = f"bot:{sid}\n| 群号 | 群名 | 共{len(gl)}个群\n" + msg 40 | await bot.send_private_msg( 41 | self_id=sid, user_id=bot.config.SUPERUSERS[0], message=msg 42 | ) 43 | 44 | 45 | async def leave_group(session: CommandSession, gid: int): 46 | if session.ctx["user_id"] in session.bot.config.SUPERUSERS: 47 | if not gid: 48 | session.finish(USAGE) 49 | else: 50 | return 51 | await session.bot.set_group_leave(group_id=gid) 52 | -------------------------------------------------------------------------------- /custom/nbnhhsh.py: -------------------------------------------------------------------------------- 1 | import aiohttp 2 | from nonebot import CommandSession 3 | from hoshino import Service, util 4 | 5 | sv = Service("nbnhhsh") 6 | 7 | 8 | @sv.on_command( 9 | "sx", aliases=("缩写", "zy", "转义", "nhnhhsh", "/sx", "\sx"), only_to_me=False 10 | ) 11 | async def nbnhhsh(session: CommandSession): 12 | episode = session.current_arg_text.strip() 13 | if not episode: 14 | await session.send( 15 | "请输入缩写的内容,缩写-nhnhhsh-能不能好好说话,web:https://lab.magiconch.com/nbnhhsh/,前缀sx触发", 16 | at_sender=True, 17 | ) 18 | else: 19 | try: 20 | url = f"https://lab.magiconch.com/api/nbnhhsh/guess" 21 | data = {"text": episode} 22 | async with aiohttp.request("POST", url, json=data) as r: 23 | try: 24 | data = (await r.json())[0]["trans"] 25 | except: 26 | await session.send( 27 | "未查询到转义,可前往https://lab.magiconch.com/nbnhhsh/ 查询/贡献词条", 28 | at_sender=True, 29 | ) 30 | return 31 | msg = "可能拼音缩写的是:" + str(data) 32 | msg += "\n如果带有屏蔽词自行前往 web:https://lab.magiconch.com/nbnhhsh/ 查询" 33 | await session.send(util.filt_message(msg), at_sender=True) 34 | except Exception as e: 35 | msg = "Error: {}".format(type(e)) 36 | await session.send(msg) 37 | -------------------------------------------------------------------------------- /custom/ping.py: -------------------------------------------------------------------------------- 1 | import time 2 | from nonebot import on_command, CommandSession 3 | 4 | 5 | @on_command("/ping", only_to_me=False) 6 | async def ping(session: CommandSession): 7 | time_from_receive = session.event["time"] 8 | if time_from_receive > 3000000000: 9 | time_from_receive = time_from_receive / 1000 10 | await session.finish( 11 | "->" + str(time.time() - time_from_receive) + "s", at_sender=True 12 | ) 13 | -------------------------------------------------------------------------------- /custom/processing_request.py: -------------------------------------------------------------------------------- 1 | import nonebot 2 | from nonebot import on_request, RequestSession, on_command, CommandSession 3 | from nonebot.permission import SUPERUSER, check_permission 4 | 5 | flag = 0 6 | bot = nonebot.get_bot() 7 | send_msg_user = bot.config.SUPERUSERS[0] 8 | 9 | # 默认同意好友的群配置,如果申请的人在下列群里,默认同意好友请求 10 | allow_group = [] # [123, 234, 567] 11 | 12 | 13 | @on_request("friend") 14 | async def friend_req(session: RequestSession): 15 | userlist = [] 16 | hasgroup = await session.bot.get_group_list() 17 | hasgroup = [i["group_id"] for i in hasgroup] 18 | for g in range(0, len(allow_group)): 19 | if allow_group[g] in hasgroup: 20 | m = await session.bot.get_group_member_list(group_id=allow_group[g]) 21 | for i in range(0, len(m)): 22 | u = m[i]["user_id"] 23 | userlist.append(u) 24 | if session.event.user_id in userlist: 25 | await session.approve() 26 | await session.bot.send_msg( 27 | message_type="private", 28 | user_id=send_msg_user, 29 | message=f"已同意{session.event.user_id}的好友请求!", 30 | ) 31 | else: 32 | global flag 33 | if flag != session.event.flag: 34 | await session.bot.send_msg( 35 | message_type="private", 36 | user_id=send_msg_user, 37 | message=f"获取到{session.event.user_id} -> {session.self_id}好友请求\n同意好友请求:/approve friend {session.event.flag}", 38 | ) 39 | flag = session.event.flag 40 | 41 | 42 | @on_request("group.invite") 43 | async def group_invite(session: RequestSession): 44 | if await check_permission(session.bot, session.event, SUPERUSER): 45 | await session.bot.set_group_add_request( 46 | flag=session.event.flag, sub_type="invite", approve=True 47 | ) 48 | else: 49 | await session.bot.send_msg( 50 | message_type="private", 51 | user_id=send_msg_user, 52 | message=f"获取到{session.event.user_id} -> {session.event.group_id}({session.self_id})群请求\n同意群请求:/approve group {session.event.flag}", 53 | ) 54 | 55 | 56 | @on_command("/approve", permission=SUPERUSER) 57 | async def processing_request(session: CommandSession): 58 | args = session.current_arg.strip() 59 | args = args.split(" ") 60 | if len(args) == 1: 61 | await session.send("格式错误!例:/approve friend/group (flag)") 62 | return 63 | t = args[0] 64 | flag = args[1] 65 | if t == "friend": 66 | await session.bot.call_action( 67 | action="set_friend_add_request", flag=flag, approve=True 68 | ) 69 | elif t == "group": 70 | await session.bot.call_action( 71 | action="set_group_add_request", flag=flag, approve=True, sub_type="invite" 72 | ) 73 | await session.finish("请求已处理!") 74 | -------------------------------------------------------------------------------- /custom/requirements.txt: -------------------------------------------------------------------------------- 1 | httpx 2 | aiohttp 3 | pyzbar # scan_qrcode.py 4 | psutil # status_info.py -------------------------------------------------------------------------------- /custom/scan_qrcode.py: -------------------------------------------------------------------------------- 1 | import aiohttp 2 | import re 3 | from pyzbar.pyzbar import decode 4 | from PIL import Image 5 | from io import BytesIO 6 | from hoshino import Service 7 | 8 | sv = Service("qrcode", help_="二维码识别") 9 | 10 | 11 | def get_reply_images(msg): 12 | ret = re.findall(r"\[CQ:image,file=.*?,url=(.*?)\]", msg) 13 | if ret: 14 | return ret 15 | return None 16 | 17 | 18 | async def get_universal_img_url(url: str) -> str: 19 | final_url = url.replace( 20 | "/c2cpicdw.qpic.cn/offpic_new/", "/gchat.qpic.cn/gchatpic_new/" 21 | ) 22 | final_url = re.sub(r"/\d+/+\d+-\d+-", "/0/0-0-", final_url) 23 | final_url = re.sub(r"\?.*$", "", final_url) 24 | async with aiohttp.ClientSession() as session: 25 | async with session.get(final_url) as resp: 26 | if resp.status == 200: 27 | return final_url 28 | return url 29 | 30 | 31 | async def get_image_content(url) -> bytes: 32 | async with aiohttp.ClientSession() as session: 33 | async with session.get(url) as resp: 34 | if resp.status == 200: 35 | return await resp.read() 36 | 37 | 38 | async def decode_qrcode(content: bytes) -> str: 39 | img = Image.open(BytesIO(content)) 40 | result = decode(img) 41 | if result: 42 | return f"解析结果:{result[0].data.decode('utf-8')}\n" 43 | return "" 44 | 45 | 46 | @sv.on_rex(r"\/qr|\/qrcode|\/二维码|\/二维码识别") 47 | async def qrcode(bot, ev): 48 | images = [] 49 | msg = "" 50 | for m in ev.message: 51 | if m.type == "reply": 52 | content = await bot.get_msg( 53 | self_id=ev.self_id, message_id=int(m.data["id"]) 54 | ) 55 | reply_images = get_reply_images(content["message"]) 56 | if not reply_images: 57 | await bot.send(ev, "未找到图片") 58 | return 59 | images.extend(reply_images) 60 | elif m.type == "image": 61 | images.append(m.data["url"]) 62 | if not images: 63 | await bot.send(ev, "未找到图片") 64 | return 65 | for i in images: 66 | url = await get_universal_img_url(i) 67 | content = await get_image_content(url) 68 | msg += await decode_qrcode(content) 69 | await bot.send(ev, msg) 70 | -------------------------------------------------------------------------------- /custom/status_info.py: -------------------------------------------------------------------------------- 1 | import psutil 2 | from nonebot.argparse import ArgumentParser 3 | from nonebot import on_command, CommandSession 4 | from nonebot.permission import SUPERUSER 5 | 6 | USAGE = r""" 7 | USAGE: status [OPTIONS] 8 | 9 | OPTIONS: 10 | -h, --help 显示本使用帮助 11 | -a, --all 显示所有信息 12 | -m, --memory 显示内存信息 13 | -d, --disk 显示硬盘信息 14 | -c, --cpu 显示cpu信息 15 | """.strip() 16 | 17 | 18 | @on_command("status", permission=SUPERUSER, only_to_me=False, shell_like=True) 19 | async def get_status(session: CommandSession): 20 | parser = ArgumentParser(session=session, usage=USAGE) 21 | parser.add_argument("-a", "--all", action="store_true") 22 | parser.add_argument("-m", "--memory", action="store_true") 23 | parser.add_argument("-d", "--disk", action="store_true") 24 | parser.add_argument("-c", "--cpu", action="store_true") 25 | args = parser.parse_args(session.argv) 26 | if args.all: 27 | memory_info = await memory_status() 28 | cpu_info = await cpu_status() 29 | disk_info = await disk_status() 30 | msg = str(disk_info) + "\n" + str(cpu_info) + "\n" + str(memory_info) 31 | await session.finish(str(msg)) 32 | elif args.memory: 33 | msg = await memory_status() 34 | await session.finish(str(msg)) 35 | elif args.disk: 36 | msg = await disk_status() 37 | await session.finish(str(msg)) 38 | elif args.cpu: 39 | msg = await cpu_status() 40 | await session.finish(str(msg)) 41 | else: 42 | await session.finish(USAGE) 43 | 44 | 45 | async def memory_status(): 46 | virtual_memory = psutil.virtual_memory() 47 | used_memory = virtual_memory.used / 1024 / 1024 / 1024 48 | free_memory = virtual_memory.free / 1024 / 1024 / 1024 49 | memory_percent = virtual_memory.percent 50 | msg = "内存使用:%0.2fG,使用率%0.1f%%,剩余内存:%0.2fG" % ( 51 | used_memory, 52 | memory_percent, 53 | free_memory, 54 | ) 55 | return msg 56 | 57 | 58 | async def cpu_status(): 59 | cpu_percent = psutil.cpu_percent(interval=1) 60 | msg = "CPU使用率:%i%%" % cpu_percent 61 | return msg 62 | 63 | 64 | async def disk_status(): 65 | content = "" 66 | for disk in psutil.disk_partitions(): 67 | # 读写方式 光盘 or 有效磁盘类型 68 | if "cdrom" in disk.opts or disk.fstype == "": 69 | continue 70 | disk_name_arr = disk.device.split(":") 71 | disk_name = disk_name_arr[0] 72 | disk_info = psutil.disk_usage(disk.device) 73 | # 磁盘剩余空间,单位G 74 | free_disk_size = disk_info.free // 1024 // 1024 // 1024 75 | # 当前磁盘使用率和剩余空间G信息 76 | info = "%s盘使用率:%s%%, 剩余空间:%iG" % ( 77 | disk_name, 78 | str(disk_info.percent), 79 | free_disk_size, 80 | ) 81 | # print(info) 82 | # 拼接多个磁盘的信息 83 | content = content + info + "\n" 84 | msg = content[:-1] 85 | return msg 86 | -------------------------------------------------------------------------------- /custom/wantwords.py: -------------------------------------------------------------------------------- 1 | import httpx 2 | import urllib.parse 3 | from nonebot import CommandSession 4 | from hoshino import Service 5 | 6 | sv = Service("wantword") 7 | 8 | 9 | @sv.on_command("ww", aliases=("反向词典"), only_to_me=False) 10 | async def nbnhhsh(session: CommandSession): 11 | episode = session.current_arg_text.strip() 12 | if not episode: 13 | await session.send( 14 | "请输入想要查找的内容,反向词典:https://wantwords.net/", 15 | at_sender=True, 16 | ) 17 | else: 18 | try: 19 | url = f"https://wantwords.net/EnglishRD/?m=ZhEn&q={urllib.parse.quote(episode)}" 20 | with httpx.Client(proxies={}) as client: 21 | r = (client.get(url, timeout=5)).json() 22 | msg = "反向词典获取到的前三个的结果:\n" 23 | for i in range(0, 3): 24 | data = r[i] 25 | msg += f"word:{data['w']} (Part of speech:{data['P']})\ndescription:{data['d']}\n" 26 | await session.send(msg, at_sendser=True) 27 | except Exception as e: 28 | msg = "Error: {}".format(type(e)) 29 | await session.send(msg) 30 | -------------------------------------------------------------------------------- /custom_reply/__init__.py: -------------------------------------------------------------------------------- 1 | try: 2 | import ujson as json 3 | except: 4 | import json 5 | import os 6 | import shutil 7 | from nonebot.log import logger 8 | 9 | 10 | class CRdata: 11 | # 仅回复带有自定义前缀的信息(能减少占用,为空默认关闭) 12 | # 例:custom_prefix = "/!" 这里就是匹配/和!开头的自定义回复,不用分隔开 13 | custom_prefix = "" 14 | 15 | # 大小写敏感(True/False),默认为True即敏感,对自定义回复的字母大小写敏感 16 | sensitive = True 17 | 18 | # 初始化 19 | try: 20 | if not os.path.exists("./data/"): 21 | os.mkdir("./data") 22 | if not os.path.exists("./data/custom_reply"): 23 | os.mkdir("./data/custom_reply") 24 | if os.path.exists("CustomReplyData.json"): 25 | shutil.move( 26 | "./CustomReplyData.json", "./data/custom_reply/CustomReplyData.json" 27 | ) 28 | if os.path.exists("HideCustomReplyList.json"): 29 | shutil.move( 30 | "./HideCustomReplyList.json", 31 | "./data/custom_reply/HideCustomReplyList.json", 32 | ) 33 | with open( 34 | "./data/custom_reply/CustomReplyData.json", "r", encoding="GB2312" 35 | ) as f: 36 | data = json.load(f) 37 | except: 38 | if os.path.exists("./data/custom_reply/CustomReplyData.json"): 39 | logger.error("自定义回复数据文件格式有误") 40 | else: 41 | data = {} 42 | with open( 43 | "./data/custom_reply/CustomReplyData.json", "w", encoding="GB2312" 44 | ) as f: 45 | json.dump(data, f, ensure_ascii=False) 46 | logger.info("未发现自定义回复数据文件,新建数据文件") 47 | -------------------------------------------------------------------------------- /custom_reply/custom_reply.py: -------------------------------------------------------------------------------- 1 | import nonebot 2 | import aiocqhttp 3 | 4 | try: 5 | import ujson as json 6 | except: 7 | import json 8 | from . import * 9 | 10 | bot = nonebot.get_bot() 11 | 12 | 13 | @bot.on_message() 14 | async def custom_reply(event: aiocqhttp.Event): 15 | if event.message: 16 | msg = str(event.message) 17 | if msg[0] in CRdata.custom_prefix or not CRdata.custom_prefix: 18 | for key in CRdata.data: 19 | if not CRdata.sensitive: 20 | ckey = key.lower() 21 | cmsg = msg.lower() 22 | if ckey == cmsg: 23 | await bot.send(event, CRdata.data[key]) 24 | break 25 | -------------------------------------------------------------------------------- /custom_reply/manage_content.py: -------------------------------------------------------------------------------- 1 | try: 2 | import ujson as json 3 | except: 4 | import json 5 | import os 6 | from nonebot import on_command, CommandSession 7 | from nonebot.permission import * 8 | from . import * 9 | 10 | 11 | @on_command( 12 | "addCR", 13 | aliases=("CRadd", "addcr", "cradd", "add_custom_reply"), 14 | permission=SUPERUSER, 15 | only_to_me=True, 16 | ) 17 | async def add_custom_reply_content(session: CommandSession): 18 | content = session.current_arg_text.strip() 19 | content = content.split("|||") 20 | with open("./data/custom_reply/CustomReplyData.json", "r", encoding="GB2312") as f: 21 | data = json.load(f) 22 | key = content[0] 23 | data[key] = content[1] 24 | hidetext = "" 25 | hidedata = {} 26 | # 处理隐藏的回复 27 | if len(content) > 2: 28 | if os.path.exists("./data/custom_reply/HideCustomReplyList.json"): 29 | with open( 30 | "./data/custom_reply/HideCustomReplyList.json", "r", encoding="GB2312" 31 | ) as f: 32 | hidedata = json.load(f) 33 | hidedata[key] = "" 34 | with open( 35 | "./data/custom_reply/HideCustomReplyList.json", "w", encoding="GB2312" 36 | ) as f: 37 | json.dump(hidedata, f, indent=4, ensure_ascii=False) 38 | hidetext = "隐藏的" 39 | # 最终写入文件 40 | with open("./data/custom_reply/CustomReplyData.json", "w", encoding="GB2312") as f: 41 | json.dump(data, f, indent=4, ensure_ascii=False) 42 | CRdata.data = data 43 | await session.finish(f'{hidetext}自定义回复"{key}"添加成功!') 44 | 45 | 46 | @on_command( 47 | "delCR", 48 | aliases=("CRdel", "delcr", "crdel", "del_custom_reply"), 49 | permission=SUPERUSER, 50 | only_to_me=True, 51 | ) 52 | async def del_custom_reply_content(session: CommandSession): 53 | content = session.current_arg_text.strip() 54 | with open("./data/custom_reply/CustomReplyData.json", "r", encoding="GB2312") as f: 55 | data = json.load(f) 56 | try: 57 | del data[content] 58 | CRdata.data = data 59 | with open( 60 | "./data/custom_reply/CustomReplyData.json", "w", encoding="GB2312" 61 | ) as f: 62 | json.dump(data, f, indent=4, ensure_ascii=False) 63 | await session.send(f'自定义回复"{content}"删除成功!') 64 | if os.path.exists("./data/custom_reply/HideCustomReplyList.json"): 65 | try: 66 | with open( 67 | "./data/custom_reply/HideCustomReplyList.json", 68 | "r", 69 | encoding="GB2312", 70 | ) as f: 71 | data = json.load(f) 72 | del data[content] 73 | with open( 74 | "./data/custom_reply/HideCustomReplyList.json", 75 | "w", 76 | encoding="GB2312", 77 | ) as f: 78 | json.dump(data, f, indent=4, ensure_ascii=False) 79 | except Exception as e: 80 | pass 81 | except Exception as e: 82 | logger.warning(repr(e)) 83 | await session.finish(f'不存在该自定义回复"{content}"') 84 | 85 | 86 | @on_command( 87 | "/crlist", aliases=("/CRlist", "/CRLIST", "crlist", "CRLIST"), only_to_me=False 88 | ) 89 | async def show_custom_reply_list(session: CommandSession): 90 | content = session.current_arg_text.strip() 91 | with open("./data/custom_reply/CustomReplyData.json", "r", encoding="GB2312") as f: 92 | data = json.load(f) 93 | if os.path.exists("./data/custom_reply/HideCustomReplyList.json"): 94 | with open( 95 | "./data/custom_reply/HideCustomReplyList.json", "r", encoding="GB2312" 96 | ) as f: 97 | hide_list = json.load(f) 98 | for i in list(hide_list.keys()): 99 | del data[i] 100 | crlist = list(data.keys()) 101 | try: 102 | limit = int(len(crlist) / 50) + 1 103 | if int(content) > limit: 104 | await session.send("输入参数无效,超过最大页数!") 105 | return 106 | count = int(content) * 50 107 | crlist = crlist[count - 50 : count] 108 | except Exception as e: 109 | content = 1 110 | crlist = crlist[0:50] 111 | await session.finish(str(crlist) + f"\n每页最多显示50个,当前为第{content}页,总共{limit}页。") 112 | -------------------------------------------------------------------------------- /custom_reply/requirements.txt: -------------------------------------------------------------------------------- 1 | ujson~=5.7.0 -------------------------------------------------------------------------------- /rss2/__init__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import nonebot 4 | from nonebot.log import logger 5 | 6 | from . import my_trigger as tr 7 | from .config import DATA_PATH 8 | from .rss_class import Rss 9 | 10 | # 启动时发送启动成功信息 11 | @nonebot.on_websocket_connect 12 | async def start(event) -> None: 13 | # 启动后检查 data 目录,不存在就创建 14 | if not DATA_PATH.is_dir(): 15 | DATA_PATH.mkdir() 16 | 17 | rss_list = Rss.read_rss() # 读取list 18 | if not rss_list: 19 | logger.info("第一次启动,你还没有订阅,记得添加哟!") 20 | logger.info("ELF_RSS 订阅器启动成功!") 21 | # 创建检查更新任务 22 | await asyncio.gather(*[tr.add_job(rss) for rss in rss_list if not rss.stop]) 23 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /rss2/command/add_cookies.py: -------------------------------------------------------------------------------- 1 | from nonebot import on_command, CommandSession 2 | from ..permission import admin_permission 3 | from .. import my_trigger as tr 4 | from ..rss_class import Rss 5 | 6 | prompt = """\ 7 | 请输入: 8 | 名称 cookies 9 | 空格分割 10 | 11 | 获取方式: 12 | PC端 Chrome 浏览器按 F12 13 | 找到Console选项卡,输入: 14 | document.cookie 15 | 输出的字符串就是了\ 16 | """ 17 | 18 | 19 | @on_command( 20 | "add_cookies", aliases=("添加cookies"), permission=admin_permission, only_to_me=False 21 | ) 22 | async def add_cookies(session: CommandSession): 23 | rss_cookies = (await session.aget("add_cookies", prompt=prompt)).strip() 24 | name, cookies = rss_cookies.split(" ", 1) 25 | 26 | # 判断是否有该名称订阅 27 | rss = Rss.get_one_by_name(name=name) 28 | if rss is None: 29 | await session.finish(f"❌ 不存在该订阅: {name}") 30 | else: 31 | rss.name = name 32 | rss.set_cookies(cookies) 33 | await tr.add_job(rss) 34 | await session.finish(f"👏 {rss.name}的Cookies添加成功!") 35 | 36 | 37 | @add_cookies.args_parser 38 | async def _(session: CommandSession): 39 | # 去掉消息首尾的空白符 40 | stripped_arg = session.current_arg_text.strip() 41 | 42 | if session.is_first_run: 43 | # 该命令第一次运行(第一次进入命令会话) 44 | if stripped_arg: 45 | session.state["add_cookies"] = stripped_arg 46 | return 47 | 48 | if not stripped_arg: 49 | # 用户没有发送有效的订阅(而是发送了空白字符),则提示重新输入 50 | # 这里 session.pause() 将会发送消息并暂停当前会话(该行后面的代码不会被运行) 51 | session.pause("输入不能为空!") 52 | 53 | # 如果当前正在向用户询问更多信息,且用户输入有效,则放入会话状态 54 | session.state[session.current_key] = stripped_arg 55 | -------------------------------------------------------------------------------- /rss2/command/add_dy.py: -------------------------------------------------------------------------------- 1 | from nonebot import on_command, CommandSession 2 | from .. import my_trigger as tr 3 | from ..rss_class import Rss 4 | from ..permission import admin_permission 5 | 6 | prompt = """\ 7 | 请输入 8 | 名称 [订阅地址] 9 | 空格分割、[]表示可选 10 | 私聊默认订阅到当前账号,群聊默认订阅到当前群组 11 | 更多信息可通过 change 命令修改\ 12 | """ 13 | 14 | 15 | @on_command( 16 | "add", 17 | aliases=("添加订阅", "sub"), 18 | permission=admin_permission, 19 | only_to_me=False, 20 | ) 21 | async def add(session: CommandSession) -> None: 22 | rss_dy_link = (await session.aget("add", prompt=prompt)).strip() 23 | 24 | try: 25 | name, url = rss_dy_link.split(" ") 26 | except ValueError: 27 | await session.finish("❌ 请输入正确的格式!") 28 | 29 | if rss := Rss.get_one_by_name(name): 30 | if rss.url != url: 31 | await session.finish(f"已存在订阅名为 {name} 的订阅") 32 | else: 33 | await add_feed(name, url, session, rss) 34 | return 35 | 36 | await add_feed(name, url, session) 37 | 38 | 39 | async def add_feed( 40 | name: str, url: str, session: CommandSession, rss: Rss = Rss() 41 | ) -> None: 42 | rss.name = name 43 | rss.url = url 44 | user = ( 45 | str(session.ctx["user_id"]) 46 | if session.ctx["message_type"] == "private" 47 | else None 48 | ) 49 | group = ( 50 | str(session.ctx.get("group_id")) 51 | if session.ctx["message_type"] == "group" 52 | else None 53 | ) 54 | guild_channel = session.ctx.get("guild_id") 55 | if guild_channel: 56 | group = None 57 | guild_channel = guild_channel + "@" + session.ctx.get("channel_id") 58 | rss.add_user_or_group_or_channel(user, group, guild_channel) 59 | await session.send(f"👏 已成功添加订阅 {name} !") 60 | await tr.add_job(rss) 61 | 62 | 63 | # add.args_parser 装饰器将函数声明为 add 命令的参数解析器 64 | # 命令解析器用于将用户输入的参数解析成命令真正需要的数据 65 | @add.args_parser 66 | async def _(session: CommandSession): 67 | # 去掉消息首尾的空白符 68 | stripped_arg = session.current_arg_text.strip() 69 | 70 | if session.is_first_run: 71 | # 该命令第一次运行(第一次进入命令会话) 72 | if stripped_arg: 73 | # 第一次运行参数不为空,意味着用户直接将订阅信息跟在命令名后面,作为参数传入 74 | # 例如用户可能发送了:订阅 test1 /twitter/user/key_official 1447027111 1037939056 1 true true #订阅名 订阅地址 qq 群组 更新时间 代理 第三方 75 | session.state["add"] = stripped_arg 76 | return 77 | 78 | if not stripped_arg: 79 | # 用户没有发送有效的订阅(而是发送了空白字符),则提示重新输入 80 | # 这里 session.pause() 将会发送消息并暂停当前会话(该行后面的代码不会被运行) 81 | session.pause("输入不能为空!") 82 | 83 | # 如果当前正在向用户询问更多信息(例如本例中的要压缩的链接),且用户输入有效,则放入会话状态 84 | session.state[session.current_key] = stripped_arg 85 | -------------------------------------------------------------------------------- /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, CommandSession 7 | from nonebot.log import logger 8 | 9 | from .. import my_trigger as tr 10 | from ..config import DATA_PATH 11 | from ..permission import admin_permission 12 | from ..rss_class import Rss 13 | from ..utils import regex_validate 14 | 15 | prompt = """\ 16 | 请输入要修改的订阅 17 | 订阅名[,订阅名,...] 属性=值[ 属性=值 ...] 18 | 如: 19 | test1[,test2,...] qq=,123,234 qun=-1 20 | 对应参数: 21 | 订阅名(-name): 禁止将多个订阅批量改名,名称相同会冲突 22 | 订阅链接(-url) 23 | QQ(-qq) 24 | 群(-qun) 25 | 更新频率(-time) 26 | 代理(-proxy) 27 | 翻译(-tl) 28 | 仅Title(ot) 29 | 仅图片(-op) 30 | 仅含图片(-ohp) 31 | 下载图片(-downpic): 下载图片到本地硬盘,仅pixiv有效 32 | 下载种子(-downopen) 33 | 白名单关键词(-wkey) 34 | 黑名单关键词(-bkey) 35 | 种子上传到群(-upgroup) 36 | 去重模式(-mode) 37 | 图片数量限制(-img_num): 只发送限定数量的图片,防止刷屏 38 | 正文移除内容(-rm_list): 从正文中移除指定内容,支持正则 39 | 停止更新(-stop): 停止更新订阅 40 | PikPak离线(-pikpak): 开启PikPak离线下载 41 | PikPak离线路径匹配(-ppk): 匹配离线下载的文件夹,设置该值后生效 42 | 注: 43 | 1. 仅含有图片不同于仅图片,除了图片还会发送正文中的其他文本信息 44 | 2. proxy/tl/ot/op/ohp/downopen/upgroup/stop/pikpak 值为 1/0 45 | 3. 去重模式分为按链接(link)、标题(title)、图片(image)判断,其中 image 模式生效对象限定为只带 1 张图片的消息。如果属性中带有 or 说明判断逻辑是任一匹配即去重,默认为全匹配 46 | 4. 白名单关键词支持正则表达式,匹配时推送消息及下载,设为空(wkey=)时不生效 47 | 5. 黑名单关键词同白名单相似,匹配时不推送,两者可以一起用 48 | 6. 正文待移除内容格式必须如:rm_list='a' 或 rm_list='a','b'。该处理过程在解析 html 标签后进行,设为空使用 rm_list='-1'" 49 | 7. QQ、群号、去重模式前加英文逗号表示追加,-1设为空 50 | 8. 各个属性使用空格分割 51 | 9. downpic保存的文件位于程序根目录下 "data/image/订阅名/图片名" 52 | 详细用法请查阅文档。\ 53 | """ 54 | 55 | # 处理带多个值的订阅参数 56 | def handle_property(value: str, property_list: List[Any]) -> List[Any]: 57 | # 清空 58 | if value == "-1": 59 | return [] 60 | value_list = value.split(",") 61 | # 追加 62 | if value_list[0] == "": 63 | value_list.pop(0) 64 | return property_list + [i for i in value_list if i not in property_list] 65 | # 防止用户输入重复参数,去重并保持原来的顺序 66 | return list(dict.fromkeys(value_list)) 67 | 68 | 69 | # 处理类型为正则表达式的订阅参数 70 | def handle_regex_property(value: str, old_value: str) -> Optional[str]: 71 | result = None 72 | if not value: 73 | result = None 74 | elif value.startswith("+"): 75 | result = f"{old_value}|{value.lstrip('+')}" if old_value else value.lstrip("+") 76 | elif value.startswith("-"): 77 | if regex_list := old_value.split("|"): 78 | with suppress(ValueError): 79 | regex_list.remove(value.lstrip("-")) 80 | result = "|".join(regex_list) if regex_list else None 81 | else: 82 | result = value 83 | if isinstance(result, str) and not regex_validate(result): 84 | result = None 85 | return result 86 | 87 | 88 | attribute_dict = { 89 | "name": "name", 90 | "url": "url", 91 | "qq": "user_id", 92 | "qun": "group_id", 93 | "channel": "guild_channel_id", 94 | "time": "time", 95 | "proxy": "img_proxy", 96 | "tl": "translation", 97 | "ot": "only_title", 98 | "op": "only_pic", 99 | "ohp": "only_has_pic", 100 | "downpic": "download_pic", 101 | "upgroup": "is_open_upload_group", 102 | "downopen": "down_torrent", 103 | "downkey": "down_torrent_keyword", 104 | "wkey": "down_torrent_keyword", 105 | "blackkey": "black_keyword", 106 | "bkey": "black_keyword", 107 | "mode": "duplicate_filter_mode", 108 | "img_num": "max_image_number", 109 | "stop": "stop", 110 | "pikpak": "pikpak_offline", 111 | "ppk": "pikpak_path_key", 112 | } 113 | 114 | 115 | # 处理要修改的订阅参数 116 | def handle_change_list( 117 | rss: Rss, 118 | key_to_change: str, 119 | value_to_change: str, 120 | group_id: Optional[int], 121 | guild_channel_id: Optional[str], 122 | ) -> None: 123 | if key_to_change == "name": 124 | tr.delete_job(rss) 125 | rss.rename_file(str(DATA_PATH / f"{Rss.handle_name(value_to_change)}.json")) 126 | elif ( 127 | key_to_change in {"qq", "qun", "channel"} 128 | and not group_id 129 | and not guild_channel_id 130 | ) or key_to_change == "mode": 131 | value_to_change = handle_property( 132 | value_to_change, getattr(rss, attribute_dict[key_to_change]) 133 | ) # type:ignore 134 | elif key_to_change == "time": 135 | if not re.search(r"[_*/,-]", value_to_change): 136 | if int(float(value_to_change)) < 1: 137 | value_to_change = "1" 138 | else: 139 | value_to_change = str(int(float(value_to_change))) 140 | elif key_to_change in { 141 | "proxy", 142 | "tl", 143 | "ot", 144 | "op", 145 | "ohp", 146 | "downpic", 147 | "upgroup", 148 | "downopen", 149 | "stop", 150 | "pikpak", 151 | }: 152 | value_to_change = bool(int(value_to_change)) # type:ignore 153 | if key_to_change == "stop" and not value_to_change and rss.error_count > 0: 154 | rss.error_count = 0 155 | elif key_to_change in {"downkey", "wkey", "blackkey", "bkey"}: 156 | value_to_change = handle_regex_property( 157 | value_to_change, getattr(rss, attribute_dict[key_to_change]) 158 | ) # type:ignore 159 | elif key_to_change == "ppk" and not value_to_change: 160 | value_to_change = None # type:ignore 161 | elif key_to_change == "img_num": 162 | value_to_change = int(value_to_change) # type:ignore 163 | setattr(rss, attribute_dict.get(key_to_change), value_to_change) # type:ignore 164 | 165 | 166 | @on_command( 167 | "change", aliases=("修改订阅", "moddy"), permission=admin_permission, only_to_me=False 168 | ) 169 | async def change(session: CommandSession) -> None: 170 | change_info = (await session.aget("change", prompt=prompt)).strip() 171 | group_id = session.ctx.get("group_id") 172 | guild_channel_id = session.ctx.get("guild_id") 173 | if guild_channel_id: 174 | group_id = None 175 | guild_channel_id = f"{guild_channel_id}@{session.ctx.get('channel_id')}" 176 | name_list = change_info.split(" ")[0].split(",") 177 | rss_list: List[Rss] = [] 178 | for name in name_list: 179 | if rss_tmp := Rss.get_one_by_name(name=name): 180 | rss_list.append(rss_tmp) 181 | 182 | # 出于公平考虑,限制订阅者只有当前群组或频道时才能修改订阅,否则只有超级管理员能修改 183 | if group_id: 184 | if re.search(" (qq|qun|channel)=", change_info): 185 | await session.finish("❌ 禁止在群组中修改订阅账号!如要取消订阅请使用 deldy 命令!") 186 | rss_list = [ 187 | rss 188 | for rss in rss_list 189 | if rss.group_id == [str(group_id)] 190 | and not rss.user_id 191 | and not rss.guild_channel_id 192 | ] 193 | 194 | if guild_channel_id: 195 | if re.search(" (qq|qun|channel)=", change_info): 196 | await session.finish("❌ 禁止在子频道中修改订阅账号!如要取消订阅请使用 deldy 命令!") 197 | rss_list = [ 198 | rss 199 | for rss in rss_list 200 | if rss.guild_channel_id == [str(guild_channel_id)] 201 | and not rss.user_id 202 | and not rss.guild_channel_id 203 | ] 204 | 205 | if not rss_list: 206 | await session.finish("❌ 请检查是否存在以下问题:\n1.要修改的订阅名不存在对应的记录\n2.当前群组或频道无权操作") 207 | elif len(rss_list) > 1 and " name=" in change_info: 208 | await session.finish("❌ 禁止将多个订阅批量改名!会因为名称相同起冲突!") 209 | 210 | # 参数特殊处理:正文待移除内容 211 | rm_list_exist = re.search("rm_list='.+'", change_info) 212 | change_list = handle_rm_list(rss_list, change_info, rm_list_exist) 213 | 214 | changed_rss_list = await batch_change_rss( 215 | session, change_list, group_id, guild_channel_id, rss_list, rm_list_exist 216 | ) 217 | # 隐私考虑,不展示除当前群组或频道外的群组、频道和QQ 218 | rss_msg_list = [ 219 | str(rss.hide_some_infos(group_id, guild_channel_id)) for rss in changed_rss_list 220 | ] 221 | result_msg = f"👏 修改了 {len(rss_msg_list)} 条订阅" 222 | if rss_msg_list: 223 | separator = "\n----------------------\n" 224 | result_msg += separator + separator.join(rss_msg_list) 225 | await session.finish(result_msg) 226 | 227 | 228 | async def batch_change_rss( 229 | session: CommandSession, 230 | change_list: List[str], 231 | group_id: Optional[int], 232 | guild_channel_id: Optional[str], 233 | rss_list: List[Rss], 234 | rm_list_exist: Optional[Match[str]] = None, 235 | ) -> List[str]: 236 | changed_rss_list = [] 237 | for rss in rss_list: 238 | new_rss = deepcopy(rss) 239 | rss_name = rss.name 240 | for change_dict in change_list: 241 | key_to_change, value_to_change = change_dict.split("=", 1) 242 | if key_to_change in attribute_dict.keys(): 243 | # 对用户输入的去重模式参数进行校验 244 | mode_property_set = {"", "-1", "link", "title", "image", "or"} 245 | if key_to_change == "mode" and ( 246 | set(value_to_change.split(",")) - mode_property_set 247 | or value_to_change == "or" 248 | ): 249 | await session.finish(f"❌ 去重模式参数错误!\n{change_dict}") 250 | elif key_to_change in { 251 | "downkey", 252 | "wkey", 253 | "blackkey", 254 | "bkey", 255 | } and not regex_validate(value_to_change.lstrip("+-")): 256 | await session.finish(f"❌ 正则表达式错误!\n{change_dict}") 257 | elif key_to_change == "ppk" and not regex_validate(value_to_change): 258 | await session.finish(f"❌ 正则表达式错误!\n{change_dict}") 259 | handle_change_list( 260 | new_rss, key_to_change, value_to_change, group_id, guild_channel_id 261 | ) 262 | else: 263 | await session.finish(f"❌ 参数错误!\n{change_dict}") 264 | 265 | if new_rss.__dict__ == rss.__dict__ and not rm_list_exist: 266 | continue 267 | changed_rss_list.append(new_rss) 268 | # 参数解析完毕,写入 269 | new_rss.upsert(rss_name) 270 | 271 | # 加入定时任务 272 | if not new_rss.stop: 273 | await tr.add_job(new_rss) 274 | elif not rss.stop: 275 | tr.delete_job(new_rss) 276 | logger.info(f"{rss_name} 已停止更新") 277 | 278 | return changed_rss_list 279 | 280 | 281 | @change.args_parser 282 | async def _(session: CommandSession): 283 | # 去掉消息首尾的空白符 284 | stripped_arg = session.current_arg_text.strip() 285 | 286 | if session.is_first_run: 287 | # 该命令第一次运行(第一次进入命令会话) 288 | if stripped_arg: 289 | session.state["change"] = stripped_arg 290 | return 291 | 292 | if not stripped_arg: 293 | # 用户没有发送有效的订阅(而是发送了空白字符),则提示重新输入 294 | # 这里 session.pause() 将会发送消息并暂停当前会话(该行后面的代码不会被运行) 295 | session.pause("输入不能为空!") 296 | 297 | # 如果当前正在向用户询问更多信息,且用户输入有效,则放入会话状态 298 | session.state[session.current_key] = stripped_arg 299 | 300 | 301 | # 参数特殊处理:正文待移除内容 302 | def handle_rm_list( 303 | rss_list: List[Rss], change_info: str, rm_list_exist: Optional[Match[str]] = None 304 | ) -> List[str]: 305 | rm_list = None 306 | 307 | if rm_list_exist: 308 | rm_list_str = rm_list_exist[0].lstrip().replace("rm_list=", "") 309 | rm_list = [i.strip("'") for i in rm_list_str.split("','")] 310 | change_info = change_info.replace(rm_list_exist[0], "") 311 | 312 | if rm_list: 313 | for rss in rss_list: 314 | if len(rm_list) == 1 and rm_list[0] == "-1": 315 | setattr(rss, "content_to_remove", None) 316 | elif valid_rm_list := [i for i in rm_list if regex_validate(i)]: 317 | setattr(rss, "content_to_remove", valid_rm_list) 318 | 319 | change_list = [i.strip() for i in change_info.split(" ") if i != ""] 320 | # 去掉订阅名 321 | change_list.pop(0) 322 | 323 | return change_list 324 | -------------------------------------------------------------------------------- /rss2/command/del_dy.py: -------------------------------------------------------------------------------- 1 | from nonebot import on_command, CommandSession 2 | from ..permission import admin_permission 3 | 4 | from .. import my_trigger as tr 5 | from ..rss_class import Rss 6 | 7 | 8 | @on_command( 9 | "deldy", aliases=("drop", "删除订阅"), permission=admin_permission, only_to_me=False 10 | ) 11 | async def deldy(session: CommandSession) -> None: 12 | rss_name = (await session.aget("deldy", prompt="输入要删除的订阅名")).strip() 13 | group_id = session.ctx.get("group_id") 14 | guild_channel_id = session.ctx.get("guild_id") 15 | if guild_channel_id: 16 | group_id = None 17 | guild_channel_id = f"{guild_channel_id}@{session.ctx.get('channel_id')}" 18 | 19 | rss_name_list = rss_name.strip().split(" ") 20 | delete_successes = [] 21 | delete_failures = [] 22 | for rss_name in rss_name_list: 23 | rss = Rss.get_one_by_name(name=rss_name) 24 | if rss is None: 25 | delete_failures.append(rss_name) 26 | elif guild_channel_id: 27 | if rss.delete_guild_channel(guild_channel=guild_channel_id): 28 | if not any([rss.group_id, rss.user_id, rss.guild_channel_id]): 29 | rss.delete_rss() 30 | tr.delete_job(rss) 31 | else: 32 | await tr.add_job(rss) 33 | delete_successes.append(rss_name) 34 | else: 35 | delete_failures.append(rss_name) 36 | elif group_id: 37 | if rss.delete_group(group=str(group_id)): 38 | if not any([rss.group_id, rss.user_id, rss.guild_channel_id]): 39 | rss.delete_rss() 40 | tr.delete_job(rss) 41 | else: 42 | await tr.add_job(rss) 43 | delete_successes.append(rss_name) 44 | else: 45 | delete_failures.append(rss_name) 46 | else: 47 | rss.delete_rss() 48 | tr.delete_job(rss) 49 | delete_successes.append(rss_name) 50 | 51 | result = [] 52 | if delete_successes: 53 | if guild_channel_id: 54 | result.append(f'👏 当前子频道成功取消订阅: {"、".join(delete_successes)} !') 55 | elif group_id: 56 | result.append(f'👏 当前群组成功取消订阅: {"、".join(delete_successes)} !') 57 | else: 58 | result.append(f'👏 成功删除订阅: {"、".join(delete_successes)} !') 59 | if delete_failures: 60 | if guild_channel_id: 61 | result.append(f'❌ 当前子频道没有订阅: {"、".join(delete_successes)} !') 62 | elif group_id: 63 | result.append(f'❌ 当前群组没有订阅: {"、".join(delete_successes)} !') 64 | else: 65 | result.append(f'❌ 未找到订阅: {"、".join(delete_successes)} !') 66 | 67 | await session.finish("\n".join(result)) 68 | 69 | 70 | @deldy.args_parser 71 | async def _(session: CommandSession): 72 | # 去掉消息首尾的空白符 73 | stripped_arg = session.current_arg_text.strip() 74 | 75 | if session.is_first_run: 76 | # 该命令第一次运行(第一次进入命令会话) 77 | if stripped_arg: 78 | session.state["deldy"] = stripped_arg 79 | return 80 | 81 | if not stripped_arg: 82 | # 用户没有发送有效的订阅(而是发送了空白字符),则提示重新输入 83 | # 这里 session.pause() 将会发送消息并暂停当前会话(该行后面的代码不会被运行) 84 | session.pause("输入不能为空!") 85 | 86 | # 如果当前正在向用户询问更多信息,且用户输入有效,则放入会话状态 87 | session.state[session.current_key] = stripped_arg 88 | -------------------------------------------------------------------------------- /rss2/command/rsshub_add.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict 2 | 3 | import aiohttp 4 | from nonebot import on_command, CommandSession 5 | from yarl import URL 6 | 7 | from ..config import config 8 | from ..permission import admin_permission 9 | from ..rss_class import Rss 10 | from .add_dy import add_feed 11 | 12 | rsshub_routes: Dict[str, Any] = {} 13 | 14 | 15 | @on_command( 16 | "rsshub_add", 17 | only_to_me=False, 18 | permission=admin_permission, 19 | ) 20 | async def rsshub_add(session: CommandSession): 21 | t = session.event.message.extract_plain_text()[10:].strip() 22 | if t: 23 | session.state["router"] = t 24 | await handle_rsshub_routes(session) 25 | else: 26 | await handle_feed_name(session) 27 | 28 | 29 | async def handle_feed_name(session: CommandSession) -> None: 30 | name = session.state.get("name") 31 | while not name: 32 | name = (await session.aget("name", prompt="请输入要订阅的订阅名")).strip() 33 | while _ := Rss.get_one_by_name(name=name): 34 | del session.state["name"] 35 | name = await session.aget("name", prompt=f"已存在名为 {name} 的订阅,请重新输入") 36 | await handle_rsshub_routes(session) 37 | 38 | 39 | async def handle_rsshub_routes(session: CommandSession) -> None: 40 | route = session.state.get("router") 41 | while not route: 42 | route = (await session.aget("router", prompt="请输入要订阅的 RSSHub 路由名")).strip() 43 | rsshub_url = URL(config.rsshub) 44 | # 对本机部署的 RSSHub 不使用代理 45 | local_host = [ 46 | "localhost", 47 | "127.0.0.1", 48 | ] 49 | if config.rss_proxy and rsshub_url.host not in local_host: 50 | proxy = f"http://{config.rss_proxy}" 51 | else: 52 | proxy = None 53 | 54 | global rsshub_routes 55 | if not rsshub_routes: 56 | async with aiohttp.ClientSession() as s: 57 | resp = await s.get(rsshub_url.with_path("api/routes"), proxy=proxy) 58 | if resp.status != 200: 59 | await session.finish("获取路由数据失败,请检查 RSSHub 的地址配置及网络连接") 60 | rsshub_routes = await resp.json() 61 | 62 | while route not in rsshub_routes["data"]: 63 | del session.state["router"] 64 | route = (await session.aget("router", prompt="没有这个路由,请重新输入")).strip() 65 | 66 | route_list = rsshub_routes["data"][route]["routes"] 67 | session.state["route_list"] = route_list 68 | if not session.state.get("name"): 69 | await handle_feed_name(session) 70 | if len(route_list) > 1: 71 | await session.send( 72 | "请输入序号来选择要订阅的 RSSHub 路由:\n" 73 | + "\n".join( 74 | f"{index + 1}. {__route}" for index, __route in enumerate(route_list) 75 | ) 76 | ) 77 | (await session.aget("route_index")).strip() 78 | else: 79 | session.state["route_index"] = "0" 80 | await handle_route_index(session) 81 | 82 | 83 | async def handle_route_index(session: CommandSession) -> None: 84 | route_index = session.state.get("route_index") 85 | route = session.state["route_list"][int(route_index) - 1] 86 | if args := [i for i in route.split("/") if i.startswith(":")]: 87 | await session.send( 88 | '请依次输入要订阅的 RSSHub 路由参数,并用 "/" 分隔:\n' 89 | + "/".join( 90 | f"{i.rstrip('?')}(可选)" if i.endswith("?") else f"{i}" for i in args 91 | ) 92 | + "\n要置空请输入#或直接留空" 93 | ) 94 | await session.aget("route_args") 95 | else: 96 | session.state["route_args"] = "" 97 | await handle_route_args(session) 98 | 99 | 100 | async def handle_route_args(session: CommandSession) -> None: 101 | name = session.state.get("name") 102 | route_index = session.state.get("route_index") 103 | route_args = session.state.get("route_args") 104 | route = session.state["route_list"][int(route_index) - 1] 105 | feed_url = "/".join([i for i in route.split("/") if not i.startswith(":")]) 106 | for i in route_args.split("/"): 107 | if len(i.strip("#")) > 0: 108 | feed_url += f"/{i}" 109 | 110 | await add_feed(name, feed_url.lstrip("/"), session) 111 | -------------------------------------------------------------------------------- /rss2/command/show_all.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import re 3 | 4 | from nonebot import on_command, CommandSession 5 | 6 | from ..rss_class import Rss 7 | from .show_dy import handle_rss_list 8 | from ..permission import admin_permission 9 | 10 | 11 | @on_command( 12 | "showall", 13 | aliases=("show_all", "selectall", "select_all", "所有订阅"), 14 | permission=admin_permission, 15 | only_to_me=False, 16 | ) 17 | async def rssShowAll(session: CommandSession) -> None: 18 | args = session.current_arg_text 19 | if args: 20 | search_keyword = args # 如果用户发送了参数则直接赋值 21 | else: 22 | search_keyword = None 23 | group_id = session.ctx.get("group_id") 24 | guild_channel_id = session.ctx.get("guild_id") 25 | if guild_channel_id: 26 | group_id = None 27 | guild_channel_id = f"{guild_channel_id}@{session.ctx.get('channel_id')}" 28 | 29 | if group_id: 30 | rss_list = Rss.get_by_group(group_id=group_id) 31 | if not rss_list: 32 | await session.finish("❌ 当前群组没有任何订阅!") 33 | elif guild_channel_id: 34 | rss_list = Rss.get_by_guild_channel(guild_channel_id=guild_channel_id) 35 | if not rss_list: 36 | await session.finish("❌ 当前子频道没有任何订阅!") 37 | else: 38 | rss_list = Rss.read_rss() 39 | 40 | result = [] 41 | if search_keyword: 42 | for i in rss_list: 43 | test = bool( 44 | re.search(search_keyword, i.name, flags=re.I) 45 | or re.search(search_keyword, i.url, flags=re.I) 46 | ) 47 | if not group_id and not guild_channel_id and search_keyword.isdigit(): 48 | if i.user_id: 49 | test = test or search_keyword in i.user_id 50 | if i.group_id: 51 | test = test or search_keyword in i.group_id 52 | if i.guild_channel_id: 53 | test = test or search_keyword in i.guild_channel_id 54 | if test: 55 | result.append(i) 56 | else: 57 | result = rss_list 58 | 59 | if result: 60 | await session.send(f"当前共有 {len(result)} 条订阅") 61 | result.sort(key=lambda x: x.get_url()) 62 | await asyncio.sleep(0.5) 63 | page_size = 30 64 | while result: 65 | current_page = result[:page_size] 66 | msg_str = handle_rss_list(current_page) 67 | try: 68 | await session.send(msg_str) 69 | except Exception: 70 | page_size -= 5 71 | continue 72 | result = result[page_size:] 73 | else: 74 | await session.finish("❌ 当前没有任何订阅!") 75 | -------------------------------------------------------------------------------- /rss2/command/show_dy.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from nonebot import on_command, CommandSession 4 | from ..permission import admin_permission 5 | 6 | from ..rss_class import Rss 7 | 8 | 9 | def handle_rss_list(rss_list: List[Rss]) -> str: 10 | rss_info_list = [ 11 | f"(已停止){i.name}:{i.url}" if i.stop else f"{i.name}:{i.url}" for i in rss_list 12 | ] 13 | return "\n\n".join(rss_info_list) 14 | 15 | 16 | # 不带订阅名称默认展示当前群组或账号的订阅,带订阅名称就显示该订阅的 17 | @on_command("show", aliases=("查看订阅"), permission=admin_permission, only_to_me=False) 18 | async def rssShow(session: CommandSession) -> None: 19 | args = session.current_arg.strip() 20 | if args: 21 | rss_name = args 22 | else: 23 | rss_name = None 24 | 25 | user_id = session.ctx["user_id"] 26 | group_id = session.ctx.get("group_id") 27 | guild_channel_id = session.ctx.get("guild_id") 28 | if guild_channel_id: 29 | group_id = None 30 | guild_channel_id = f"{guild_channel_id}@{session.ctx.get('channel_id')}" 31 | 32 | if rss_name: 33 | rss = Rss.get_one_by_name(rss_name) 34 | if ( 35 | rss is None 36 | or (guild_channel_id and guild_channel_id not in rss.guild_channel_id) 37 | or (group_id and str(group_id) not in rss.group_id) 38 | ): 39 | await session.finish(f"❌ 订阅 {rss_name} 不存在或未订阅!") 40 | else: 41 | # 隐私考虑,不展示除当前群组或频道外的群组、频道和QQ 42 | rss_msg = str(rss.hide_some_infos(group_id, guild_channel_id)) 43 | await session.finish(rss_msg) 44 | 45 | if group_id: 46 | rss_list = Rss.get_by_group(group_id=group_id) 47 | if not rss_list: 48 | await session.finish("❌ 当前群组没有任何订阅!") 49 | elif guild_channel_id: 50 | rss_list = Rss.get_by_guild_channel(guild_channel_id=guild_channel_id) 51 | if not rss_list: 52 | await session.finish("❌ 当前子频道没有任何订阅!") 53 | else: 54 | rss_list = Rss.get_by_user(user=user_id) 55 | 56 | if rss_list: 57 | msg_str = handle_rss_list(rss_list) 58 | await session.finish(msg_str) 59 | else: 60 | await session.finish("❌ 当前没有任何订阅!") 61 | -------------------------------------------------------------------------------- /rss2/command/upload_group_file.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from nonebot import on_command, CommandSession 4 | 5 | from ..qbittorrent_download import start_down 6 | from ..parsing.utils import get_proxy 7 | 8 | 9 | @on_command("upload_file", aliases=("uploadfile"), only_to_me=True) 10 | async def upload_group_file(session: CommandSession) -> None: 11 | if session.event.message_type == "private": 12 | await session.send("请在群聊中使用该命令") 13 | elif session.event.message_type == "group": 14 | target = re.search( 15 | "(magnet:\?xt=urn:btih:([a-fA-F0-9]{40}|[2-7A-Za-z]{32}))|(http.*?\.torrent)", 16 | str(session.event.message), 17 | ) 18 | if not target: 19 | await session.finish("请输入种子链接") 20 | await start_down( 21 | url=target[0], 22 | group_ids=[str(session.event.group_id)], 23 | name="手动上传", 24 | proxy=get_proxy(True), 25 | ) 26 | -------------------------------------------------------------------------------- /rss2/config.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Any, List, Optional 3 | 4 | from nonebot.log import logger 5 | from pydantic import AnyHttpUrl, BaseSettings, Extra 6 | 7 | DATA_PATH = Path.cwd() / "data" 8 | JSON_PATH = DATA_PATH / "rss.json" 9 | 10 | 11 | class ELFConfig(BaseSettings): 12 | class Config: 13 | extra = Extra.allow 14 | env_file = ".env" 15 | env_file_encoding = "utf-8" 16 | 17 | rss_proxy: Optional[str] = None 18 | rsshub: AnyHttpUrl = "https://rsshub.app" # type: ignore 19 | rsshub_backup: Optional[List[AnyHttpUrl]] = None 20 | db_cache_expire = 30 21 | limit = 200 22 | max_length: int = 1024 # 正文长度限制,防止消息太长刷屏,以及消息过长发送失败的情况 23 | 24 | zip_size: int = 2 * 1024 25 | gif_zip_size: int = 6 * 1024 26 | img_format: Optional[str] = None 27 | img_down_path: Optional[str] = None 28 | 29 | blockquote: bool = True 30 | black_word: Optional[List[str]] = None 31 | 32 | baidu_id: Optional[str] = None 33 | baidu_key: Optional[str] = None 34 | deepl_translator_api_key: Optional[str] = None 35 | single_detection_api_key: Optional[ 36 | str 37 | ] = None # 配合 deepl_translator 使用的语言检测接口,前往https://detectlanguage.com/documentation注册获取api_key 38 | 39 | qb_username: Optional[str] = None # qbittorrent 用户名 40 | qb_password: Optional[str] = None # qbittorrent 密码 41 | qb_web_url: Optional[str] = None 42 | qb_down_path: Optional[str] = None # qb的文件下载地址,这个地址必须是 go-cqhttp能访问到的 43 | down_status_msg_group: Optional[List[int]] = None 44 | down_status_msg_date: int = 10 45 | 46 | pikpak_username: Optional[str] = None # pikpak 用户名 47 | pikpak_password: Optional[str] = None # pikpak 密码 48 | pikpak_download_path: str = ( 49 | "" # pikpak 离线保存的目录, 默认是根目录,示例: ELF_RSS/Downloads ,目录不存在会自动创建, 不能/结尾 50 | ) 51 | 52 | version: str = "" 53 | superusers: List[str] = [] 54 | guild_superusers: Optional[List[str]] = None 55 | 56 | def __getattr__(self, name: str) -> Any: 57 | data = self.dict() 58 | return next( 59 | (v for k, v in data.items() if k.casefold() == name.casefold()), None 60 | ) 61 | 62 | 63 | config = ELFConfig() 64 | logger.info(f"RSS Config loaded: {config!r}") 65 | -------------------------------------------------------------------------------- /rss2/my_trigger.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import re 3 | 4 | from apscheduler.executors.pool import ProcessPoolExecutor, ThreadPoolExecutor 5 | from apscheduler.triggers.cron import CronTrigger 6 | from apscheduler.triggers.interval import IntervalTrigger 7 | from nonebot import scheduler 8 | from nonebot.log import logger 9 | 10 | from . import rss_parsing 11 | from .rss_class import Rss 12 | 13 | wait_for = 5 * 60 14 | 15 | 16 | # 检测某个rss更新 17 | async def check_update(rss: Rss) -> None: 18 | logger.info(f"{rss.name} 检查更新") 19 | try: 20 | await asyncio.wait_for(rss_parsing.start(rss), timeout=wait_for) 21 | except asyncio.TimeoutError: 22 | logger.error(f"{rss.name} 检查更新超时,结束此次任务!") 23 | 24 | 25 | def delete_job(rss: Rss) -> None: 26 | if scheduler.get_job(rss.name): 27 | scheduler.remove_job(rss.name) 28 | 29 | 30 | # 加入订阅任务队列并立即执行一次 31 | async def add_job(rss: Rss) -> None: 32 | delete_job(rss) 33 | # 加入前判断是否存在子频道或群组或用户,三者不能同时为空 34 | if any([rss.user_id, rss.group_id, rss.guild_channel_id]): 35 | rss_trigger(rss) 36 | await check_update(rss) 37 | 38 | 39 | def rss_trigger(rss: Rss) -> None: 40 | if re.search(r"[_*/,-]", rss.time): 41 | my_trigger_cron(rss) 42 | return 43 | # 制作一个“time分钟/次”触发器 44 | trigger = IntervalTrigger(minutes=int(rss.time), jitter=10) 45 | # 添加任务 46 | scheduler.add_job( 47 | func=check_update, # 要添加任务的函数,不要带参数 48 | trigger=trigger, # 触发器 49 | args=(rss,), # 函数的参数列表,注意:只有一个值时,不能省略末尾的逗号 50 | id=rss.name, 51 | misfire_grace_time=30, # 允许的误差时间,建议不要省略 52 | max_instances=1, # 最大并发 53 | default=ThreadPoolExecutor(64), # 最大线程 54 | processpool=ProcessPoolExecutor(8), # 最大进程 55 | coalesce=True, # 积攒的任务是否只跑一次,是否合并所有错过的Job 56 | ) 57 | logger.info(f"定时任务 {rss.name} 添加成功") 58 | 59 | 60 | # cron 表达式 61 | # 参考 https://www.runoob.com/linux/linux-comm-crontab.html 62 | 63 | 64 | def my_trigger_cron(rss: Rss) -> None: 65 | # 解析参数 66 | tmp_list = rss.time.split("_") 67 | times_list = ["*/5", "*", "*", "*", "*"] 68 | for index, value in enumerate(tmp_list): 69 | if value: 70 | times_list[index] = value 71 | try: 72 | # 制作一个触发器 73 | trigger = CronTrigger( 74 | minute=times_list[0], 75 | hour=times_list[1], 76 | day=times_list[2], 77 | month=times_list[3], 78 | day_of_week=times_list[4], 79 | ) 80 | except Exception: 81 | logger.exception(f"创建定时器错误!cron:{times_list}") 82 | return 83 | 84 | # 添加任务 85 | scheduler.add_job( 86 | func=check_update, # 要添加任务的函数,不要带参数 87 | trigger=trigger, # 触发器 88 | args=(rss,), # 函数的参数列表,注意:只有一个值时,不能省略末尾的逗号 89 | id=rss.name, 90 | misfire_grace_time=30, # 允许的误差时间,建议不要省略 91 | max_instances=1, # 最大并发 92 | default=ThreadPoolExecutor(64), # 最大线程 93 | processpool=ProcessPoolExecutor(8), # 最大进程 94 | coalesce=True, # 积攒的任务是否只跑一次,是否合并所有错过的Job 95 | ) 96 | logger.info(f"定时任务 {rss.name} 添加成功") 97 | -------------------------------------------------------------------------------- /rss2/parsing/__init__.py: -------------------------------------------------------------------------------- 1 | import re 2 | import sqlite3 3 | from difflib import SequenceMatcher 4 | from typing import Any, Dict, List 5 | 6 | import arrow 7 | import emoji 8 | from nonebot.log import logger 9 | from pyquery import PyQuery as Pq 10 | 11 | from ..config import DATA_PATH, config 12 | from ..rss_class import Rss 13 | from .cache_manage import ( 14 | cache_db_manage, 15 | cache_json_manage, 16 | duplicate_exists, 17 | insert_into_cache_db, 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 * 27 | from .send_message import send_msg 28 | from .utils import get_proxy, get_summary 29 | 30 | 31 | # 检查更新 32 | @ParsingBase.append_before_handler(priority=10) 33 | async def handle_check_update(rss: Rss, state: Dict[str, Any]): 34 | db = state.get("tinydb") 35 | change_data = check_update(db, state.get("new_data")) 36 | return {"change_data": change_data} 37 | 38 | 39 | # 判断是否满足推送条件 40 | @ParsingBase.append_before_handler(priority=11) # type: ignore 41 | async def handle_check_update(rss: Rss, state: Dict[str, Any]): 42 | change_data = state.get("change_data") 43 | db = state.get("tinydb") 44 | for item in change_data.copy(): 45 | summary = get_summary(item) 46 | # 检查是否包含屏蔽词 47 | if config.black_word and re.findall("|".join(config.black_word), summary): 48 | logger.info("内含屏蔽词,已经取消推送该消息") 49 | write_item(db, item) 50 | change_data.remove(item) 51 | continue 52 | # 检查是否匹配关键词 使用 down_torrent_keyword 字段,命名是历史遗留导致,实际应该是白名单关键字 53 | if rss.down_torrent_keyword and not re.search( 54 | rss.down_torrent_keyword, summary 55 | ): 56 | write_item(db, item) 57 | change_data.remove(item) 58 | continue 59 | # 检查是否匹配黑名单关键词 使用 black_keyword 字段 60 | if rss.black_keyword and ( 61 | re.search(rss.black_keyword, item["title"]) 62 | or re.search(rss.black_keyword, summary) 63 | ): 64 | write_item(db, item) 65 | change_data.remove(item) 66 | continue 67 | # 检查是否只推送有图片的消息 68 | if (rss.only_pic or rss.only_has_pic) and not re.search( 69 | r"]+>|\[img]", summary 70 | ): 71 | logger.info(f"{rss.name} 已开启仅图片/仅含有图片,该消息没有图片,将跳过") 72 | write_item(db, item) 73 | change_data.remove(item) 74 | 75 | return {"change_data": change_data} 76 | 77 | 78 | # 如果启用了去重模式,对推送列表进行过滤 79 | @ParsingBase.append_before_handler(priority=12) # type: ignore 80 | async def handle_check_update(rss: Rss, state: Dict[str, Any]): 81 | change_data = state.get("change_data") 82 | conn = state.get("conn") 83 | db = state.get("tinydb") 84 | 85 | # 检查是否启用去重 使用 duplicate_filter_mode 字段 86 | if not rss.duplicate_filter_mode: 87 | return {"change_data": change_data} 88 | 89 | if not conn: 90 | conn = sqlite3.connect(str(DATA_PATH / "cache.db")) 91 | conn.set_trace_callback(logger.debug) 92 | 93 | cache_db_manage(conn) 94 | 95 | delete = [] 96 | for index, item in enumerate(change_data): 97 | is_duplicate, image_hash = await duplicate_exists( 98 | rss=rss, 99 | conn=conn, 100 | item=item, 101 | summary=get_summary(item), 102 | ) 103 | if is_duplicate: 104 | write_item(db, item) 105 | delete.append(index) 106 | else: 107 | change_data[index]["image_hash"] = str(image_hash) 108 | 109 | change_data = [ 110 | item for index, item in enumerate(change_data) if index not in delete 111 | ] 112 | 113 | return { 114 | "change_data": change_data, 115 | "conn": conn, 116 | } 117 | 118 | 119 | # 处理标题 120 | @ParsingBase.append_handler(parsing_type="title") 121 | async def handle_title( 122 | rss: Rss, 123 | state: Dict[str, Any], 124 | item: Dict[str, Any], 125 | item_msg: str, 126 | tmp: str, 127 | tmp_state: Dict[str, Any], 128 | ) -> str: 129 | # 判断是否开启了只推送图片 130 | if rss.only_pic: 131 | return "" 132 | 133 | title = item["title"] 134 | 135 | if not config.blockquote: 136 | title = re.sub(r" - 转发 .*", "", title) 137 | 138 | res = f"标题:{title}\n" 139 | # 隔开标题和正文 140 | if not rss.only_title: 141 | res += "\n" 142 | if rss.translation: 143 | res += await handle_translation(content=title) 144 | 145 | # 如果开启了只推送标题,跳过下面判断标题与正文相似度的处理 146 | if rss.only_title: 147 | return emoji.emojize(res, language="alias") 148 | 149 | # 判断标题与正文相似度,避免标题正文一样,或者是标题为正文前N字等情况 150 | try: 151 | summary_html = Pq(get_summary(item)) 152 | if not config.blockquote: 153 | summary_html.remove("blockquote") 154 | similarity = SequenceMatcher(None, summary_html.text()[: len(title)], title) 155 | # 标题正文相似度 156 | if similarity.ratio() > 0.6: 157 | res = "" 158 | except Exception as e: 159 | logger.warning(f"{rss.name} 没有正文内容!{e}") 160 | 161 | return emoji.emojize(res, language="alias") 162 | 163 | 164 | # 处理正文 判断是否是仅推送标题 、是否仅推送图片 165 | @ParsingBase.append_handler(parsing_type="summary", priority=1) 166 | async def handle_summary( 167 | rss: Rss, 168 | state: Dict[str, Any], 169 | item: Dict[str, Any], 170 | item_msg: str, 171 | tmp: str, 172 | tmp_state: Dict[str, Any], 173 | ) -> str: 174 | if rss.only_title or rss.only_pic: 175 | tmp_state["continue"] = False 176 | return "" 177 | 178 | 179 | # 处理正文 处理网页 tag 180 | @ParsingBase.append_handler(parsing_type="summary", priority=10) # type: ignore 181 | async def handle_summary( 182 | rss: Rss, 183 | state: Dict[str, Any], 184 | item: Dict[str, Any], 185 | item_msg: str, 186 | tmp: str, 187 | tmp_state: Dict[str, Any], 188 | ) -> str: 189 | try: 190 | tmp += handle_html_tag(html=Pq(get_summary(item))) 191 | except Exception as e: 192 | logger.warning(f"{rss.name} 没有正文内容!{e}") 193 | return tmp 194 | 195 | 196 | # 处理正文 移除指定内容 197 | @ParsingBase.append_handler(parsing_type="summary", priority=11) # type: ignore 198 | async def handle_summary( 199 | rss: Rss, 200 | state: Dict[str, Any], 201 | item: Dict[str, Any], 202 | item_msg: str, 203 | tmp: str, 204 | tmp_state: Dict[str, Any], 205 | ) -> str: 206 | # 移除指定内容 207 | if rss.content_to_remove: 208 | for pattern in rss.content_to_remove: 209 | tmp = re.sub(pattern, "", tmp) 210 | # 去除多余换行 211 | while "\n\n\n" in tmp: 212 | tmp = tmp.replace("\n\n\n", "\n\n") 213 | tmp = tmp.strip() 214 | return emoji.emojize(tmp, language="alias") 215 | 216 | 217 | # 处理正文 翻译 218 | @ParsingBase.append_handler(parsing_type="summary", priority=12) # type: ignore 219 | async def handle_summary( 220 | rss: Rss, 221 | state: Dict[str, Any], 222 | item: Dict[str, Any], 223 | item_msg: str, 224 | tmp: str, 225 | tmp_state: Dict[str, Any], 226 | ) -> str: 227 | if rss.translation: 228 | tmp += await handle_translation(tmp) 229 | return tmp 230 | 231 | 232 | # 处理图片 233 | @ParsingBase.append_handler(parsing_type="picture") 234 | async def handle_picture( 235 | rss: Rss, 236 | state: Dict[str, Any], 237 | item: Dict[str, Any], 238 | item_msg: str, 239 | tmp: str, 240 | tmp_state: Dict[str, Any], 241 | ) -> str: 242 | 243 | # 判断是否开启了只推送标题 244 | if rss.only_title: 245 | return "" 246 | 247 | res = "" 248 | try: 249 | res += await handle_img( 250 | item=item, 251 | img_proxy=rss.img_proxy, 252 | img_num=rss.max_image_number, 253 | ) 254 | except Exception as e: 255 | logger.warning(f"{rss.name} 没有正文内容!{e}") 256 | 257 | # 判断是否开启了只推送图片 258 | return f"{res}\n" if rss.only_pic else f"{tmp + res}\n" 259 | 260 | 261 | # 处理来源 262 | @ParsingBase.append_handler(parsing_type="source") 263 | async def handle_source( 264 | rss: Rss, 265 | state: Dict[str, Any], 266 | item: Dict[str, Any], 267 | item_msg: str, 268 | tmp: str, 269 | tmp_state: Dict[str, Any], 270 | ) -> str: 271 | return f"链接:{item['link']}\n" 272 | 273 | 274 | # 处理种子 275 | @ParsingBase.append_handler(parsing_type="torrent") 276 | async def handle_torrent( 277 | rss: Rss, 278 | state: Dict[str, Any], 279 | item: Dict[str, Any], 280 | item_msg: str, 281 | tmp: str, 282 | tmp_state: Dict[str, Any], 283 | ) -> str: 284 | res: List[str] = [] 285 | if not rss.is_open_upload_group: 286 | rss.group_id = [] 287 | if rss.down_torrent: 288 | # 处理种子 289 | try: 290 | hash_list = await down_torrent( 291 | rss=rss, item=item, proxy=get_proxy(rss.img_proxy) 292 | ) 293 | if hash_list and hash_list[0] is not None: 294 | res.append("\n磁力:") 295 | res.extend([f"magnet:?xt=urn:btih:{h}" for h in hash_list]) 296 | except Exception: 297 | logger.exception("下载种子时出错") 298 | if rss.pikpak_offline: 299 | try: 300 | result = await pikpak_offline( 301 | rss=rss, item=item, proxy=get_proxy(rss.img_proxy) 302 | ) 303 | if result: 304 | res.append("\nPikPak 离线成功") 305 | res.extend( 306 | [ 307 | f"{r.get('name')}\n{r.get('file_size')} - {r.get('path')}" 308 | for r in result 309 | ] 310 | ) 311 | except Exception: 312 | logger.exception("PikPak 离线时出错") 313 | return "\n".join(res) 314 | 315 | 316 | # 处理日期 317 | @ParsingBase.append_handler(parsing_type="date") 318 | async def handle_date( 319 | rss: Rss, 320 | state: Dict[str, Any], 321 | item: Dict[str, Any], 322 | item_msg: str, 323 | tmp: str, 324 | tmp_state: Dict[str, Any], 325 | ) -> str: 326 | date = get_item_date(item) 327 | date = date.replace(tzinfo="local") if date > arrow.now() else date.to("local") 328 | return f"日期:{date.format('YYYY年MM月DD日 HH:mm:ss')}" 329 | 330 | 331 | # 发送消息 332 | @ParsingBase.append_handler(parsing_type="after") 333 | async def handle_message( 334 | rss: Rss, 335 | state: Dict[str, Any], 336 | item: Dict[str, Any], 337 | item_msg: str, 338 | tmp: str, 339 | tmp_state: Dict[str, Any], 340 | ) -> str: 341 | db = state["tinydb"] 342 | 343 | # 发送消息并写入文件 344 | if await send_msg(rss=rss, msg=item_msg, item=item): 345 | 346 | if rss.duplicate_filter_mode: 347 | insert_into_cache_db( 348 | conn=state["conn"], item=item, image_hash=item["image_hash"] 349 | ) 350 | 351 | if item.get("to_send"): 352 | item.pop("to_send") 353 | 354 | state["item_count"] += 1 355 | else: 356 | item["to_send"] = True 357 | 358 | write_item(db, item) 359 | 360 | return "" 361 | 362 | 363 | @ParsingBase.append_after_handler() 364 | async def after_handler(rss: Rss, state: Dict[str, Any]) -> Dict[str, Any]: 365 | item_count: int = state["item_count"] 366 | conn = state["conn"] 367 | db = state["tinydb"] 368 | 369 | if item_count > 0: 370 | logger.info(f"{rss.name} 新消息推送完毕,共计:{item_count}") 371 | else: 372 | logger.info(f"{rss.name} 没有新信息") 373 | 374 | if conn is not None: 375 | conn.close() 376 | 377 | new_data_length = len(state["new_data"]) 378 | cache_json_manage(db, new_data_length) 379 | db.close() 380 | 381 | return {} 382 | -------------------------------------------------------------------------------- /rss2/parsing/cache_manage.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | from sqlite3 import Connection 3 | from typing import Any, Dict, Optional, Tuple 4 | 5 | import imagehash 6 | from nonebot.log import logger 7 | from PIL import Image, UnidentifiedImageError 8 | from pyquery import PyQuery as Pq 9 | from tinydb import Query, TinyDB 10 | from tinydb.operations import delete 11 | 12 | from ..config import config 13 | from ..rss_class import Rss 14 | from .check_update import get_item_date 15 | from .handle_images import download_image 16 | 17 | 18 | # 精简 xxx.json (缓存) 中的字段 19 | def cache_filter(data: Dict[str, Any]) -> Dict[str, Any]: 20 | keys = [ 21 | "guid", 22 | "link", 23 | "published", 24 | "updated", 25 | "title", 26 | "hash", 27 | ] 28 | if data.get("to_send"): 29 | keys += [ 30 | "content", 31 | "summary", 32 | "to_send", 33 | ] 34 | return {k: data[k] for k in keys if k in data} 35 | 36 | 37 | # 对去重数据库进行管理 38 | def cache_db_manage(conn: Connection) -> None: 39 | cursor = conn.cursor() 40 | # 用来去重的 sqlite3 数据表如果不存在就创建一个 41 | cursor.execute( 42 | """ 43 | CREATE TABLE IF NOT EXISTS main ( 44 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 45 | "link" TEXT, 46 | "title" TEXT, 47 | "image_hash" TEXT, 48 | "datetime" TEXT DEFAULT (DATETIME('Now', 'LocalTime')) 49 | ); 50 | """ 51 | ) 52 | cursor.close() 53 | conn.commit() 54 | cursor = conn.cursor() 55 | # 移除超过 config.db_cache_expire 天没重复过的记录 56 | cursor.execute( 57 | "DELETE FROM main WHERE datetime <= DATETIME('Now', 'LocalTime', ?);", 58 | (f"-{config.db_cache_expire} Day",), 59 | ) 60 | cursor.close() 61 | conn.commit() 62 | 63 | 64 | # 对缓存 json 进行管理 65 | def cache_json_manage(db: TinyDB, new_data_length: int) -> None: 66 | # 只保留最多 config.limit + new_data_length 条的记录 67 | limit = config.limit + new_data_length 68 | retains = db.all() 69 | retains.sort(key=get_item_date) 70 | retains = retains[-limit:] 71 | db.truncate() 72 | db.insert_multiple(retains) 73 | 74 | 75 | # 去重判断 76 | async def duplicate_exists( 77 | rss: Rss, conn: Connection, item: Dict[str, Any], summary: str 78 | ) -> Tuple[bool, Optional[str]]: 79 | flag = False 80 | link = item["link"].replace("'", "''") 81 | title = item["title"].replace("'", "''") 82 | image_hash = None 83 | cursor = conn.cursor() 84 | sql = "SELECT * FROM main WHERE 1=1" 85 | args = [] 86 | for mode in rss.duplicate_filter_mode: 87 | if mode == "image": 88 | try: 89 | summary_doc = Pq(summary) 90 | except Exception as e: 91 | logger.warning(e) 92 | # 没有正文内容直接跳过 93 | continue 94 | img_doc = summary_doc("img") 95 | # 只处理仅有一张图片的情况 96 | if len(img_doc) != 1: 97 | continue 98 | url = img_doc.attr("src") 99 | # 通过图像的指纹来判断是否实际是同一张图片 100 | content = await download_image(url, rss.img_proxy) 101 | if not content: 102 | continue 103 | try: 104 | im = Image.open(BytesIO(content)) 105 | except UnidentifiedImageError: 106 | continue 107 | item["image_content"] = content 108 | # GIF 图片的 image_hash 实际上是第一帧的值,为了避免误伤直接跳过 109 | if im.format == "GIF": 110 | item["gif_url"] = url 111 | continue 112 | image_hash = str(imagehash.dhash(im)) 113 | logger.debug(f"image_hash: {image_hash}") 114 | sql += " AND image_hash=?" 115 | args.append(image_hash) 116 | if mode == "link": 117 | sql += " AND link=?" 118 | args.append(link) 119 | elif mode == "title": 120 | sql += " AND title=?" 121 | args.append(title) 122 | if "or" in rss.duplicate_filter_mode: 123 | sql = sql.replace("AND", "OR").replace("OR", "AND", 1) 124 | cursor.execute(f"{sql};", args) 125 | result = cursor.fetchone() 126 | if result is not None: 127 | result_id = result[0] 128 | cursor.execute( 129 | "UPDATE main SET datetime = DATETIME('Now','LocalTime') WHERE id = ?;", 130 | (result_id,), 131 | ) 132 | cursor.close() 133 | conn.commit() 134 | flag = True 135 | return flag, image_hash 136 | 137 | 138 | # 消息发送后存入去重数据库 139 | def insert_into_cache_db( 140 | conn: Connection, item: Dict[str, Any], image_hash: str 141 | ) -> None: 142 | cursor = conn.cursor() 143 | link = item["link"].replace("'", "''") 144 | title = item["title"].replace("'", "''") 145 | cursor.execute( 146 | "INSERT INTO main (link, title, image_hash) VALUES (?, ?, ?);", 147 | (link, title, image_hash), 148 | ) 149 | cursor.close() 150 | conn.commit() 151 | 152 | 153 | # 写入缓存 json 154 | def write_item(db: TinyDB, new_item: Dict[str, Any]) -> None: 155 | if not new_item.get("to_send"): 156 | db.update(delete("to_send"), Query().hash == str(new_item.get("hash"))) # type: ignore 157 | db.upsert(cache_filter(new_item), Query().hash == str(new_item.get("hash"))) 158 | -------------------------------------------------------------------------------- /rss2/parsing/check_update.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | from contextlib import suppress 3 | from email.utils import parsedate_to_datetime 4 | from typing import Any, Dict, List 5 | 6 | import arrow 7 | from tinydb import Query, TinyDB 8 | 9 | 10 | # 对 dict 对象计算哈希值,供后续比较 11 | def dict_hash(dictionary: Dict[str, Any]) -> str: 12 | string = str(dictionary.get("guid", dictionary.get("link"))) 13 | result = hashlib.md5(string.encode()) 14 | return result.hexdigest() 15 | 16 | 17 | # 检查更新 18 | def check_update(db: TinyDB, new: List[Dict[str, Any]]) -> List[Dict[str, Any]]: 19 | 20 | # 发送失败 1 次 21 | to_send_list: List[Dict[str, Any]] = db.search(Query().to_send.exists()) 22 | 23 | if not new and not to_send_list: 24 | return [] 25 | 26 | old_hash_list = [r.get("hash") for r in db.all()] 27 | for i in new: 28 | hash_temp = dict_hash(i) 29 | if hash_temp not in old_hash_list: 30 | i["hash"] = hash_temp 31 | to_send_list.append(i) 32 | 33 | # 对结果按照发布时间排序 34 | to_send_list.sort(key=get_item_date) 35 | 36 | return to_send_list 37 | 38 | 39 | def get_item_date(item: Dict[str, Any]) -> arrow.Arrow: 40 | if date := item.get("published", item.get("updated")): 41 | with suppress(Exception): 42 | date = parsedate_to_datetime(date) 43 | return arrow.get(date) 44 | return arrow.now() 45 | -------------------------------------------------------------------------------- /rss2/parsing/download_torrent.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Any, Dict, List, Optional 3 | 4 | import aiohttp 5 | from nonebot import get_bot 6 | from nonebot.log import logger 7 | 8 | from ..config import config 9 | from ..parsing.utils import get_summary 10 | from ..pikpak_offline import pikpak_offline_download 11 | from ..qbittorrent_download import start_down 12 | from ..rss_class import Rss 13 | from ..utils import convert_size, get_torrent_b16_hash, send_msg 14 | 15 | 16 | async def down_torrent( 17 | rss: Rss, item: Dict[str, Any], proxy: Optional[str] 18 | ) -> List[str]: 19 | """ 20 | 创建下载种子任务 21 | """ 22 | hash_list = [] 23 | for tmp in item["links"]: 24 | if ( 25 | tmp["type"] == "application/x-bittorrent" 26 | or tmp["href"].find(".torrent") > 0 27 | ): 28 | hash_list.append( 29 | await start_down( 30 | url=tmp["href"], 31 | group_ids=rss.group_id, 32 | name=rss.name, 33 | proxy=proxy, 34 | ) 35 | ) 36 | return hash_list 37 | 38 | 39 | async def pikpak_offline( 40 | rss: Rss, item: Dict[str, Any], proxy: Optional[str] 41 | ) -> List[Dict[str, Any]]: 42 | """ 43 | 创建pikpak 离线下载任务 44 | 下载到 config.pikpak_download_path/rss.name or find rss.pikpak_path_rex 45 | """ 46 | download_infos = [] 47 | for tmp in item["links"]: 48 | if ( 49 | tmp["type"] == "application/x-bittorrent" 50 | or tmp["href"].find(".torrent") > 0 51 | ): 52 | url = tmp["href"] 53 | if not re.search(r"magnet:\?xt=urn:btih:", tmp["href"]): 54 | async with aiohttp.ClientSession( 55 | timeout=aiohttp.ClientTimeout(total=100) 56 | ) as session: 57 | try: 58 | resp = await session.get(tmp["href"], proxy=proxy) 59 | content = await resp.read() 60 | url = f"magnet:?xt=urn:btih:{get_torrent_b16_hash(content)}" 61 | except Exception as e: 62 | msg = f"{rss.name} 下载种子失败: {e}" 63 | logger.error(msg) 64 | await send_msg( 65 | msg=msg, user_ids=rss.user_id, group_ids=rss.group_id 66 | ) 67 | continue 68 | try: 69 | path = f"{config.pikpak_download_path}/{rss.name}" 70 | summary = get_summary(item) 71 | if rss.pikpak_path_key and ( 72 | result := re.findall(rss.pikpak_path_key, summary) 73 | ): 74 | path = ( 75 | config.pikpak_download_path 76 | + "/" 77 | + re.sub(r'[?*:"<>\\/|]', "_", result[0]) 78 | ) 79 | logger.info(f"Offline download {url} to {path}") 80 | info = await pikpak_offline_download(url=url, path=path) 81 | download_infos.append( 82 | { 83 | "name": info["task"]["name"], 84 | "file_size": convert_size(int(info["task"]["file_size"])), 85 | "path": path, 86 | } 87 | ) 88 | except Exception as e: 89 | msg = f"{rss.name} PikPak 离线下载失败: {e}" 90 | logger.error(msg) 91 | await send_msg(msg=msg, user_ids=rss.user_id, group_ids=rss.group_id) 92 | return download_infos 93 | -------------------------------------------------------------------------------- /rss2/parsing/handle_html_tag.py: -------------------------------------------------------------------------------- 1 | import re 2 | from html import unescape as html_unescape 3 | 4 | import bbcode 5 | from pyquery import PyQuery as Pq 6 | from yarl import URL 7 | 8 | from ..config import config 9 | 10 | 11 | # 处理 bbcode 12 | def handle_bbcode(html: Pq) -> str: 13 | rss_str = html_unescape(str(html)) 14 | 15 | # issue 36 处理 bbcode 16 | rss_str = re.sub( 17 | r"(\[url=[^]]+])?\[img[^]]*].+\[/img](\[/url])?", "", rss_str, flags=re.I 18 | ) 19 | 20 | # 处理一些 bbcode 标签 21 | bbcode_tags = [ 22 | "align", 23 | "b", 24 | "backcolor", 25 | "color", 26 | "font", 27 | "size", 28 | "table", 29 | "tbody", 30 | "td", 31 | "tr", 32 | "u", 33 | "url", 34 | ] 35 | 36 | for i in bbcode_tags: 37 | rss_str = re.sub(rf"\[{i}=[^]]+]", "", rss_str, flags=re.I) 38 | rss_str = re.sub(rf"\[/?{i}]", "", rss_str, flags=re.I) 39 | 40 | # 去掉结尾被截断的信息 41 | rss_str = re.sub( 42 | r"(\[[^]]+|\[img][^\[\]]+) \.\.\n?

", "

", rss_str, flags=re.I 43 | ) 44 | 45 | # 检查正文是否为 bbcode ,没有成对的标签也当作不是,从而不进行处理 46 | bbcode_search = re.search(r"\[/(\w+)]", rss_str) 47 | if bbcode_search and re.search(f"\\[{bbcode_search[1]}", rss_str): 48 | parser = bbcode.Parser() 49 | parser.escape_html = False 50 | rss_str = parser.format(rss_str) 51 | 52 | return rss_str 53 | 54 | 55 | # HTML标签等处理 56 | def handle_html_tag(html: Pq) -> str: 57 | rss_str = html_unescape(str(html)) 58 | 59 | # 有序/无序列表 标签处理 60 | for ul in html("ul").items(): 61 | for li in ul("li").items(): 62 | li_str_search = re.search("
  • (.+)
  • ", repr(str(li))) 63 | rss_str = rss_str.replace( 64 | str(li), f"\n- {li_str_search[1]}" # type: ignore 65 | ).replace("\\n", "\n") 66 | for ol in html("ol").items(): 67 | for index, li in enumerate(ol("li").items()): 68 | li_str_search = re.search("
  • (.+)
  • ", repr(str(li))) 69 | rss_str = rss_str.replace( 70 | str(li), f"\n{index + 1}. {li_str_search[1]}" # type: ignore 71 | ).replace("\\n", "\n") 72 | rss_str = re.sub("", "\n", rss_str) 73 | # 处理没有被 ul / ol 标签包围的 li 标签 74 | rss_str = rss_str.replace("
  • ", "- ").replace("
  • ", "") 75 | 76 | # 标签处理 77 | for a in html("a").items(): 78 | a_str = re.search( 79 | r"]+>.*?", html_unescape(str(a)), flags=re.DOTALL 80 | ).group() # type: ignore 81 | if a.text() and str(a.text()) != a.attr("href"): 82 | # 去除微博超话 83 | if re.search( 84 | r"https://m\.weibo\.cn/p/index\?extparam=\S+&containerid=\w+", 85 | a.attr("href"), 86 | ): 87 | rss_str = rss_str.replace(a_str, "") 88 | # 去除微博话题对应链接 及 微博用户主页链接,只保留文本 89 | elif ( 90 | a.attr("href").startswith("https://m.weibo.cn/search?containerid=") 91 | and re.search("#.+#", a.text()) 92 | ) or ( 93 | a.attr("href").startswith("https://weibo.com/") 94 | and a.text().startswith("@") 95 | ): 96 | rss_str = rss_str.replace(a_str, a.text()) 97 | else: 98 | if a.attr("href").startswith("https://weibo.cn/sinaurl?u="): 99 | a.attr("href", URL(a.attr("href")).query["u"]) 100 | rss_str = rss_str.replace(a_str, f" {a.text()}: {a.attr('href')}\n") 101 | else: 102 | rss_str = rss_str.replace(a_str, f" {a.attr('href')}\n") 103 | 104 | # 处理一些 HTML 标签 105 | html_tags = [ 106 | "b", 107 | "blockquote", 108 | "code", 109 | "dd", 110 | "del", 111 | "div", 112 | "dl", 113 | "dt", 114 | "em", 115 | "figure", 116 | "font", 117 | "i", 118 | "iframe", 119 | "ol", 120 | "p", 121 | "pre", 122 | "s", 123 | "small", 124 | "span", 125 | "strong", 126 | "sub", 127 | "table", 128 | "tbody", 129 | "td", 130 | "th", 131 | "thead", 132 | "tr", 133 | "u", 134 | "ul", 135 | ] 136 | 137 | #

     标签后增加俩个换行
    138 |     for i in ["p", "pre"]:
    139 |         rss_str = re.sub(f"", f"\n\n", rss_str)
    140 | 
    141 |     # 直接去掉标签,留下内部文本信息
    142 |     for i in html_tags:
    143 |         rss_str = re.sub(f"<{i} [^>]+>", "", rss_str)
    144 |         rss_str = re.sub(f"", "", rss_str)
    145 | 
    146 |     rss_str = re.sub(r"<(br|hr)\s?/?>|<(br|hr) [^>]+>", "\n", rss_str)
    147 |     rss_str = re.sub(r"]+>", "\n", rss_str)
    148 |     rss_str = re.sub(r"", "\n", rss_str)
    149 | 
    150 |     # 删除图片、视频标签
    151 |     rss_str = re.sub(
    152 |         r"]*>(.*?)?|]+>", "", rss_str, flags=re.DOTALL
    153 |     )
    154 | 
    155 |     # 去掉多余换行
    156 |     while "\n\n\n" in rss_str:
    157 |         rss_str = rss_str.replace("\n\n\n", "\n\n")
    158 |     rss_str = rss_str.strip()
    159 | 
    160 |     if 0 < config.max_length < len(rss_str):
    161 |         rss_str = f"{rss_str[: config.max_length]}..."
    162 | 
    163 |     return rss_str
    164 | 
    
    
    --------------------------------------------------------------------------------
    /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 |     content = await download_image(url, img_proxy)
    210 |     if content:
    211 |         if rss is not None and rss.download_pic:
    212 |             _url = URL(url)
    213 |             logger.debug(f"正在保存图片: {url}")
    214 |             try:
    215 |                 save_image(content=content, file_url=_url, rss=rss)
    216 |             except Exception as e:
    217 |                 logger.warning(e)
    218 |                 logger.warning("在保存图片到本地时出现错误")
    219 |         resize_content = await zip_pic(url, content)
    220 |         if img_base64 := get_pic_base64(resize_content):
    221 |             return f"[CQ:image,file=base64://{img_base64}]"
    222 |     return f"\n图片走丢啦: {url}\n"
    223 | 
    224 | 
    225 | async def handle_img_combo_with_content(gif_url: str, content: bytes) -> str:
    226 |     resize_content = await zip_pic(gif_url, content)
    227 |     if img_base64 := get_pic_base64(resize_content):
    228 |         return f"[CQ:image,file=base64://{img_base64}]"
    229 |     return "\n图片走丢啦\n"
    230 | 
    231 | 
    232 | # 处理图片、视频
    233 | async def handle_img(item: Dict[str, Any], img_proxy: bool, img_num: int) -> str:
    234 |     if item.get("image_content"):
    235 |         return await handle_img_combo_with_content(
    236 |             item.get("gif_url", ""), item["image_content"]
    237 |         )
    238 |     html = Pq(get_summary(item))
    239 |     img_str = ""
    240 |     # 处理图片
    241 |     doc_img = list(html("img").items())
    242 |     # 只发送限定数量的图片,防止刷屏
    243 |     if 0 < img_num < len(doc_img):
    244 |         img_str += f"\n因启用图片数量限制,目前只有 {img_num} 张图片:"
    245 |         doc_img = doc_img[:img_num]
    246 |     for img in doc_img:
    247 |         url = img.attr("src")
    248 |         img_str += await handle_img_combo(url, img_proxy)
    249 | 
    250 |     # 处理视频
    251 |     if doc_video := html("video"):
    252 |         img_str += "\n视频封面:"
    253 |         for video in doc_video.items():
    254 |             url = video.attr("poster")
    255 |             img_str += await handle_img_combo(url, img_proxy)
    256 | 
    257 |     return img_str
    258 | 
    259 | 
    260 | # 处理 bbcode 图片
    261 | async def handle_bbcode_img(html: Pq, img_proxy: bool, img_num: int) -> str:
    262 |     img_str = ""
    263 |     # 处理图片
    264 |     img_list = re.findall(r"\[img[^]]*](.+)\[/img]", str(html), flags=re.I)
    265 |     # 只发送限定数量的图片,防止刷屏
    266 |     if 0 < img_num < len(img_list):
    267 |         img_str += f"\n因启用图片数量限制,目前只有 {img_num} 张图片:"
    268 |         img_list = img_list[:img_num]
    269 |     for img_tmp in img_list:
    270 |         img_str += await handle_img_combo(img_tmp, img_proxy)
    271 | 
    272 |     return img_str
    273 | 
    274 | 
    275 | def file_name_format(file_url: URL, rss: Rss) -> Tuple[Path, str]:
    276 |     """
    277 |     可以根据用户设置的规则来格式化文件名
    278 |     """
    279 |     format_rule = config.img_format
    280 |     down_path = config.img_down_path
    281 |     rules = {  # 替换格式化字符串
    282 |         "{subs}": rss.name,
    283 |         "{name}": file_url.name
    284 |         if "{ext}" not in format_rule
    285 |         else Path(file_url.name).stem,
    286 |         "{ext}": file_url.suffix if "{ext}" in format_rule else "",
    287 |     }
    288 |     for k, v in rules.items():
    289 |         format_rule = format_rule.replace(k, v)
    290 |     if down_path == "":  # 如果没设置保存路径的话,就保存到默认目录下
    291 |         save_path = Path().cwd() / "data" / "image"
    292 |     elif down_path[0] == ".":
    293 |         save_path = Path().cwd() / Path(down_path)
    294 |     else:
    295 |         save_path = Path(down_path)
    296 |     full_path = save_path / format_rule
    297 |     save_path = full_path.parents[0]
    298 |     save_name = full_path.name
    299 |     return save_path, save_name
    300 | 
    301 | 
    302 | def save_image(content: bytes, file_url: URL, rss: Rss) -> None:
    303 |     """
    304 |     将压缩之前的原图保存到本地的电脑上
    305 |     """
    306 |     save_path, save_name = file_name_format(file_url=file_url, rss=rss)
    307 | 
    308 |     full_save_path = save_path / save_name
    309 |     try:
    310 |         full_save_path.write_bytes(content)
    311 |     except FileNotFoundError:
    312 |         # 初次写入时文件夹不存在,需要创建一下
    313 |         save_path.mkdir(parents=True)
    314 |         full_save_path.write_bytes(content)
    315 | 
    
    
    --------------------------------------------------------------------------------
    /rss2/parsing/handle_translation.py:
    --------------------------------------------------------------------------------
      1 | import hashlib
      2 | import random
      3 | import re
      4 | from typing import Dict, Optional
      5 | 
      6 | import aiohttp
      7 | import emoji
      8 | from deep_translator import DeeplTranslator, GoogleTranslator, single_detection
      9 | from nonebot.log import logger
     10 | 
     11 | from ..config import config
     12 | 
     13 | 
     14 | async def baidu_translator(content: str, appid: str, secret_key: str) -> str:
     15 |     url = "https://api.fanyi.baidu.com/api/trans/vip/translate"
     16 |     salt = str(random.randint(32768, 65536))
     17 |     sign = hashlib.md5((appid + content + salt + secret_key).encode()).hexdigest()
     18 |     params = {
     19 |         "q": content,
     20 |         "from": "auto",
     21 |         "to": "zh",
     22 |         "appid": appid,
     23 |         "salt": salt,
     24 |         "sign": sign,
     25 |     }
     26 |     async with aiohttp.ClientSession() as session:
     27 |         resp = await session.get(url, params=params, timeout=aiohttp.ClientTimeout(10))
     28 |         data = await resp.json()
     29 |         try:
     30 |             content = "".join(i["dst"] + "\n" for i in data["trans_result"])
     31 |             return "\n百度翻译:\n" + content[:-1]
     32 |         except Exception as e:
     33 |             error_msg = f"百度翻译失败:{data['error_msg']}"
     34 |             logger.warning(error_msg)
     35 |             raise Exception(error_msg) from e
     36 | 
     37 | 
     38 | async def google_translation(text: str, proxies: Optional[Dict[str, str]]) -> str:
     39 |     # text 是处理过emoji的
     40 |     try:
     41 |         translator = GoogleTranslator(source="auto", target="zh-CN", proxies=proxies)
     42 |         return "\n谷歌翻译:\n" + str(translator.translate(re.escape(text)))
     43 |     except Exception as e:
     44 |         error_msg = "\nGoogle翻译失败:" + str(e) + "\n"
     45 |         logger.warning(error_msg)
     46 |         raise Exception(error_msg) from e
     47 | 
     48 | 
     49 | async def deepl_translator(text: str, proxies: Optional[Dict[str, str]]) -> str:
     50 |     try:
     51 |         lang = None
     52 |         if config.single_detection_api_key:
     53 |             lang = single_detection(text, api_key=config.single_detection_api_key)
     54 |         translator = DeeplTranslator(
     55 |             api_key=config.deepl_translator_api_key,
     56 |             source=lang,
     57 |             target="zh",
     58 |             use_free_api=True,
     59 |             proxies=proxies,
     60 |         )
     61 |         return "\nDeepl翻译:\n" + str(translator.translate(re.escape(text)))
     62 |     except Exception as e:
     63 |         error_msg = "\nDeeplTranslator翻译失败:" + str(e) + "\n"
     64 |         logger.warning(error_msg)
     65 |         raise Exception(error_msg) from e
     66 | 
     67 | 
     68 | # 翻译
     69 | async def handle_translation(content: str) -> str:
     70 |     proxies = (
     71 |         {
     72 |             "https": config.rss_proxy,
     73 |             "http": config.rss_proxy,
     74 |         }
     75 |         if config.rss_proxy
     76 |         else None
     77 |     )
     78 | 
     79 |     text = emoji.demojize(content)
     80 |     text = re.sub(r":[A-Za-z_]*:", " ", text)
     81 |     try:
     82 |         # 优先级 DeeplTranslator > 百度翻译 > GoogleTranslator
     83 |         # 异常时使用 GoogleTranslator 重试
     84 |         google_translator_flag = False
     85 |         try:
     86 |             if config.deepl_translator_api_key:
     87 |                 text = await deepl_translator(text=text, proxies=proxies)
     88 |             elif config.baidu_id and config.baidu_key:
     89 |                 text = await baidu_translator(
     90 |                     content, config.baidu_id, config.baidu_key
     91 |                 )
     92 |             else:
     93 |                 google_translator_flag = True
     94 |         except Exception:
     95 |             google_translator_flag = True
     96 |         if google_translator_flag:
     97 |             text = await google_translation(text=text, proxies=proxies)
     98 |     except Exception as e:
     99 |         logger.error(e)
    100 |         text = str(e)
    101 | 
    102 |     text = text.replace("\\", "")
    103 |     return text
    104 | 
    
    
    --------------------------------------------------------------------------------
    /rss2/parsing/parsing_rss.py:
    --------------------------------------------------------------------------------
      1 | import re
      2 | from typing import Any, Callable, Dict, List
      3 | 
      4 | from tinydb import TinyDB
      5 | from tinydb.middlewares import CachingMiddleware
      6 | from tinydb.storages import JSONStorage
      7 | 
      8 | from ..config import DATA_PATH
      9 | from ..rss_class import Rss
     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 |         "before": [],  # item的预处理
     52 |         "title": [],
     53 |         "summary": [],
     54 |         "picture": [],
     55 |         "source": [],
     56 |         "date": [],
     57 |         "torrent": [],
     58 |         "after": [],  # item的最后处理,此处调用消息截取、发送
     59 |     }
     60 | 
     61 |     """
     62 |      - **类型**: ``List[ParsingItem]``
     63 |     - **说明**: 最后执行的解析器,在消息发送后,也可以多条消息合并发送
     64 |     """
     65 |     after_handler: List[ParsingItem] = []
     66 | 
     67 |     # 增加解析器
     68 |     @classmethod
     69 |     def append_handler(
     70 |         cls,
     71 |         parsing_type: str,
     72 |         rex: str = "(.*)",
     73 |         priority: int = 10,
     74 |         block: bool = False,
     75 |     ) -> Callable[..., Any]:
     76 |         def _decorator(func: Callable[..., Any]) -> Callable[..., Any]:
     77 |             cls.handler[parsing_type].append(ParsingItem(func, rex, priority, block))
     78 |             cls.handler.update({parsing_type: _sort(cls.handler[parsing_type])})
     79 |             return func
     80 | 
     81 |         return _decorator
     82 | 
     83 |     @classmethod
     84 |     def append_before_handler(
     85 |         cls, rex: str = "(.*)", priority: int = 10, block: bool = False
     86 |     ) -> Callable[..., Any]:
     87 |         """
     88 |         装饰一个方法,作为将其一个前置处理器
     89 |         参数:
     90 |             rex: 用于正则匹配目标订阅地址,匹配成功后执行器将适用
     91 |             proirity: 执行器优先级,自定义执行器会覆盖掉相同优先级的默认执行器
     92 |             block: 是否要阻断后续执行器进行
     93 |         """
     94 | 
     95 |         def _decorator(func: Callable[..., Any]) -> Callable[..., Any]:
     96 |             cls.before_handler.append(ParsingItem(func, rex, priority, block))
     97 |             cls.before_handler = _sort(cls.before_handler)
     98 |             return func
     99 | 
    100 |         return _decorator
    101 | 
    102 |     @classmethod
    103 |     def append_after_handler(
    104 |         cls, rex: str = "(.*)", priority: int = 10, block: bool = False
    105 |     ) -> Callable[..., Any]:
    106 |         """
    107 |         装饰一个方法,作为将其一个后置处理器
    108 |         参数:
    109 |             rex: 用于正则匹配目标订阅地址,匹配成功后执行器将适用
    110 |             proirity: 执行器优先级,自定义执行器会覆盖掉相同优先级的默认执行器
    111 |             block: 是否要阻断后续执行器进行
    112 |         """
    113 | 
    114 |         def _decorator(func: Callable[..., Any]) -> Callable[..., Any]:
    115 |             cls.after_handler.append(ParsingItem(func, rex, priority, block))
    116 |             cls.after_handler = _sort(cls.after_handler)
    117 |             return func
    118 | 
    119 |         return _decorator
    120 | 
    121 | 
    122 | # 对处理器进行过滤
    123 | def _handler_filter(_handler_list: List[ParsingItem], _url: str) -> List[ParsingItem]:
    124 |     _result = [h for h in _handler_list if re.search(h.rex, _url)]
    125 |     # 删除优先级相同时默认的处理器
    126 |     _delete = [
    127 |         (h.func.__name__, "(.*)", h.priority) for h in _result if h.rex != "(.*)"
    128 |     ]
    129 |     _result = [
    130 |         h for h in _result if (h.func.__name__, h.rex, h.priority) not in _delete
    131 |     ]
    132 |     return _result
    133 | 
    134 | 
    135 | # 解析实例
    136 | class ParsingRss:
    137 | 
    138 |     # 初始化解析实例
    139 |     def __init__(self, rss: Rss):
    140 |         self.state: Dict[str, Any] = {}  # 用于存储实例处理中上下文数据
    141 |         self.rss: Rss = rss
    142 | 
    143 |         # 对处理器进行过滤
    144 |         self.before_handler: List[ParsingItem] = _handler_filter(
    145 |             ParsingBase.before_handler, self.rss.get_url()
    146 |         )
    147 |         self.handler: Dict[str, List[ParsingItem]] = {}
    148 |         for k, v in ParsingBase.handler.items():
    149 |             self.handler[k] = _handler_filter(v, self.rss.get_url())
    150 |         self.after_handler = _handler_filter(
    151 |             ParsingBase.after_handler, self.rss.get_url()
    152 |         )
    153 | 
    154 |     # 开始解析
    155 |     async def start(self, rss_name: str, new_rss: Dict[str, Any]) -> None:
    156 |         # new_data 是完整的 rss 解析后的 dict
    157 |         # 前置处理
    158 |         rss_title = new_rss["feed"]["title"]
    159 |         new_data = new_rss["entries"]
    160 |         _file = DATA_PATH / f"{Rss.handle_name(rss_name)}.json"
    161 |         db = TinyDB(
    162 |             _file,
    163 |             storage=CachingMiddleware(JSONStorage),  # type: ignore
    164 |             encoding="utf-8",
    165 |             sort_keys=True,
    166 |             indent=4,
    167 |             ensure_ascii=False,
    168 |         )
    169 |         self.state.update(
    170 |             {
    171 |                 "rss_title": rss_title,
    172 |                 "new_data": new_data,
    173 |                 "change_data": [],  # 更新的消息列表
    174 |                 "conn": None,  # 数据库连接
    175 |                 "tinydb": db,  # 缓存 json
    176 |             }
    177 |         )
    178 |         for handler in self.before_handler:
    179 |             self.state.update(await handler.func(rss=self.rss, state=self.state))
    180 |             if handler.block:
    181 |                 break
    182 | 
    183 |         # 分条处理
    184 |         self.state.update(
    185 |             {
    186 |                 "messages": [],
    187 |                 "item_count": 0,
    188 |             }
    189 |         )
    190 |         for item in self.state["change_data"]:
    191 |             item_msg = f"【{self.state.get('rss_title')}】更新了!\n----------------------\n"
    192 | 
    193 |             for handler_list in self.handler.values():
    194 |                 # 用于保存上一次处理结果
    195 |                 tmp = ""
    196 |                 tmp_state = {"continue": True}  # 是否继续执行后续处理
    197 | 
    198 |                 # 某一个内容的处理如正文,传入原文与上一次处理结果,此次处理完后覆盖
    199 |                 for handler in handler_list:
    200 |                     tmp = await handler.func(
    201 |                         rss=self.rss,
    202 |                         state=self.state,
    203 |                         item=item,
    204 |                         item_msg=item_msg,
    205 |                         tmp=tmp,
    206 |                         tmp_state=tmp_state,
    207 |                     )
    208 |                     if handler.block or not tmp_state["continue"]:
    209 |                         break
    210 |                 item_msg += tmp
    211 |             self.state["messages"].append(item_msg)
    212 | 
    213 |         # 最后处理
    214 |         for handler in self.after_handler:
    215 |             self.state.update(await handler.func(rss=self.rss, state=self.state))
    216 |             if handler.block:
    217 |                 break
    218 | 
    
    
    --------------------------------------------------------------------------------
    /rss2/parsing/routes/__init__.py:
    --------------------------------------------------------------------------------
    1 | from . import danbooru, nga, pixiv, south_plus, twitter, weibo, yande_re, youtube
    2 | 
    
    
    --------------------------------------------------------------------------------
    /rss2/parsing/routes/danbooru.py:
    --------------------------------------------------------------------------------
      1 | import sqlite3
      2 | from typing import Any, Dict
      3 | 
      4 | import aiohttp
      5 | from nonebot.log import logger
      6 | from pyquery import PyQuery as Pq
      7 | from tenacity import RetryError, retry, stop_after_attempt, stop_after_delay
      8 | 
      9 | from ...config import DATA_PATH
     10 | from ...rss_class import Rss
     11 | from .. import ParsingBase, cache_db_manage, duplicate_exists, write_item
     12 | from ..handle_images import (
     13 |     get_preview_gif_from_video,
     14 |     handle_img_combo,
     15 |     handle_img_combo_with_content,
     16 | )
     17 | from ..utils import get_proxy
     18 | 
     19 | 
     20 | # 处理图片
     21 | @ParsingBase.append_handler(parsing_type="picture", rex="danbooru")
     22 | async def handle_picture(
     23 |     rss: Rss,
     24 |     state: Dict[str, Any],
     25 |     item: Dict[str, Any],
     26 |     item_msg: str,
     27 |     tmp: str,
     28 |     tmp_state: Dict[str, Any],
     29 | ) -> str:
     30 | 
     31 |     # 判断是否开启了只推送标题
     32 |     if rss.only_title:
     33 |         return ""
     34 | 
     35 |     try:
     36 |         res = await handle_img(
     37 |             item=item,
     38 |             img_proxy=rss.img_proxy,
     39 |         )
     40 |     except RetryError:
     41 |         res = "预览图获取失败"
     42 |         logger.warning(f"[{item['link']}]的预览图获取失败")
     43 | 
     44 |     # 判断是否开启了只推送图片
     45 |     return f"{res}\n" if rss.only_pic else f"{tmp + res}\n"
     46 | 
     47 | 
     48 | # 处理图片、视频
     49 | @retry(stop=(stop_after_attempt(5) | stop_after_delay(30)))
     50 | async def handle_img(item: Dict[str, Any], img_proxy: bool) -> str:
     51 |     if item.get("image_content"):
     52 |         return await handle_img_combo_with_content(
     53 |             item.get("gif_url", ""), item["image_content"]
     54 |         )
     55 |     img_str = ""
     56 | 
     57 |     # 处理图片
     58 |     async with aiohttp.ClientSession() as session:
     59 |         resp = await session.get(item["link"], proxy=get_proxy(img_proxy))
     60 |         d = Pq(await resp.text())
     61 |         if img := d("img#image"):
     62 |             url = img.attr("src")
     63 |         else:
     64 |             img_str += "视频预览:"
     65 |             url = d("video#image").attr("src")
     66 |             try:
     67 |                 url = await get_preview_gif_from_video(url)
     68 |             except RetryError:
     69 |                 logger.warning("视频预览获取失败,将发送原视频封面")
     70 |                 url = d("meta[property='og:image']").attr("content")
     71 |         img_str += await handle_img_combo(url, img_proxy)
     72 | 
     73 |     return img_str
     74 | 
     75 | 
     76 | # 如果启用了去重模式,对推送列表进行过滤
     77 | @ParsingBase.append_before_handler(rex="danbooru", priority=12)
     78 | async def handle_check_update(rss: Rss, state: Dict[str, Any]) -> Dict[str, Any]:
     79 |     change_data = state["change_data"]
     80 |     conn = state["conn"]
     81 |     db = state["tinydb"]
     82 | 
     83 |     # 检查是否启用去重 使用 duplicate_filter_mode 字段
     84 |     if not rss.duplicate_filter_mode:
     85 |         return {"change_data": change_data}
     86 | 
     87 |     if not conn:
     88 |         conn = sqlite3.connect(str(DATA_PATH / "cache.db"))
     89 |         conn.set_trace_callback(logger.debug)
     90 | 
     91 |     cache_db_manage(conn)
     92 | 
     93 |     delete = []
     94 |     for index, item in enumerate(change_data):
     95 |         try:
     96 |             summary = await get_summary(item, rss.img_proxy)
     97 |         except RetryError:
     98 |             logger.warning(f"[{item['link']}]的预览图获取失败")
     99 |             continue
    100 |         is_duplicate, image_hash = await duplicate_exists(
    101 |             rss=rss,
    102 |             conn=conn,
    103 |             item=item,
    104 |             summary=summary,
    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 | @retry(stop=(stop_after_attempt(5) | stop_after_delay(30)))
    124 | async def get_summary(item: Dict[str, Any], img_proxy: bool) -> str:
    125 |     summary = (
    126 |         item["content"][0].get("value") if item.get("content") else item["summary"]
    127 |     )
    128 |     # 如果图片非视频封面,替换为更清晰的预览图;否则移除,以此跳过图片去重检查
    129 |     summary_doc = Pq(summary)
    130 |     async with aiohttp.ClientSession() as session:
    131 |         resp = await session.get(item["link"], proxy=get_proxy(img_proxy))
    132 |         d = Pq(await resp.text())
    133 |         if img := d("img#image"):
    134 |             summary_doc("img").attr("src", img.attr("src"))
    135 |         else:
    136 |             summary_doc.remove("img")
    137 |     return str(summary_doc)
    138 | 
    
    
    --------------------------------------------------------------------------------
    /rss2/parsing/routes/nga.py:
    --------------------------------------------------------------------------------
     1 | import re
     2 | from typing import Any, Dict, List
     3 | 
     4 | from tinydb import Query, TinyDB
     5 | 
     6 | from ...rss_class import Rss
     7 | from .. import ParsingBase
     8 | from ..check_update import get_item_date
     9 | 
    10 | 
    11 | # 检查更新
    12 | @ParsingBase.append_before_handler(rex="/nga/", priority=10)
    13 | async def handle_check_update(rss: Rss, state: Dict[str, Any]) -> Dict[str, Any]:
    14 |     new_data = state["new_data"]
    15 |     db = state["tinydb"]
    16 | 
    17 |     for i in new_data:
    18 |         i["link"] = re.sub(r"&rand=\d+", "", i["link"])
    19 | 
    20 |     change_data = check_update(db, new_data)
    21 |     return {"change_data": change_data}
    22 | 
    23 | 
    24 | # 检查更新
    25 | def check_update(db: TinyDB, new: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
    26 | 
    27 |     # 发送失败 1 次
    28 |     to_send_list: List[Dict[str, Any]] = db.search(Query().to_send.exists())
    29 | 
    30 |     if not new and not to_send_list:
    31 |         return []
    32 | 
    33 |     old_link_list = [i["id"] for i in db.all()]
    34 |     to_send_list.extend([i for i in new if i["link"] not in old_link_list])
    35 | 
    36 |     # 对结果按照发布时间排序
    37 |     to_send_list.sort(key=get_item_date)
    38 | 
    39 |     return to_send_list
    40 | 
    
    
    --------------------------------------------------------------------------------
    /rss2/parsing/routes/pixiv.py:
    --------------------------------------------------------------------------------
      1 | import re
      2 | import sqlite3
      3 | from typing import Any, Dict, List
      4 | 
      5 | import aiohttp
      6 | from nonebot.log import logger
      7 | from pyquery import PyQuery as Pq
      8 | from tenacity import RetryError, TryAgain, retry, stop_after_attempt, stop_after_delay
      9 | from tinydb import Query, TinyDB
     10 | 
     11 | from ...config import DATA_PATH
     12 | from ...rss_class import Rss
     13 | from .. import ParsingBase, cache_db_manage, duplicate_exists, write_item
     14 | from ..check_update import get_item_date
     15 | from ..handle_images import (
     16 |     get_preview_gif_from_video,
     17 |     handle_img_combo,
     18 |     handle_img_combo_with_content,
     19 | )
     20 | from ..utils import get_summary
     21 | 
     22 | 
     23 | # 如果启用了去重模式,对推送列表进行过滤
     24 | @ParsingBase.append_before_handler(priority=12, rex="/pixiv/")
     25 | async def handle_check_update(rss: Rss, state: Dict[str, Any]) -> Dict[str, Any]:
     26 |     change_data = state["change_data"]
     27 |     conn = state["conn"]
     28 |     db = state["tinydb"]
     29 | 
     30 |     # 检查是否启用去重 使用 duplicate_filter_mode 字段
     31 |     if not rss.duplicate_filter_mode:
     32 |         return {"change_data": change_data}
     33 | 
     34 |     if not conn:
     35 |         conn = sqlite3.connect(str(DATA_PATH / "cache.db"))
     36 |         conn.set_trace_callback(logger.debug)
     37 | 
     38 |     cache_db_manage(conn)
     39 | 
     40 |     delete = []
     41 |     for index, item in enumerate(change_data):
     42 |         summary = get_summary(item)
     43 |         try:
     44 |             summary_doc = Pq(summary)
     45 |             # 如果图片为动图,通过移除来跳过图片去重检查
     46 |             if re.search("类型:ugoira", str(summary_doc)):
     47 |                 summary_doc.remove("img")
     48 |                 summary = str(summary_doc)
     49 |         except Exception as e:
     50 |             logger.warning(e)
     51 |         is_duplicate, image_hash = await duplicate_exists(
     52 |             rss=rss,
     53 |             conn=conn,
     54 |             item=item,
     55 |             summary=summary,
     56 |         )
     57 |         if is_duplicate:
     58 |             write_item(db, item)
     59 |             delete.append(index)
     60 |         else:
     61 |             change_data[index]["image_hash"] = str(image_hash)
     62 | 
     63 |     change_data = [
     64 |         item for index, item in enumerate(change_data) if index not in delete
     65 |     ]
     66 | 
     67 |     return {
     68 |         "change_data": change_data,
     69 |         "conn": conn,
     70 |     }
     71 | 
     72 | 
     73 | # 处理图片
     74 | @ParsingBase.append_handler(parsing_type="picture", rex="pixiv")
     75 | async def handle_picture(
     76 |     rss: Rss,
     77 |     state: Dict[str, Any],
     78 |     item: Dict[str, Any],
     79 |     item_msg: str,
     80 |     tmp: str,
     81 |     tmp_state: Dict[str, Any],
     82 | ) -> str:
     83 | 
     84 |     # 判断是否开启了只推送标题
     85 |     if rss.only_title:
     86 |         return ""
     87 | 
     88 |     res = ""
     89 |     try:
     90 |         res += await handle_img(
     91 |             item=item, img_proxy=rss.img_proxy, img_num=rss.max_image_number, rss=rss
     92 |         )
     93 |     except Exception as e:
     94 |         logger.warning(f"{rss.name} 没有正文内容!{e}")
     95 | 
     96 |     # 判断是否开启了只推送图片
     97 |     return f"{res}\n" if rss.only_pic else f"{tmp + res}\n"
     98 | 
     99 | 
    100 | # 处理图片、视频
    101 | @retry(stop=(stop_after_attempt(5) | stop_after_delay(30)))
    102 | async def handle_img(
    103 |     item: Dict[str, Any], img_proxy: bool, img_num: int, rss: Rss
    104 | ) -> str:
    105 |     if item.get("image_content"):
    106 |         return await handle_img_combo_with_content(
    107 |             item.get("gif_url", ""), item["image_content"]
    108 |         )
    109 |     html = Pq(get_summary(item))
    110 |     link = item["link"]
    111 |     img_str = ""
    112 |     # 处理动图
    113 |     if re.search("类型:ugoira", str(html)):
    114 |         ugoira_id = re.search(r"\d+", link).group()  # type: ignore
    115 |         try:
    116 |             url = await get_ugoira_video(ugoira_id)
    117 |             url = await get_preview_gif_from_video(url)
    118 |             img_str += await handle_img_combo(url, img_proxy)
    119 |         except RetryError:
    120 |             logger.warning(f"动图[{link}]的预览图获取失败,将发送原动图封面")
    121 |             url = html("img").attr("src")
    122 |             img_str += await handle_img_combo(url, img_proxy)
    123 |     else:
    124 |         # 处理图片
    125 |         doc_img = list(html("img").items())
    126 |         # 只发送限定数量的图片,防止刷屏
    127 |         if 0 < img_num < len(doc_img):
    128 |             img_str += f"\n因启用图片数量限制,目前只有 {img_num} 张图片:"
    129 |             doc_img = doc_img[:img_num]
    130 |         for img in doc_img:
    131 |             url = img.attr("src")
    132 |             img_str += await handle_img_combo(url, img_proxy, rss)
    133 | 
    134 |     return img_str
    135 | 
    136 | 
    137 | # 获取动图为视频
    138 | @retry(stop=(stop_after_attempt(5) | stop_after_delay(30)))
    139 | async def get_ugoira_video(ugoira_id: str) -> Any:
    140 |     async with aiohttp.ClientSession() as session:
    141 |         data = {"id": ugoira_id, "type": "ugoira"}
    142 |         resp = await session.post("https://ugoira.huggy.moe/api/illusts", data=data)
    143 |         url = (await resp.json()).get("data")[0].get("url")
    144 |         if not url:
    145 |             raise TryAgain
    146 |         return url
    147 | 
    148 | 
    149 | # 处理来源
    150 | @ParsingBase.append_handler(parsing_type="source", rex="pixiv")
    151 | async def handle_source(
    152 |     rss: Rss,
    153 |     state: Dict[str, Any],
    154 |     item: Dict[str, Any],
    155 |     item_msg: str,
    156 |     tmp: str,
    157 |     tmp_state: Dict[str, Any],
    158 | ) -> str:
    159 |     source = item["link"]
    160 |     # 缩短 pixiv 链接
    161 |     str_link = re.sub("https://www.pixiv.net/artworks/", "https://pixiv.net/i/", source)
    162 |     return f"链接:{str_link}\n"
    163 | 
    164 | 
    165 | # 检查更新
    166 | @ParsingBase.append_before_handler(rex="pixiv/ranking", priority=10)  # type: ignore
    167 | async def handle_check_update(rss: Rss, state: Dict[str, Any]) -> Dict[str, Any]:
    168 |     db = state["tinydb"]
    169 |     change_data = check_update(db, state["new_data"])
    170 |     return {"change_data": change_data}
    171 | 
    172 | 
    173 | # 检查更新
    174 | def check_update(db: TinyDB, new: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
    175 | 
    176 |     # 发送失败 1 次
    177 |     to_send_list: List[Dict[str, Any]] = db.search(Query().to_send.exists())
    178 | 
    179 |     if not new and not to_send_list:
    180 |         return []
    181 | 
    182 |     old_link_list = [i["link"] for i in db.all()]
    183 |     to_send_list.extend([i for i in new if i["link"] not in old_link_list])
    184 | 
    185 |     # 对结果按照发布时间排序
    186 |     to_send_list.sort(key=get_item_date)
    187 | 
    188 |     return to_send_list
    189 | 
    
    
    --------------------------------------------------------------------------------
    /rss2/parsing/routes/south_plus.py:
    --------------------------------------------------------------------------------
     1 | import re
     2 | from typing import Any, Dict
     3 | 
     4 | from nonebot.log import logger
     5 | from pyquery import PyQuery as Pq
     6 | 
     7 | from ...rss_class import Rss
     8 | from .. import ParsingBase, handle_html_tag
     9 | from ..handle_html_tag import handle_bbcode
    10 | from ..handle_images import handle_bbcode_img
    11 | from ..utils import get_summary
    12 | 
    13 | 
    14 | # 处理正文 处理网页 tag
    15 | @ParsingBase.append_handler(
    16 |     parsing_type="summary", rex="(south|spring)-plus.net", priority=10
    17 | )
    18 | async def handle_summary(
    19 |     rss: Rss,
    20 |     state: Dict[str, Any],
    21 |     item: Dict[str, Any],
    22 |     item_msg: str,
    23 |     tmp: str,
    24 |     tmp_state: Dict[str, Any],
    25 | ) -> str:
    26 |     rss_str = handle_bbcode(html=Pq(get_summary(item)))
    27 |     tmp += handle_html_tag(html=Pq(rss_str))
    28 |     return tmp
    29 | 
    30 | 
    31 | # 处理图片
    32 | @ParsingBase.append_handler(parsing_type="picture", rex="(south|spring)-plus.net")
    33 | async def handle_picture(
    34 |     rss: Rss,
    35 |     state: Dict[str, Any],
    36 |     item: Dict[str, Any],
    37 |     item_msg: str,
    38 |     tmp: str,
    39 |     tmp_state: Dict[str, Any],
    40 | ) -> str:
    41 | 
    42 |     # 判断是否开启了只推送标题
    43 |     if rss.only_title:
    44 |         return ""
    45 | 
    46 |     res = ""
    47 |     try:
    48 |         res += await handle_bbcode_img(
    49 |             html=Pq(get_summary(item)),
    50 |             img_proxy=rss.img_proxy,
    51 |             img_num=rss.max_image_number,
    52 |         )
    53 |     except Exception as e:
    54 |         logger.warning(f"{rss.name} 没有正文内容!{e}")
    55 | 
    56 |     # 判断是否开启了只推送图片
    57 |     return f"{res}\n" if rss.only_pic else f"{tmp + res}\n"
    58 | 
    59 | 
    60 | # 处理来源
    61 | @ParsingBase.append_handler(parsing_type="source", rex="(south|spring)-plus.net")
    62 | async def handle_source(
    63 |     rss: Rss,
    64 |     state: Dict[str, Any],
    65 |     item: Dict[str, Any],
    66 |     item_msg: str,
    67 |     tmp: str,
    68 |     tmp_state: Dict[str, Any],
    69 | ) -> str:
    70 |     source = item["link"]
    71 |     # issue 36 处理链接
    72 |     if re.search(r"^//", source):
    73 |         source = source.replace("//", "https://")
    74 |     return f"链接:{source}\n"
    75 | 
    
    
    --------------------------------------------------------------------------------
    /rss2/parsing/routes/twitter.py:
    --------------------------------------------------------------------------------
     1 | from typing import Any, Dict
     2 | 
     3 | from nonebot.log import logger
     4 | from pyquery import PyQuery as Pq
     5 | from tenacity import RetryError
     6 | 
     7 | from ...rss_class import Rss
     8 | from .. import ParsingBase
     9 | from ..handle_images import (
    10 |     get_preview_gif_from_video,
    11 |     handle_img_combo,
    12 |     handle_img_combo_with_content,
    13 | )
    14 | from ..utils import get_summary
    15 | 
    16 | 
    17 | # 处理图片
    18 | @ParsingBase.append_handler(parsing_type="picture", rex="twitter")
    19 | async def handle_picture(
    20 |     rss: Rss,
    21 |     state: Dict[str, Any],
    22 |     item: Dict[str, Any],
    23 |     item_msg: str,
    24 |     tmp: str,
    25 |     tmp_state: Dict[str, Any],
    26 | ) -> str:
    27 | 
    28 |     # 判断是否开启了只推送标题
    29 |     if rss.only_title:
    30 |         return ""
    31 | 
    32 |     res = await handle_img(
    33 |         item=item,
    34 |         img_proxy=rss.img_proxy,
    35 |         img_num=rss.max_image_number,
    36 |     )
    37 | 
    38 |     # 判断是否开启了只推送图片
    39 |     return f"{res}\n" if rss.only_pic else f"{tmp + res}\n"
    40 | 
    41 | 
    42 | # 处理图片、视频
    43 | async def handle_img(item: Dict[str, Any], img_proxy: bool, img_num: int) -> str:
    44 |     if item.get("image_content"):
    45 |         return await handle_img_combo_with_content(
    46 |             item.get("gif_url", ""), item["image_content"]
    47 |         )
    48 |     html = Pq(get_summary(item))
    49 |     img_str = ""
    50 |     # 处理图片
    51 |     doc_img = list(html("img").items())
    52 |     # 只发送限定数量的图片,防止刷屏
    53 |     if 0 < img_num < len(doc_img):
    54 |         img_str += f"\n因启用图片数量限制,目前只有 {img_num} 张图片:"
    55 |         doc_img = doc_img[:img_num]
    56 |     for img in doc_img:
    57 |         url = img.attr("src")
    58 |         img_str += await handle_img_combo(url, img_proxy)
    59 | 
    60 |     # 处理视频
    61 |     if doc_video := html("video"):
    62 |         img_str += "\n视频预览:"
    63 |         for video in doc_video.items():
    64 |             url = video.attr("src")
    65 |             try:
    66 |                 url = await get_preview_gif_from_video(url)
    67 |             except RetryError:
    68 |                 logger.warning("视频预览获取失败,将发送原视频封面")
    69 |                 url = video.attr("poster")
    70 |             img_str += await handle_img_combo(url, img_proxy)
    71 | 
    72 |     return img_str
    73 | 
    
    
    --------------------------------------------------------------------------------
    /rss2/parsing/routes/weibo.py:
    --------------------------------------------------------------------------------
     1 | from typing import Any, Dict
     2 | 
     3 | from nonebot.log import logger
     4 | from pyquery import PyQuery as Pq
     5 | 
     6 | from ...config import config
     7 | from ...rss_class import Rss
     8 | from .. import ParsingBase, handle_html_tag
     9 | from ..handle_images import handle_img_combo, handle_img_combo_with_content
    10 | from ..utils import get_summary
    11 | 
    12 | 
    13 | # 处理正文 处理网页 tag
    14 | @ParsingBase.append_handler(parsing_type="summary", rex="weibo", priority=10)
    15 | async def handle_summary(
    16 |     rss: Rss,
    17 |     state: Dict[str, Any],
    18 |     item: Dict[str, Any],
    19 |     item_msg: str,
    20 |     tmp: str,
    21 |     tmp_state: Dict[str, Any],
    22 | ) -> str:
    23 |     summary_html = Pq(get_summary(item))
    24 | 
    25 |     # 判断是否保留转发内容
    26 |     if not config.blockquote:
    27 |         summary_html.remove("blockquote")
    28 | 
    29 |     tmp += handle_html_tag(html=summary_html)
    30 | 
    31 |     return tmp
    32 | 
    33 | 
    34 | # 处理图片
    35 | @ParsingBase.append_handler(parsing_type="picture", rex="weibo")
    36 | async def handle_picture(
    37 |     rss: Rss,
    38 |     state: Dict[str, Any],
    39 |     item: Dict[str, Any],
    40 |     item_msg: str,
    41 |     tmp: str,
    42 |     tmp_state: Dict[str, Any],
    43 | ) -> str:
    44 | 
    45 |     # 判断是否开启了只推送标题
    46 |     if rss.only_title:
    47 |         return ""
    48 | 
    49 |     res = ""
    50 |     try:
    51 |         res += await handle_img(
    52 |             item=item,
    53 |             img_proxy=rss.img_proxy,
    54 |             img_num=rss.max_image_number,
    55 |         )
    56 |     except Exception as e:
    57 |         logger.warning(f"{rss.name} 没有正文内容!{e}")
    58 | 
    59 |     # 判断是否开启了只推送图片
    60 |     return f"{res}\n" if rss.only_pic else f"{tmp + res}\n"
    61 | 
    62 | 
    63 | # 处理图片、视频
    64 | async def handle_img(item: Dict[str, Any], img_proxy: bool, img_num: int) -> str:
    65 |     if item.get("image_content"):
    66 |         return await handle_img_combo_with_content(
    67 |             item.get("gif_url", ""), item["image_content"]
    68 |         )
    69 |     html = Pq(get_summary(item))
    70 |     # 移除多余图标
    71 |     html.remove("span.url-icon")
    72 |     img_str = ""
    73 |     # 处理图片
    74 |     doc_img = list(html("img").items())
    75 |     # 只发送限定数量的图片,防止刷屏
    76 |     if 0 < img_num < len(doc_img):
    77 |         img_str += f"\n因启用图片数量限制,目前只有 {img_num} 张图片:"
    78 |         doc_img = doc_img[:img_num]
    79 |     for img in doc_img:
    80 |         url = img.attr("src")
    81 |         img_str += await handle_img_combo(url, img_proxy)
    82 | 
    83 |     # 处理视频
    84 |     if doc_video := html("video"):
    85 |         img_str += "\n视频封面:"
    86 |         for video in doc_video.items():
    87 |             url = video.attr("poster")
    88 |             img_str += await handle_img_combo(url, img_proxy)
    89 | 
    90 |     return img_str
    91 | 
    
    
    --------------------------------------------------------------------------------
    /rss2/parsing/routes/yande_re.py:
    --------------------------------------------------------------------------------
     1 | import re
     2 | from typing import Any, Dict
     3 | 
     4 | from ...rss_class import Rss
     5 | from .. import ParsingBase, check_update
     6 | 
     7 | 
     8 | # 检查更新
     9 | @ParsingBase.append_before_handler(
    10 |     priority=10, rex=r"https\:\/\/yande\.re\/post\/piclens\?tags\="
    11 | )
    12 | async def handle_check_update(rss: Rss, state: Dict[str, Any]) -> Dict[str, Any]:
    13 |     db = state["tinydb"]
    14 |     change_data = check_update(db, state["new_data"])
    15 |     for i in change_data:
    16 |         if i.get("media_content"):
    17 |             i["summary"] = re.sub(
    18 |                 r'https://[^"]+', i["media_content"][0]["url"], i["summary"]
    19 |             )
    20 |     return {"change_data": change_data}
    21 | 
    
    
    --------------------------------------------------------------------------------
    /rss2/parsing/routes/youtube.py:
    --------------------------------------------------------------------------------
     1 | from typing import Any, Dict
     2 | 
     3 | from ...rss_class import Rss
     4 | from .. import ParsingBase
     5 | from ..handle_images import handle_img_combo
     6 | 
     7 | 
     8 | # 处理图片
     9 | @ParsingBase.append_handler(
    10 |     parsing_type="picture",
    11 |     rex=r"https:\/\/www\.youtube\.com\/feeds\/videos\.xml\?channel_id=",
    12 | )
    13 | async def handle_picture(
    14 |     rss: Rss,
    15 |     state: Dict[str, Any],
    16 |     item: Dict[str, Any],
    17 |     item_msg: str,
    18 |     tmp: str,
    19 |     tmp_state: Dict[str, Any],
    20 | ) -> str:
    21 | 
    22 |     # 判断是否开启了只推送标题
    23 |     if rss.only_title:
    24 |         return ""
    25 | 
    26 |     img_url = item["media_thumbnail"][0]["url"]
    27 |     res = await handle_img_combo(img_url, rss.img_proxy)
    28 | 
    29 |     # 判断是否开启了只推送图片
    30 |     return f"{res}\n" if rss.only_pic else f"{tmp + res}\n"
    31 | 
    
    
    --------------------------------------------------------------------------------
    /rss2/parsing/send_message.py:
    --------------------------------------------------------------------------------
      1 | import asyncio
      2 | from collections import defaultdict
      3 | from contextlib import suppress
      4 | from typing import Any, DefaultDict, Dict, Tuple, Union, List
      5 | 
      6 | import arrow
      7 | import nonebot
      8 | from nonebot import logger
      9 | 
     10 | from ..rss_class import Rss
     11 | from ..utils import get_bot_friend_list, get_bot_group_list, get_all_bot_channel_list
     12 | 
     13 | sending_lock: DefaultDict[Tuple[Union[int, str], str], asyncio.Lock] = defaultdict(
     14 |     asyncio.Lock
     15 | )
     16 | 
     17 | # 发送消息
     18 | async def send_msg(rss: Rss, msg: str, item: Dict[str, Any]) -> bool:
     19 |     bot = nonebot.get_bot()
     20 |     if not msg:
     21 |         return False
     22 |     flag = False
     23 |     error_msg = f"消息发送失败!\n链接:[{item.get('link')}]"
     24 |     if rss.user_id:
     25 |         all_friend = (await get_bot_friend_list(bot))[1]
     26 |         flag = any(
     27 |             await asyncio.gather(
     28 |                 *[
     29 |                     send_private_msg(
     30 |                         bot, msg, int(user_id), item, error_msg, all_friend
     31 |                     )
     32 |                     for user_id in rss.user_id
     33 |                 ]
     34 |             )
     35 |         )
     36 | 
     37 |     if rss.group_id:
     38 |         all_group = (await get_bot_group_list(bot))[1]
     39 |         flag = (
     40 |             any(
     41 |                 await asyncio.gather(
     42 |                     *[
     43 |                         send_group_msg(
     44 |                             bot, msg, int(group_id), item, error_msg, all_group
     45 |                         )
     46 |                         for group_id in rss.group_id
     47 |                     ]
     48 |                 )
     49 |             )
     50 |             or flag
     51 |         )
     52 | 
     53 |     if rss.guild_channel_id:
     54 |         all_channels = (await get_all_bot_channel_list(bot))[1]
     55 |         flag = (
     56 |             any(
     57 |                 await asyncio.gather(
     58 |                     *[
     59 |                         send_guild_channel_msg(
     60 |                             bot, msg, guild_channel_id, item, error_msg, all_channels
     61 |                         )
     62 |                         for guild_channel_id in rss.guild_channel_id
     63 |                     ]
     64 |                 )
     65 |             )
     66 |             or flag
     67 |         )
     68 |     return flag
     69 | 
     70 | 
     71 | # 发送私聊消息
     72 | async def send_private_msg(
     73 |     bot,
     74 |     msg: str,
     75 |     user_id: int,
     76 |     item: Dict[str, Any],
     77 |     error_msg: str,
     78 |     all_friend: Dict[int, List[int]],
     79 | ) -> bool:
     80 |     flag = False
     81 |     start_time = arrow.now()
     82 |     sid = [k for k, v in all_friend.items() if int(user_id) in v][0]
     83 |     async with sending_lock[(user_id, "private")]:
     84 |         try:
     85 |             await bot.send_msg(
     86 |                 self_id=sid,
     87 |                 message_type="private",
     88 |                 user_id=user_id,
     89 |                 message=msg,
     90 |             )
     91 |             await asyncio.sleep(max(1 - (arrow.now() - start_time).total_seconds(), 0))
     92 |             flag = True
     93 |         except Exception as e:
     94 |             logger.error(f"E: {repr(e)} 链接:[{item.get('link')}]")
     95 |             if item.get("to_send"):
     96 |                 flag = True
     97 |                 with suppress(Exception):
     98 |                     await bot.send_msg(
     99 |                         self_id=sid,
    100 |                         message_type="private",
    101 |                         user_id=user_id,
    102 |                         message=f"{error_msg}\nE: {repr(e)}",
    103 |                     )
    104 |         return flag
    105 | 
    106 | 
    107 | # 发送群聊消息
    108 | async def send_group_msg(
    109 |     bot,
    110 |     msg: str,
    111 |     group_id: int,
    112 |     item: Dict[str, Any],
    113 |     error_msg: str,
    114 |     all_group: Dict[int, List[int]],
    115 | ) -> bool:
    116 |     flag = False
    117 |     start_time = arrow.now()
    118 |     sid = [k for k, v in all_group.items() if int(group_id) in v][0]
    119 |     async with sending_lock[(group_id, "group")]:
    120 |         try:
    121 |             await bot.send_msg(
    122 |                 self_id=sid,
    123 |                 message_type="group",
    124 |                 group_id=group_id,
    125 |                 message=msg,
    126 |             )
    127 |             await asyncio.sleep(max(1 - (arrow.now() - start_time).total_seconds(), 0))
    128 |             flag = True
    129 |         except Exception as e:
    130 |             logger.error(f"E: {repr(e)} 链接:[{item.get('link')}]")
    131 |             if item.get("to_send"):
    132 |                 flag = True
    133 |                 with suppress(Exception):
    134 |                     await bot.send_msg(
    135 |                         self_id=sid,
    136 |                         message_type="group",
    137 |                         group_id=group_id,
    138 |                         message=f"E: {repr(e)}\n{error_msg}",
    139 |                     )
    140 |         return flag
    141 | 
    142 | 
    143 | # 发送频道消息
    144 | async def send_guild_channel_msg(
    145 |     bot,
    146 |     msg: str,
    147 |     guild_channel_id: str,
    148 |     item: Dict[str, Any],
    149 |     error_msg: str,
    150 |     all_channels: Dict,
    151 | ) -> bool:
    152 |     flag = False
    153 |     start_time = arrow.now()
    154 |     guild_id, channel_id = guild_channel_id.split("@")
    155 |     sid = [k for k, v in all_channels.items() if channel_id in v][0]
    156 |     async with sending_lock[(guild_channel_id, "guild_channel")]:
    157 |         try:
    158 |             await bot.send_guild_channel_msg(
    159 |                 self_id=sid,
    160 |                 message=msg,
    161 |                 guild_id=guild_id,
    162 |                 channel_id=channel_id,
    163 |             )
    164 |             await asyncio.sleep(max(1 - (arrow.now() - start_time).total_seconds(), 0))
    165 |             flag = True
    166 |         except Exception as e:
    167 |             logger.error(f"E: {repr(e)} 链接:[{item.get('link')}]")
    168 |             if item.get("to_send"):
    169 |                 flag = True
    170 |                 with suppress(Exception):
    171 |                     await bot.send_guild_channel_msg(
    172 |                         self_id=sid,
    173 |                         message=f"E: {repr(e)}\n{error_msg}",
    174 |                         guild_id=guild_id,
    175 |                         channel_id=channel_id,
    176 |                     )
    177 |         return flag
    178 | 
    
    
    --------------------------------------------------------------------------------
    /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) -> 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"
    {summary}
    " if re.search("^https?://", summary) else summary 20 | -------------------------------------------------------------------------------- /rss2/permission.py: -------------------------------------------------------------------------------- 1 | from nonebot import SenderRoles 2 | from .config import config 3 | 4 | 5 | def admin_permission(sender: SenderRoles): 6 | return ( 7 | sender.is_superuser 8 | or sender.is_admin 9 | or sender.is_owner 10 | or sender.sent_by(config.guild_superusers) 11 | or sender.sent_by(config.superusers) 12 | ) 13 | -------------------------------------------------------------------------------- /rss2/pikpak_offline.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, List, Optional 2 | 3 | from nonebot.log import logger 4 | from pikpakapi.async_api import PikPakApiAsync 5 | from pikpakapi.PikpakException import PikpakAccessTokenExpireException, PikpakException 6 | 7 | from .config import config 8 | 9 | pikpak_client = PikPakApiAsync( 10 | username=config.pikpak_username, 11 | password=config.pikpak_password, 12 | ) 13 | 14 | 15 | async def refresh_access_token() -> None: 16 | """ 17 | Login or Refresh access_token PikPak 18 | 19 | """ 20 | try: 21 | await pikpak_client.refresh_access_token() 22 | except (PikpakException, PikpakAccessTokenExpireException) as e: 23 | logger.warning(f"refresh_access_token {e}") 24 | await pikpak_client.login() 25 | 26 | 27 | async def login() -> None: 28 | if not pikpak_client.access_token: 29 | await pikpak_client.login() 30 | 31 | 32 | async def path_to_id( 33 | path: Optional[str] = None, create: bool = False 34 | ) -> List[Dict[str, Any]]: 35 | """ 36 | path: str like "/1/2/3" 37 | create: bool create path if not exist 38 | 将形如 /path/a/b 的路径转换为 文件夹的id 39 | """ 40 | if not path: 41 | return [] 42 | paths = [p.strip() for p in path.split("/") if len(p) > 0] 43 | path_ids = [] 44 | count = 0 45 | next_page_token = None 46 | parent_id = None 47 | while count < len(paths): 48 | data = await pikpak_client.file_list( 49 | parent_id=parent_id, next_page_token=next_page_token 50 | ) 51 | if _id := next( 52 | ( 53 | f.get("id") 54 | for f in data.get("files", []) 55 | if f.get("kind", "") == "drive#folder" and f.get("name") == paths[count] 56 | ), 57 | "", 58 | ): 59 | path_ids.append( 60 | { 61 | "id": _id, 62 | "name": paths[count], 63 | } 64 | ) 65 | count += 1 66 | parent_id = _id 67 | elif data.get("next_page_token"): 68 | next_page_token = data.get("next_page_token") 69 | elif create: 70 | data = await pikpak_client.create_folder( 71 | name=paths[count], parent_id=parent_id 72 | ) 73 | _id = data.get("file").get("id") 74 | path_ids.append( 75 | { 76 | "id": _id, 77 | "name": paths[count], 78 | } 79 | ) 80 | count += 1 81 | parent_id = _id 82 | else: 83 | break 84 | return path_ids 85 | 86 | 87 | async def pikpak_offline_download( 88 | url: str, 89 | path: Optional[str] = None, 90 | parent_id: Optional[str] = None, 91 | name: Optional[str] = None, 92 | ) -> Dict[str, Any]: 93 | """ 94 | Offline download 95 | 当有path时, 表示下载到指定的文件夹, 否则下载到根目录 96 | 如果存在 parent_id, 以 parent_id 为准 97 | """ 98 | await login() 99 | try: 100 | if not parent_id: 101 | path_ids = await path_to_id(path, create=True) 102 | if path_ids and len(path_ids) > 0: 103 | parent_id = path_ids[-1].get("id") 104 | return await pikpak_client.offline_download(url, parent_id=parent_id, name=name) # type: ignore 105 | except (PikpakAccessTokenExpireException, PikpakException) as e: 106 | logger.warning(e) 107 | await refresh_access_token() 108 | return await pikpak_offline_download( 109 | url=url, path=path, parent_id=parent_id, name=name 110 | ) 111 | except Exception as e: 112 | msg = f"PikPak Offline Download Error: {e}" 113 | logger.error(msg) 114 | raise Exception(msg) from e 115 | -------------------------------------------------------------------------------- /rss2/qbittorrent_download.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import base64 3 | import re 4 | from typing import Any, Dict, List, Optional 5 | from pathlib import Path 6 | 7 | import aiohttp 8 | import arrow 9 | from apscheduler.triggers.interval import IntervalTrigger 10 | from aiocqhttp import ActionFailed, NetworkError 11 | from nonebot import get_bot, scheduler 12 | from nonebot.log import logger 13 | from qbittorrent import Client 14 | 15 | from .config import config 16 | from .utils import ( 17 | convert_size, 18 | get_torrent_b16_hash, 19 | get_bot_group_list, 20 | send_message_to_admin, 21 | ) 22 | 23 | # 计划 24 | # 创建一个全局定时器用来检测种子下载情况 25 | # 群文件上传成功回调 26 | # 文件三种状态1.下载中2。上传中3.上传完成 27 | # 文件信息持久化存储 28 | # 关键词正则表达式 29 | # 下载开关 30 | 31 | DOWN_STATUS_DOWNING = 1 # 下载中 32 | DOWN_STATUS_UPLOADING = 2 # 上传中 33 | DOWN_STATUS_UPLOAD_OK = 3 # 上传完成 34 | down_info: Dict[str, Dict[str, Any]] = {} 35 | 36 | # 示例 37 | # { 38 | # "hash值": { 39 | # "status":DOWN_STATUS_DOWNING, 40 | # "start_time":None, # 下载开始时间 41 | # "downing_tips_msg_id":[] # 下载中通知群上一条通知的信息,用于撤回,防止刷屏 42 | # } 43 | # } 44 | 45 | 46 | # 发送通知 47 | async def send_msg( 48 | msg: str, notice_group: Optional[List[str]] = None 49 | ) -> List[Dict[str, Any]]: 50 | logger.info(msg) 51 | bot = get_bot() 52 | msg_id = [] 53 | group_list, all_groups = 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 | sid = [k for k, v in all_groups.items() if int(group_id) in v][0] 60 | msg_id.append( 61 | await bot.send_group_msg( 62 | self_id=sid, group_id=int(group_id), message=msg 63 | ) 64 | ) 65 | return msg_id 66 | 67 | 68 | async def get_qb_client() -> Optional[Client]: 69 | try: 70 | qb = Client(config.qb_web_url) 71 | if config.qb_username and config.qb_password: 72 | qb.login(config.qb_username, config.qb_password) 73 | else: 74 | qb.login() 75 | except Exception: 76 | bot = get_bot() 77 | msg = ( 78 | "❌ 无法连接到 qbittorrent ,请检查:\n" 79 | "1. 是否启动程序\n" 80 | "2. 是否勾选了“Web用户界面(远程控制)”\n" 81 | "3. 连接地址、端口是否正确" 82 | ) 83 | logger.exception(msg) 84 | await send_message_to_admin(msg, bot) 85 | return None 86 | try: 87 | qb.get_default_save_path() 88 | except Exception: 89 | bot = get_bot() 90 | msg = "❌ 无法连登录到 qbittorrent ,请检查相关配置是否正确" 91 | logger.exception(msg) 92 | await send_message_to_admin(msg, bot) 93 | return None 94 | return qb 95 | 96 | 97 | async def get_torrent_info_from_hash( 98 | qb: Client, url: str, proxy: Optional[str] 99 | ) -> Dict[str, str]: 100 | info = None 101 | if re.search(r"magnet:\?xt=urn:btih:", url): 102 | qb.download_from_link(link=url) 103 | if _hash_str := re.search(r"[A-F\d]{40}", url, flags=re.I): 104 | hash_str = _hash_str[0].lower() 105 | else: 106 | hash_str = ( 107 | base64.b16encode( 108 | base64.b32decode(re.search(r"[2-7A-Z]{32}", url, flags=re.I)[0]) # type: ignore 109 | ) 110 | .decode("utf-8") 111 | .lower() 112 | ) 113 | 114 | else: 115 | async with aiohttp.ClientSession( 116 | timeout=aiohttp.ClientTimeout(total=100) 117 | ) as session: 118 | try: 119 | resp = await session.get(url, proxy=proxy) 120 | content = await resp.read() 121 | qb.download_from_file(content) 122 | hash_str = get_torrent_b16_hash(content) 123 | except Exception as e: 124 | await send_msg(f"下载种子失败,可能需要代理\n{e}") 125 | return {} 126 | 127 | while not info: 128 | for tmp_torrent in qb.torrents(): 129 | if tmp_torrent["hash"] == hash_str and tmp_torrent["size"]: 130 | info = { 131 | "hash": tmp_torrent["hash"], 132 | "filename": tmp_torrent["name"], 133 | "size": convert_size(tmp_torrent["size"]), 134 | } 135 | await asyncio.sleep(1) 136 | return info 137 | 138 | 139 | # 种子地址,种子下载路径,群文件上传 群列表,订阅名称 140 | async def start_down( 141 | url: str, group_ids: List[str], name: str, proxy: Optional[str] 142 | ) -> str: 143 | qb = await get_qb_client() 144 | if not qb: 145 | return "" 146 | # 获取种子 hash 147 | info = await get_torrent_info_from_hash(qb=qb, url=url, proxy=proxy) 148 | await rss_trigger( 149 | hash_str=info["hash"], 150 | group_ids=group_ids, 151 | name=f"订阅:{name}\n{info['filename']}\n文件大小:{info['size']}", 152 | ) 153 | down_info[info["hash"]] = { 154 | "status": DOWN_STATUS_DOWNING, 155 | "start_time": arrow.now(), # 下载开始时间 156 | "downing_tips_msg_id": [], # 下载中通知群上一条通知的信息,用于撤回,防止刷屏 157 | } 158 | return info["hash"] 159 | 160 | 161 | # 检查下载状态 162 | async def check_down_status(hash_str: str, group_ids: List[str], name: str) -> None: 163 | qb = await get_qb_client() 164 | if not qb: 165 | return 166 | # 防止中途删掉任务,无限执行 167 | try: 168 | info = qb.get_torrent(hash_str) 169 | files = qb.get_torrent_files(hash_str) 170 | except Exception as e: 171 | logger.exception(e) 172 | scheduler.remove_job(hash_str) 173 | return 174 | bot = get_bot() 175 | all_groups = (await get_bot_group_list(bot))[1] 176 | sid = None 177 | if info["total_downloaded"] - info["total_size"] >= 0.000000: 178 | all_time = arrow.now() - down_info[hash_str]["start_time"] 179 | await send_msg( 180 | f"👏 {name}\n" 181 | f"Hash:{hash_str}\n" 182 | f"下载完成!耗时:{str(all_time).split('.', 2)[0]}" 183 | ) 184 | down_info[hash_str]["status"] = DOWN_STATUS_UPLOADING 185 | for group_id in group_ids: 186 | for tmp in files: 187 | # 异常包起来防止超时报错导致后续不执行 188 | try: 189 | path = Path(info.get("save_path", "")) / tmp["name"] 190 | if config.qb_down_path: 191 | if (_path := Path(config.qb_down_path)).is_dir(): 192 | path = _path / tmp["name"] 193 | await send_msg(f"{name}\nHash:{hash_str}\n开始上传到群:{group_id}") 194 | sid = [k for k, v in all_groups.items() if int(group_id) in v][0] 195 | try: 196 | await bot.call_action( 197 | self_id=sid, 198 | action="upload_group_file", 199 | group_id=group_id, 200 | file=str(path), 201 | name=tmp["name"], 202 | ) 203 | except ActionFailed: 204 | msg = f"{name}\nHash:{hash_str}\n上传到群:{group_id}失败!请手动上传!" 205 | await send_msg(msg, [group_id]) 206 | logger.exception(msg) 207 | except NetworkError as e: 208 | logger.warning(e) 209 | except TimeoutError as e: 210 | logger.warning(e) 211 | scheduler.remove_job(hash_str) 212 | down_info[hash_str]["status"] = DOWN_STATUS_UPLOAD_OK 213 | else: 214 | await delete_msg(bot, sid, down_info[hash_str]["downing_tips_msg_id"]) 215 | msg_id = await send_msg( 216 | f"{name}\n" 217 | f"Hash:{hash_str}\n" 218 | f"下载了 {round(info['total_downloaded'] / info['total_size'] * 100, 2)}%\n" 219 | f"平均下载速度: {round(info['dl_speed_avg'] / 1024, 2)} KB/s" 220 | ) 221 | down_info[hash_str]["downing_tips_msg_id"] = msg_id 222 | 223 | 224 | # 撤回消息 225 | async def delete_msg(bot, sid, msg_ids: List[Dict[str, Any]]) -> None: 226 | for msg_id in msg_ids: 227 | try: 228 | await bot.call_action( 229 | "delete_msg", message_id=msg_id["message_id"], self_id=sid 230 | ) 231 | except Exception as e: 232 | logger.warning("下载进度消息撤回失败!", e) 233 | 234 | 235 | async def rss_trigger(hash_str: str, group_ids: List[str], name: str) -> None: 236 | # 制作一个频率为“ n 秒 / 次”的触发器 237 | trigger = IntervalTrigger(seconds=int(config.down_status_msg_date), jitter=10) 238 | job_defaults = {"max_instances": 1} 239 | # 添加任务 240 | scheduler.add_job( 241 | func=check_down_status, # 要添加任务的函数,不要带参数 242 | trigger=trigger, # 触发器 243 | args=(hash_str, group_ids, name), # 函数的参数列表,注意:只有一个值时,不能省略末尾的逗号 244 | id=hash_str, 245 | misfire_grace_time=60, # 允许的误差时间,建议不要省略 246 | job_defaults=job_defaults, 247 | ) 248 | await send_msg(f"👏 {name}\nHash:{hash_str}\n下载任务添加成功!", group_ids) 249 | -------------------------------------------------------------------------------- /rss2/requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp~=3.8.3 2 | aiohttp[speedups]~=3.8.3 3 | APScheduler==3.9.1.post1 4 | arrow~=1.2.3 5 | bbcode~=1.1.0 6 | cachetools~=5.2.1 7 | emoji~=2.1.0 8 | feedparser~=6.0.10 9 | deep-translator~=1.9.1 10 | ImageHash~=4.3.1 11 | magneturi~=1.3 12 | nonebot~=1.9.0 13 | pikpakapi~=0.0.7 14 | Pillow~=9.4.0 15 | pydantic~=1.10.4 16 | pyquery~=1.4.3 17 | python-qbittorrent~=0.4.3 18 | tenacity==8.1.0 19 | tinydb~=4.7.0 20 | typing-extensions==4.4.0 21 | pydantic[dotenv] 22 | yarl~=1.8.2 -------------------------------------------------------------------------------- /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: Dict[str, 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 | if data: 43 | self.__dict__.update(data) 44 | 45 | # 返回订阅链接 46 | def get_url(self, rsshub: str = config.rsshub) -> str: 47 | if URL(self.url).scheme in ["http", "https"]: 48 | return self.url 49 | # 先判断地址是否 / 开头 50 | if self.url.startswith("/"): 51 | return rsshub + self.url 52 | 53 | return f"{rsshub}/{self.url}" 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 | mode_name = {"link": "链接", "title": "标题", "image": "图片"} 221 | mode_msg = "" 222 | if self.duplicate_filter_mode: 223 | delimiter = " 或 " if "or" in self.duplicate_filter_mode else "、" 224 | mode_msg = ( 225 | "已启用去重模式," 226 | f"{delimiter.join(mode_name[i] for i in self.duplicate_filter_mode if i != 'or')} 相同时去重" 227 | ) 228 | ret_list = [ 229 | f"名称:{self.name}", 230 | f"订阅地址:{self.url}", 231 | f"订阅QQ:{self.user_id}" if self.user_id else "", 232 | f"订阅群:{self.group_id}" if self.group_id else "", 233 | f"订阅子频道:{self.guild_channel_id}" if self.guild_channel_id else "", 234 | f"更新时间:{self.time}", 235 | f"代理:{self.img_proxy}" if self.img_proxy else "", 236 | f"翻译:{self.translation}" if self.translation else "", 237 | f"仅标题:{self.only_title}" if self.only_title else "", 238 | f"仅图片:{self.only_pic}" if self.only_pic else "", 239 | f"下载图片:{self.download_pic}" if self.download_pic else "", 240 | f"仅含有图片:{self.only_has_pic}" if self.only_has_pic else "", 241 | f"白名单关键词:{self.down_torrent_keyword}" if self.down_torrent_keyword else "", 242 | f"黑名单关键词:{self.black_keyword}" if self.black_keyword else "", 243 | f"cookies:{self.cookies}" if self.cookies else "", 244 | "种子自动下载功能已启用" if self.down_torrent else "", 245 | "" if self.is_open_upload_group else f"是否上传到群:{self.is_open_upload_group}", 246 | f"{mode_msg}" if self.duplicate_filter_mode else "", 247 | f"图片数量限制:{self.max_image_number}" if self.max_image_number else "", 248 | f"正文待移除内容:{self.content_to_remove}" if self.content_to_remove else "", 249 | f"连续抓取失败的次数:{self.error_count}" if self.error_count else "", 250 | f"停止更新:{self.stop}" if self.stop else "", 251 | f"PikPak离线: {self.pikpak_offline}" if self.pikpak_offline else "", 252 | f"PikPak离线路径匹配: {self.pikpak_path_key}" if self.pikpak_path_key else "", 253 | ] 254 | return "\n".join([i for i in ret_list if i != ""]) 255 | -------------------------------------------------------------------------------- /rss2/rss_parsing.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Tuple 2 | 3 | import aiohttp 4 | import feedparser 5 | from nonebot import get_bot 6 | from nonebot.log import logger 7 | from tinydb import TinyDB 8 | from tinydb.middlewares import CachingMiddleware 9 | from tinydb.storages import JSONStorage 10 | from yarl import URL 11 | 12 | from . import my_trigger as tr 13 | from .config import DATA_PATH, config 14 | from .parsing import get_proxy 15 | from .parsing.cache_manage import cache_filter 16 | from .parsing.check_update import dict_hash 17 | from .parsing.parsing_rss import ParsingRss 18 | from .rss_class import Rss 19 | from .utils import ( 20 | filter_valid_group_id_list, 21 | filter_valid_guild_channel_id_list, 22 | filter_valid_user_id_list, 23 | get_http_caching_headers, 24 | send_message_to_admin, 25 | ) 26 | 27 | HEADERS = { 28 | "Accept": "application/xhtml+xml,application/xml,*/*", 29 | "Accept-Language": "en-US,en;q=0.9", 30 | "Cache-Control": "max-age=0", 31 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.114 Safari/537.36", 32 | "Connection": "keep-alive", 33 | "Content-Type": "application/xml; charset=utf-8", 34 | } 35 | 36 | 37 | # 抓取 feed,读取缓存,检查更新,对更新进行处理 38 | async def start(rss: Rss) -> None: 39 | bot = get_bot() # type: ignore 40 | # 先检查订阅者是否合法 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 | if not any([rss.user_id, rss.group_id, rss.guild_channel_id]): 50 | await auto_stop_and_notify_admin(rss, bot) 51 | return 52 | new_rss, cached = await fetch_rss(rss) 53 | # 检查是否存在rss记录 54 | _file = DATA_PATH / f"{Rss.handle_name(rss.name)}.json" 55 | first_time_fetch = not _file.exists() 56 | if cached: 57 | logger.info(f"{rss.name} 没有新信息") 58 | return 59 | if not new_rss or not new_rss.get("feed"): 60 | rss.error_count += 1 61 | logger.warning(f"{rss.name} 抓取失败!") 62 | if first_time_fetch: 63 | # 第一次抓取失败,如果配置了代理,则自动使用代理抓取 64 | if config.rss_proxy and not rss.img_proxy: 65 | rss.img_proxy = True 66 | logger.info(f"{rss.name} 第一次抓取失败,自动使用代理抓取") 67 | await start(rss) 68 | else: 69 | await auto_stop_and_notify_admin(rss, bot) 70 | if rss.error_count >= 100: 71 | await auto_stop_and_notify_admin(rss, bot) 72 | return 73 | if new_rss.get("feed") and rss.error_count > 0: 74 | rss.error_count = 0 75 | if first_time_fetch: 76 | with TinyDB( 77 | _file, 78 | storage=CachingMiddleware(JSONStorage), # type: ignore 79 | encoding="utf-8", 80 | sort_keys=True, 81 | indent=4, 82 | ensure_ascii=False, 83 | ) as db: 84 | entries = new_rss["entries"] 85 | result = [] 86 | for i in entries: 87 | i["hash"] = dict_hash(i) 88 | result.append(cache_filter(i)) 89 | db.insert_multiple(result) 90 | logger.info(f"{rss.name} 第一次抓取成功!") 91 | return 92 | 93 | pr = ParsingRss(rss=rss) 94 | await pr.start(rss_name=rss.name, new_rss=new_rss) 95 | 96 | 97 | async def auto_stop_and_notify_admin(rss: Rss, bot) -> None: 98 | rss.stop = True 99 | rss.upsert() 100 | tr.delete_job(rss) 101 | cookies_str = "及 cookies " if rss.cookies else "" 102 | if not any([rss.user_id, rss.group_id, rss.guild_channel_id]): 103 | msg = f"{rss.name}[{rss.get_url()}]无人订阅!已自动停止更新!" 104 | elif rss.error_count >= 100: 105 | msg = ( 106 | f"{rss.name}[{rss.get_url()}]已经连续抓取失败超过 100 次!已自动停止更新!请检查订阅地址{cookies_str}!" 107 | ) 108 | else: 109 | msg = f"{rss.name}[{rss.get_url()}]第一次抓取失败!已自动停止更新!请检查订阅地址{cookies_str}!" 110 | await send_message_to_admin(msg, bot) 111 | 112 | 113 | # 获取 RSS 并解析为 json 114 | async def fetch_rss(rss: Rss) -> Tuple[Dict[str, Any], bool]: 115 | rss_url = rss.get_url() 116 | # 对本机部署的 RSSHub 不使用代理 117 | local_host = [ 118 | "localhost", 119 | "127.0.0.1", 120 | ] 121 | proxy = get_proxy(rss.img_proxy) if URL(rss_url).host not in local_host else None 122 | 123 | # 判断是否使用cookies 124 | cookies = rss.cookies or None 125 | 126 | # 获取 xml 127 | d: Dict[str, Any] = {} 128 | cached = False 129 | headers = HEADERS.copy() 130 | if not config.rsshub_backup: 131 | if rss.etag: 132 | headers["If-None-Match"] = rss.etag 133 | if rss.last_modified: 134 | headers["If-Modified-Since"] = rss.last_modified 135 | async with aiohttp.ClientSession( 136 | cookies=cookies, 137 | headers=HEADERS, 138 | raise_for_status=True, 139 | ) as session: 140 | try: 141 | resp = await session.get(rss_url, proxy=proxy) 142 | if not config.rsshub_backup: 143 | http_caching_headers = get_http_caching_headers(resp.headers) 144 | rss.etag = http_caching_headers["ETag"] 145 | rss.last_modified = http_caching_headers["Last-Modified"] 146 | rss.upsert() 147 | if ( 148 | resp.status == 200 and int(resp.headers.get("Content-Length", "1")) == 0 149 | ) or resp.status == 304: 150 | cached = True 151 | # 解析为 JSON 152 | d = feedparser.parse(await resp.text()) 153 | except Exception: 154 | if not URL(rss.url).scheme and config.rsshub_backup: 155 | logger.debug(f"[{rss_url}]访问失败!将使用备用 RSSHub 地址!") 156 | for rsshub_url in list(config.rsshub_backup): 157 | rss_url = rss.get_url(rsshub=rsshub_url) 158 | try: 159 | resp = await session.get(rss_url, proxy=proxy) 160 | d = feedparser.parse(await resp.text()) 161 | except Exception: 162 | logger.debug(f"[{rss_url}]访问失败!将使用备用 RSSHub 地址!") 163 | continue 164 | if d.get("feed"): 165 | logger.info(f"[{rss_url}]抓取成功!") 166 | break 167 | return d, cached 168 | -------------------------------------------------------------------------------- /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, List, Mapping, Optional, Tuple 7 | 8 | from cachetools import TTLCache 9 | from cachetools.keys import hashkey 10 | import nonebot 11 | from nonebot.log import logger 12 | from .config import config 13 | 14 | 15 | def get_http_caching_headers( 16 | headers: Optional[Mapping[str, Any]], 17 | ) -> Dict[str, Optional[str]]: 18 | return ( 19 | { 20 | "Last-Modified": headers.get("Last-Modified") or headers.get("Date"), 21 | "ETag": headers.get("ETag"), 22 | } 23 | if headers 24 | else {"Last-Modified": None, "ETag": None} 25 | ) 26 | 27 | 28 | def convert_size(size_bytes: int) -> str: 29 | if size_bytes == 0: 30 | return "0 B" 31 | size_name = ("B", "KB", "MB", "GB", "TB") 32 | i = int(math.floor(math.log(size_bytes, 1024))) 33 | p = math.pow(1024, i) 34 | s = round(size_bytes / p, 2) 35 | return f"{s} {size_name[i]}" 36 | 37 | 38 | def cached_async(cache, key=hashkey): # type: ignore 39 | """ 40 | https://github.com/tkem/cachetools/commit/3f073633ed4f36f05b57838a3e5655e14d3e3524 41 | """ 42 | 43 | def decorator(func): # type: ignore 44 | if cache is None: 45 | 46 | async def wrapper(*args, **kwargs): # type: ignore 47 | return await func(*args, **kwargs) 48 | 49 | else: 50 | 51 | async def wrapper(*args, **kwargs): # type: ignore 52 | k = key(*args, **kwargs) 53 | with suppress(KeyError): # key not found 54 | return cache[k] 55 | v = await func(*args, **kwargs) 56 | with suppress(ValueError): # value too large 57 | cache[k] = v 58 | return v 59 | 60 | return functools.update_wrapper(wrapper, func) 61 | 62 | return decorator 63 | 64 | 65 | def get_bot_qq(bot) -> List[int]: 66 | return bot._wsr_api_clients.keys() 67 | 68 | 69 | @cached_async(TTLCache(maxsize=1, ttl=300)) # type: ignore 70 | async def get_bot_friend_list(bot) -> Tuple[List[int], Dict[int, List[int]]]: 71 | bot_qq = list(get_bot_qq(bot)) 72 | all_friends = {} 73 | friend_list = [] 74 | for sid in bot_qq: 75 | f = await bot.get_friend_list(self_id=sid) 76 | all_friends[sid] = [i["user_id"] for i in f] 77 | friend_list.extend(all_friends[sid]) 78 | return set(friend_list), all_friends 79 | 80 | 81 | @cached_async(TTLCache(maxsize=1, ttl=300)) # type: ignore 82 | async def get_bot_group_list(bot) -> Tuple[List[int], Dict[int, List[int]]]: 83 | bot_qq = list(get_bot_qq(bot)) 84 | all_groups = {} 85 | group_list = [] 86 | for sid in bot_qq: 87 | g = await bot.get_group_list(self_id=sid) 88 | all_groups[sid] = [i["group_id"] for i in g] 89 | group_list.extend(all_groups[sid]) 90 | return set(group_list), all_groups 91 | 92 | 93 | @cached_async(TTLCache(maxsize=1, ttl=300)) # type: ignore 94 | async def get_all_bot_guild_list(bot) -> Tuple[List[int], Dict[int, List[str]]]: 95 | bot_qq = list(get_bot_qq(bot)) 96 | # 获取频道列表 97 | all_guilds = {} 98 | guild_list = [] 99 | for sid in bot_qq: 100 | g = await bot.get_guild_list(self_id=sid) 101 | all_guilds[sid] = [i["guild_id"] for i in g] 102 | guild_list.extend(all_guilds[sid]) 103 | return set(guild_list), all_guilds 104 | 105 | 106 | @cached_async(TTLCache(maxsize=1, ttl=300)) # type: ignore 107 | async def get_all_bot_channel_list(bot) -> Tuple[List[str], Dict[int, List[str]]]: 108 | guild_list, all_guilds = await get_all_bot_guild_list(bot) 109 | # 获取子频道列表 110 | all_channels = {} 111 | channel_list = [] 112 | for guild in guild_list: 113 | sid = [k for k, v in all_guilds.items() if guild in v][0] 114 | c = await bot.get_guild_channel_list(self_id=sid, guild_id=guild) 115 | all_channels[sid] = [i["channel_id"] for i in c] 116 | channel_list.extend(all_channels[sid]) 117 | return set(channel_list), all_channels 118 | 119 | 120 | @cached_async(TTLCache(maxsize=1, ttl=300)) # type: ignore 121 | async def get_bot_guild_channel_list(bot, guild_id: Optional[str] = None) -> List[str]: 122 | guild_list, all_guilds = await get_all_bot_guild_list(bot) 123 | if guild_id is None: 124 | return guild_list 125 | if guild_id in guild_list: 126 | # 获取子频道列表 127 | sid = [k for k, v in all_guilds.items() if guild_id in v][0] 128 | channel_list = await bot.get_guild_channel_list(self_id=sid, guild_id=guild_id) 129 | return [i["channel_id"] for i in channel_list] 130 | return [] 131 | 132 | 133 | def get_torrent_b16_hash(content: bytes) -> str: 134 | import magneturi 135 | 136 | # mangetlink = magneturi.from_torrent_file(torrentname) 137 | manget_link = magneturi.from_torrent_data(content) 138 | # print(mangetlink) 139 | ch = "" 140 | n = 20 141 | b32_hash = n * ch + manget_link[20:52] 142 | # print(b32Hash) 143 | b16_hash = base64.b16encode(base64.b32decode(b32_hash)) 144 | b16_hash = b16_hash.lower() 145 | # print("40位info hash值:" + '\n' + b16Hash) 146 | # print("磁力链:" + '\n' + "magnet:?xt=urn:btih:" + b16Hash) 147 | return str(b16_hash, "utf-8") 148 | 149 | 150 | async def send_message_to_admin(message: str, bot=nonebot.get_bot()) -> None: 151 | await bot.send_private_msg(user_id=str(list(config.superusers)[0]), message=message) 152 | 153 | 154 | async def send_msg( 155 | msg: str, 156 | user_ids: Optional[List[str]] = None, 157 | group_ids: Optional[List[str]] = None, 158 | ) -> List[Dict[str, Any]]: 159 | """ 160 | msg: str 161 | user: List[str] 162 | group: List[str] 163 | 164 | 发送消息到私聊或群聊 165 | """ 166 | bot = nonebot.get_bot() 167 | msg_id = [] 168 | if group_ids: 169 | for group_id in group_ids: 170 | msg_id.append(await bot.send_group_msg(group_id=int(group_id), message=msg)) 171 | if user_ids: 172 | for user_id in user_ids: 173 | msg_id.append(await bot.send_private_msg(user_id=int(user_id), message=msg)) 174 | return msg_id 175 | 176 | 177 | # 校验正则表达式合法性 178 | def regex_validate(regex: str) -> bool: 179 | try: 180 | re.compile(regex) 181 | return True 182 | except re.error: 183 | return False 184 | 185 | 186 | # 过滤合法好友 187 | async def filter_valid_user_id_list(bot, user_id_list: List[str]) -> List[str]: 188 | friend_list, _ = await get_bot_friend_list(bot) 189 | valid_user_id_list = [ 190 | user_id for user_id in user_id_list if int(user_id) in friend_list 191 | ] 192 | if invalid_user_id_list := [ 193 | user_id for user_id in user_id_list if user_id not in valid_user_id_list 194 | ]: 195 | logger.warning(f"QQ号[{','.join(invalid_user_id_list)}]不是Bot[{bot.self_id}]的好友") 196 | return valid_user_id_list 197 | 198 | 199 | # 过滤合法群组 200 | async def filter_valid_group_id_list(bot, group_id_list: List[str]) -> List[str]: 201 | group_list, _ = await get_bot_group_list(bot) 202 | valid_group_id_list = [ 203 | group_id for group_id in group_id_list if int(group_id) in group_list 204 | ] 205 | if invalid_group_id_list := [ 206 | group_id for group_id in group_id_list if group_id not in valid_group_id_list 207 | ]: 208 | logger.warning(f"Bot[{bot.self_id}]未加入群组[{','.join(invalid_group_id_list)}]") 209 | return valid_group_id_list 210 | 211 | 212 | # 过滤合法频道 213 | async def filter_valid_guild_channel_id_list( 214 | bot, guild_channel_id_list: List[str] 215 | ) -> List[str]: 216 | valid_guild_channel_id_list = [] 217 | for guild_channel_id in guild_channel_id_list: 218 | guild_id, channel_id = guild_channel_id.split("@") 219 | guild_list = await get_bot_guild_channel_list(bot) 220 | if guild_id not in guild_list: 221 | guild_name = (await bot.get_guild_meta_by_guest(guild_id=guild_id))[ 222 | "guild_name" 223 | ] 224 | logger.warning(f"Bot[{bot.self_id}]未加入频道 {guild_name}[{guild_id}]") 225 | continue 226 | 227 | channel_list = await get_bot_guild_channel_list(bot, guild_id=guild_id) 228 | if channel_id not in channel_list: 229 | guild_name = (await bot.get_guild_meta_by_guest(guild_id=guild_id))[ 230 | "guild_name" 231 | ] 232 | logger.warning( 233 | f"Bot[{bot.self_id}]未加入频道 {guild_name}[{guild_id}]的子频道[{channel_id}]" 234 | ) 235 | continue 236 | valid_guild_channel_id_list.append(guild_channel_id) 237 | return valid_guild_channel_id_list 238 | --------------------------------------------------------------------------------