这是您的第237次签到, + # 已连续签到237天。
本次签到获得300克猫粮。
"} + logger.info(f"{site} 签到成功") + return True, '签到成功' + else: + # {"status":"0","data":"抱歉","message":"您今天已经签到过了,请勿重复刷新。"} + logger.info(f"{site} 今日已签到") + return True, '今日已签到' diff --git a/plugins.v2/autosignin/sites/pttime.py b/plugins.v2/autosignin/sites/pttime.py new file mode 100644 index 0000000..6c766d2 --- /dev/null +++ b/plugins.v2/autosignin/sites/pttime.py @@ -0,0 +1,64 @@ +from typing import Tuple + +from ruamel.yaml import CommentedMap + +from app.log import logger +from app.plugins.autosignin.sites import _ISiteSigninHandler +from app.utils.string import StringUtils + + +class PTTime(_ISiteSigninHandler): + """ + PT时间签到 + """ + # 匹配的站点Url,每一个实现类都需要设置为自己的站点Url + site_url = "pttime.org" + + # 签到成功 + _succeed_regex = ['签到成功'] + + @classmethod + def match(cls, url: str) -> bool: + """ + 根据站点Url判断是否匹配当前站点签到类,大部分情况使用默认实现即可 + :param url: 站点Url + :return: 是否匹配,如匹配则会调用该类的signin方法 + """ + return True if StringUtils.url_equal(url, cls.site_url) else False + + def signin(self, site_info: CommentedMap) -> Tuple[bool, str]: + """ + 执行签到操作 + :param site_info: 站点信息,含有站点Url、站点Cookie、UA等信息 + :return: 签到结果信息 + """ + site = site_info.get("name") + site_cookie = site_info.get("cookie") + ua = site_info.get("ua") + proxy = site_info.get("proxy") + render = site_info.get("render") + + # 签到 + # 签到返回:签到成功 + html_text = self.get_page_source(url='https://www.pttime.org/attendance.php', + cookie=site_cookie, + ua=ua, + proxy=proxy, + render=render) + + if not html_text: + logger.error(f"{site} 签到失败,请检查站点连通性") + return False, '签到失败,请检查站点连通性' + + if "login.php" in html_text: + logger.error(f"{site} 签到失败,Cookie已失效") + return False, '签到失败,Cookie已失效' + + sign_status = self.sign_in_result(html_res=html_text, + regexs=self._succeed_regex) + if sign_status: + logger.info(f"{site} 签到成功") + return True, '签到成功' + + logger.error(f"{site} 签到失败,签到接口返回 {html_text}") + return False, '签到失败' diff --git a/plugins.v2/autosignin/sites/tjupt.py b/plugins.v2/autosignin/sites/tjupt.py new file mode 100644 index 0000000..4a20f84 --- /dev/null +++ b/plugins.v2/autosignin/sites/tjupt.py @@ -0,0 +1,274 @@ +import json +import os +import time +from io import BytesIO +from typing import Tuple + +from PIL import Image +from lxml import etree +from ruamel.yaml import CommentedMap + +from app.core.config import settings +from app.log import logger +from app.plugins.autosignin.sites import _ISiteSigninHandler +from app.utils.http import RequestUtils +from app.utils.string import StringUtils + + +class Tjupt(_ISiteSigninHandler): + """ + 北洋签到 + """ + # 匹配的站点Url,每一个实现类都需要设置为自己的站点Url + site_url = "tjupt.org" + + # 签到地址 + _sign_in_url = 'https://www.tjupt.org/attendance.php' + + # 已签到 + _sign_regex = ['今日已签到'] + + # 签到成功 + _succeed_regex = ['这是您的首次签到,本次签到获得\\d+个魔力值。', + '签到成功,这是您的第\\d+次签到,已连续签到\\d+天,本次签到获得\\d+个魔力值。', + '重新签到成功,本次签到获得\\d+个魔力值'] + + # 存储正确的答案,后续可直接查 + _answer_path = settings.TEMP_PATH / "signin/" + _answer_file = _answer_path / "tjupt.json" + + @classmethod + def match(cls, url: str) -> bool: + """ + 根据站点Url判断是否匹配当前站点签到类,大部分情况使用默认实现即可 + :param url: 站点Url + :return: 是否匹配,如匹配则会调用该类的signin方法 + """ + return True if StringUtils.url_equal(url, cls.site_url) else False + + def signin(self, site_info: CommentedMap) -> Tuple[bool, str]: + """ + 执行签到操作 + :param site_info: 站点信息,含有站点Url、站点Cookie、UA等信息 + :return: 签到结果信息 + """ + site = site_info.get("name") + site_cookie = site_info.get("cookie") + ua = site_info.get("ua") + proxy = site_info.get("proxy") + render = site_info.get("render") + + # 创建正确答案存储目录 + if not os.path.exists(os.path.dirname(self._answer_file)): + os.makedirs(os.path.dirname(self._answer_file)) + + # 获取北洋签到页面html + html_text = self.get_page_source(url=self._sign_in_url, + cookie=site_cookie, + ua=ua, + proxy=proxy, + render=render) + + # 获取签到后返回html,判断是否签到成功 + if not html_text: + logger.error(f"{site} 签到失败,请检查站点连通性") + return False, '签到失败,请检查站点连通性' + + if "login.php" in html_text: + logger.error(f"{site} 签到失败,Cookie已失效") + return False, '签到失败,Cookie已失效' + + sign_status = self.sign_in_result(html_res=html_text, + regexs=self._sign_regex) + if sign_status: + logger.info(f"{site} 今日已签到") + return True, '今日已签到' + + # 没有签到则解析html + html = etree.HTML(html_text) + if not html: + return False, '签到失败' + img_url = html.xpath('//table[@class="captcha"]//img/@src')[0] + + if not img_url: + logger.error(f"{site} 签到失败,未获取到签到图片") + return False, '签到失败,未获取到签到图片' + + # 签到图片 + img_url = "https://www.tjupt.org" + img_url + logger.info(f"获取到签到图片 {img_url}") + # 获取签到图片hash + captcha_img_res = RequestUtils(cookies=site_cookie, + ua=ua, + proxies=settings.PROXY if proxy else None + ).get_res(url=img_url) + if not captcha_img_res or captcha_img_res.status_code != 200: + logger.error(f"{site} 签到图片 {img_url} 请求失败") + return False, '签到失败,未获取到签到图片' + captcha_img = Image.open(BytesIO(captcha_img_res.content)) + captcha_img_hash = self._tohash(captcha_img) + logger.debug(f"签到图片hash {captcha_img_hash}") + + # 签到答案选项 + values = html.xpath("//input[@name='answer']/@value") + options = html.xpath("//input[@name='answer']/following-sibling::text()") + + if not values or not options: + logger.error(f"{site} 签到失败,未获取到答案选项") + return False, '签到失败,未获取到答案选项' + + # value+选项 + answers = list(zip(values, options)) + logger.debug(f"获取到所有签到选项 {answers}") + + # 查询已有答案 + exits_answers = {} + try: + with open(self._answer_file, 'r') as f: + json_str = f.read() + exits_answers = json.loads(json_str) + # 查询本地本次验证码hash答案 + captcha_answer = exits_answers[captcha_img_hash] + + # 本地存在本次hash对应的正确答案再遍历查询 + if captcha_answer: + for value, answer in answers: + if str(captcha_answer) == str(answer): + # 确实是答案 + return self.__signin(answer=value, + site_cookie=site_cookie, + ua=ua, + proxy=proxy, + site=site) + except (FileNotFoundError, IOError, OSError) as e: + logger.debug(f"查询本地已知答案失败:{str(e)},继续请求豆瓣查询") + + # 本地不存在正确答案则请求豆瓣查询匹配 + for value, answer in answers: + if answer: + # 豆瓣检索 + db_res = RequestUtils().get_res(url=f'https://movie.douban.com/j/subject_suggest?q={answer}') + if not db_res or db_res.status_code != 200: + logger.debug(f"签到选项 {answer} 未查询到豆瓣数据") + continue + + # 豆瓣返回结果 + db_answers = json.loads(db_res.text) + if not isinstance(db_answers, list): + db_answers = [db_answers] + + if len(db_answers) == 0: + logger.debug(f"签到选项 {answer} 查询到豆瓣数据为空") + + for db_answer in db_answers: + answer_img_url = db_answer['img'] + + # 获取答案hash + answer_img_res = RequestUtils(referer="https://movie.douban.com").get_res(url=answer_img_url) + if not answer_img_res or answer_img_res.status_code != 200: + logger.debug(f"签到答案 {answer} {answer_img_url} 请求失败") + continue + + answer_img = Image.open(BytesIO(answer_img_res.content)) + answer_img_hash = self._tohash(answer_img) + logger.debug(f"签到答案图片hash {answer} {answer_img_hash}") + + # 获取选项图片与签到图片相似度,大于0.9默认是正确答案 + score = self._comparehash(captcha_img_hash, answer_img_hash) + logger.info(f"签到图片与选项 {answer} 豆瓣图片相似度 {score}") + if score > 0.9: + # 确实是答案 + return self.__signin(answer=value, + site_cookie=site_cookie, + ua=ua, + proxy=proxy, + site=site, + exits_answers=exits_answers, + captcha_img_hash=captcha_img_hash) + + # 间隔5s,防止请求太频繁被豆瓣屏蔽ip + time.sleep(5) + logger.error(f"豆瓣图片匹配,未获取到匹配答案") + + # 没有匹配签到成功,则签到失败 + return False, '签到失败,未获取到匹配答案' + + def __signin(self, answer, site_cookie, ua, proxy, site, exits_answers=None, captcha_img_hash=None): + """ + 签到请求 + """ + data = { + 'answer': answer, + 'submit': '提交' + } + logger.debug(f"提交data {data}") + sign_in_res = RequestUtils(cookies=site_cookie, + ua=ua, + proxies=settings.PROXY if proxy else None + ).post_res(url=self._sign_in_url, data=data) + if not sign_in_res or sign_in_res.status_code != 200: + logger.error(f"{site} 签到失败,签到接口请求失败") + return False, '签到失败,签到接口请求失败' + + # 获取签到后返回html,判断是否签到成功 + sign_status = self.sign_in_result(html_res=sign_in_res.text, + regexs=self._succeed_regex) + if sign_status: + logger.info(f"签到成功") + if exits_answers and captcha_img_hash: + # 签到成功写入本地文件 + self.__write_local_answer(exits_answers=exits_answers or {}, + captcha_img_hash=captcha_img_hash, + answer=answer) + return True, '签到成功' + else: + logger.error(f"{site} 签到失败,请到页面查看") + return False, '签到失败,请到页面查看' + + def __write_local_answer(self, exits_answers, captcha_img_hash, answer): + """ + 签到成功写入本地文件 + """ + try: + exits_answers[captcha_img_hash] = answer + # 序列化数据 + formatted_data = json.dumps(exits_answers, indent=4) + with open(self._answer_file, 'w') as f: + f.write(formatted_data) + except (FileNotFoundError, IOError, OSError) as e: + logger.debug(f"签到成功写入本地文件失败:{str(e)}") + + @staticmethod + def _tohash(img, shape=(10, 10)): + """ + 获取图片hash + """ + img = img.resize(shape) + gray = img.convert('L') + s = 0 + hash_str = '' + for i in range(shape[1]): + for j in range(shape[0]): + s = s + gray.getpixel((j, i)) + avg = s / (shape[0] * shape[1]) + for i in range(shape[1]): + for j in range(shape[0]): + if gray.getpixel((j, i)) > avg: + hash_str = hash_str + '1' + else: + hash_str = hash_str + '0' + return hash_str + + @staticmethod + def _comparehash(hash1, hash2, shape=(10, 10)): + """ + 比较图片hash + 返回相似度 + """ + n = 0 + if len(hash1) != len(hash2): + return -1 + for i in range(len(hash1)): + if hash1[i] == hash2[i]: + n = n + 1 + return n / (shape[0] * shape[1]) diff --git a/plugins.v2/autosignin/sites/ttg.py b/plugins.v2/autosignin/sites/ttg.py new file mode 100644 index 0000000..d3470a6 --- /dev/null +++ b/plugins.v2/autosignin/sites/ttg.py @@ -0,0 +1,97 @@ +import re +from typing import Tuple + +from ruamel.yaml import CommentedMap + +from app.core.config import settings +from app.log import logger +from app.plugins.autosignin.sites import _ISiteSigninHandler +from app.utils.http import RequestUtils +from app.utils.string import StringUtils + + +class TTG(_ISiteSigninHandler): + """ + TTG签到 + """ + # 匹配的站点Url,每一个实现类都需要设置为自己的站点Url + site_url = "totheglory.im" + + # 已签到 + _sign_regex = ['已签到'] + _sign_text = '亲,您今天已签到过,不要太贪哦' + + # 签到成功 + _success_text = '您已连续签到' + + @classmethod + def match(cls, url: str) -> bool: + """ + 根据站点Url判断是否匹配当前站点签到类,大部分情况使用默认实现即可 + :param url: 站点Url + :return: 是否匹配,如匹配则会调用该类的signin方法 + """ + return True if StringUtils.url_equal(url, cls.site_url) else False + + def signin(self, site_info: CommentedMap) -> Tuple[bool, str]: + """ + 执行签到操作 + :param site_info: 站点信息,含有站点Url、站点Cookie、UA等信息 + :return: 签到结果信息 + """ + site = site_info.get("name") + site_cookie = site_info.get("cookie") + ua = site_info.get("ua") + proxy = site_info.get("proxy") + render = site_info.get("render") + + # 获取页面html + html_text = self.get_page_source(url="https://totheglory.im", + cookie=site_cookie, + ua=ua, + proxy=proxy, + render=render) + if not html_text: + logger.error(f"{site} 签到失败,请检查站点连通性") + return False, '签到失败,请检查站点连通性' + + if "login.php" in html_text: + logger.error(f"{site} 签到失败,Cookie已失效") + return False, '签到失败,Cookie已失效' + + # 判断是否已签到 + sign_status = self.sign_in_result(html_res=html_text, + regexs=self._sign_regex) + if sign_status: + logger.info(f"{site} 今日已签到") + return True, '今日已签到' + + # 获取签到参数 + signed_timestamp = re.search('(?<=signed_timestamp: ")\\d{10}', html_text).group() + signed_token = re.search('(?<=signed_token: ").*(?=")', html_text).group() + logger.debug(f"signed_timestamp={signed_timestamp} signed_token={signed_token}") + + data = { + 'signed_timestamp': signed_timestamp, + 'signed_token': signed_token + } + # 签到 + sign_res = RequestUtils(cookies=site_cookie, + ua=ua, + proxies=settings.PROXY if proxy else None + ).post_res(url="https://totheglory.im/signed.php", + data=data) + if not sign_res or sign_res.status_code != 200: + logger.error(f"{site} 签到失败,签到接口请求失败") + return False, '签到失败,签到接口请求失败' + + sign_res.encoding = "utf-8" + if self._success_text in sign_res.text: + logger.info(f"{site} 签到成功") + return True, '签到成功' + if self._sign_text in sign_res.text: + logger.info(f"{site} 今日已签到") + return True, '今日已签到' + + logger.error(f"{site} 签到失败,未知原因") + return False, '签到失败,未知原因' diff --git a/plugins.v2/autosignin/sites/u2.py b/plugins.v2/autosignin/sites/u2.py new file mode 100644 index 0000000..2c45c2c --- /dev/null +++ b/plugins.v2/autosignin/sites/u2.py @@ -0,0 +1,123 @@ +import datetime +import random +import re +from typing import Tuple + +from lxml import etree +from ruamel.yaml import CommentedMap + +from app.core.config import settings +from app.log import logger +from app.plugins.autosignin.sites import _ISiteSigninHandler +from app.utils.http import RequestUtils +from app.utils.string import StringUtils + + +class U2(_ISiteSigninHandler): + """ + U2签到 随机 + """ + # 匹配的站点Url,每一个实现类都需要设置为自己的站点Url + site_url = "u2.dmhy.org" + + # 已签到 + _sign_regex = ['已签到', + 'Show Up', + 'Показать', + '已簽到', + '已簽到'] + + # 签到成功 + _success_text = "window.location.href = 'showup.php';" + + @classmethod + def match(cls, url: str) -> bool: + """ + 根据站点Url判断是否匹配当前站点签到类,大部分情况使用默认实现即可 + :param url: 站点Url + :return: 是否匹配,如匹配则会调用该类的signin方法 + """ + return True if StringUtils.url_equal(url, cls.site_url) else False + + def signin(self, site_info: CommentedMap) -> Tuple[bool, str]: + """ + 执行签到操作 + :param site_info: 站点信息,含有站点Url、站点Cookie、UA等信息 + :return: 签到结果信息 + """ + site = site_info.get("name") + site_cookie = site_info.get("cookie") + ua = site_info.get("ua") + proxy = site_info.get("proxy") + render = site_info.get("render") + + now = datetime.datetime.now() + # 判断当前时间是否小于9点 + if now.hour < 9: + logger.error(f"{site} 签到失败,9点前不签到") + return False, '签到失败,9点前不签到' + + # 获取页面html + html_text = self.get_page_source(url="https://u2.dmhy.org/showup.php", + cookie=site_cookie, + ua=ua, + proxy=proxy, + render=render) + if not html_text: + logger.error(f"{site} 签到失败,请检查站点连通性") + return False, '签到失败,请检查站点连通性' + + if "login.php" in html_text: + logger.error(f"{site} 签到失败,Cookie已失效") + return False, '签到失败,Cookie已失效' + + # 判断是否已签到 + sign_status = self.sign_in_result(html_res=html_text, + regexs=self._sign_regex) + if sign_status: + logger.info(f"{site} 今日已签到") + return True, '今日已签到' + + # 没有签到则解析html + html = etree.HTML(html_text) + + if not html: + return False, '签到失败' + + # 获取签到参数 + req = html.xpath("//form//td/input[@name='req']/@value")[0] + hash_str = html.xpath("//form//td/input[@name='hash']/@value")[0] + form = html.xpath("//form//td/input[@name='form']/@value")[0] + submit_name = html.xpath("//form//td/input[@type='submit']/@name") + submit_value = html.xpath("//form//td/input[@type='submit']/@value") + if not re or not hash_str or not form or not submit_name or not submit_value: + logger.error("{site} 签到失败,未获取到相关签到参数") + return False, '签到失败' + + # 随机一个答案 + answer_num = random.randint(0, 3) + data = { + 'req': req, + 'hash': hash_str, + 'form': form, + 'message': '一切随缘~', + submit_name[answer_num]: submit_value[answer_num] + } + # 签到 + sign_res = RequestUtils(cookies=site_cookie, + ua=ua, + proxies=settings.PROXY if proxy else None + ).post_res(url="https://u2.dmhy.org/showup.php?action=show", + data=data) + if not sign_res or sign_res.status_code != 200: + logger.error(f"{site} 签到失败,签到接口请求失败") + return False, '签到失败,签到接口请求失败' + + # 判断是否签到成功 + # sign_res.text = "" + if self._success_text in sign_res.text: + logger.info(f"{site} 签到成功") + return True, '签到成功' + else: + logger.error(f"{site} 签到失败,未知原因") + return False, '签到失败,未知原因' diff --git a/plugins.v2/autosignin/sites/yema.py b/plugins.v2/autosignin/sites/yema.py new file mode 100644 index 0000000..879611f --- /dev/null +++ b/plugins.v2/autosignin/sites/yema.py @@ -0,0 +1,78 @@ +from typing import Tuple +from urllib.parse import urljoin + +from ruamel.yaml import CommentedMap + +from app.core.config import settings +from app.plugins.autosignin.sites import _ISiteSigninHandler +from app.utils.http import RequestUtils + + +class YemaPT(_ISiteSigninHandler): + """ + YemaPT 签到 + """ + # 匹配的站点Url,每一个实现类都需要设置为自己的站点Url + site_url = "yemapt.org" + + @classmethod + def match(cls, url: str) -> bool: + """ + 根据站点Url判断是否匹配当前站点签到类,大部分情况使用默认实现即可 + :param url: 站点Url + :return: 是否匹配,如匹配则会调用该类的signin方法 + """ + return True if cls.site_url in url else False + + def signin(self, site_info: CommentedMap) -> Tuple[bool, str]: + """ + 执行签到操作 + :param site_info: 站点信息,含有站点Url、站点Cookie、UA等信息 + :return: 签到结果信息 + """ + headers = { + "Content-Type": "application/json", + "User-Agent": site_info.get("ua"), + "Accept": "application/json, text/plain, */*", + } + # 获取用户信息,更新最后访问时间 + res = (RequestUtils(headers=headers, + timeout=15, + cookies=site_info.get("cookie"), + proxies=settings.PROXY if site_info.get("proxy") else None, + referer=site_info.get('url') + ).get_res(urljoin(site_info.get('url'), "api/consumer/checkIn"))) + + if res and res.json().get("success"): + return True, "签到成功" + elif res is not None: + return False, f"签到失败,签到结果:{res.json().get('errorMessage')}" + else: + return False, "签到失败,无法打开网站" + + def login(self, site_info: CommentedMap) -> Tuple[bool, str]: + """ + 执行登录操作 + :param site_info: 站点信息,含有站点Url、站点Cookie、UA等信息 + :return: 登录结果信息 + """ + + headers = { + "Content-Type": "application/json", + "User-Agent": site_info.get("ua"), + "Accept": "application/json, text/plain, */*", + } + # 获取用户信息,更新最后访问时间 + res = (RequestUtils(headers=headers, + timeout=15, + cookies=site_info.get("cookie"), + proxies=settings.PROXY if site_info.get("proxy") else None, + referer=site_info.get('url') + ).get_res(urljoin(site_info.get('url'), "api/user/profile"))) + + if res and res.json().get("success"): + return True, "模拟登录成功" + elif res is not None: + return False, f"模拟登录失败,状态码:{res.status_code}" + else: + return False, "模拟登录失败,无法打开网站" diff --git a/plugins.v2/autosignin/sites/zhuque.py b/plugins.v2/autosignin/sites/zhuque.py new file mode 100644 index 0000000..f3375f5 --- /dev/null +++ b/plugins.v2/autosignin/sites/zhuque.py @@ -0,0 +1,88 @@ +import json +from typing import Tuple + +from lxml import etree +from ruamel.yaml import CommentedMap + +from app.core.config import settings +from app.log import logger +from app.plugins.autosignin.sites import _ISiteSigninHandler +from app.utils.http import RequestUtils +from app.utils.string import StringUtils + + +class ZhuQue(_ISiteSigninHandler): + """ + ZHUQUE签到 + """ + # 匹配的站点Url,每一个实现类都需要设置为自己的站点Url + site_url = "zhuque.in" + + @classmethod + def match(cls, url: str) -> bool: + """ + 根据站点Url判断是否匹配当前站点签到类,大部分情况使用默认实现即可 + :param url: 站点Url + :return: 是否匹配,如匹配则会调用该类的signin方法 + """ + return True if StringUtils.url_equal(url, cls.site_url) else False + + def signin(self, site_info: CommentedMap) -> Tuple[bool, str]: + """ + 执行签到操作 + :param site_info: 站点信息,含有站点Url、站点Cookie、UA等信息 + :return: 签到结果信息 + """ + site = site_info.get("name") + site_cookie = site_info.get("cookie") + ua = site_info.get("ua") + proxy = site_info.get("proxy") + render = site_info.get("render") + + # 获取页面html + html_text = self.get_page_source(url="https://zhuque.in", + cookie=site_cookie, + ua=ua, + proxy=proxy, + render=render) + if not html_text: + logger.error(f"{site} 模拟登录失败,请检查站点连通性") + return False, '模拟登录失败,请检查站点连通性' + + if "login.php" in html_text: + logger.error(f"{site} 模拟登录失败,Cookie已失效") + return False, '模拟登录失败,Cookie已失效' + + html = etree.HTML(html_text) + + if not html: + return False, '模拟登录失败' + + # 释放技能 + msg = '失败' + x_csrf_token = html.xpath("//meta[@name='x-csrf-token']/@content")[0] + if x_csrf_token: + data = { + "all": 1, + "resetModal": "true" + } + headers = { + "x-csrf-token": str(x_csrf_token), + "Content-Type": "application/json; charset=utf-8", + "User-Agent": ua + } + skill_res = RequestUtils(cookies=site_cookie, + headers=headers, + proxies=settings.PROXY if proxy else None + ).post_res(url="https://zhuque.in/api/gaming/fireGenshinCharacterMagic", json=data) + if not skill_res or skill_res.status_code != 200: + logger.error(f"模拟登录失败,释放技能失败") + + # '{"status":200,"data":{"code":"FIRE_GENSHIN_CHARACTER_MAGIC_SUCCESS","bonus":0}}' + skill_dict = json.loads(skill_res.text) + if skill_dict['status'] == 200: + bonus = int(skill_dict['data']['bonus']) + msg = f'成功,获得{bonus}魔力' + + logger.info(f'【{site}】模拟登录成功,技能释放{msg}') + return True, f'模拟登录成功,技能释放{msg}' diff --git a/plugins.v2/brushflow/__init__.py b/plugins.v2/brushflow/__init__.py new file mode 100644 index 0000000..08876ad --- /dev/null +++ b/plugins.v2/brushflow/__init__.py @@ -0,0 +1,3855 @@ +import base64 +import json +import random +import re +import threading +import time +from datetime import datetime, timedelta +from typing import Any, List, Dict, Tuple, Optional, Union, Set +from urllib.parse import urlparse, parse_qs, unquote, parse_qsl, urlencode, urlunparse + +import pytz +from app.helper.sites import SitesHelper +from apscheduler.schedulers.background import BackgroundScheduler + +from app import schemas +from app.chain.torrents import TorrentsChain +from app.core.config import settings +from app.core.context import MediaInfo +from app.core.metainfo import MetaInfo +from app.db.site_oper import SiteOper +from app.db.subscribe_oper import SubscribeOper +from app.helper.downloader import DownloaderHelper +from app.log import logger +from app.modules.qbittorrent import Qbittorrent +from app.modules.transmission import Transmission +from app.plugins import _PluginBase +from app.schemas import NotificationType, TorrentInfo, MediaType, ServiceInfo +from app.schemas.types import EventType +from app.utils.http import RequestUtils +from app.utils.string import StringUtils + +lock = threading.Lock() + + +class BrushConfig: + """ + 刷流配置 + """ + + def __init__(self, config: dict, process_site_config=True): + self.enabled = config.get("enabled", False) + self.notify = config.get("notify", True) + self.onlyonce = config.get("onlyonce", False) + self.brushsites = config.get("brushsites", []) + self.downloader = config.get("downloader") + self.disksize = self.__parse_number(config.get("disksize")) + self.freeleech = config.get("freeleech", "free") + self.hr = config.get("hr", "no") + self.maxupspeed = self.__parse_number(config.get("maxupspeed")) + self.maxdlspeed = self.__parse_number(config.get("maxdlspeed")) + self.maxdlcount = self.__parse_number(config.get("maxdlcount")) + self.include = config.get("include") + self.exclude = config.get("exclude") + self.size = config.get("size") + self.seeder = config.get("seeder") + self.pubtime = config.get("pubtime") + self.seed_time = self.__parse_number(config.get("seed_time")) + self.hr_seed_time = self.__parse_number(config.get("hr_seed_time")) + self.seed_ratio = self.__parse_number(config.get("seed_ratio")) + self.seed_size = self.__parse_number(config.get("seed_size")) + self.download_time = self.__parse_number(config.get("download_time")) + self.seed_avgspeed = self.__parse_number(config.get("seed_avgspeed")) + self.seed_inactivetime = self.__parse_number(config.get("seed_inactivetime")) + self.delete_size_range = config.get("delete_size_range") + self.up_speed = self.__parse_number(config.get("up_speed")) + self.dl_speed = self.__parse_number(config.get("dl_speed")) + self.auto_archive_days = self.__parse_number(config.get("auto_archive_days")) + self.save_path = config.get("save_path") + self.clear_task = config.get("clear_task", False) + self.delete_except_tags = config.get("delete_except_tags") + self.except_subscribe = config.get("except_subscribe", True) + self.brush_sequential = config.get("brush_sequential", False) + self.proxy_delete = config.get("proxy_delete", False) + self.active_time_range = config.get("active_time_range") + self.qb_category = config.get("qb_category") + self.site_hr_active = config.get("site_hr_active", False) + self.site_skip_tips = config.get("site_skip_tips", False) + + self.brush_tag = "刷流" + # 站点独立配置 + self.enable_site_config = config.get("enable_site_config", False) + self.site_config = config.get("site_config", "[]") + self.group_site_configs = {} + + # 如果开启了独立站点配置,那么则初始化,否则判断配置是否为空,如果为空,则恢复默认配置 + if process_site_config: + if self.enable_site_config: + self.__initialize_site_config() + elif not self.site_config: + self.site_config = self.get_demo_site_config() + + def __initialize_site_config(self): + if not self.site_config: + logger.error(f"没有设置站点配置,已关闭站点独立配置并恢复默认配置示例,请检查配置项") + self.site_config = self.get_demo_site_config() + self.group_site_configs = {} + self.enable_site_config = False + return + + # 定义允许覆盖的字段列表 + allowed_fields = { + "freeleech", + "hr", + "include", + "exclude", + "size", + "seeder", + "pubtime", + "seed_time", + "hr_seed_time", + "seed_ratio", + "seed_size", + "download_time", + "seed_avgspeed", + "seed_inactivetime", + "save_path", + "proxy_delete", + "qb_category", + "site_hr_active", + "site_skip_tips" + # 当新增支持字段时,仅在此处添加字段名 + } + try: + # site_config中去掉以//开始的行 + site_config = re.sub(r'//.*?\n', '', self.site_config).strip() + site_configs = json.loads(site_config) + self.group_site_configs = {} + for config in site_configs: + sitename = config.get("sitename") + if not sitename: + continue + + # 只从站点特定配置中获取允许的字段 + site_specific_config = {key: config[key] for key in allowed_fields & set(config.keys())} + + full_config = {key: getattr(self, key) for key in vars(self) if + key not in ["group_site_configs", "site_config"]} + full_config.update(site_specific_config) + + self.group_site_configs[sitename] = BrushConfig(config=full_config, process_site_config=False) + except Exception as e: + logger.error(f"解析站点配置失败,已停用插件并关闭站点独立配置,请检查配置项,错误详情: {e}") + self.group_site_configs = {} + self.enable_site_config = False + self.enabled = False + + @staticmethod + def get_demo_site_config() -> str: + desc = ( + "// 以下为配置示例,请参考:https://github.com/InfinityPacer/MoviePilot-Plugins/blob/main/plugins.v2/brushflowlowfreq/README.md 进行配置\n" + "// 如与全局保持一致的配置项,请勿在站点配置中配置\n" + "// 注意无关内容需使用 // 注释\n") + config = """[{ + "sitename": "站点1", + "seed_time": 96, + "hr_seed_time": 144 +}, { + "sitename": "站点2", + "hr": "yes", + "size": "10-500", + "seeder": "5-10", + "pubtime": "5-120", + "seed_time": 96, + "save_path": "/downloads/site2", + "hr_seed_time": 144 +}, { + "sitename": "站点3", + "freeleech": "free", + "hr": "yes", + "include": "", + "exclude": "", + "size": "10-500", + "seeder": "1", + "pubtime": "5-120", + "seed_time": 120, + "hr_seed_time": 144, + "seed_ratio": "", + "seed_size": "", + "download_time": "", + "seed_avgspeed": "", + "seed_inactivetime": "", + "save_path": "/downloads/site1", + "proxy_delete": false, + "qb_category": "刷流", + "site_hr_active": true, + "site_skip_tips": true +}]""" + return desc + config + + def get_site_config(self, sitename): + """ + 根据站点名称获取特定的BrushConfig实例。如果没有找到站点特定的配置,则返回全局的BrushConfig实例。 + """ + if not self.enable_site_config: + return self + return self if not sitename else self.group_site_configs.get(sitename, self) + + @staticmethod + def __parse_number(value): + if value is None or value == "": # 更精确地检查None或空字符串 + return value + elif isinstance(value, int): # 直接判断是否为int + return value + elif isinstance(value, float): # 直接判断是否为float + return value + else: + try: + number = float(value) + # 检查number是否等于其整数形式 + if number == int(number): + return int(number) + else: + return number + except (ValueError, TypeError): + return 0 + + def __format_value(self, v): + """ + Format the value to mimic JSON serialization. This is now an instance method. + """ + if isinstance(v, str): + return f'"{v}"' + elif isinstance(v, (int, float, bool)): + return str(v).lower() if isinstance(v, bool) else str(v) + elif isinstance(v, list): + return '[' + ', '.join(self.__format_value(i) for i in v) + ']' + elif isinstance(v, dict): + return '{' + ', '.join(f'"{k}": {self.__format_value(val)}' for k, val in v.items()) + '}' + else: + return str(v) + + def __str__(self): + attrs = vars(self) + # Note the use of self.format_value(v) here to call the instance method + attrs_str = ', '.join(f'"{k}": {self.__format_value(v)}' for k, v in attrs.items()) + return f'{{ {attrs_str} }}' + + def __repr__(self): + return self.__str__() + + +class BrushFlow(_PluginBase): + # region 全局定义 + + # 插件名称 + plugin_name = "站点刷流" + # 插件描述 + plugin_desc = "自动托管刷流,将会提高对应站点的访问频率。" + # 插件图标 + plugin_icon = "brush.jpg" + # 插件版本 + plugin_version = "4.0.1" + # 插件作者 + plugin_author = "jxxghp,InfinityPacer" + # 作者主页 + author_url = "https://github.com/InfinityPacer" + # 插件配置项ID前缀 + plugin_config_prefix = "brushflow_" + # 加载顺序 + plugin_order = 21 + # 可使用的用户级别 + auth_level = 2 + + # 私有属性 + sites_helper = None + site_oper = None + torrents_chain = None + subscribe_oper = None + downloader_helper = None + # 刷流配置 + _brush_config = None + # Brush任务是否启动 + _task_brush_enable = False + # 订阅缓存信息 + _subscribe_infos = None + # Brush定时 + _brush_interval = 10 + # Check定时 + _check_interval = 5 + # 退出事件 + _event = threading.Event() + _scheduler = None + # tabs + _tabs = None + + # endregion + + def init_plugin(self, config: dict = None): + self.sites_helper = SitesHelper() + self.site_oper = SiteOper() + self.torrents_chain = TorrentsChain() + self.subscribe_oper = SubscribeOper() + self.downloader_helper = DownloaderHelper() + self._task_brush_enable = False + + if not config: + logger.info("站点刷流任务出错,无法获取插件配置") + return False + + self._tabs = config.get("_tabs", None) + + # 如果配置校验没有通过,那么这里修改配置文件后退出 + if not self.__validate_and_fix_config(config=config): + self._brush_config = BrushConfig(config=config) + self._brush_config.enabled = False + self.__update_config() + return + + self._brush_config = BrushConfig(config=config) + + brush_config = self._brush_config + + # 这里先过滤掉已删除的站点并保存,特别注意的是,这里保留了界面选择站点时的顺序,以便后续站点随机刷流或顺序刷流 + if brush_config.brushsites: + site_id_to_public_status = {site.get("id"): site.get("public") for site in self.sites_helper.get_indexers()} + brush_config.brushsites = [ + site_id for site_id in brush_config.brushsites + if site_id in site_id_to_public_status and not site_id_to_public_status[site_id] + ] + + self.__update_config() + + if brush_config.clear_task: + self.__clear_tasks() + brush_config.clear_task = False + self.__update_config() + + if brush_config.enable_site_config: + logger.debug(f"已开启站点独立配置,配置信息:{brush_config}") + else: + logger.debug(f"没有开启站点独立配置,配置信息:{brush_config}") + + # 停止现有任务 + self.stop_service() + + # 如果站点都没有配置,则不开启定时刷流服务 + if not brush_config.brushsites: + logger.info(f"站点刷流定时服务停止,没有配置站点") + + # 如果开启&存在站点时,才需要启用后台任务 + self._task_brush_enable = brush_config.enabled and brush_config.brushsites + + # 如果下载器都没有配置,那么这里也不需要继续 + if not brush_config.downloader: + brush_config.enabled = False + self.__update_config() + logger.info(f"站点刷流服务停止,没有配置下载器") + return + + if not self.service_info: + return + + # 检查是否启用了一次性任务 + if brush_config.onlyonce: + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + + logger.info(f"站点刷流服务启动,立即运行一次") + self._scheduler.add_job(self.brush, "date", + run_date=datetime.now( + tz=pytz.timezone(settings.TZ) + ) + timedelta(seconds=3), + name="站点刷流服务") + + logger.info(f"站点刷流检查服务启动,立即运行一次") + self._scheduler.add_job(self.check, "date", + run_date=datetime.now( + tz=pytz.timezone(settings.TZ) + ) + timedelta(seconds=3), + name="站点刷流检查服务") + + # 关闭一次性开关 + brush_config.onlyonce = False + self.__update_config() + + # 存在任务则启动任务 + if self._scheduler.get_jobs(): + # 启动服务 + self._scheduler.print_jobs() + self._scheduler.start() + + @property + def service_info(self) -> Optional[ServiceInfo]: + """ + 服务信息 + """ + brush_config = self.__get_brush_config() + service = self.downloader_helper.get_service(name=brush_config.downloader) + if not service: + self.__log_and_notify_error("站点刷流任务出错,获取下载器实例失败,请检查配置") + return None + + if service.instance.is_inactive(): + self.__log_and_notify_error("站点刷流任务出错,下载器未连接") + return None + + return service + + @property + def downloader(self) -> Optional[Union[Qbittorrent, Transmission]]: + """ + 下载器实例 + """ + return self.service_info.instance if self.service_info else None + + def get_state(self) -> bool: + brush_config = self.__get_brush_config() + return True if brush_config and brush_config.enabled else False + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + pass + + def get_api(self) -> List[Dict[str, Any]]: + pass + + def get_service(self) -> List[Dict[str, Any]]: + """ + 注册插件公共服务 + [{ + "id": "服务ID", + "name": "服务名称", + "trigger": "触发器:cron/interval/date/CronTrigger.from_crontab()", + "func": self.xxx, + "kwargs": {} # 定时器参数 + }] + """ + services = [] + + brush_config = self.__get_brush_config() + if not brush_config: + return services + + if self._task_brush_enable: + logger.info(f"站点刷流定时服务启动,时间间隔 {self._brush_interval} 分钟") + services.append({ + "id": "BrushFlow", + "name": "站点刷流服务", + "trigger": "interval", + "func": self.brush, + "kwargs": {"minutes": self._brush_interval} + }) + + if brush_config.enabled: + logger.info(f"站点刷流检查定时服务启动,时间间隔 {self._check_interval} 分钟") + services.append({ + "id": "BrushFlowCheck", + "name": "站点刷流检查服务", + "trigger": "interval", + "func": self.check, + "kwargs": {"minutes": self._check_interval} + }) + + if not services: + logger.info("站点刷流服务未开启") + + return services + + def __get_total_elements(self) -> List[dict]: + """ + 组装汇总元素 + """ + # 统计数据 + statistic_info = self.__get_statistic_info() + # 总上传量 + total_uploaded = StringUtils.str_filesize(statistic_info.get("uploaded") or 0) + # 总下载量 + total_downloaded = StringUtils.str_filesize(statistic_info.get("downloaded") or 0) + # 下载种子数 + total_count = statistic_info.get("count") or 0 + # 删除种子数 + total_deleted = statistic_info.get("deleted") or 0 + # 待归档种子数 + total_unarchived = statistic_info.get("unarchived") or 0 + # 活跃种子数 + total_active = statistic_info.get("active") or 0 + # 活跃上传量 + total_active_uploaded = StringUtils.str_filesize(statistic_info.get("active_uploaded") or 0) + # 活跃下载量 + total_active_downloaded = StringUtils.str_filesize(statistic_info.get("active_downloaded") or 0) + + return [ + # 总上传量 + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3, + 'sm': 6 + }, + 'content': [ + { + 'component': 'VCard', + 'props': { + 'variant': 'tonal', + }, + 'content': [ + { + 'component': 'VCardText', + 'props': { + 'class': 'd-flex align-center', + }, + 'content': [ + { + 'component': 'VAvatar', + 'props': { + 'rounded': True, + 'variant': 'text', + 'class': 'me-3' + }, + 'content': [ + { + 'component': 'VImg', + 'props': { + 'src': '/plugin_icon/upload.png' + } + } + ] + }, + { + 'component': 'div', + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-caption' + }, + 'text': '总上传量 / 活跃' + }, + { + 'component': 'div', + 'props': { + 'class': 'd-flex align-center flex-wrap' + }, + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-h6' + }, + 'text': f"{total_uploaded} / {total_active_uploaded}" + } + ] + } + ] + } + ] + } + ] + }, + ] + }, + # 总下载量 + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3, + 'sm': 6 + }, + 'content': [ + { + 'component': 'VCard', + 'props': { + 'variant': 'tonal', + }, + 'content': [ + { + 'component': 'VCardText', + 'props': { + 'class': 'd-flex align-center', + }, + 'content': [ + { + 'component': 'VAvatar', + 'props': { + 'rounded': True, + 'variant': 'text', + 'class': 'me-3' + }, + 'content': [ + { + 'component': 'VImg', + 'props': { + 'src': '/plugin_icon/download.png' + } + } + ] + }, + { + 'component': 'div', + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-caption' + }, + 'text': '总下载量 / 活跃' + }, + { + 'component': 'div', + 'props': { + 'class': 'd-flex align-center flex-wrap' + }, + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-h6' + }, + 'text': f"{total_downloaded} / {total_active_downloaded}" + } + ] + } + ] + } + ] + } + ] + }, + ] + }, + # 下载种子数 + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3, + 'sm': 6 + }, + 'content': [ + { + 'component': 'VCard', + 'props': { + 'variant': 'tonal', + }, + 'content': [ + { + 'component': 'VCardText', + 'props': { + 'class': 'd-flex align-center', + }, + 'content': [ + { + 'component': 'VAvatar', + 'props': { + 'rounded': True, + 'variant': 'text', + 'class': 'me-3' + }, + 'content': [ + { + 'component': 'VImg', + 'props': { + 'src': '/plugin_icon/seed.png' + } + } + ] + }, + { + 'component': 'div', + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-caption' + }, + 'text': '下载种子数 / 活跃' + }, + { + 'component': 'div', + 'props': { + 'class': 'd-flex align-center flex-wrap' + }, + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-h6' + }, + 'text': f"{total_count} / {total_active}" + } + ] + } + ] + } + ] + } + ] + }, + ] + }, + # 删除种子数 + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3, + 'sm': 6 + }, + 'content': [ + { + 'component': 'VCard', + 'props': { + 'variant': 'tonal', + }, + 'content': [ + { + 'component': 'VCardText', + 'props': { + 'class': 'd-flex align-center', + }, + 'content': [ + { + 'component': 'VAvatar', + 'props': { + 'rounded': True, + 'variant': 'text', + 'class': 'me-3' + }, + 'content': [ + { + 'component': 'VImg', + 'props': { + 'src': '/plugin_icon/delete.png' + } + } + ] + }, + { + 'component': 'div', + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-caption' + }, + 'text': '删除种子数 / 待归档' + }, + { + 'component': 'div', + 'props': { + 'class': 'd-flex align-center flex-wrap' + }, + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-h6' + }, + 'text': f"{total_deleted} / {total_unarchived}" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + ] + + def get_dashboard(self, key: str, **kwargs) -> Optional[Tuple[Dict[str, Any], Dict[str, Any], List[dict]]]: + """ + 获取插件仪表盘页面,需要返回:1、仪表板col配置字典;2、全局配置(自动刷新等);3、仪表板页面元素配置json(含数据) + 1、col配置参考: + { + "cols": 12, "md": 6 + } + 2、全局配置参考: + { + "refresh": 10 // 自动刷新时间,单位秒 + } + 3、页面配置使用Vuetify组件拼装,参考:https://vuetifyjs.com/ + """ + # 列配置 + cols = { + "cols": 12 + } + # 全局配置 + attrs = {} + # 拼装页面元素 + elements = [ + { + 'component': 'VRow', + 'content': self.__get_total_elements() + } + ] + return cols, attrs, elements + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + + # 站点选项 + site_options = [{"title": site.get("name"), "value": site.get("id")} + for site in self.sites_helper.get_indexers()] + # 下载器选项 + downloader_options = [{"title": config.name, "value": config.name} + for config in self.downloader_helper.get_configs().values()] + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'notify', + 'label': '发送通知', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'onlyonce', + 'label': '立即运行一次', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'multiple': True, + 'chips': True, + 'clearable': True, + 'model': 'brushsites', + 'label': '刷流站点', + 'items': site_options + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'model': 'downloader', + 'label': '下载器', + 'items': downloader_options + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'active_time_range', + 'label': '开启时间段', + 'placeholder': '如:00:00-08:00' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'delete_size_range', + 'label': '动态删种阈值(GB)', + 'placeholder': '如:500 或 500-1000,达到后删除任务' + } + } + ] + } + ] + }, + { + 'component': 'VTabs', + 'props': { + 'model': '_tabs', + 'style': { + 'margin-top': '8px', + 'margin-bottom': '16px' + }, + 'stacked': True, + 'fixed-tabs': True + }, + 'content': [ + { + 'component': 'VTab', + 'props': { + 'value': 'base_tab' + }, + 'text': '基本配置' + }, { + 'component': 'VTab', + 'props': { + 'value': 'download_tab' + }, + 'text': '选种规则' + }, { + 'component': 'VTab', + 'props': { + 'value': 'delete_tab' + }, + 'text': '删除规则' + }, { + 'component': 'VTab', + 'props': { + 'value': 'other_tab' + }, + 'text': '更多配置' + } + ] + }, + { + 'component': 'VWindow', + 'props': { + 'model': '_tabs' + }, + 'content': [ + { + 'component': 'VWindowItem', + 'props': { + 'value': 'base_tab' + }, + 'content': [ + { + 'component': 'VRow', + 'props': { + 'style': { + 'margin-top': '0px' + } + }, + 'content': [ + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'maxdlcount', + 'label': '同时下载任务数', + 'placeholder': '达到后停止新增任务' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'disksize', + 'label': '保种体积(GB)', + 'placeholder': '如:500,达到后停止新增任务' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'qb_category', + 'label': '种子分类', + 'placeholder': '仅支持qBittorrent,需提前创建' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'maxupspeed', + 'label': '总上传带宽(KB/s)', + 'placeholder': '达到后停止新增任务' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'maxdlspeed', + 'label': '总下载带宽(KB/s)', + 'placeholder': '达到后停止新增任务' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'save_path', + 'label': '保存目录', + 'placeholder': '留空自动' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'up_speed', + 'label': '单任务上传限速(KB/s)', + 'placeholder': '种子上传限速' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'dl_speed', + 'label': '单任务下载限速(KB/s)', + 'placeholder': '种子下载限速' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'auto_archive_days', + 'label': '自动归档记录天数', + 'placeholder': '超过此天数后自动归档', + 'type': 'number', + "min": "0" + } + } + ] + } + ] + } + ] + }, + { + 'component': 'VWindowItem', + 'props': { + 'value': 'download_tab' + }, + 'content': [ + { + 'component': 'VRow', + 'props': { + 'style': { + 'margin-top': '0px' + } + }, + 'content': [ + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'model': 'hr', + 'label': '排除H&R', + 'items': [ + {'title': '是', 'value': 'yes'}, + {'title': '否', 'value': 'no'}, + ] + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'model': 'freeleech', + 'label': '促销', + 'items': [ + {'title': '全部(包括普通)', 'value': ''}, + {'title': '免费', 'value': 'free'}, + {'title': '2X免费', 'value': '2xfree'}, + ] + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'pubtime', + 'label': '发布时间(分钟)', + 'placeholder': '如:5 或 5-10' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'size', + 'label': '种子大小(GB)', + 'placeholder': '如:5 或 5-10' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'seeder', + 'label': '做种人数', + 'placeholder': '如:5 或 5-10' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'include', + 'label': '包含规则', + 'placeholder': '支持正式表达式' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'exclude', + 'label': '排除规则', + 'placeholder': '支持正式表达式' + } + } + ] + } + ] + } + ] + }, + { + 'component': 'VWindowItem', + 'props': { + 'value': 'delete_tab' + }, + 'content': [ + { + 'component': 'VRow', + 'props': { + 'style': { + 'margin-top': '0px' + } + }, + 'content': [ + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'seed_time', + 'label': '做种时间(小时)', + 'placeholder': '达到后删除任务' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'hr_seed_time', + 'label': 'H&R做种时间(小时)', + 'placeholder': '达到后删除任务' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'seed_ratio', + 'label': '分享率', + 'placeholder': '达到后删除任务' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'seed_size', + 'label': '上传量(GB)', + 'placeholder': '达到后删除任务' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'seed_avgspeed', + 'label': '平均上传速度(KB/s)', + 'placeholder': '低于时删除任务' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'download_time', + 'label': '下载超时时间(小时)', + 'placeholder': '达到后删除任务' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'seed_inactivetime', + 'label': '未活动时间(分钟)', + 'placeholder': '超过时删除任务' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'delete_except_tags', + 'label': '删除排除标签', + 'placeholder': '如:MOVIEPILOT,H&R' + } + } + ] + } + ] + } + ] + }, + { + 'component': 'VWindowItem', + 'props': { + 'value': 'other_tab' + }, + 'content': [ + { + 'component': 'VRow', + 'props': { + 'style': { + 'margin-top': '-16px' + } + }, + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'brush_sequential', + 'label': '站点顺序刷流', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'except_subscribe', + 'label': '排除订阅(实验性功能)', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'proxy_delete', + 'label': '动态删除种子(实验性功能)', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'clear_task', + 'label': '清除统计数据', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enable_site_config', + 'label': '站点独立配置', + } + } + ] + }, + { + "component": "VCol", + "props": { + "cols": 12, + "md": 4 + }, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "dialog_closed", + "label": "打开站点配置窗口" + } + } + ] + } + ] + } + ] + } + ] + }, + { + 'component': 'VRow', + 'props': { + 'style': { + 'margin-top': '12px' + }, + }, + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'success', + 'variant': 'tonal' + }, + 'content': [ + { + 'component': 'span', + 'text': '注意:详细配置说明以及刷流规则请参考:' + }, + { + 'component': 'a', + 'props': { + 'href': 'https://github.com/InfinityPacer/MoviePilot-Plugins/blob/main/plugins.v2/brushflowlowfreq/README.md', + 'target': '_blank' + }, + 'content': [ + { + 'component': 'u', + 'text': 'README' + } + ] + } + ] + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'error', + 'variant': 'tonal', + 'text': '注意:排除H&R并不保证能完全适配所有站点(部分站点在列表页不显示H&R标志,但实际上是有H&R的),请注意核对使用' + } + } + ] + } + ] + }, + { + "component": "VDialog", + "props": { + "model": "dialog_closed", + "max-width": "65rem", + "overlay-class": "v-dialog--scrollable v-overlay--scroll-blocked", + "content-class": "v-card v-card--density-default v-card--variant-elevated rounded-t" + }, + "content": [ + { + "component": "VCard", + "props": { + "title": "设置站点配置" + }, + "content": [ + { + "component": "VDialogCloseBtn", + "props": { + "model": "dialog_closed" + } + }, + { + "component": "VCardText", + "props": {}, + "content": [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAceEditor', + 'props': { + 'modelvalue': 'site_config', + 'lang': 'json', + 'theme': 'monokai', + 'style': 'height: 30rem', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal' + }, + 'content': [ + { + 'component': 'span', + 'text': '注意:只有启用站点独立配置时,该配置项才会生效,详细配置参考:' + }, + { + 'component': 'a', + 'props': { + 'href': 'https://github.com/InfinityPacer/MoviePilot-Plugins/blob/main/plugins.v2/brushflowlowfreq/README.md', + 'target': '_blank' + }, + 'content': [ + { + 'component': 'u', + 'text': 'README' + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "notify": True, + "onlyonce": False, + "clear_task": False, + "delete_except_tags": f"{settings.TORRENT_TAG},H&R" if settings.TORRENT_TAG else "H&R", + "except_subscribe": True, + "brush_sequential": False, + "proxy_delete": False, + "freeleech": "free", + "hr": "yes", + "enable_site_config": False, + "site_config": BrushConfig.get_demo_site_config() + } + + def get_page(self) -> List[dict]: + # 种子明细 + torrents = self.get_data("torrents") or {} + + if not torrents: + return [ + { + 'component': 'div', + 'text': '暂无数据', + 'props': { + 'class': 'text-center', + } + } + ] + else: + data_list = torrents.values() + # 按time倒序排序 + data_list = sorted(data_list, key=lambda x: x.get("time") or 0, reverse=True) + + # 表格标题 + headers = [ + {'title': '站点', 'key': 'site', 'sortable': True}, + {'title': '标题', 'key': 'title', 'sortable': True}, + {'title': '大小', 'key': 'size', 'sortable': True}, + {'title': '上传量', 'key': 'uploaded', 'sortable': True}, + {'title': '下载量', 'key': 'downloaded', 'sortable': True}, + {'title': '分享率', 'key': 'ratio', 'sortable': True}, + {'title': '状态', 'key': 'status', 'sortable': True}, + ] + # 种子数据明细 + items = [ + { + 'site': data.get("site_name"), + 'title': data.get("title"), + 'size': StringUtils.str_filesize(data.get("size")), + 'uploaded': StringUtils.str_filesize(data.get("uploaded") or 0), + 'downloaded': StringUtils.str_filesize(data.get("downloaded") or 0), + 'ratio': round(data.get('ratio') or 0, 2), + 'status': "已删除" if data.get("deleted") else "正常" + } for data in data_list + ] + + # 拼装页面 + return [ + { + 'component': 'VRow', + 'props': { + 'style': { + 'overflow': 'hidden', + } + }, + 'content': self.__get_total_elements() + [ + # 种子明细 + { + 'component': 'VRow', + 'props': { + 'class': 'd-none d-sm-block', + }, + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VDataTableVirtual', + 'props': { + 'class': 'text-sm', + 'headers': headers, + 'items': items, + 'height': '30rem', + 'density': 'compact', + 'fixed-header': True, + 'hide-no-data': True, + 'hover': True + } + } + ] + } + ] + } + ] + } + ] + + def stop_service(self): + """ + 退出插件 + """ + try: + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._event.set() + self._scheduler.shutdown() + self._event.clear() + self._scheduler = None + except Exception as e: + print(str(e)) + + # region Brush + + def brush(self): + """ + 定时刷流,添加下载任务 + """ + brush_config = self.__get_brush_config() + + if not brush_config.brushsites or not brush_config.downloader or not self.downloader: + return + + if not self.__is_current_time_in_range(): + logger.info(f"当前不在指定的刷流时间区间内,刷流操作将暂时暂停") + return + + with lock: + logger.info(f"开始执行刷流任务 ...") + + torrent_tasks: Dict[str, dict] = self.get_data("torrents") or {} + torrents_size = self.__calculate_seeding_torrents_size(torrent_tasks=torrent_tasks) + + # 判断能否通过保种体积前置条件 + size_condition_passed, reason = self.__evaluate_size_condition_for_brush(torrents_size=torrents_size) + self.__log_brush_conditions(passed=size_condition_passed, reason=reason) + if not size_condition_passed: + logger.info(f"刷流任务执行完成") + return + + # 判断能否通过刷流前置条件 + pre_condition_passed, reason = self.__evaluate_pre_conditions_for_brush() + self.__log_brush_conditions(passed=pre_condition_passed, reason=reason) + if not pre_condition_passed: + logger.info(f"刷流任务执行完成") + return + + statistic_info = self.__get_statistic_info() + + # 获取所有站点的信息,并过滤掉不存在的站点 + site_infos = [] + for siteid in brush_config.brushsites: + siteinfo = self.site_oper.get(siteid) + if siteinfo: + site_infos.append(siteinfo) + + # 根据是否开启顺序刷流来决定是否需要打乱顺序 + if not brush_config.brush_sequential: + random.shuffle(site_infos) + + logger.info(f"即将针对站点 {', '.join(site.name for site in site_infos)} 开始刷流") + + # 获取订阅标题 + subscribe_titles = self.__get_subscribe_titles() + + # 处理所有站点 + for site in site_infos: + # 如果站点刷流没有正确响应,说明没有通过前置条件,其他站点也不需要继续刷流了 + if not self.__brush_site_torrents(siteid=site.id, torrent_tasks=torrent_tasks, + statistic_info=statistic_info, + subscribe_titles=subscribe_titles): + logger.info(f"站点 {site.name} 刷流中途结束,停止后续刷流") + break + else: + logger.info(f"站点 {site.name} 刷流完成") + + # 保存数据 + self.save_data("torrents", torrent_tasks) + # 保存统计数据 + self.save_data("statistic", statistic_info) + logger.info(f"刷流任务执行完成") + + def __brush_site_torrents(self, siteid, torrent_tasks: Dict[str, dict], statistic_info: Dict[str, int], + subscribe_titles: Set[str]) -> bool: + """ + 针对站点进行刷流 + """ + siteinfo = self.site_oper.get(siteid) + if not siteinfo: + logger.warning(f"站点不存在:{siteid}") + return True + + logger.info(f"开始获取站点 {siteinfo.name} 的新种子 ...") + torrents = self.torrents_chain.browse(domain=siteinfo.domain) + if not torrents: + logger.info(f"站点 {siteinfo.name} 没有获取到种子") + return True + + brush_config = self.__get_brush_config(sitename=siteinfo.name) + + if brush_config.site_hr_active: + logger.info(f"站点 {siteinfo.name} 已开启全站H&R选项,所有种子设置为H&R种子") + + # 排除包含订阅的种子 + if brush_config.except_subscribe: + torrents = self.__filter_torrents_contains_subscribe(torrents=torrents, subscribe_titles=subscribe_titles) + + # 按发布日期降序排列 + torrents.sort(key=lambda x: x.pubdate or '', reverse=True) + + torrents_size = self.__calculate_seeding_torrents_size(torrent_tasks=torrent_tasks) + + logger.info(f"正在准备种子刷流,数量 {len(torrents)}") + + # 过滤种子 + for torrent in torrents: + # 判断能否通过刷流前置条件 + pre_condition_passed, reason = self.__evaluate_pre_conditions_for_brush(include_network_conditions=False) + self.__log_brush_conditions(passed=pre_condition_passed, reason=reason) + if not pre_condition_passed: + return False + + logger.debug(f"种子详情:{torrent}") + + # 判断能否通过保种体积刷流条件 + size_condition_passed, reason = self.__evaluate_size_condition_for_brush(torrents_size=torrents_size, + add_torrent_size=torrent.size) + self.__log_brush_conditions(passed=size_condition_passed, reason=reason, torrent=torrent) + if not size_condition_passed: + continue + + # 判断能否通过刷流条件 + condition_passed, reason = self.__evaluate_conditions_for_brush(torrent=torrent, + torrent_tasks=torrent_tasks) + self.__log_brush_conditions(passed=condition_passed, reason=reason, torrent=torrent) + if not condition_passed: + continue + + # 添加下载任务 + hash_string = self.__download(torrent=torrent) + if not hash_string: + logger.warning(f"{torrent.title} 添加刷流任务失败!") + continue + + # 触发刷流下载时间并保存任务信息 + torrent_task = { + "site": siteinfo.id, + "site_name": siteinfo.name, + "title": torrent.title, + "size": torrent.size, + "pubdate": torrent.pubdate, + # "site_cookie": torrent.site_cookie, + # "site_ua": torrent.site_ua, + # "site_proxy": torrent.site_proxy, + # "site_order": torrent.site_order, + "description": torrent.description, + "imdbid": torrent.imdbid, + # "enclosure": torrent.enclosure, + "page_url": torrent.page_url, + # "seeders": torrent.seeders, + # "peers": torrent.peers, + # "grabs": torrent.grabs, + "date_elapsed": torrent.date_elapsed, + "freedate": torrent.freedate, + "uploadvolumefactor": torrent.uploadvolumefactor, + "downloadvolumefactor": torrent.downloadvolumefactor, + "hit_and_run": torrent.hit_and_run or brush_config.site_hr_active, + "volume_factor": torrent.volume_factor, + "freedate_diff": torrent.freedate_diff, + # "labels": torrent.labels, + # "pri_order": torrent.pri_order, + # "category": torrent.category, + "ratio": 0, + "downloaded": 0, + "uploaded": 0, + "seeding_time": 0, + "deleted": False, + "time": time.time() + } + + self.eventmanager.send_event(etype=EventType.PluginTriggered, data={ + "plugin_id": self.__class__.__name__, + "event_name": "brushflow_download_added", + "hash": hash_string, + "data": torrent_task, + "downloader": self.service_info.name + }) + torrent_tasks[hash_string] = torrent_task + + # 统计数据 + torrents_size += torrent.size + statistic_info["count"] += 1 + logger.info(f"站点 {siteinfo.name},新增刷流种子下载:{torrent.title}|{torrent.description}") + self.__send_add_message(torrent) + + return True + + def __evaluate_size_condition_for_brush(self, torrents_size: float, + add_torrent_size: float = 0.0) -> Tuple[bool, Optional[str]]: + """ + 过滤体积不符合条件的种子 + """ + brush_config = self.__get_brush_config() + + # 如果没有明确指定增加的种子大小,则检查配置中是否有种子大小下限,如果有,使用这个大小作为增加的种子大小 + preset_condition = False + if not add_torrent_size and brush_config.size: + size_limits = [float(size) * 1024 ** 3 for size in brush_config.size.split("-")] + add_torrent_size = size_limits[0] # 使用配置的种子大小下限 + preset_condition = True + + total_size = self.__bytes_to_gb(torrents_size + add_torrent_size) # 预计总做种体积 + + def generate_message(config): + if add_torrent_size: + if preset_condition: + return (f"当前做种体积 {self.__bytes_to_gb(torrents_size):.1f} GB," + f"刷流种子下限 {self.__bytes_to_gb(add_torrent_size):.1f} GB," + f"预计做种体积 {total_size:.1f} GB," + f"超过设定的保种体积 {config} GB,暂时停止新增任务") + else: + return (f"当前做种体积 {self.__bytes_to_gb(torrents_size):.1f} GB," + f"刷流种子大小 {self.__bytes_to_gb(add_torrent_size):.1f} GB," + f"预计做种体积 {total_size:.1f} GB," + f"超过设定的保种体积 {config} GB") + else: + return (f"当前做种体积 {self.__bytes_to_gb(torrents_size):.1f} GB," + f"超过设定的保种体积 {config} GB,暂时停止新增任务") + + reasons = [ + ("disksize", + lambda config: torrents_size + add_torrent_size > float(config) * 1024 ** 3, generate_message) + ] + + for condition, check, message in reasons: + config_value = getattr(brush_config, condition, None) + if config_value and check(config_value): + reason = message(config_value) + return False, reason + + return True, None + + def __evaluate_pre_conditions_for_brush(self, include_network_conditions: bool = True) \ + -> Tuple[bool, Optional[str]]: + """ + 前置过滤不符合条件的种子 + """ + reasons = [ + ("maxdlcount", lambda config: self.__get_downloading_count() >= int(config), + lambda config: f"当前同时下载任务数已达到最大值 {config},暂时停止新增任务") + ] + + if include_network_conditions: + downloader_info = self.__get_downloader_info() + if downloader_info: + current_upload_speed = downloader_info.upload_speed or 0 + current_download_speed = downloader_info.download_speed or 0 + reasons.extend([ + ("maxupspeed", lambda config: current_upload_speed >= float(config) * 1024, + lambda config: f"当前总上传带宽 {StringUtils.str_filesize(current_upload_speed)}," + f"已达到最大值 {config} KB/s,暂时停止新增任务"), + ("maxdlspeed", lambda config: current_download_speed >= float(config) * 1024, + lambda config: f"当前总下载带宽 {StringUtils.str_filesize(current_download_speed)}," + f"已达到最大值 {config} KB/s,暂时停止新增任务"), + ]) + + brush_config = self.__get_brush_config() + for condition, check, message in reasons: + config_value = getattr(brush_config, condition, None) + if config_value and check(config_value): + reason = message(config_value) + return False, reason + + return True, None + + def __evaluate_conditions_for_brush(self, torrent, torrent_tasks) -> Tuple[bool, Optional[str]]: + """ + 过滤不符合条件的种子 + """ + brush_config = self.__get_brush_config(torrent.site_name) + + # 排除重复种子 + # 默认根据标题和站点名称进行排除 + task_key = f"{torrent.site_name}{torrent.title}" + if any(task_key == f"{task.get('site_name')}{task.get('title')}" for task in torrent_tasks.values()): + return False, "重复种子" + + # 部分站点标题会上新时携带后缀,这里进一步根据种子详情地址进行排除 + if torrent.page_url: + task_page_url = f"{torrent.site_name}{torrent.page_url}" + if any(task_page_url == f"{task.get('site_name')}{task.get('page_url')}" for task in + torrent_tasks.values()): + return False, "重复种子" + + # 不同站点如果遇到相同种子,判断前一个种子是否已经在做种,否则排除处理 + if torrent.title: + if any(torrent.site_name != f"{task.get('site_name')}" and torrent.title == f"{task.get('title')}" + and not task.get("seed_time") for task in torrent_tasks.values()): + return False, "其他站点存在尚未下载完成的相同种子" + + # 促销条件 + if brush_config.freeleech and torrent.downloadvolumefactor != 0: + return False, "非免费种子" + if brush_config.freeleech == "2xfree" and torrent.uploadvolumefactor != 2: + return False, "非双倍上传种子" + + # H&R + if brush_config.hr == "yes" and torrent.hit_and_run: + return False, "存在H&R" + + # 包含规则 + if brush_config.include and not ( + re.search(brush_config.include, torrent.title, re.I) or re.search(brush_config.include, + torrent.description, re.I)): + return False, "不符合包含规则" + + # 排除规则 + if brush_config.exclude and ( + re.search(brush_config.exclude, torrent.title, re.I) or re.search(brush_config.exclude, + torrent.description, re.I)): + return False, "符合排除规则" + + # 种子大小(GB) + if brush_config.size: + sizes = [float(size) * 1024 ** 3 for size in brush_config.size.split("-")] + if len(sizes) == 1 and torrent.size < sizes[0]: + return False, f"种子大小 {self.__bytes_to_gb(torrent.size):.1f} GB,不符合条件" + elif len(sizes) > 1 and not sizes[0] <= torrent.size <= sizes[1]: + return False, f"种子大小 {self.__bytes_to_gb(torrent.size):.1f} GB,不在指定范围内" + + # 做种人数 + if brush_config.seeder: + seeders_range = [float(n) for n in brush_config.seeder.split("-")] + # 检查是否仅指定了一个数字,即做种人数需要小于等于该数字 + if len(seeders_range) == 1: + # 当做种人数大于该数字时,不符合条件 + if torrent.seeders > seeders_range[0]: + return False, f"做种人数 {torrent.seeders},超过单个指定值" + # 如果指定了一个范围 + elif len(seeders_range) > 1: + # 检查做种人数是否在指定的范围内(包括边界) + if not (seeders_range[0] <= torrent.seeders <= seeders_range[1]): + return False, f"做种人数 {torrent.seeders},不在指定范围内" + + # 发布时间 + pubdate_minutes = self.__get_pubminutes(torrent.pubdate) + # 已支持独立站点配置,取消单独适配站点时区逻辑,可通过配置项「pubtime」自行适配 + # pubdate_minutes = self.__adjust_site_pubminutes(pubdate_minutes, torrent) + if brush_config.pubtime: + pubtimes = [float(n) for n in brush_config.pubtime.split("-")] + if len(pubtimes) == 1: + # 单个值:选择发布时间小于等于该值的种子 + if pubdate_minutes > pubtimes[0]: + return False, f"发布时间 {torrent.pubdate},{pubdate_minutes:.0f} 分钟前,不符合条件" + else: + # 范围值:选择发布时间在范围内的种子 + if not (pubtimes[0] <= pubdate_minutes <= pubtimes[1]): + return False, f"发布时间 {torrent.pubdate},{pubdate_minutes:.0f} 分钟前,不在指定范围内" + + return True, None + + @staticmethod + def __log_brush_conditions(passed: bool, reason: str, torrent: Any = None): + """ + 记录刷流日志 + """ + if not passed: + if not torrent: + logger.warning(f"没有通过前置刷流条件校验,原因:{reason}") + else: + logger.debug(f"种子没有通过刷流条件校验,原因:{reason} 种子:{torrent.title}|{torrent.description}") + + # endregion + + # region Check + + def check(self): + """ + 定时检查,删除下载任务 + """ + brush_config = self.__get_brush_config() + + if not brush_config.downloader or not self.downloader: + return + + with lock: + logger.info("开始检查刷流下载任务 ...") + torrent_tasks: Dict[str, dict] = self.get_data("torrents") or {} + unmanaged_tasks: Dict[str, dict] = self.get_data("unmanaged") or {} + + downloader = self.downloader + seeding_torrents, error = downloader.get_torrents() + if error: + logger.warning("连接下载器出错,将在下个时间周期重试") + return + + seeding_torrents_dict = {self.__get_hash(torrent): torrent for torrent in seeding_torrents} + + # 检查种子刷流标签变更情况 + self.__update_seeding_tasks_based_on_tags(torrent_tasks=torrent_tasks, unmanaged_tasks=unmanaged_tasks, + seeding_torrents_dict=seeding_torrents_dict) + + torrent_check_hashes = list(torrent_tasks.keys()) + if not torrent_tasks or not torrent_check_hashes: + logger.info("没有需要检查的刷流下载任务") + return + + logger.info(f"共有 {len(torrent_check_hashes)} 个任务正在刷流,开始检查任务状态") + + # 获取到当前所有做种数据中需要被检查的种子数据 + check_torrents = [seeding_torrents_dict[th] for th in torrent_check_hashes if th in seeding_torrents_dict] + + # 先更新刷流任务的最新状态,上下传,分享率 + self.__update_torrent_tasks_state(torrents=check_torrents, torrent_tasks=torrent_tasks) + + # 更新刷流任务列表中在下载器中删除的种子为删除状态 + self.__update_undeleted_torrents_missing_in_downloader(torrent_tasks, torrent_check_hashes, check_torrents) + + # 根据配置的标签进行种子排除 + if check_torrents: + logger.info(f"当前刷流任务共 {len(check_torrents)} 个有效种子,正在准备按设定的种子标签进行排除") + # 初始化一个空的列表来存储需要排除的标签 + tags_to_exclude = set() + # 如果 delete_except_tags 非空且不是纯空白,则添加到排除列表中 + if brush_config.delete_except_tags and brush_config.delete_except_tags.strip(): + tags_to_exclude.update(tag.strip() for tag in brush_config.delete_except_tags.split(',')) + # 将所有需要排除的标签组合成一个字符串,每个标签之间用逗号分隔 + combined_tags = ",".join(tags_to_exclude) + if combined_tags: # 确保有标签需要排除 + pre_filter_count = len(check_torrents) # 获取过滤前的任务数量 + check_torrents = self.__filter_torrents_by_tag(torrents=check_torrents, exclude_tag=combined_tags) + post_filter_count = len(check_torrents) # 获取过滤后的任务数量 + excluded_count = pre_filter_count - post_filter_count # 计算被排除的任务数量 + logger.info( + f"有效种子数 {pre_filter_count},排除标签 '{combined_tags}' 后," + f"剩余种子数 {post_filter_count},排除种子数 {excluded_count}") + else: + logger.info("没有配置有效的排除标签,所有种子均参与后续处理") + + # 种子删除检查 + if not check_torrents: + logger.info("没有需要检查的任务,跳过") + else: + need_delete_hashes = [] + + # 如果配置了动态删除以及删种阈值,则根据动态删种进行分组处理 + if brush_config.proxy_delete and brush_config.delete_size_range: + logger.info("已开启动态删种,按系统默认动态删种条件开始检查任务") + proxy_delete_hashes = self.__delete_torrent_for_proxy(torrents=check_torrents, + torrent_tasks=torrent_tasks) or [] + need_delete_hashes.extend(proxy_delete_hashes) + # 否则均认为是没有开启动态删种 + else: + logger.info("没有开启动态删种,按用户设置删种条件开始检查任务") + not_proxy_delete_hashes = self.__delete_torrent_for_evaluate_conditions(torrents=check_torrents, + torrent_tasks=torrent_tasks) or [] + need_delete_hashes.extend(not_proxy_delete_hashes) + + if need_delete_hashes: + # 如果是QB,则重新汇报Tracker + if self.downloader_helper.is_downloader("qbittorrent", service=self.service_info): + self.__qb_torrents_reannounce(torrent_hashes=need_delete_hashes) + # 删除种子 + if downloader.delete_torrents(ids=need_delete_hashes, delete_file=True): + for torrent_hash in need_delete_hashes: + torrent_tasks[torrent_hash]["deleted"] = True + torrent_tasks[torrent_hash]["deleted_time"] = time.time() + + # 归档数据 + self.__auto_archive_tasks(torrent_tasks=torrent_tasks) + + self.__update_and_save_statistic_info(torrent_tasks) + + self.save_data("torrents", torrent_tasks) + + logger.info("刷流下载任务检查完成") + + def __update_torrent_tasks_state(self, torrents: List[Any], torrent_tasks: Dict[str, dict]): + """ + 更新刷流任务的最新状态,上下传,分享率 + """ + for torrent in torrents: + torrent_hash = self.__get_hash(torrent) + torrent_task = torrent_tasks.get(torrent_hash, None) + # 如果找不到种子任务,说明不在管理的种子范围内,直接跳过 + if not torrent_task: + continue + + torrent_info = self.__get_torrent_info(torrent) + + # 更新上传量、下载量 + torrent_task.update({ + "downloaded": torrent_info.get("downloaded"), + "uploaded": torrent_info.get("uploaded"), + "ratio": torrent_info.get("ratio"), + "seeding_time": torrent_info.get("seeding_time"), + }) + + def __update_seeding_tasks_based_on_tags(self, torrent_tasks: Dict[str, dict], unmanaged_tasks: Dict[str, dict], + seeding_torrents_dict: Dict[str, Any]): + brush_config = self.__get_brush_config() + + if not self.downloader_helper.is_downloader("qbittorrent", service=self.service_info): + logger.info("同步种子刷流标签记录目前仅支持qbittorrent") + return + + # 初始化汇总信息 + added_tasks = [] + reset_tasks = [] + removed_tasks = [] + # 基于 seeding_torrents_dict 的信息更新或添加到 torrent_tasks + for torrent_hash, torrent in seeding_torrents_dict.items(): + tags = self.__get_label(torrent=torrent) + # 判断是否包含刷流标签 + if brush_config.brush_tag in tags: + # 如果包含刷流标签又不在刷流任务中,则需要加入管理 + if torrent_hash not in torrent_tasks: + # 检查该种子是否在 unmanaged_tasks 中 + if torrent_hash in unmanaged_tasks: + # 如果在 unmanaged_tasks 中,移除并转移到 torrent_tasks + torrent_task = unmanaged_tasks.pop(torrent_hash) + torrent_tasks[torrent_hash] = torrent_task + added_tasks.append(torrent_task) + logger.info(f"站点 {torrent_task.get('site_name')}," + f"刷流任务种子再次加入:{torrent_task.get('title')}|{torrent_task.get('description')}") + else: + # 否则,创建一个新的任务 + torrent_task = self.__convert_torrent_info_to_task(torrent) + torrent_tasks[torrent_hash] = torrent_task + added_tasks.append(torrent_task) + logger.info(f"站点 {torrent_task.get('site_name')}," + f"刷流任务种子加入:{torrent_task.get('title')}|{torrent_task.get('description')}") + # 包含刷流标签又在刷流任务中,这里额外处理一个特殊逻辑,就是种子在刷流任务中可能被标记删除但实际上又还在下载器中,这里进行重置 + else: + torrent_task = torrent_tasks[torrent_hash] + if torrent_task.get("deleted"): + torrent_task["deleted"] = False + reset_tasks.append(torrent_task) + logger.info( + f"站点 {torrent_task.get('site_name')},在下载器中找到已标记删除的刷流任务对应的种子信息," + f"更新刷流任务状态为正常:{torrent_task.get('title')}|{torrent_task.get('description')}") + else: + # 不包含刷流标签但又在刷流任务中,则移除管理 + if torrent_hash in torrent_tasks: + # 如果种子不符合刷流条件但在 torrent_tasks 中,移除并加入 unmanaged_tasks + torrent_task = torrent_tasks.pop(torrent_hash) + unmanaged_tasks[torrent_hash] = torrent_task + removed_tasks.append(torrent_task) + logger.info(f"站点 {torrent_task.get('site_name')}," + f"刷流任务种子移除:{torrent_task.get('title')}|{torrent_task.get('description')}") + + self.save_data("torrents", torrent_tasks) + self.save_data("unmanaged", unmanaged_tasks) + + # 发送汇总消息 + if added_tasks: + self.__log_and_send_torrent_task_update_message(title="【刷流任务种子加入】", status="纳入刷流管理", + reason="刷流标签添加", torrent_tasks=added_tasks) + if removed_tasks: + self.__log_and_send_torrent_task_update_message(title="【刷流任务种子移除】", status="移除刷流管理", + reason="刷流标签移除", torrent_tasks=removed_tasks) + if reset_tasks: + self.__log_and_send_torrent_task_update_message(title="【刷流任务状态更新】", status="更新刷流状态为正常", + reason="在下载器中找到已标记删除的刷流任务对应的种子信息", + torrent_tasks=reset_tasks) + + def __group_torrents_by_proxy_delete(self, torrents: List[Any], torrent_tasks: Dict[str, dict]): + """ + 根据是否启用动态删种进行分组 + """ + proxy_delete_torrents = [] + not_proxy_delete_torrents = [] + + for torrent in torrents: + torrent_hash = self.__get_hash(torrent) + torrent_task = torrent_tasks.get(torrent_hash, None) + + # 如果找不到种子任务,说明不在管理的种子范围内,直接跳过 + if not torrent_task: + continue + + site_name = torrent_task.get("site_name", "") + + brush_config = self.__get_brush_config(site_name) + if brush_config.proxy_delete: + proxy_delete_torrents.append(torrent) + else: + not_proxy_delete_torrents.append(torrent) + + return proxy_delete_torrents, not_proxy_delete_torrents + + def __evaluate_conditions_for_delete(self, site_name: str, torrent_info: dict, torrent_task: dict) \ + -> Tuple[bool, str]: + """ + 评估删除条件并返回是否应删除种子及其原因 + """ + brush_config = self.__get_brush_config(sitename=site_name) + + reason = "未能满足设置的删除条件" + + # 当配置了H&R做种时间/分享率时,则H&R种子只有达到预期行为时,才会进行删除,如果没有配置H&R做种时间/分享率,则普通种子的删除规则也适用于H&R种子 + # 判断是否为H&R种子并且是否配置了特定的H&R条件 + hit_and_run = torrent_task.get("hit_and_run", False) + hr_specific_conditions_configured = hit_and_run and (brush_config.hr_seed_time or brush_config.seed_ratio) + if hr_specific_conditions_configured: + if (brush_config.hr_seed_time and torrent_info.get("seeding_time") + >= float(brush_config.hr_seed_time) * 3600): + return True, (f"H&R种子,做种时间 {torrent_info.get('seeding_time') / 3600:.1f} 小时," + f"大于 {brush_config.hr_seed_time} 小时") + if brush_config.seed_ratio and torrent_info.get("ratio") >= float(brush_config.seed_ratio): + return True, f"H&R种子,分享率 {torrent_info.get('ratio'):.2f},大于 {brush_config.seed_ratio}" + return False, "H&R种子,未能满足设置的H&R删除条件" + + # 处理其他场景,1. 不是H&R种子;2. 是H&R种子但没有特定条件配置 + reason = reason if not hit_and_run else "H&R种子(未设置H&R条件),未能满足设置的删除条件" + if brush_config.seed_time and torrent_info.get("seeding_time") >= float(brush_config.seed_time) * 3600: + reason = f"做种时间 {torrent_info.get('seeding_time') / 3600:.1f} 小时,大于 {brush_config.seed_time} 小时" + elif brush_config.seed_ratio and torrent_info.get("ratio") >= float(brush_config.seed_ratio): + reason = f"分享率 {torrent_info.get('ratio'):.2f},大于 {brush_config.seed_ratio}" + elif brush_config.seed_size and torrent_info.get("uploaded") >= float(brush_config.seed_size) * 1024 ** 3: + reason = f"上传量 {torrent_info.get('uploaded') / 1024 ** 3:.1f} GB,大于 {brush_config.seed_size} GB" + elif brush_config.download_time and torrent_info.get("downloaded") < torrent_info.get( + "total_size") and torrent_info.get("dltime") >= float(brush_config.download_time) * 3600: + reason = f"下载耗时 {torrent_info.get('dltime') / 3600:.1f} 小时,大于 {brush_config.download_time} 小时" + elif brush_config.seed_avgspeed and torrent_info.get("avg_upspeed") <= float( + brush_config.seed_avgspeed) * 1024 and torrent_info.get("seeding_time") >= 30 * 60: + reason = f"平均上传速度 {torrent_info.get('avg_upspeed') / 1024:.1f} KB/s,低于 {brush_config.seed_avgspeed} KB/s" + elif brush_config.seed_inactivetime and torrent_info.get("iatime") >= float( + brush_config.seed_inactivetime) * 60: + reason = f"未活动时间 {torrent_info.get('iatime') / 60:.0f} 分钟,大于 {brush_config.seed_inactivetime} 分钟" + else: + return False, reason + + return True, reason if not hit_and_run else "H&R种子(未设置H&R条件)," + reason + + def __evaluate_proxy_pre_conditions_for_delete(self, site_name: str, torrent_info: dict) -> Tuple[bool, str]: + """ + 评估动态删除前置条件并返回是否应删除种子及其原因 + """ + brush_config = self.__get_brush_config(sitename=site_name) + + reason = "未能满足动态删除设置的前置删除条件" + + if brush_config.download_time and torrent_info.get("downloaded") < torrent_info.get( + "total_size") and torrent_info.get("dltime") >= float(brush_config.download_time) * 3600: + reason = f"下载耗时 {torrent_info.get('dltime') / 3600:.1f} 小时,大于 {brush_config.download_time} 小时" + else: + return False, reason + + return True, reason + + def __delete_torrent_for_evaluate_conditions(self, torrents: List[Any], torrent_tasks: Dict[str, dict], + proxy_delete: bool = False) -> List: + """ + 根据条件删除种子并获取已删除列表 + """ + delete_hashes = [] + + for torrent in torrents: + torrent_hash = self.__get_hash(torrent) + torrent_task = torrent_tasks.get(torrent_hash, None) + # 如果找不到种子任务,说明不在管理的种子范围内,直接跳过 + if not torrent_task: + continue + site_name = torrent_task.get("site_name", "") + torrent_title = torrent_task.get("title", "") + torrent_desc = torrent_task.get("description", "") + + torrent_info = self.__get_torrent_info(torrent) + + # 删除种子的具体实现可能会根据实际情况略有不同 + should_delete, reason = self.__evaluate_conditions_for_delete(site_name=site_name, + torrent_info=torrent_info, + torrent_task=torrent_task) + if should_delete: + delete_hashes.append(torrent_hash) + reason = "触发动态删除阈值," + reason if proxy_delete else reason + self.__send_delete_message(site_name=site_name, torrent_title=torrent_title, torrent_desc=torrent_desc, + reason=reason) + logger.info(f"站点:{site_name},{reason},删除种子:{torrent_title}|{torrent_desc}") + else: + logger.debug(f"站点:{site_name},{reason},不删除种子:{torrent_title}|{torrent_desc}") + + return delete_hashes + + def __delete_torrent_for_evaluate_proxy_pre_conditions(self, torrents: List[Any], + torrent_tasks: Dict[str, dict]) -> List: + """ + 根据动态删除前置条件排除H&R种子后删除种子并获取已删除列表 + """ + delete_hashes = [] + + for torrent in torrents: + torrent_hash = self.__get_hash(torrent) + torrent_task = torrent_tasks.get(torrent_hash, None) + # 如果找不到种子任务,说明不在管理的种子范围内,直接跳过 + if not torrent_task: + continue + + # 如果是H&R种子,前置条件中不进行处理 + if torrent_task.get('hit_and_run', False): + continue + + site_name = torrent_task.get("site_name", "") + torrent_title = torrent_task.get("title", "") + torrent_desc = torrent_task.get("description", "") + + torrent_info = self.__get_torrent_info(torrent) + + # 删除种子的具体实现可能会根据实际情况略有不同 + should_delete, reason = self.__evaluate_proxy_pre_conditions_for_delete(site_name=site_name, + torrent_info=torrent_info) + if should_delete: + delete_hashes.append(torrent_hash) + self.__send_delete_message(site_name=site_name, torrent_title=torrent_title, torrent_desc=torrent_desc, + reason=reason) + logger.info(f"站点:{site_name},{reason},删除种子:{torrent_title}|{torrent_desc}") + else: + logger.debug(f"站点:{site_name},{reason},不删除种子:{torrent_title}|{torrent_desc}") + + return delete_hashes + + def __delete_torrent_for_proxy(self, torrents: List[Any], torrent_tasks: Dict[str, dict]) -> List: + """ + 动态删除种子,删除规则如下; + - 不管做种体积是否超过设定的动态删除阈值,默认优先执行排除H&R种子后满足「下载超时时间」的种子 + - 上述规则执行完成后,当做种体积依旧超过设定的动态删除阈值时,继续执行下述种子删除规则 + - 优先删除满足用户设置删除规则的全部种子,即便在删除过程中已经低于了阈值下限,也会继续删除 + - 若删除后还没有达到阈值,则在已完成种子中排除H&R种子后按做种时间倒序进行删除 + - 动态删除阈值:100,当做种体积 > 100G 时,则开始删除种子,直至降低至 100G + - 动态删除阈值:50-100,当做种体积 > 100G 时,则开始删除种子,直至降至为 50G + """ + brush_config = self.__get_brush_config() + + # 如果没有启用动态删除或没有设置删除阈值,则不执行删除操作 + if not (brush_config.proxy_delete and brush_config.delete_size_range): + return [] + + # 获取种子信息Map + torrent_info_map = {self.__get_hash(torrent): self.__get_torrent_info(torrent=torrent) for torrent in torrents} + + # 计算当前总做种体积 + total_torrent_size = self.__calculate_seeding_torrents_size(torrent_tasks=torrent_tasks) + + logger.info( + f"当前做种体积 {self.__bytes_to_gb(total_torrent_size):.1f} GB,正在准备计算满足动态前置删除条件的种子") + + # 执行排除H&R种子后满足前置删除条件的种子 + pre_delete_hashes = self.__delete_torrent_for_evaluate_proxy_pre_conditions(torrents=torrents, + torrent_tasks=torrent_tasks) or [] + + # 如果存在前置删除种子,这里进行额外判断,总做种体积排除前置删除种子的体积 + if pre_delete_hashes: + pre_delete_total_size = sum(torrent_info_map[self.__get_hash(torrent)].get("total_size", 0) + for torrent in torrents if self.__get_hash(torrent) in pre_delete_hashes) + total_torrent_size = total_torrent_size - pre_delete_total_size + torrents = [torrent for torrent in torrents if self.__get_hash(torrent) not in pre_delete_hashes] + logger.info( + f"满足动态删除前置条件的种子共 {len(pre_delete_hashes)} 个,体积 {self.__bytes_to_gb(pre_delete_total_size):.1f} GB," + f"删除种子后,当前做种体积 {self.__bytes_to_gb(total_torrent_size):.1f} GB") + else: + logger.info(f"没有找到任何满足动态删除前置条件的种子") + + # 解析删除阈值范围 + sizes = [float(size) * 1024 ** 3 for size in brush_config.delete_size_range.split("-")] + min_size = sizes[0] # 至少需要达到的做种体积 + max_size = sizes[1] if len(sizes) > 1 else sizes[0] # 触发删除操作的做种体积上限 + + # 判断是否为区间删除 + proxy_size_range = len(sizes) > 1 + + # 当总体积未超过最大阈值时,不需要执行删除操作 + if total_torrent_size < max_size: + logger.info( + f"当前做种体积 {self.__bytes_to_gb(total_torrent_size):.1f} GB,上限 {self.__bytes_to_gb(max_size):.1f} GB," + f"下限 {self.__bytes_to_gb(min_size):.1f} GB,未进一步触发动态删除") + return pre_delete_hashes or [] + else: + logger.info( + f"当前做种体积 {self.__bytes_to_gb(total_torrent_size):.1f} GB,上限 {self.__bytes_to_gb(max_size):.1f} GB," + f"下限 {self.__bytes_to_gb(min_size):.1f} GB,进一步触发动态删除") + + need_delete_hashes = [] + need_delete_hashes.extend(pre_delete_hashes) + + # 即使开了动态删除,但是也有可能部分站点单独设置了关闭,这里根据种子托管进行分组,先处理不需要托管的种子,按设置的规则进行删除 + proxy_delete_torrents, not_proxy_delete_torrents = self.__group_torrents_by_proxy_delete(torrents=torrents, + torrent_tasks=torrent_tasks) + logger.info(f"托管种子数 {len(proxy_delete_torrents)},未托管种子数 {len(not_proxy_delete_torrents)}") + if not_proxy_delete_torrents: + not_proxy_delete_hashes = self.__delete_torrent_for_evaluate_conditions(torrents=not_proxy_delete_torrents, + torrent_tasks=torrent_tasks) or [] + need_delete_hashes.extend(not_proxy_delete_hashes) + total_torrent_size -= sum( + torrent_info_map[self.__get_hash(torrent)].get("total_size", 0) for torrent in not_proxy_delete_torrents + if self.__get_hash(torrent) in not_proxy_delete_hashes) + + # 如果删除非托管种子后仍未达到最小体积要求,则处理托管种子 + if total_torrent_size > min_size and proxy_delete_torrents: + proxy_delete_hashes = self.__delete_torrent_for_evaluate_conditions(torrents=proxy_delete_torrents, + torrent_tasks=torrent_tasks, + proxy_delete=True) or [] + need_delete_hashes.extend(proxy_delete_hashes) + total_torrent_size -= sum( + torrent_info_map[self.__get_hash(torrent)].get("total_size", 0) for torrent in proxy_delete_torrents if + self.__get_hash(torrent) in proxy_delete_hashes) + + # 在完成初始删除步骤后,如果总体积仍然超过最小阈值,则进一步找到已完成种子并排除HR种子后按做种时间正序进行删除 + if total_torrent_size > min_size: + # 重新计算当前的种子列表,排除已删除的种子 + remaining_hashes = list( + {self.__get_hash(torrent) for torrent in proxy_delete_torrents} - set(need_delete_hashes)) + # 这里根据排除后的种子列表,再次从下载器中找到已完成的任务 + downloader = self.downloader + completed_torrents = downloader.get_completed_torrents(ids=remaining_hashes) + remaining_hashes = {self.__get_hash(torrent) for torrent in completed_torrents} + remaining_torrents = [(_hash, torrent_info_map[_hash]) for _hash in remaining_hashes] + + # 准备一个列表,用于存放满足条件的种子,即非HR种子且有明确做种时间 + filtered_torrents = [(_hash, info['seeding_time']) for _hash, info in remaining_torrents if + not torrent_tasks[_hash].get("hit_and_run", False)] + sorted_torrents = sorted(filtered_torrents, key=lambda x: x[1], reverse=True) + + # 进行额外的删除操作,直到满足最小阈值或没有更多种子可删除 + for torrent_hash, _ in sorted_torrents: + if total_torrent_size <= min_size: + break + torrent_task = torrent_tasks.get(torrent_hash, None) + torrent_info = torrent_info_map.get(torrent_hash, None) + if not torrent_task or not torrent_info: + continue + + need_delete_hashes.append(torrent_hash) + total_torrent_size -= torrent_info.get("total_size", 0) + + site_name = torrent_task.get("site_name", "") + torrent_title = torrent_task.get("title", "") + torrent_desc = torrent_task.get("description", "") + seeding_time = torrent_task.get("seeding_time", 0) + if seeding_time: + reason = (f"触发动态删除阈值,系统自动删除,做种时间 {seeding_time / 3600:.1f} 小时," + f"当前做种体积 {self.__bytes_to_gb(total_torrent_size):.1f} GB") + # 如果是区间删除,一次性删除的数据过多,取消消息推送 + if not proxy_size_range: + self.__send_delete_message(site_name=site_name, torrent_title=torrent_title, + torrent_desc=torrent_desc, + reason=reason) + logger.info(f"站点:{site_name},{reason},删除种子:{torrent_title}|{torrent_desc}") + + delete_sites = {torrent_tasks[hash_key].get('site_name', '') for hash_key in need_delete_hashes if + hash_key in torrent_tasks} + msg = (f"站点:{','.join(delete_sites)}\n内容:已完成 {len(need_delete_hashes)} 个种子删除," + f"当前做种体积 {self.__bytes_to_gb(total_torrent_size):.1f} GB\n原因:触发动态删除阈值,系统自动删除") + logger.info(msg) + + # 如果是区间删除,这里则进行统一推送 + if proxy_size_range: + self.__send_message(title="【刷流任务种子删除】", text=msg) + + # 返回所有需要删除的种子的哈希列表 + return need_delete_hashes + + def __update_undeleted_torrents_missing_in_downloader(self, torrent_tasks, torrent_check_hashes, torrents): + """ + 处理已经被删除,但是任务记录中还没有被标记删除的种子 + """ + # 先通过获取的全量种子,判断已经被删除,但是任务记录中还没有被标记删除的种子 + torrent_all_hashes = self.__get_all_hashes(torrents) + missing_hashes = [hash_value for hash_value in torrent_check_hashes if hash_value not in torrent_all_hashes] + undeleted_hashes = [hash_value for hash_value in missing_hashes if not torrent_tasks[hash_value].get("deleted")] + + if not undeleted_hashes: + return + + # 初始化汇总信息 + delete_tasks = [] + for hash_value in undeleted_hashes: + # 获取对应的任务信息 + torrent_task = torrent_tasks[hash_value] + # 标记为已删除 + torrent_task["deleted"] = True + torrent_task["deleted_time"] = time.time() + # 处理日志相关内容 + delete_tasks.append(torrent_task) + site_name = torrent_task.get("site_name", "") + torrent_title = torrent_task.get("title", "") + torrent_desc = torrent_task.get("description", "") + logger.info( + f"站点:{site_name},无法在下载器中找到对应种子信息,更新刷流任务状态为已删除,种子:{torrent_title}|{torrent_desc}") + + self.__log_and_send_torrent_task_update_message(title="【刷流任务状态更新】", status="更新刷流状态为已删除", + reason="无法在下载器中找到对应的种子信息", + torrent_tasks=delete_tasks) + + def __convert_torrent_info_to_task(self, torrent: Any) -> dict: + """ + 根据torrent_info转换成torrent_task + """ + torrent_info = self.__get_torrent_info(torrent=torrent) + + site_id, site_name = self.__get_site_by_torrent(torrent=torrent) + + torrent_task = { + "site": site_id, + "site_name": site_name, + "title": torrent_info.get("title", ""), + "size": torrent_info.get("total_size", 0), # 假设total_size对应于size + "pubdate": None, + "description": None, + "imdbid": None, + "page_url": None, + "date_elapsed": None, + "freedate": None, + "uploadvolumefactor": None, + "downloadvolumefactor": None, + "hit_and_run": None, + "volume_factor": None, + "freedate_diff": None, # 假设无法从torrent_info直接获取 + "ratio": torrent_info.get("ratio", 0), + "downloaded": torrent_info.get("downloaded", 0), + "uploaded": torrent_info.get("uploaded", 0), + "deleted": False, + "time": torrent_info.get("add_on", time.time()) + } + return torrent_task + + # endregion + + def __update_and_save_statistic_info(self, torrent_tasks): + """ + 更新并保存统计信息 + """ + total_count, total_uploaded, total_downloaded, total_deleted = 0, 0, 0, 0 + active_uploaded, active_downloaded, active_count, total_unarchived = 0, 0, 0, 0 + + statistic_info = self.__get_statistic_info() + archived_tasks = self.get_data("archived") or {} + combined_tasks = {**torrent_tasks, **archived_tasks} + + for task in combined_tasks.values(): + if task.get("deleted", False): + total_deleted += 1 + total_downloaded += task.get("downloaded", 0) + total_uploaded += task.get("uploaded", 0) + + # 计算torrent_tasks中未标记为删除的活跃任务的统计信息,及待归档的任务数 + for task in torrent_tasks.values(): + if not task.get("deleted", False): + active_uploaded += task.get("uploaded", 0) + active_downloaded += task.get("downloaded", 0) + active_count += 1 + else: + total_unarchived += 1 + + # 更新统计信息 + total_count = len(combined_tasks) + statistic_info.update({ + "uploaded": total_uploaded, + "downloaded": total_downloaded, + "deleted": total_deleted, + "unarchived": total_unarchived, + "count": total_count, + "active": active_count, + "active_uploaded": active_uploaded, + "active_downloaded": active_downloaded + }) + + logger.info(f"刷流任务统计数据,总任务数:{total_count},活跃任务数:{active_count},已删除:{total_deleted}," + f"待归档:{total_unarchived}," + f"活跃上传量:{StringUtils.str_filesize(active_uploaded)}," + f"活跃下载量:{StringUtils.str_filesize(active_downloaded)}," + f"总上传量:{StringUtils.str_filesize(total_uploaded)}," + f"总下载量:{StringUtils.str_filesize(total_downloaded)}") + + self.save_data("statistic", statistic_info) + self.save_data("torrents", torrent_tasks) + + def __get_brush_config(self, sitename: str = None) -> BrushConfig: + """ + 获取BrushConfig + """ + return self._brush_config if not sitename else self._brush_config.get_site_config(sitename=sitename) + + def __validate_and_fix_config(self, config: dict = None) -> bool: + """ + 检查并修正配置值 + """ + if config is None: + logger.error("配置为None,无法验证和修正") + return False + + # 设置一个标志,用于跟踪是否发现校验错误 + found_error = False + + config_number_attr_to_desc = { + "disksize": "保种体积", + "maxupspeed": "总上传带宽", + "maxdlspeed": "总下载带宽", + "maxdlcount": "同时下载任务数", + "seed_time": "做种时间", + "hr_seed_time": "H&R做种时间", + "seed_ratio": "分享率", + "seed_size": "上传量", + "download_time": "下载超时时间", + "seed_avgspeed": "平均上传速度", + "seed_inactivetime": "未活动时间", + "up_speed": "单任务上传限速", + "dl_speed": "单任务下载限速", + "auto_archive_days": "自动清理记录天数" + } + + config_range_number_attr_to_desc = { + "pubtime": "发布时间", + "size": "种子大小", + "seeder": "做种人数", + "delete_size_range": "动态删种阈值" + } + + for attr, desc in config_number_attr_to_desc.items(): + value = config.get(attr) + if value and not self.__is_number(value): + self.__log_and_notify_error(f"站点刷流任务出错,{desc}设置错误:{value}") + config[attr] = None + found_error = True # 更新错误标志 + + for attr, desc in config_range_number_attr_to_desc.items(): + value = config.get(attr) + # 检查 value 是否存在且是否符合数字或数字-数字的模式 + if value and not self.__is_number_or_range(str(value)): + self.__log_and_notify_error(f"站点刷流任务出错,{desc}设置错误:{value}") + config[attr] = None + found_error = True # 更新错误标志 + + active_time_range = config.get("active_time_range") + if active_time_range and not self.__is_valid_time_range(time_range=active_time_range): + self.__log_and_notify_error(f"站点刷流任务出错,开启时间段设置错误:{active_time_range}") + config["active_time_range"] = None + found_error = True # 更新错误标志 + + # 如果发现任何错误,返回False;否则返回True + return not found_error + + def __update_config(self, brush_config: BrushConfig = None): + """ + 根据传入的BrushConfig实例更新配置 + """ + if brush_config is None: + brush_config = self._brush_config + + if brush_config is None: + return + + # 创建一个将配置属性名称映射到BrushConfig属性值的字典 + config_mapping = { + "onlyonce": brush_config.onlyonce, + "enabled": brush_config.enabled, + "notify": brush_config.notify, + "brushsites": brush_config.brushsites, + "downloader": brush_config.downloader, + "disksize": brush_config.disksize, + "freeleech": brush_config.freeleech, + "hr": brush_config.hr, + "maxupspeed": brush_config.maxupspeed, + "maxdlspeed": brush_config.maxdlspeed, + "maxdlcount": brush_config.maxdlcount, + "include": brush_config.include, + "exclude": brush_config.exclude, + "size": brush_config.size, + "seeder": brush_config.seeder, + "pubtime": brush_config.pubtime, + "seed_time": brush_config.seed_time, + "hr_seed_time": brush_config.hr_seed_time, + "seed_ratio": brush_config.seed_ratio, + "seed_size": brush_config.seed_size, + "download_time": brush_config.download_time, + "seed_avgspeed": brush_config.seed_avgspeed, + "seed_inactivetime": brush_config.seed_inactivetime, + "delete_size_range": brush_config.delete_size_range, + "up_speed": brush_config.up_speed, + "dl_speed": brush_config.dl_speed, + "auto_archive_days": brush_config.auto_archive_days, + "save_path": brush_config.save_path, + "clear_task": brush_config.clear_task, + "delete_except_tags": brush_config.delete_except_tags, + "except_subscribe": brush_config.except_subscribe, + "brush_sequential": brush_config.brush_sequential, + "proxy_delete": brush_config.proxy_delete, + "active_time_range": brush_config.active_time_range, + "qb_category": brush_config.qb_category, + "enable_site_config": brush_config.enable_site_config, + "site_config": brush_config.site_config, + "_tabs": self._tabs + } + + # 使用update_config方法或其等效方法更新配置 + self.update_config(config_mapping) + + @staticmethod + def __get_redict_url(url: str, proxies: str = None, ua: str = None, cookie: str = None) -> Optional[str]: + """ + 获取下载链接, url格式:[base64]url + """ + # 获取[]中的内容 + m = re.search(r"\[(.*)](.*)", url) + if m: + # 参数 + base64_str = m.group(1) + # URL + url = m.group(2) + if not base64_str: + return url + # 解码参数 + req_str = base64.b64decode(base64_str.encode('utf-8')).decode('utf-8') + req_params: Dict[str, dict] = json.loads(req_str) + # 是否使用cookie + if not req_params.get('cookie'): + cookie = None + # 请求头 + if req_params.get('header'): + headers = req_params.get('header') + else: + headers = None + if req_params.get('method') == 'get': + # GET请求 + res = RequestUtils( + ua=ua, + proxies=proxies, + cookies=cookie, + headers=headers + ).get_res(url, params=req_params.get('params')) + else: + # POST请求 + res = RequestUtils( + ua=ua, + proxies=proxies, + cookies=cookie, + headers=headers + ).post_res(url, params=req_params.get('params')) + if not res: + return None + if not req_params.get('result'): + return res.text + else: + data = res.json() + for key in str(req_params.get('result')).split("."): + data = data.get(key) + if not data: + return None + logger.debug(f"获取到下载地址:{data}") + return data + return None + + def __reset_download_url(self, torrent_url, site_id) -> str: + """ + 处理下载地址 + """ + try: + # 检查 torrent_url 是否为有效的下载 URL,并且 site 是 NexusPHP + if not torrent_url or torrent_url.startswith("magnet"): + return torrent_url + + indexers = self.sites_helper.get_indexers() + if not indexers: + return torrent_url + + unsupported_sites = {"天空"} + site = next((item for item in indexers if item.get("id") == site_id), None) + if site.get("name") in unsupported_sites or not site.get("schema", "").startswith("Nexus"): + return torrent_url + + # 解析 URL + parsed_url = urlparse(torrent_url) + + # 如果 URL 中已有查询参数,使用 urlencode 进行拼接 + query_params = dict(parse_qsl(parsed_url.query)) + query_params["letdown"] = "1" + + # 重新构造带有新参数的 URL + new_query = urlencode(query_params) + new_url = str(urlunparse(parsed_url._replace(query=new_query))) + return new_url + except Exception as e: + logger.error(f"Error while resetting downloader URL for torrent: {torrent_url}. Error: {str(e)}") + return torrent_url + + def __download(self, torrent: TorrentInfo) -> Optional[str]: + """ + 添加下载任务 + """ + if not torrent.enclosure: + logger.error(f"获取下载链接失败:{torrent.title}") + return None + + brush_config = self.__get_brush_config(torrent.site_name) + + # 上传限速 + up_speed = int(brush_config.up_speed) if brush_config.up_speed else None + # 下载限速 + down_speed = int(brush_config.dl_speed) if brush_config.dl_speed else None + # 保存地址 + download_dir = brush_config.save_path or None + # 获取下载链接 + torrent_content = torrent.enclosure + # proxies + proxies = settings.PROXY if torrent.site_proxy else None + # cookie + cookies = torrent.site_cookie + if torrent_content.startswith("["): + torrent_content = self.__get_redict_url(url=torrent_content, + proxies=proxies, + ua=torrent.site_ua, + cookie=cookies) + # 目前馒头请求实际种子时,不能传入Cookie + cookies = None + if not torrent_content: + logger.error(f"获取下载链接失败:{torrent.title}") + return None + + if brush_config.site_skip_tips: + torrent_content = self.__reset_download_url(torrent_url=torrent_content, site_id=torrent.site) + logger.debug(f"站点 {torrent.site_name} 已启用自动跳过提示,种子下载地址更新为 {torrent_content}") + + downloader = self.downloader + if not downloader: + return None + + if self.downloader_helper.is_downloader("qbittorrent", service=self.service_info): + # 限速值转为bytes + up_speed = up_speed * 1024 if up_speed else None + down_speed = down_speed * 1024 if down_speed else None + # 生成随机Tag + tag = StringUtils.generate_random_str(10) + # 如果开启代理下载以及种子地址不是磁力地址,则请求种子到内存再传入下载器 + if not torrent_content.startswith("magnet"): + response = RequestUtils(cookies=cookies, + proxies=proxies, + ua=torrent.site_ua).get_res(url=torrent_content) + if response and response.ok: + torrent_content = response.content + else: + logger.error("尝试通过MP下载种子失败,继续尝试传递种子地址到下载器进行下载") + if torrent_content: + state = downloader.add_torrent(content=torrent_content, + download_dir=download_dir, + cookie=cookies, + category=brush_config.qb_category, + tag=["已整理", brush_config.brush_tag, tag], + upload_limit=up_speed, + download_limit=down_speed) + if not state: + return None + else: + # 获取种子Hash + torrent_hash = downloader.get_torrent_id_by_tag(tags=tag) + if not torrent_hash: + logger.error(f"{brush_config.downloader} 获取种子Hash失败,详细信息请查看 README") + return None + return torrent_hash + return None + + elif self.downloader_helper.is_downloader("transmission", service=self.service_info): + # 如果开启代理下载以及种子地址不是磁力地址,则请求种子到内存再传入下载器 + if not torrent_content.startswith("magnet"): + response = RequestUtils(cookies=cookies, + proxies=proxies, + ua=torrent.site_ua).get_res(url=torrent_content) + if response and response.ok: + torrent_content = response.content + else: + logger.error("尝试通过MP下载种子失败,继续尝试传递种子地址到下载器进行下载") + if torrent_content: + torrent = downloader.add_torrent(content=torrent_content, + download_dir=download_dir, + cookie=cookies, + labels=["已整理", brush_config.brush_tag]) + if not torrent: + return None + else: + if brush_config.up_speed or brush_config.dl_speed: + downloader.change_torrent(hash_string=torrent.hashString, + upload_limit=up_speed, + download_limit=down_speed) + return torrent.hashString + return None + + def __qb_torrents_reannounce(self, torrent_hashes: List[str]): + """强制重新汇报""" + downloader = self.downloader + if not downloader: + return + + if not downloader.qbc: + return + + if not torrent_hashes: + return + + try: + # 重新汇报 + downloader.qbc.torrents_reannounce(torrent_hashes=torrent_hashes) + except Exception as err: + logger.error(f"强制重新汇报失败:{str(err)}") + + def __get_hash(self, torrent: Any): + """ + 获取种子hash + """ + try: + return torrent.get("hash") if self.downloader_helper.is_downloader("qbittorrent", service=self.service_info) \ + else torrent.hashString + except Exception as e: + print(str(e)) + return "" + + def __get_all_hashes(self, torrents): + """ + 获取torrents列表中所有种子的Hash值 + + :param torrents: 包含种子信息的列表 + :return: 包含所有Hash值的列表 + """ + try: + all_hashes = [] + for torrent in torrents: + # 根据下载器类型获取Hash值 + hash_value = torrent.get("hash") if self.downloader_helper.is_downloader("qbittorrent", + service=self.service_info) \ + else torrent.hashString + if hash_value: + all_hashes.append(hash_value) + return all_hashes + except Exception as e: + print(str(e)) + return [] + + def __get_label(self, torrent: Any): + """ + 获取种子标签 + """ + try: + return [str(tag).strip() for tag in torrent.get("tags").split(',')] \ + if self.downloader_helper.is_downloader("qbittorrent", + service=self.service_info) else torrent.labels or [] + except Exception as e: + print(str(e)) + return [] + + def __get_torrent_info(self, torrent: Any) -> dict: + """ + 获取种子信息 + """ + date_now = int(time.time()) + # QB + if self.downloader_helper.is_downloader("qbittorrent", service=self.service_info): + """ + { + "added_on": 1693359031, + "amount_left": 0, + "auto_tmm": false, + "availability": -1, + "category": "tJU", + "completed": 67759229411, + "completion_on": 1693609350, + "content_path": "/mnt/sdb/qb/downloads/Steel.Division.2.Men.of.Steel-RUNE", + "dl_limit": -1, + "dlspeed": 0, + "download_path": "", + "downloaded": 67767365851, + "downloaded_session": 0, + "eta": 8640000, + "f_l_piece_prio": false, + "force_start": false, + "hash": "116bc6f3efa6f3b21a06ce8f1cc71875", + "infohash_v1": "116bc6f306c40e072bde8f1cc71875", + "infohash_v2": "", + "last_activity": 1693609350, + "magnet_uri": "magnet:?xt=", + "max_ratio": -1, + "max_seeding_time": -1, + "name": "Steel.Division.2.Men.of.Steel-RUNE", + "num_complete": 1, + "num_incomplete": 0, + "num_leechs": 0, + "num_seeds": 0, + "priority": 0, + "progress": 1, + "ratio": 0, + "ratio_limit": -2, + "save_path": "/mnt/sdb/qb/downloads", + "seeding_time": 615035, + "seeding_time_limit": -2, + "seen_complete": 1693609350, + "seq_dl": false, + "size": 67759229411, + "state": "stalledUP", + "super_seeding": false, + "tags": "", + "time_active": 865354, + "total_size": 67759229411, + "tracker": "https://tracker", + "trackers_count": 2, + "up_limit": -1, + "uploaded": 0, + "uploaded_session": 0, + "upspeed": 0 + } + """ + # ID + torrent_id = torrent.get("hash") + # 标题 + torrent_title = torrent.get("name") + # 下载时间 + if (not torrent.get("added_on") + or torrent.get("added_on") < 0): + dltime = 0 + else: + dltime = date_now - torrent.get("added_on") + # 做种时间 + if (not torrent.get("completion_on") + or torrent.get("completion_on") < 0): + seeding_time = 0 + else: + seeding_time = date_now - torrent.get("completion_on") + # 分享率 + ratio = torrent.get("ratio") or 0 + # 上传量 + uploaded = torrent.get("uploaded") or 0 + # 平均上传速度 Byte/s + if dltime: + avg_upspeed = int(uploaded / dltime) + else: + avg_upspeed = uploaded + # 已未活动 秒 + if (not torrent.get("last_activity") + or torrent.get("last_activity") < 0): + iatime = 0 + else: + iatime = date_now - torrent.get("last_activity") + # 下载量 + downloaded = torrent.get("downloaded") + # 种子大小 + total_size = torrent.get("total_size") + # 添加时间 + add_on = (torrent.get("added_on") or 0) + add_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(add_on)) + # 种子标签 + tags = torrent.get("tags") + # tracker + tracker = torrent.get("tracker") + # TR + else: + # ID + torrent_id = torrent.hashString + # 标题 + torrent_title = torrent.name + # 做种时间 + if (not torrent.date_done + or torrent.date_done.timestamp() < 1): + seeding_time = 0 + else: + seeding_time = date_now - int(torrent.date_done.timestamp()) + # 下载耗时 + if (not torrent.date_added + or torrent.date_added.timestamp() < 1): + dltime = 0 + else: + dltime = date_now - int(torrent.date_added.timestamp()) + # 下载量 + downloaded = int(torrent.total_size * torrent.progress / 100) + # 分享率 + ratio = torrent.ratio or 0 + # 上传量 + uploaded = int(downloaded * torrent.ratio) + # 平均上传速度 + if dltime: + avg_upspeed = int(uploaded / dltime) + else: + avg_upspeed = uploaded + # 未活动时间 + if (not torrent.date_active + or torrent.date_active.timestamp() < 1): + iatime = 0 + else: + iatime = date_now - int(torrent.date_active.timestamp()) + # 种子大小 + total_size = torrent.total_size + # 添加时间 + add_on = (torrent.date_added.timestamp() if torrent.date_added else 0) + add_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(add_on)) + # 种子标签 + tags = torrent.get("tags") + # tracker + tracker = torrent.get("tracker") + + return { + "hash": torrent_id, + "title": torrent_title, + "seeding_time": seeding_time, + "ratio": ratio, + "uploaded": uploaded, + "downloaded": downloaded, + "avg_upspeed": avg_upspeed, + "iatime": iatime, + "dltime": dltime, + "total_size": total_size, + "add_time": add_time, + "add_on": add_on, + "tags": tags, + "tracker": tracker + } + + def __log_and_notify_error(self, message): + """ + 记录错误日志并发送系统通知 + """ + logger.error(message) + self.systemmessage.put(message, title="站点刷流") + + def __send_delete_message(self, site_name: str, torrent_title: str, torrent_desc: str, reason: str, + title: str = "【刷流任务种子删除】"): + """ + 发送删除种子的消息 + """ + brush_config = self.__get_brush_config() + if not brush_config.notify: + return + msg_text = "" + if site_name: + msg_text = f"站点:{site_name}" + if torrent_title: + msg_text = f"{msg_text}\n标题:{torrent_title}" + if torrent_desc: + msg_text = f"{msg_text}\n内容:{torrent_desc}" + if reason: + msg_text = f"{msg_text}\n原因:{reason}" + + self.post_message(mtype=NotificationType.SiteMessage, title=title, text=msg_text) + + @staticmethod + def __build_add_message_text(torrent): + """ + 构建消息文本,兼容TorrentInfo对象和torrent_task字典 + """ + + # 定义一个辅助函数来统一获取数据的方式 + def get_data(_key, default=None): + if isinstance(torrent, dict): + return torrent.get(_key, default) + else: + return getattr(torrent, _key, default) + + # 构造消息文本,确保使用中文标签 + msg_parts = [] + label_mapping = { + "site_name": "站点", + "title": "标题", + "description": "内容", + "size": "大小", + "pubdate": "发布时间", + "seeders": "做种数", + "volume_factor": "促销", + "hit_and_run": "Hit&Run" + } + for key in label_mapping: + value = get_data(key) + if key == "size" and value and str(value).replace(".", "", 1).isdigit(): + value = StringUtils.str_filesize(value) + if value: + msg_parts.append(f"{label_mapping[key]}:{'是' if key == 'hit_and_run' and value else value}") + + return "\n".join(msg_parts) + + def __send_add_message(self, torrent, title: str = "【刷流任务种子下载】"): + """ + 发送添加下载的消息 + """ + brush_config = self.__get_brush_config() + if not brush_config.notify: + return + + # 使用辅助方法构建消息文本 + msg_text = self.__build_add_message_text(torrent) + self.post_message(mtype=NotificationType.SiteMessage, title=title, text=msg_text) + + def __send_message(self, title: str, text: str): + """ + 发送消息 + """ + brush_config = self.__get_brush_config() + if not brush_config.notify: + return + + self.post_message(mtype=NotificationType.SiteMessage, title=title, text=text) + + def __log_and_send_torrent_task_update_message(self, title: str, status: str, reason: str, + torrent_tasks: List[dict]): + """ + 记录和发送刷流任务更新消息 + """ + if torrent_tasks: + sites_names = ', '.join({task.get("site_name", "N/A") for task in torrent_tasks}) + first_title = torrent_tasks[0].get('title', 'N/A') + count = len(torrent_tasks) + msg = f"站点:{sites_names}\n内容:{first_title} 等 {count} 个种子已经{status}\n原因:{reason}" + logger.info(f"{title},{msg}") + self.__send_message(title=title, text=msg) + + def __get_torrents_size(self) -> int: + """ + 获取任务中的种子总大小 + """ + # 读取种子记录 + task_info = self.get_data("torrents") or {} + if not task_info: + return 0 + total_size = sum([task.get("size") or 0 for task in task_info.values()]) + return total_size + + def __get_downloader_info(self) -> schemas.DownloaderInfo: + """ + 获取下载器实时信息(所有下载器) + """ + ret_info = schemas.DownloaderInfo() + + downloader = self.downloader + if not downloader: + return ret_info + + transfer_infos = self.chain.run_module("downloader_info") + if transfer_infos: + for transfer_info in transfer_infos: + ret_info.download_speed += transfer_info.download_speed + ret_info.upload_speed += transfer_info.upload_speed + ret_info.download_size += transfer_info.download_size + ret_info.upload_size += transfer_info.upload_size + + return ret_info + + def __get_downloading_count(self) -> int: + """ + 获取正在下载的任务数量 + """ + try: + brush_config = self.__get_brush_config() + downloader = self.downloader + if not downloader: + return 0 + + torrents = downloader.get_downloading_torrents(tags=brush_config.brush_tag) + if torrents is None: + logger.warning("获取下载数量失败,可能是下载器连接发生异常") + return 0 + + return len(torrents) + except Exception as e: + logger.error(f"获取下载数量发生异常: {e}") + return 0 + + @staticmethod + def __get_pubminutes(pubdate: str) -> float: + """ + 将字符串转换为时间,并计算与当前时间差)(分钟) + """ + try: + if not pubdate: + return 0 + pubdate = pubdate.replace("T", " ").replace("Z", "") + pubdate = datetime.strptime(pubdate, "%Y-%m-%d %H:%M:%S") + now = datetime.now() + return (now - pubdate).total_seconds() // 60 + except Exception as e: + logger.error(f"发布时间 {pubdate} 获取分钟失败,错误详情: {e}") + return 0 + + @staticmethod + def __adjust_site_pubminutes(pub_minutes: float, torrent: TorrentInfo) -> float: + """ + 处理部分站点的时区逻辑 + """ + try: + if not torrent: + return pub_minutes + + if torrent.site_name == "我堡": + # 获取当前时区的UTC偏移量(以秒为单位) + utc_offset_seconds = time.timezone + + # 将UTC偏移量转换为分钟 + utc_offset_minutes = utc_offset_seconds / 60 + + # 增加UTC偏移量到pub_minutes + adjusted_pub_minutes = pub_minutes + utc_offset_minutes + + return adjusted_pub_minutes + + return pub_minutes + except Exception as e: + logger.error(str(e)) + return 0 + + def __filter_torrents_by_tag(self, torrents: List[Any], exclude_tag: str) -> List[Any]: + """ + 根据标签过滤torrents,排除标签格式为逗号分隔的字符串,例如 "MOVIEPILOT, H&R" + """ + # 如果排除标签字符串为空,则返回原始列表 + if not exclude_tag: + return torrents + + # 将 exclude_tag 字符串分割成一个集合,并去除每个标签两端的空白,忽略空白标签并自动去重 + exclude_tags = set(tag.strip() for tag in exclude_tag.split(',') if tag.strip()) + + filter_torrents = [] + for torrent in torrents: + # 使用 __get_label 方法获取每个 torrent 的标签列表 + labels = self.__get_label(torrent) + # 检查是否有任何一个排除标签存在于标签列表中 + if not any(exclude in labels for exclude in exclude_tags): + filter_torrents.append(torrent) + return filter_torrents + + def __get_subscribe_titles(self) -> Set[str]: + """ + 获取当前订阅的所有标题,返回一个不包含None和空白字符的集合 + """ + brush_config = self.__get_brush_config() + if not brush_config.except_subscribe: + logger.info("没有开启排除订阅,取消订阅标题匹配") + return set() + + logger.info("已开启排除订阅,正在准备订阅标题匹配 ...") + + if not self._subscribe_infos: + self._subscribe_infos = {} + + subscribes = self.subscribe_oper.list() + if subscribes: + # 遍历订阅 + for subscribe in subscribes: + # 判断当前订阅是否已经在缓存中,如果已经处理过,那么这里直接跳过 + subscribe_key = f"{subscribe.id}_{subscribe.name}" + if subscribe_key in self._subscribe_infos: + continue + + subscribe_titles = [subscribe.name] + try: + # 生成元数据 + meta = MetaInfo(subscribe.name) + meta.year = subscribe.year + meta.begin_season = subscribe.season or None + meta.type = MediaType(subscribe.type) + # 识别媒体信息 + mediainfo: MediaInfo = self.chain.recognize_media(meta=meta, mtype=meta.type, + tmdbid=subscribe.tmdbid, + doubanid=subscribe.doubanid, + cache=True) + if mediainfo: + logger.info(f"订阅 {subscribe.name} 已识别到媒体信息") + logger.debug(f"subscribe {subscribe.name} {mediainfo.to_dict()}") + subscribe_titles.extend(mediainfo.names) + subscribe_titles = [title.strip() for title in subscribe_titles if title and title.strip()] + self._subscribe_infos[subscribe_key] = subscribe_titles + else: + logger.info(f"订阅 {subscribe.name} 没有识别到媒体信息,跳过订阅标题匹配") + except Exception as e: + logger.error(f"识别订阅 {subscribe.name} 媒体信息失败,错误详情: {e}") + + # 移除不再存在的订阅 + current_keys = {f"{subscribe.id}_{subscribe.name}" for subscribe in subscribes} + for key in set(self._subscribe_infos) - current_keys: + del self._subscribe_infos[key] + + logger.info("订阅标题匹配完成") + logger.debug(f"当前订阅的标题集合为:{self._subscribe_infos}") + unique_titles = {title for titles in self._subscribe_infos.values() for title in titles} + return unique_titles + + @staticmethod + def __filter_torrents_contains_subscribe(torrents: Any, subscribe_titles: Set[str]): + # 初始化两个列表,一个用于收集未被排除的种子,一个用于记录被排除的种子 + included_torrents = [] + excluded_torrents = [] + + # 单次遍历处理 + for torrent in torrents: + # 确保title和description至少是空字符串 + title = torrent.title or '' + description = torrent.description or '' + + if any(subscribe_title in title or subscribe_title in description for subscribe_title in subscribe_titles): + # 如果种子的标题或描述包含订阅标题中的任一项,则记录为被排除 + excluded_torrents.append(torrent) + logger.info(f"命中订阅内容,排除种子:{title}|{description}") + else: + # 否则,收集为未被排除的种子 + included_torrents.append(torrent) + + if not excluded_torrents: + logger.info(f"没有命中订阅内容,不需要排除种子") + + # 返回未被排除的种子列表 + return included_torrents + + @staticmethod + def __bytes_to_gb(size_in_bytes: float) -> float: + """ + 将字节单位的大小转换为千兆字节(GB)。 + + :param size_in_bytes: 文件大小,单位为字节。 + :return: 文件大小,单位为千兆字节(GB)。 + """ + if not size_in_bytes: + return 0.0 + return size_in_bytes / (1024 ** 3) + + @staticmethod + def __is_number_or_range(value): + """ + 检查字符串是否表示单个数字或数字范围(如'5', '5.5', '5-10' 或 '5.5-10.2') + """ + return bool(re.match(r"^\d+(\.\d+)?(-\d+(\.\d+)?)?$", value)) + + @staticmethod + def __is_number(value): + """ + 检查给定的值是否可以被转换为数字(整数或浮点数) + """ + try: + float(value) + return True + except ValueError: + return False + + @staticmethod + def __calculate_seeding_torrents_size(torrent_tasks: Dict[str, dict]) -> float: + """ + 计算保种种子体积 + """ + return sum(task.get("size", 0) for task in torrent_tasks.values() if not task.get("deleted", False)) + + def __auto_archive_tasks(self, torrent_tasks: Dict[str, dict]) -> None: + """ + 自动归档已经删除的种子数据 + """ + if not self._brush_config.auto_archive_days or self._brush_config.auto_archive_days <= 0: + logger.info("自动归档记录天数小于等于0,取消自动归档") + return + + # 用于存储已删除的数据 + archived_tasks: Dict[str, dict] = self.get_data("archived") or {} + + current_time = time.time() + archive_threshold_seconds = self._brush_config.auto_archive_days * 86400 # 将天数转换为秒数 + + # 准备一个列表,记录所有需要从原始数据中删除的键 + keys_to_delete = set() + + # 遍历所有 torrent 条目 + for key, value in torrent_tasks.items(): + deleted_time = value.get("deleted_time") + # 场景 1: 检查任务是否已被标记为删除且超出保留天数 + if (value.get("deleted") and isinstance(deleted_time, (int, float)) and + current_time - deleted_time > archive_threshold_seconds): + keys_to_delete.add(key) + archived_tasks[key] = value + continue + + # 场景 2: 检查没有明确删除时间的历史数据 + if value.get("deleted") and deleted_time is None: + keys_to_delete.add(key) + archived_tasks[key] = value + continue + + # 从原始字典中移除已删除的条目 + for key in keys_to_delete: + del torrent_tasks[key] + + self.save_data("archived", archived_tasks) + + def __clear_tasks(self): + """ + 清除统计数据 + 彻底重置所有刷流数据,如当前还存在正在做种的刷流任务,待定时检查任务执行后,会自动纳入刷流管理 + """ + self.save_data("torrents", {}) + self.save_data("archived", {}) + self.save_data("unmanaged", {}) + self.save_data("statistic", {}) + + def __get_statistic_info(self) -> Dict[str, int]: + """ + 获取统计数据 + """ + statistic_info = self.get_data("statistic") or { + "count": 0, + "deleted": 0, + "uploaded": 0, + "downloaded": 0, + "unarchived": 0, + "active": 0, + "active_uploaded": 0, + "active_downloaded": 0 + } + return statistic_info + + @staticmethod + def __is_valid_time_range(time_range: str) -> bool: + """检查时间范围字符串是否有效:格式为"HH:MM-HH:MM",且时间有效""" + if not time_range: + return False + + # 使用正则表达式匹配格式 + pattern = re.compile(r'^\d{2}:\d{2}-\d{2}:\d{2}$') + if not pattern.match(time_range): + return False + + try: + start_str, end_str = time_range.split('-') + datetime.strptime(start_str, '%H:%M').time() + datetime.strptime(end_str, '%H:%M').time() + except Exception as e: + print(str(e)) + return False + + return True + + def __is_current_time_in_range(self) -> bool: + """判断当前时间是否在开启时间区间内""" + + brush_config = self.__get_brush_config() + active_time_range = brush_config.active_time_range + + if not self.__is_valid_time_range(active_time_range): + # 如果时间范围格式不正确或不存在,说明当前没有开启时间段,返回True + return True + + start_str, end_str = active_time_range.split('-') + start_time = datetime.strptime(start_str, '%H:%M').time() + end_time = datetime.strptime(end_str, '%H:%M').time() + now = datetime.now().time() + + if start_time <= end_time: + # 情况1: 时间段不跨越午夜 + return start_time <= now <= end_time + else: + # 情况2: 时间段跨越午夜 + return now >= start_time or now <= end_time + + def __get_site_by_torrent(self, torrent: Any) -> Tuple[int, str]: + """ + 根据tracker获取站点信息 + """ + trackers = [] + try: + tracker_url = torrent.get("tracker") + if tracker_url: + trackers.append(tracker_url) + + magnet_link = torrent.get("magnet_uri") + if magnet_link: + query_params: dict = parse_qs(urlparse(magnet_link).query) + encoded_tracker_urls = query_params.get('tr', []) + # 解码tracker URLs然后扩展到trackers列表中 + decoded_tracker_urls = [unquote(url) for url in encoded_tracker_urls] + trackers.extend(decoded_tracker_urls) + except Exception as e: + logger.error(e) + + domain = "未知" + if not trackers: + return 0, domain + + # 特定tracker到域名的映射 + tracker_mappings = { + "chdbits.xyz": "ptchdbits.co", + "agsvpt.trackers.work": "agsvpt.com", + "tracker.cinefiles.info": "audiences.me", + } + + for tracker in trackers: + if not tracker: + continue + # 检查tracker是否包含特定的关键字,并进行相应的映射 + for key, mapped_domain in tracker_mappings.items(): + if key in tracker: + domain = mapped_domain + break + else: + # 使用StringUtils工具类获取tracker的域名 + domain = StringUtils.get_url_domain(tracker) + + site_info = self.sites_helper.get_indexer(domain) + if site_info: + return site_info.get("id"), site_info.get("name") + + # 当找不到对应的站点信息时,返回一个默认值 + return 0, domain diff --git a/plugins.v2/chatgpt/__init__.py b/plugins.v2/chatgpt/__init__.py new file mode 100644 index 0000000..bf64853 --- /dev/null +++ b/plugins.v2/chatgpt/__init__.py @@ -0,0 +1,263 @@ +from typing import Any, List, Dict, Tuple + +from app.core.config import settings +from app.core.event import eventmanager, Event +from app.log import logger +from app.plugins import _PluginBase +from app.plugins.chatgpt.openai import OpenAi +from app.schemas.types import EventType, ChainEventType + + +class ChatGPT(_PluginBase): + # 插件名称 + plugin_name = "ChatGPT" + # 插件描述 + plugin_desc = "消息交互支持与ChatGPT对话。" + # 插件图标 + plugin_icon = "Chatgpt_A.png" + # 插件版本 + plugin_version = "2.0.1" + # 插件作者 + plugin_author = "jxxghp" + # 作者主页 + author_url = "https://github.com/jxxghp" + # 插件配置项ID前缀 + plugin_config_prefix = "chatgpt_" + # 加载顺序 + plugin_order = 15 + # 可使用的用户级别 + auth_level = 1 + + # 私有属性 + openai = None + _enabled = False + _proxy = False + _recognize = False + _openai_url = None + _openai_key = None + _model = None + + def init_plugin(self, config: dict = None): + if config: + self._enabled = config.get("enabled") + self._proxy = config.get("proxy") + self._recognize = config.get("recognize") + self._openai_url = config.get("openai_url") + self._openai_key = config.get("openai_key") + self._model = config.get("model") + if self._openai_url and self._openai_key: + self.openai = OpenAi(api_key=self._openai_key, api_url=self._openai_url, + proxy=settings.PROXY if self._proxy else None, + model=self._model) + + def get_state(self) -> bool: + return self._enabled + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + pass + + def get_api(self) -> List[Dict[str, Any]]: + pass + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'proxy', + 'label': '使用代理服务器', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'recognize', + 'label': '辅助识别', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'openai_url', + 'label': 'OpenAI API Url', + 'placeholder': 'https://api.openai.com', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'openai_key', + 'label': 'sk-xxx' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'model', + 'label': '自定义模型', + 'placeholder': 'gpt-3.5-turbo', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '开启插件后,消息交互时使用请[问帮你]开头,或者以?号结尾,或者超过10个汉字/单词,则会触发ChatGPT回复。' + '开启辅助识别后,内置识别功能无法正常识别种子/文件名称时,将使用ChatGTP进行AI辅助识别,可以提升动漫等非规范命名的识别成功率。' + } + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "proxy": False, + "recognize": False, + "openai_url": "https://api.openai.com", + "openai_key": "", + "model": "gpt-3.5-turbo" + } + + def get_page(self) -> List[dict]: + pass + + @eventmanager.register(EventType.UserMessage) + def talk(self, event: Event): + """ + 监听用户消息,获取ChatGPT回复 + """ + if not self._enabled: + return + if not self.openai: + return + text = event.event_data.get("text") + userid = event.event_data.get("userid") + channel = event.event_data.get("channel") + if not text: + return + response = self.openai.get_response(text=text, userid=userid) + if response: + self.post_message(channel=channel, title=response, userid=userid) + + @eventmanager.register(ChainEventType.NameRecognize) + def recognize(self, event: Event): + """ + 监听识别事件,使用ChatGPT辅助识别名称 + """ + if not self._recognize: + return + if not event.event_data: + return + title = event.event_data.get("title") + if not title: + return + # 调用ChatGPT + response = self.openai.get_media_name(filename=title) + logger.info(f"ChatGPT返回结果:{response}") + if response: + event.event_data = { + 'title': title, + 'name': response.get("title"), + 'year': response.get("year"), + 'season': response.get("season"), + 'episode': response.get("episode") + } + else: + event.event_data = {} + + def stop_service(self): + """ + 退出插件 + """ + pass diff --git a/plugins.v2/chatgpt/openai.py b/plugins.v2/chatgpt/openai.py new file mode 100644 index 0000000..937ecea --- /dev/null +++ b/plugins.v2/chatgpt/openai.py @@ -0,0 +1,206 @@ +import json +import time +from typing import List, Union + +import openai +from cacheout import Cache + +OpenAISessionCache = Cache(maxsize=100, ttl=3600, timer=time.time, default=None) + + +class OpenAi: + _api_key: str = None + _api_url: str = None + _model: str = "gpt-3.5-turbo" + + def __init__(self, api_key: str = None, api_url: str = None, proxy: dict = None, model: str = None): + self._api_key = api_key + self._api_url = api_url + openai.api_base = self._api_url + "/v1" + openai.api_key = self._api_key + if proxy and proxy.get("https"): + openai.proxy = proxy.get("https") + if model: + self._model = model + + def get_state(self) -> bool: + return True if self._api_key else False + + @staticmethod + def __save_session(session_id: str, message: str): + """ + 保存会话 + :param session_id: 会话ID + :param message: 消息 + :return: + """ + seasion = OpenAISessionCache.get(session_id) + if seasion: + seasion.append({ + "role": "assistant", + "content": message + }) + OpenAISessionCache.set(session_id, seasion) + + @staticmethod + def __get_session(session_id: str, message: str) -> List[dict]: + """ + 获取会话 + :param session_id: 会话ID + :return: 会话上下文 + """ + seasion = OpenAISessionCache.get(session_id) + if seasion: + seasion.append({ + "role": "user", + "content": message + }) + else: + seasion = [ + { + "role": "system", + "content": "请在接下来的对话中请使用中文回复,并且内容尽可能详细。" + }, + { + "role": "user", + "content": message + }] + OpenAISessionCache.set(session_id, seasion) + return seasion + + def __get_model(self, message: Union[str, List[dict]], + prompt: str = None, + user: str = "MoviePilot", + **kwargs): + """ + 获取模型 + """ + if not isinstance(message, list): + if prompt: + message = [ + { + "role": "system", + "content": prompt + }, + { + "role": "user", + "content": message + } + ] + else: + message = [ + { + "role": "user", + "content": message + } + ] + return openai.ChatCompletion.create( + model=self._model, + user=user, + messages=message, + **kwargs + ) + + @staticmethod + def __clear_session(session_id: str): + """ + 清除会话 + :param session_id: 会话ID + :return: + """ + if OpenAISessionCache.get(session_id): + OpenAISessionCache.delete(session_id) + + def get_media_name(self, filename: str): + """ + 从文件名中提取媒体名称等要素 + :param filename: 文件名 + :return: Json + """ + if not self.get_state(): + return None + result = "" + try: + _filename_prompt = "I will give you a movie/tvshow file name.You need to return a Json." \ + "\nPay attention to the correct identification of the film name." \ + "\n{\"title\":string,\"version\":string,\"part\":string,\"year\":string,\"resolution\":string,\"season\":number|null,\"episode\":number|null}" + completion = self.__get_model(prompt=_filename_prompt, message=filename) + result = completion.choices[0].message.content + return json.loads(result) + except Exception as e: + print(f"{str(e)}:{result}") + return {} + + def get_response(self, text: str, userid: str): + """ + 聊天对话,获取答案 + :param text: 输入文本 + :param userid: 用户ID + :return: + """ + if not self.get_state(): + return "" + try: + if not userid: + return "用户信息错误" + else: + userid = str(userid) + if text == "#清除": + self.__clear_session(userid) + return "会话已清除" + # 获取历史上下文 + messages = self.__get_session(userid, text) + completion = self.__get_model(message=messages, user=userid) + result = completion.choices[0].message.content + if result: + self.__save_session(userid, text) + return result + except openai.error.RateLimitError as e: + return f"请求被ChatGPT拒绝了,{str(e)}" + except openai.error.APIConnectionError as e: + return f"ChatGPT网络连接失败:{str(e)}" + except openai.error.Timeout as e: + return f"没有接收到ChatGPT的返回消息:{str(e)}" + except Exception as e: + return f"请求ChatGPT出现错误:{str(e)}" + + def translate_to_zh(self, text: str): + """ + 翻译为中文 + :param text: 输入文本 + """ + if not self.get_state(): + return False, None + system_prompt = "You are a translation engine that can only translate text and cannot interpret it." + user_prompt = f"translate to zh-CN:\n\n{text}" + result = "" + try: + completion = self.__get_model(prompt=system_prompt, + message=user_prompt, + temperature=0, + top_p=1, + frequency_penalty=0, + presence_penalty=0) + result = completion.choices[0].message.content.strip() + return True, result + except Exception as e: + print(f"{str(e)}:{result}") + return False, str(e) + + def get_question_answer(self, question: str): + """ + 从给定问题和选项中获取正确答案 + :param question: 问题及选项 + :return: Json + """ + if not self.get_state(): + return None + result = "" + try: + _question_prompt = "下面我们来玩一个游戏,你是老师,我是学生,你需要回答我的问题,我会给你一个题目和几个选项,你的回复必须是给定选项中正确答案对应的序号,请直接回复数字" + completion = self.__get_model(prompt=_question_prompt, message=question) + result = completion.choices[0].message.content + return result + except Exception as e: + print(f"{str(e)}:{result}") + return {} diff --git a/plugins.v2/chinesesubfinder/__init__.py b/plugins.v2/chinesesubfinder/__init__.py new file mode 100644 index 0000000..eb80ff7 --- /dev/null +++ b/plugins.v2/chinesesubfinder/__init__.py @@ -0,0 +1,255 @@ +from functools import lru_cache +from pathlib import Path +from typing import List, Tuple, Dict, Any + +from app.core.config import settings +from app.core.context import MediaInfo +from app.core.event import eventmanager, Event +from app.log import logger +from app.plugins import _PluginBase +from app.schemas import TransferInfo, FileItem +from app.schemas.types import EventType, MediaType +from app.utils.http import RequestUtils +from app.utils.system import SystemUtils + + +class ChineseSubFinder(_PluginBase): + # 插件名称 + plugin_name = "ChineseSubFinder" + # 插件描述 + plugin_desc = "整理入库时通知ChineseSubFinder下载字幕。" + # 插件图标 + plugin_icon = "chinesesubfinder.png" + # 插件版本 + plugin_version = "2.0" + # 插件作者 + plugin_author = "jxxghp" + # 作者主页 + author_url = "https://github.com/jxxghp" + # 插件配置项ID前缀 + plugin_config_prefix = "chinesesubfinder_" + # 加载顺序 + plugin_order = 5 + # 可使用的用户级别 + auth_level = 1 + + # 私有属性 + _save_tmp_path = None + _enabled = False + _host = None + _api_key = None + _remote_path = None + _local_path = None + + def init_plugin(self, config: dict = None): + self._save_tmp_path = settings.TEMP_PATH + if config: + self._enabled = config.get("enabled") + self._api_key = config.get("api_key") + self._host = config.get('host') + if self._host: + if not self._host.startswith('http'): + self._host = "http://" + self._host + if not self._host.endswith('/'): + self._host = self._host + "/" + self._local_path = config.get("local_path") + self._remote_path = config.get("remote_path") + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + pass + + def get_api(self) -> List[Dict[str, Any]]: + pass + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'host', + 'label': '服务器' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'api_key', + 'label': 'API密钥' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'local_path', + 'label': '本地路径' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'remote_path', + 'label': '远端路径' + } + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "host": "", + "api_key": "", + "local_path": "", + "remote_path": "" + } + + def get_state(self) -> bool: + return self._enabled + + def get_page(self) -> List[dict]: + pass + + def stop_service(self): + pass + + @eventmanager.register(EventType.TransferComplete) + def download(self, event: Event): + """ + 调用ChineseSubFinder下载字幕 + """ + if not self._enabled or not self._host or not self._api_key: + return + item = event.event_data + if not item: + return + # 请求地址 + req_url = "%sapi/v1/add-job" % self._host + + # 媒体信息 + item_media: MediaInfo = item.get("mediainfo") + # 转移信息 + item_transfer: TransferInfo = item.get("transferinfo") + # 类型 + item_type = item_media.type + # 目的路径 + item_dest: FileItem = item_transfer.target_diritem + # 是否蓝光原盘 + item_bluray = SystemUtils.is_bluray_dir(Path(item_dest.path)) + # 文件清单 + item_file_list = item_transfer.file_list_new + + if item_bluray: + # 蓝光原盘虚拟个文件 + item_file_list = ["%s.mp4" % Path(item_dest.path) / item_dest.name] + + for file_path in item_file_list: + # 路径替换 + if self._local_path and self._remote_path and file_path.startswith(self._local_path): + file_path = file_path.replace(self._local_path, self._remote_path).replace('\\', '/') + + # 调用CSF下载字幕 + self.__request_csf(req_url=req_url, + file_path=file_path, + item_type=0 if item_type == MediaType.MOVIE else 1, + item_bluray=item_bluray) + + @lru_cache(maxsize=128) + def __request_csf(self, req_url, file_path, item_type, item_bluray): + # 一个名称只建一个任务 + logger.info("通知ChineseSubFinder下载字幕: %s" % file_path) + params = { + "video_type": item_type, + "physical_video_file_full_path": file_path, + "task_priority_level": 3, + "media_server_inside_video_id": "", + "is_bluray": item_bluray + } + try: + res = RequestUtils(headers={ + "Authorization": "Bearer %s" % self._api_key + }).post(req_url, json=params) + if not res or res.status_code != 200: + logger.error("调用ChineseSubFinder API失败!") + else: + # 如果文件目录没有识别的nfo元数据, 此接口会返回控制符,推测是ChineseSubFinder的原因 + # emby refresh元数据时异步的 + if res.text: + job_id = res.json().get("job_id") + message = res.json().get("message") + if not job_id: + logger.warn("ChineseSubFinder下载字幕出错:%s" % message) + else: + logger.info("ChineseSubFinder任务添加成功:%s" % job_id) + elif res.status_code != 200: + logger.warn(f"ChineseSubFinder调用出错:{res.status_code} - {res.reason}") + except Exception as e: + logger.error("连接ChineseSubFinder出错:" + str(e)) diff --git a/plugins.v2/cleaninvalidseed/__init__.py b/plugins.v2/cleaninvalidseed/__init__.py new file mode 100644 index 0000000..8f2c820 --- /dev/null +++ b/plugins.v2/cleaninvalidseed/__init__.py @@ -0,0 +1,1002 @@ +import glob +import os +import shutil +import time +from datetime import datetime, timedelta +from pathlib import Path + +import pytz +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger +from app.utils.string import StringUtils +from app.schemas.types import EventType +from app.schemas import ServiceInfo +from app.core.event import eventmanager, Event + +from app.core.config import settings +from app.plugins import _PluginBase +from typing import Any, List, Dict, Tuple, Optional +from app.log import logger +from app.schemas import NotificationType +from app.helper.downloader import DownloaderHelper + +class CleanInvalidSeed(_PluginBase): + # 插件名称 + plugin_name = "清理QB无效做种" + # 插件描述 + plugin_desc = "清理已经被站点删除的种子及源文件,仅支持QB" + # 插件图标 + plugin_icon = "clean_a.png" + # 插件版本 + plugin_version = "2.0" + # 插件作者 + plugin_author = "DzAvril" + # 作者主页 + author_url = "https://github.com/DzAvril" + # 插件配置项ID前缀 + plugin_config_prefix = "cleaninvalidseed" + # 加载顺序 + plugin_order = 1 + # 可使用的用户级别 + auth_level = 1 + + # 私有属性 + _enabled = False + _cron = None + _notify = False + _onlyonce = False + _detect_invalid_files = False + _delete_invalid_files = False + _delete_invalid_torrents = False + _notify_all = False + _label_only = False + _label = "" + _download_dirs = "" + _exclude_keywords = "" + _exclude_categories = "" + _exclude_labels = "" + _more_logs = False + # 定时器 + _scheduler: Optional[BackgroundScheduler] = None + _error_msg = [ + "torrent not registered with this tracker", + "Torrent not registered with this tracker", + "torrent banned", + "err torrent banned", + ] + _custom_error_msg = "" + + def init_plugin(self, config: dict = None): + self.downloader_helper = DownloaderHelper() + # 停止现有任务 + self.stop_service() + + if config: + self._enabled = config.get("enabled") + self._cron = config.get("cron") + self._notify = config.get("notify") + self._onlyonce = config.get("onlyonce") + self._delete_invalid_torrents = config.get("delete_invalid_torrents") + self._delete_invalid_files = config.get("delete_invalid_files") + self._detect_invalid_files = config.get("detect_invalid_files") + self._notify_all = config.get("notify_all") + self._label_only = config.get("label_only") + self._label = config.get("label") + self._download_dirs = config.get("download_dirs") + self._exclude_keywords = config.get("exclude_keywords") + self._exclude_categories = config.get("exclude_categories") + self._exclude_labels = config.get("exclude_labels") + self._custom_error_msg = config.get("custom_error_msg") + self._more_logs = config.get("more_logs") + self._downloaders = config.get("downloaders") + + # 加载模块 + if self._onlyonce: + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + logger.info(f"清理无效种子服务启动,立即运行一次") + self._scheduler.add_job( + func=self.clean_invalid_seed, + trigger="date", + run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + + timedelta(seconds=3), + name="清理无效种子", + ) + # 关闭一次性开关 + self._onlyonce = False + self._update_config() + + # 启动任务 + if self._scheduler.get_jobs(): + self._scheduler.print_jobs() + self._scheduler.start() + + def get_state(self) -> bool: + return self._enabled + + def _update_config(self): + self.update_config( + { + "onlyonce": False, + "cron": self._cron, + "enabled": self._enabled, + "notify": self._notify, + "delete_invalid_torrents": self._delete_invalid_torrents, + "delete_invalid_files": self._delete_invalid_files, + "detect_invalid_files": self._detect_invalid_files, + "notify_all": self._notify_all, + "label_only": self._label_only, + "label": self._label, + "download_dirs": self._download_dirs, + "exclude_keywords": self._exclude_keywords, + "exclude_categories": self._exclude_categories, + "exclude_labels": self._exclude_labels, + "custom_error_msg": self._custom_error_msg, + "more_logs": self._more_logs, + "downloaders": self._downloaders, + } + ) + + @property + def service_info(self) -> Optional[ServiceInfo]: + """ + 服务信息 + """ + if not self._downloaders: + logger.warning("尚未配置下载器,请检查配置") + return None + + services = self.downloader_helper.get_services(name_filters=self._downloaders) + + if not services: + logger.warning("获取下载器实例失败,请检查配置") + return None + + active_services = {} + for service_name, service_info in services.items(): + if service_info.instance.is_inactive(): + logger.warning(f"下载器 {service_name} 未连接,请检查配置") + elif not self.check_is_qb(service_info): + logger.warning(f"不支持的下载器类型 {service_name},仅支持QB,请检查配置") + else: + active_services[service_name] = service_info + + if not active_services: + logger.warning("没有已连接的下载器,请检查配置") + return None + + return active_services + + def check_is_qb(self, service_info) -> bool: + """ + 检查下载器类型是否为 qbittorrent 或 transmission + """ + if self.downloader_helper.is_downloader(service_type="qbittorrent", service=service_info): + return True + + return False + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + """ + 定义远程控制命令 + :return: 命令关键字、事件、描述、附带数据 + """ + return [ + { + "cmd": "/detect_invalid_torrents", + "event": EventType.PluginAction, + "desc": "检测无效做种", + "category": "QB", + "data": {"action": "detect_invalid_torrents"}, + }, + { + "cmd": "/delete_invalid_torrents", + "event": EventType.PluginAction, + "desc": "清理无效做种", + "category": "QB", + "data": {"action": "delete_invalid_torrents"}, + }, + { + "cmd": "/detect_invalid_files", + "event": EventType.PluginAction, + "desc": "检测无效源文件", + "category": "QB", + "data": {"action": "detect_invalid_files"}, + }, + { + "cmd": "/delete_invalid_files", + "event": EventType.PluginAction, + "desc": "清理无效源文件", + "category": "QB", + "data": {"action": "delete_invalid_files"}, + }, + { + "cmd": "/toggle_notify_all", + "event": EventType.PluginAction, + "desc": "QB清理插件切换全量通知", + "category": "QB", + "data": {"action": "toggle_notify_all"}, + }, + ] + + @eventmanager.register(EventType.PluginAction) + def handle_commands(self, event: Event): + if event: + event_data = event.event_data + if event_data: + if not ( + event_data.get("action") == "detect_invalid_torrents" + or event_data.get("action") == "delete_invalid_torrents" + or event_data.get("action") == "detect_invalid_files" + or event_data.get("action") == "delete_invalid_files" + or event_data.get("action") == "toggle_notify_all" + ): + return + self.post_message( + channel=event.event_data.get("channel"), + title="开始执行远程命令...", + userid=event.event_data.get("user"), + ) + old_delete_invalid_torrents = self._delete_invalid_torrents + old_detect_invalid_files = self._detect_invalid_files + old_delete_invalid_files = self._delete_invalid_files + if event_data.get("action") == "detect_invalid_torrents": + logger.info("收到远程命令,开始检测无效做种") + self._delete_invalid_torrents = False + self._detect_invalid_files = False + self._delete_invalid_files = False + self.clean_invalid_seed() + elif event_data.get("action") == "delete_invalid_torrents": + logger.info("收到远程命令,开始清理无效做种") + self._delete_invalid_torrents = True + self._detect_invalid_files = False + self._delete_invalid_files = False + self.clean_invalid_seed() + elif event_data.get("action") == "detect_invalid_files": + logger.info("收到远程命令,开始检测无效源文件") + self._delete_invalid_files = False + self.detect_invalid_files() + elif event_data.get("action") == "delete_invalid_files": + logger.info("收到远程命令,开始清理无效源文件") + self._delete_invalid_files = True + self.detect_invalid_files() + elif event_data.get("action") == "toggle_notify_all": + self._notify_all = not self._notify_all + self._update_config() + if self._notify_all: + self.post_message( + channel=event.event_data.get("channel"), + title="已开启全量通知", + userid=event.event_data.get("user"), + ) + else: + self.post_message( + channel=event.event_data.get("channel"), + title="已关闭全量通知", + userid=event.event_data.get("user"), + ) + return + else: + logger.error("收到未知远程命令") + return + self._delete_invalid_torrents = old_delete_invalid_torrents + self._detect_invalid_files = old_detect_invalid_files + self._delete_invalid_files = old_delete_invalid_files + self.post_message( + channel=event.event_data.get("channel"), + title="远程命令执行完成!", + userid=event.event_data.get("user"), + ) + + def get_api(self) -> List[Dict[str, Any]]: + pass + + def get_service(self) -> List[Dict[str, Any]]: + """ + 注册插件公共服务 + [{ + "id": "服务ID", + "name": "服务名称", + "trigger": "触发器:cron/interval/date/CronTrigger.from_crontab()", + "func": self.xxx, + "kwargs": {} # 定时器参数 + }] + """ + if self._enabled and self._cron: + return [ + { + "id": "CleanInvalidSeed", + "name": "清理QB无效做种", + "trigger": CronTrigger.from_crontab(self._cron), + "func": self.clean_invalid_seed, + "kwargs": {}, + } + ] + + def get_all_torrents(self, service): + downloader_name = service.name + downloader_obj = service.instance + all_torrents, error = downloader_obj.get_torrents() + + if error: + logger.error(f"获取下载器:{downloader_name}种子失败: {error}") + if self._notify: + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【清理QB无效做种】", + text=f"获取下载器:{downloader_name}种子失败,请检查下载器配置", + ) + return [] + + if not all_torrents: + logger.warning(f"下载器:{downloader_name}") + if self._notify: + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【清理QB无效做种】", + text=f"下载器:{downloader_name}中没有种子", + ) + return [] + return all_torrents + + def clean_invalid_seed(self): + for service in self.service_info.values(): + downloader_name = service.name + downloader_obj = service.instance + if not downloader_obj: + logger.error(f"{self.LOG_TAG} 获取下载器失败 {downloader_name}") + continue + logger.info(f"开始清理 {downloader_name} 无效做种...") + all_torrents = self.get_all_torrents(service) + temp_invalid_torrents = [] + # tracker未工作,但暂时不能判定为失效做种,需人工判断 + tracker_not_working_torrents = [] + working_tracker_set = set() + exclude_categories = ( + self._exclude_categories.split("\n") if self._exclude_categories else [] + ) + exclude_labels = ( + self._exclude_labels.split("\n") if self._exclude_labels else [] + ) + custom_msgs = ( + self._custom_error_msg.split("\n") if self._custom_error_msg else [] + ) + error_msgs = self._error_msg + custom_msgs + # 第一轮筛选出所有未工作的种子 + for torrent in all_torrents: + trackers = torrent.trackers + is_invalid = True + is_tracker_working = False + for tracker in trackers: + if tracker.get("tier") == -1: + continue + tracker_domian = StringUtils.get_url_netloc((tracker.get("url")))[1] + # 有一个tracker工作即为有效做种 + if (tracker.get("status") == 2) or (tracker.get("status") == 3): + is_tracker_working = True + + if not ( + (tracker.get("status") == 4) and (tracker.get("msg") in error_msgs) + ): + is_invalid = False + working_tracker_set.add(tracker_domian) + + if self._more_logs: + logger.info(f"处理 [{torrent.name}] tracker [{tracker_domian}]: 分类: [{torrent.category}], 标签: [{torrent.tags}], 状态: [{tracker.get('status')}], msg: [{tracker.get('msg')}], is_invalid: [{is_invalid}], is_working: [{is_tracker_working}]") + if is_invalid: + temp_invalid_torrents.append(torrent) + elif not is_tracker_working: + # 排除已暂停的种子 + if not torrent.state_enum.is_paused: + tracker_not_working_torrents.append(torrent) + + logger.info(f"初筛共有{len(temp_invalid_torrents)}个无效做种") + # 第二轮筛选出tracker有正常工作种子而当前种子未工作的,避免因临时关站或tracker失效导致误删的问题 + # 失效做种但通过种子分类排除的种子 + invalid_torrents_exclude_categories = [] + # 失效做种但通过种子标签排除的种子 + invalid_torrents_exclude_labels = [] + # 将invalid_torrents基本信息保存起来,在种子被删除后依然可以打印这些信息 + invalid_torrent_tuple_list = [] + deleted_torrent_tuple_list = [] + for torrent in temp_invalid_torrents: + trackers = torrent.trackers + for tracker in trackers: + if tracker.get("tier") == -1: + continue + tracker_domian = StringUtils.get_url_netloc((tracker.get("url")))[1] + if tracker_domian in working_tracker_set: + # tracker是正常的,说明该种子是无效的 + invalid_torrent_tuple_list.append( + ( + torrent.name, + torrent.category, + torrent.tags, + torrent.size, + tracker_domian, + tracker.msg, + ) + ) + if self._delete_invalid_torrents or self._label_only: + # 检查种子分类和标签是否排除 + is_excluded = False + if torrent.category in exclude_categories: + is_excluded = True + invalid_torrents_exclude_categories.append(torrent) + torrent_labels = [ + tag.strip() for tag in torrent.tags.split(",") + ] + for label in torrent_labels: + if label in exclude_labels: + is_excluded = True + invalid_torrents_exclude_labels.append(torrent) + if not is_excluded: + if self._label_only: + # 仅标记 + downloader_obj.set_torrents_tag(ids=torrent.get("hash"), tags=[self._label if self._label != "" else "无效做种"]) + else: + # 只删除种子不删除文件,以防其它站点辅种 + downloader_obj.delete_torrents(False, torrent.get("hash")) + # 标记已处理种子信息 + deleted_torrent_tuple_list.append( + ( + torrent.name, + torrent.category, + torrent.tags, + torrent.size, + tracker_domian, + tracker.msg, + ) + ) + break + invalid_msg = f"检测到{len(invalid_torrent_tuple_list)}个失效做种\n" + tracker_not_working_msg = f"检测到{len(tracker_not_working_torrents)}个tracker未工作做种,请检查种子状态\n" + + if self._label_only or self._delete_invalid_torrents: + if self._label_only: + deleted_msg = f"标记了{len(deleted_torrent_tuple_list)}个失效种子\n" + else: + deleted_msg = f"删除了{len(deleted_torrent_tuple_list)}个失效种子\n" + if len(exclude_categories) != 0: + exclude_categories_msg = f"分类排除{len(invalid_torrents_exclude_categories)}个失效种子未删除,请手动处理\n" + if len(exclude_labels) != 0: + exclude_labels_msg = f"标签排除{len(invalid_torrents_exclude_labels)}个失效种子未删除,请手动处理\n" + for index in range(len(invalid_torrent_tuple_list)): + torrent = invalid_torrent_tuple_list[index] + invalid_msg += f"{index + 1}. {torrent[0]},分类:{torrent[1]},标签:{torrent[2]}, 大小:{StringUtils.str_filesize(torrent[3])},Trackers: {torrent[4]}:{torrent[5]}\n" + + for index in range(len(tracker_not_working_torrents)): + torrent = tracker_not_working_torrents[index] + trackers = torrent.trackers + tracker_msg = "" + for tracker in trackers: + if tracker.get("tier") == -1: + continue + tracker_domian = StringUtils.get_url_netloc((tracker.get("url")))[1] + tracker_msg += f" {tracker_domian}:{tracker.msg} " + tracker_not_working_msg += f"{index + 1}. {torrent.name},分类:{torrent.category},标签:{torrent.tags}, 大小:{StringUtils.str_filesize(torrent.size)},Trackers: {tracker_msg}\n" + + for index in range(len(invalid_torrents_exclude_categories)): + torrent = invalid_torrents_exclude_categories[index] + trackers = torrent.trackers + tracker_msg = "" + for tracker in trackers: + if tracker.get("tier") == -1: + continue + tracker_domian = StringUtils.get_url_netloc((tracker.get("url")))[1] + tracker_msg += f" {tracker_domian}:{tracker.msg} " + exclude_categories_msg += f"{index + 1}. {torrent.name},分类:{torrent.category},标签:{torrent.tags}, 大小:{StringUtils.str_filesize(torrent.size)},Trackers: {tracker_msg}\n" + + for index in range(len(invalid_torrents_exclude_labels)): + torrent = invalid_torrents_exclude_labels[index] + trackers = torrent.trackers + tracker_msg = "" + for tracker in trackers: + if tracker.get("tier") == -1: + continue + tracker_domian = StringUtils.get_url_netloc((tracker.get("url")))[1] + tracker_msg += f" {tracker_domian}:{tracker.msg} " + exclude_labels_msg += f"{index + 1}. {torrent.name},分类:{torrent.category},标签:{torrent.tags}, 大小:{StringUtils.str_filesize(torrent.size)},Trackers: {tracker_msg}\n" + + for index in range(len(deleted_torrent_tuple_list)): + torrent = deleted_torrent_tuple_list[index] + deleted_msg += f"{index + 1}. {torrent[0]},分类:{torrent[1]},标签:{torrent[2]}, 大小:{StringUtils.str_filesize(torrent[3])},Trackers: {torrent[4]}:{torrent[5]}\n" + + # 日志 + logger.info(invalid_msg) + logger.info(tracker_not_working_msg) + if self._delete_invalid_torrents: + logger.info(deleted_msg) + if len(exclude_categories) != 0: + logger.info(exclude_categories_msg) + if len(exclude_labels) != 0: + logger.info(exclude_labels_msg) + # 通知 + if self._notify: + invalid_msg = invalid_msg.replace("_", "\_") + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【清理无效做种】", + text=invalid_msg, + ) + if self._notify_all: + tracker_not_working_msg = tracker_not_working_msg.replace("_", "\_") + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【清理无效做种】", + text=tracker_not_working_msg, + ) + if self._label_only or self._delete_invalid_torrents: + deleted_msg = deleted_msg.replace("_", "\_") + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【清理无效做种】", + text=deleted_msg, + ) + if self._notify_all: + exclude_categories_msg = exclude_categories_msg.replace("_", "\_") + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【清理无效做种】", + text=exclude_categories_msg, + ) + exclude_labels_msg = exclude_labels_msg.replace("_", "\_") + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【清理无效做种】", + text=exclude_labels_msg, + ) + logger.info("检测无效做种任务结束") + if self._detect_invalid_files: + self.detect_invalid_files() + + def detect_invalid_files(self): + logger.info("开始检测未做种的无效源文件") + + all_torrents = [] + + for service in self.service_info.values(): + downloader_name = service.name + downloader_obj = service.instance + if not downloader_obj: + logger.error(f"{self.LOG_TAG} 获取下载器失败 {downloader_name}") + continue + all_torrents += self.get_all_torrents(service) + + source_path_map = {} + source_paths = [] + total_size = 0 + deleted_file_cnt = 0 + exclude_key_words = ( + self._exclude_keywords.split("\n") if self._exclude_keywords else [] + ) + if not self._download_dirs: + logger.error("未配置下载目录,无法检测未做种无效源文件") + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【检测无效源文件】", + text="未配置下载目录,无法检测未做种无效源文件", + ) + return + for path in self._download_dirs.split("\n"): + mp_path, qb_path = path.split(":") + source_path_map[mp_path] = qb_path + source_paths.append(mp_path) + # 所有做种源文件路径 + content_path_set = set() + for torrent in all_torrents: + content_path_set.add(torrent.content_path) + + message = "检测未做种无效源文件:\n" + for source_path_str in source_paths: + source_path = Path(source_path_str) + # 判断source_path是否存在 + if not source_path.exists(): + logger.error(f"{source_path} 不存在,无法检测未做种无效源文件") + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【检测无效源文件】", + text=f"{source_path} 不存在,无法检测未做种无效源文件", + ) + continue + source_files = [] + # 获取source_path下的所有文件包括文件夹 + for file in source_path.iterdir(): + source_files.append(file) + for source_file in source_files: + skip = False + for key_word in exclude_key_words: + if key_word in source_file.name: + logger.info(f"{str(source_file)}命中关键字{key_word},不做处理") + skip = True + break + if skip: + continue + # 将mp_path替换成 qb_path + qb_path = (str(source_file)).replace( + source_path_str, source_path_map[source_path_str] + ) + # todo: 优化性能 + is_exist = False + for content_path in content_path_set: + if qb_path in content_path: + is_exist = True + break + + if not is_exist: + deleted_file_cnt += 1 + message += f"{deleted_file_cnt}. {str(source_file)}\n" + total_size += self.get_size(source_file) + if self._delete_invalid_files: + if source_file.is_file(): + source_file.unlink() + elif source_file.is_dir(): + shutil.rmtree(source_file) + + message += f"检测到{deleted_file_cnt}个未做种的无效源文件,共占用{StringUtils.str_filesize(total_size)}空间。\n" + if self._delete_invalid_files: + message += f"***已删除无效源文件,释放{StringUtils.str_filesize(total_size)}空间!***\n" + logger.info(message) + if self._notify: + message = message.replace("_", "\_") + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【清理无效做种】", + text=message, + ) + logger.info("检测无效源文件任务结束") + + def get_size(self, path: Path): + total_size = 0 + if path.is_file(): + return path.stat().st_size + # rglob 方法用于递归遍历所有文件和目录 + for entry in path.rglob("*"): + if entry.is_file(): + total_size += entry.stat().st_size + return total_size + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + return [ + { + "component": "VForm", + "content": [ + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "enabled", + "label": "启用插件", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "notify", + "label": "开启通知", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "onlyonce", + "label": "立即运行一次", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "delete_invalid_torrents", + "label": "删除无效种子(确认无误后再开启)", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "detect_invalid_files", + "label": "检测无效源文件", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "delete_invalid_files", + "label": "删除无效源文件(确认无误后再开启)", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "notify_all", + "label": "全量通知", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "label_only", + "label": "仅标记模式(开启后不会删种)", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "more_logs", + "label": "打印更多日志", + }, + } + ], + }, + ], + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'multiple': True, + 'chips': True, + 'clearable': True, + 'model': 'downloaders', + 'label': '请选择下载器', + 'items': [{"title": config.name, "value": config.name} + for config in self.downloader_helper.get_configs().values()] + } + } + ] + } + ] + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": { "cols": 12, "md": 6 }, + "content": [ + { + "component": "VTextField", + "props": { + "model": "cron", + "label": "执行周期", + }, + } + ], + }, + { + "component": "VCol", + "props": { "cols": 12, "md": 6 }, + "content": [ + { + "component": "VTextField", + "props": { + "model": "label", + "label": "增加标签", + "placeholder": "仅标记模式下生效,给待处理的种子打标签", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "download_dirs", + "label": "下载目录映射", + "rows": 2, + "placeholder": "填写要监控的源文件目录,并设置MP和QB的目录映射关系,如/mp/download:/qb/download,多个目录请换行", + }, + } + ], + } + ], + }, + { + "component": "VRow", + "props": {"style": {"margin-top": "0px"}}, + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "exclude_keywords", + "label": "过滤删源文件关键字", + "rows": 2, + "placeholder": "多个关键字请换行,仅针对删除源文件", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "exclude_categories", + "label": "过滤删种分类", + "rows": 2, + "placeholder": "多个分类请换行,仅针对删除种子", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "exclude_labels", + "label": "过滤删种标签", + "rows": 2, + "placeholder": "多个标签请换行,仅针对删除种子", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "custom_error_msg", + "label": "自定义无效做种tracker错误信息", + "rows": 5, + "placeholder": "填入想要清理的种子的tracker错误信息,如'skipping tracker announce (unreachable)',多个信息请换行", + }, + } + ], + } + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": { + "cols": 12, + }, + "content": [ + { + "component": "VAlert", + "props": { + "type": "error", + "variant": "tonal", + "text": "谨慎起见删除种子/源文件功能做了开关,请确认无误后再开启删除功能", + }, + } + ], + }, + { + "component": "VCol", + "props": { + "cols": 12, + }, + "content": [ + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "text": "下载目录映射填入源文件根目录,并设置MP和QB的目录映射关系。如某种子下载的源文件A存放路径为/qb/download/A,则目录映射填入/mp/download:/qb/download,多个目录请换行。注意映射目录不要有多余的'/'", + }, + } + ], + }, + ], + }, + ], + } + ], { + "enabled": False, + "notify": False, + "download_dirs": "", + "delete_invalid_torrents": False, + "delete_invalid_files": False, + "detect_invalid_files": False, + "notify_all": False, + "onlyonce": False, + "cron": "0 0 * * *", + "label_only": False, + "label": "", + "more_logs": False, + } + + def get_page(self) -> List[dict]: + pass + + def stop_service(self): + """ + 退出插件 + """ + try: + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._scheduler.shutdown() + self._scheduler = None + except Exception as e: + logger.error("退出插件失败:%s" % str(e)) diff --git a/plugins.v2/downloadsitetag/__init__.py b/plugins.v2/downloadsitetag/__init__.py new file mode 100644 index 0000000..26b8275 --- /dev/null +++ b/plugins.v2/downloadsitetag/__init__.py @@ -0,0 +1,859 @@ +import datetime +import threading +from typing import List, Tuple, Dict, Any, Optional + +import pytz +from app.helper.sites import SitesHelper +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger + +from app.core.config import settings +from app.core.context import Context +from app.core.event import eventmanager, Event +from app.db.downloadhistory_oper import DownloadHistoryOper +from app.db.models.downloadhistory import DownloadHistory +from app.helper.downloader import DownloaderHelper +from app.log import logger +from app.plugins import _PluginBase +from app.schemas import ServiceInfo +from app.schemas.types import EventType, MediaType +from app.utils.string import StringUtils + + +class DownloadSiteTag(_PluginBase): + # 插件名称 + plugin_name = "下载任务分类与标签" + # 插件描述 + plugin_desc = "自动给下载任务分类与打站点标签、剧集名称标签" + # 插件图标 + plugin_icon = "Youtube-dl_B.png" + # 插件版本 + plugin_version = "2.2" + # 插件作者 + plugin_author = "叮叮当" + # 作者主页 + author_url = "https://github.com/cikezhu" + # 插件配置项ID前缀 + plugin_config_prefix = "DownloadSiteTag_" + # 加载顺序 + plugin_order = 2 + # 可使用的用户级别 + auth_level = 1 + # 日志前缀 + LOG_TAG = "[DownloadSiteTag] " + + # 退出事件 + _event = threading.Event() + # 私有属性 + downloadhistory_oper = None + sites_helper = None + downloader_helper = None + _scheduler = None + _enabled = False + _onlyonce = False + _interval = "计划任务" + _interval_cron = "5 4 * * *" + _interval_time = 6 + _interval_unit = "小时" + _enabled_media_tag = False + _enabled_tag = True + _enabled_category = False + _category_movie = None + _category_tv = None + _category_anime = None + _downloaders = None + + def init_plugin(self, config: dict = None): + self.downloadhistory_oper = DownloadHistoryOper() + self.downloader_helper = DownloaderHelper() + self.sites_helper = SitesHelper() + # 读取配置 + if config: + self._enabled = config.get("enabled") + self._onlyonce = config.get("onlyonce") + self._interval = config.get("interval") or "计划任务" + self._interval_cron = config.get("interval_cron") or "5 4 * * *" + self._interval_time = self.str_to_number(config.get("interval_time"), 6) + self._interval_unit = config.get("interval_unit") or "小时" + self._enabled_media_tag = config.get("enabled_media_tag") + self._enabled_tag = config.get("enabled_tag") + self._enabled_category = config.get("enabled_category") + self._category_movie = config.get("category_movie") or "电影" + self._category_tv = config.get("category_tv") or "电视" + self._category_anime = config.get("category_anime") or "动漫" + self._downloaders = config.get("downloaders") + + # 停止现有任务 + self.stop_service() + + if self._onlyonce: + # 创建定时任务控制器 + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + # 执行一次, 关闭onlyonce + self._onlyonce = False + config.update({"onlyonce": self._onlyonce}) + self.update_config(config) + # 添加 补全下载历史的标签与分类 任务 + self._scheduler.add_job(func=self._complemented_history, trigger='date', + run_date=datetime.datetime.now( + tz=pytz.timezone(settings.TZ)) + datetime.timedelta(seconds=3) + ) + + if self._scheduler and self._scheduler.get_jobs(): + # 启动服务 + self._scheduler.print_jobs() + self._scheduler.start() + + @property + def service_infos(self) -> Optional[Dict[str, ServiceInfo]]: + """ + 服务信息 + """ + if not self._downloaders: + logger.warning("尚未配置下载器,请检查配置") + return None + + services = self.downloader_helper.get_services(name_filters=self._downloaders) + if not services: + logger.warning("获取下载器实例失败,请检查配置") + return None + + active_services = {} + for service_name, service_info in services.items(): + if service_info.instance.is_inactive(): + logger.warning(f"下载器 {service_name} 未连接,请检查配置") + else: + active_services[service_name] = service_info + + if not active_services: + logger.warning("没有已连接的下载器,请检查配置") + return None + + return active_services + + def get_state(self) -> bool: + return self._enabled + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + pass + + def get_api(self) -> List[Dict[str, Any]]: + pass + + def get_service(self) -> List[Dict[str, Any]]: + """ + 注册插件公共服务 + [{ + "id": "服务ID", + "name": "服务名称", + "trigger": "触发器:cron/interval/date/CronTrigger.from_crontab()", + "func": self.xxx, + "kwargs": {} # 定时器参数 + }] + """ + if self._enabled: + if self._interval == "计划任务" or self._interval == "固定间隔": + if self._interval == "固定间隔": + if self._interval_unit == "小时": + return [{ + "id": "DownloadSiteTag", + "name": "补全下载历史的标签与分类", + "trigger": "interval", + "func": self._complemented_history, + "kwargs": { + "hours": self._interval_time + } + }] + else: + if self._interval_time < 5: + self._interval_time = 5 + logger.info(f"{self.LOG_TAG}启动定时服务: 最小不少于5分钟, 防止执行间隔太短任务冲突") + return [{ + "id": "DownloadSiteTag", + "name": "补全下载历史的标签与分类", + "trigger": "interval", + "func": self._complemented_history, + "kwargs": { + "minutes": self._interval_time + } + }] + else: + return [{ + "id": "DownloadSiteTag", + "name": "补全下载历史的标签与分类", + "trigger": CronTrigger.from_crontab(self._interval_cron), + "func": self._complemented_history, + "kwargs": {} + }] + return [] + + @staticmethod + def str_to_number(s: str, i: int) -> int: + try: + return int(s) + except ValueError: + return i + + def _complemented_history(self): + """ + 补全下载历史的标签与分类 + """ + if not self.service_infos: + return + logger.info(f"{self.LOG_TAG}开始执行 ...") + # 记录处理的种子, 供辅种(无下载历史)使用 + dispose_history = {} + # 所有站点索引 + indexers = [indexer.get("name") for indexer in self.sites_helper.get_indexers()] + # JackettIndexers索引器支持多个站点, 如果不存在历史记录, 则通过tracker会再次附加其他站点名称 + indexers.append("JackettIndexers") + indexers = set(indexers) + tracker_mappings = { + "chdbits.xyz": "ptchdbits.co", + "agsvpt.trackers.work": "agsvpt.com", + "tracker.cinefiles.info": "audiences.me", + } + for service in self.service_infos.values(): + downloader = service.name + downloader_obj = service.instance + logger.info(f"{self.LOG_TAG}开始扫描下载器 {downloader} ...") + if not downloader_obj: + logger.error(f"{self.LOG_TAG} 获取下载器失败 {downloader}") + continue + # 获取下载器中的种子 + torrents, error = downloader_obj.get_torrents() + # 如果下载器获取种子发生错误 或 没有种子 则跳过 + if error or not torrents: + continue + logger.info(f"{self.LOG_TAG}按时间重新排序 {downloader} 种子数:{len(torrents)}") + # 按添加时间进行排序, 时间靠前的按大小和名称加入处理历史, 判定为原始种子, 其他为辅种 + torrents = self._torrents_sort(torrents=torrents, dl_type=service.type) + logger.info(f"{self.LOG_TAG}下载器 {downloader} 分析种子信息中 ...") + for torrent in torrents: + try: + if self._event.is_set(): + logger.info( + f"{self.LOG_TAG}停止服务") + return + # 获取已处理种子的key (size, name) + _key = self._torrent_key(torrent=torrent, dl_type=service.type) + # 获取种子hash + _hash = self._get_hash(torrent=torrent, dl_type=service.type) + if not _hash: + continue + # 获取种子当前标签 + torrent_tags = self._get_label(torrent=torrent, dl_type=service.type) + torrent_cat = self._get_category(torrent=torrent, dl_type=service.type) + # 提取种子hash对应的下载历史 + history: DownloadHistory = self.downloadhistory_oper.get_by_hash(_hash) + if not history: + # 如果找到已处理种子的历史, 表明当前种子是辅种, 否则创建一个空DownloadHistory + if _key and _key in dispose_history: + history = dispose_history[_key] + # 因为辅种站点必定不同, 所以需要更新站点名字 history.torrent_site + history.torrent_site = None + else: + history = DownloadHistory() + else: + # 加入历史记录 + if _key: + dispose_history[_key] = history + # 如果标签已经存在任意站点, 则不再添加站点标签 + if indexers.intersection(set(torrent_tags)): + history.torrent_site = None + # 如果站点名称为空, 尝试通过trackers识别 + elif not history.torrent_site: + trackers = self._get_trackers(torrent=torrent, dl_type=service.type) + for tracker in trackers: + # 检查tracker是否包含特定的关键字,并进行相应的映射 + for key, mapped_domain in tracker_mappings.items(): + if key in tracker: + domain = mapped_domain + break + else: + domain = StringUtils.get_url_domain(tracker) + site_info = self.sites_helper.get_indexer(domain) + if site_info: + history.torrent_site = site_info.get("name") + break + # 如果通过tracker还是无法获取站点名称, 且tmdbid, type, title都是空的, 那么跳过当前种子 + if not history.torrent_site and not history.tmdbid and not history.type and not history.title: + continue + # 按设置生成需要写入的标签与分类 + _tags = [] + _cat = None + # 站点标签, 如果勾选开关的话 因允许torrent_site为空时运行到此, 因此需要判断torrent_site不为空 + if self._enabled_tag and history.torrent_site: + _tags.append(history.torrent_site) + # 媒体标题标签, 如果勾选开关的话 因允许title为空时运行到此, 因此需要判断title不为空 + if self._enabled_media_tag and history.title: + _tags.append(history.title) + # 分类, 如果勾选开关的话注意: 该插件仅会将公开的收藏添加到订阅。
' + ), + } + ], + } + ], + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + }, + 'content': parse_html( + '注意: 开启自动取消订阅并通知后,已添加的订阅在下一次执行时若不在已选择的收藏类型中,将会被取消订阅。
' + ), + } + ], + } + ], + }, + ], + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + }, + 'content': parse_html( + '注意: 开启不跟随TMDB变动后,从Bangumi API获取的总集数将不再跟随TMDB的集数变动。
' + ), + }, + ], + }, + ], + }, + ], { + "enabled": False, + "total_change": False, + "notify": False, + "onlyonce": False, + "cron": "", + "uid": "", + "collection_type": [3], + "include": "", + "exclude": "", + "save_path": "", + "sites": [], + } + + +def parse_html(html_string: str) -> list: + soup = BeautifulSoup(html_string, 'html.parser') + result: list = [] + + # 定义需要直接转为文本的标签 + inline_text_tags = {'strong', 'u', 'em', 'b', 'i'} + + def process_element(element: BeautifulSoup): + # 处理纯文本节点 + if element.name is None: + text = element.strip() + return text if text else "" + + # 处理HTML标签 + component = element.name + props = {attr: element[attr] for attr in element.attrs} + content = [] + + # 递归处理子元素 + for child in element.children: + child_content = process_element(child) + if isinstance(child_content, str): + content.append({'component': 'span', 'text': child_content}) + elif child_content: # 只有在child_content不为空时添加 + content.append(child_content) + + # 构建标签对象 + tag_data = { + 'component': component, + 'props': props, + 'content': content if component not in inline_text_tags else [], + } + + if content and component in inline_text_tags: + tag_data['text'] = ' '.join( + item['text'] for item in content if 'text' in item + ) + + return tag_data + + # 遍历所有子元素 + for element in soup.children: + element_content = process_element(element) + if element_content: # 只增加非空内容 + result.append(element_content) + + return result diff --git a/plugins/brushflow/__init__.py b/plugins/brushflow/__init__.py index 0189777..faa6c05 100644 --- a/plugins/brushflow/__init__.py +++ b/plugins/brushflow/__init__.py @@ -25,6 +25,7 @@ from app.modules.qbittorrent import Qbittorrent from app.modules.transmission import Transmission from app.plugins import _PluginBase from app.schemas import NotificationType, TorrentInfo, MediaType +from app.schemas.types import EventType from app.utils.http import RequestUtils from app.utils.string import StringUtils @@ -63,15 +64,15 @@ class BrushConfig: self.delete_size_range = config.get("delete_size_range") self.up_speed = self.__parse_number(config.get("up_speed")) self.dl_speed = self.__parse_number(config.get("dl_speed")) + self.auto_archive_days = self.__parse_number(config.get("auto_archive_days")) self.save_path = config.get("save_path") self.clear_task = config.get("clear_task", False) self.archive_task = config.get("archive_task", False) - self.except_tags = config.get("except_tags", True) + self.delete_except_tags = config.get("delete_except_tags") self.except_subscribe = config.get("except_subscribe", True) self.brush_sequential = config.get("brush_sequential", False) self.proxy_download = config.get("proxy_download", False) self.proxy_delete = config.get("proxy_delete", False) - self.log_more = config.get("log_more", False) self.active_time_range = config.get("active_time_range") self.downloader_monitor = config.get("downloader_monitor") self.qb_category = config.get("qb_category") @@ -257,7 +258,7 @@ class BrushFlow(_PluginBase): # 插件图标 plugin_icon = "brush.jpg" # 插件版本 - plugin_version = "3.3" + plugin_version = "3.8" # 插件作者 plugin_author = "jxxghp,InfinityPacer" # 作者主页 @@ -295,7 +296,6 @@ class BrushFlow(_PluginBase): # endregion def init_plugin(self, config: dict = None): - logger.info(f"站点刷流服务初始化") self.siteshelper = SitesHelper() self.siteoper = SiteOper() self.torrents = TorrentsChain() @@ -340,11 +340,10 @@ class BrushFlow(_PluginBase): brush_config.archive_task = False self.__update_config() - if brush_config.log_more: - if brush_config.enable_site_config: - logger.info(f"已开启站点独立配置,配置信息:{brush_config}") - else: - logger.info(f"没有开启站点独立配置,配置信息:{brush_config}") + if brush_config.enable_site_config: + logger.debug(f"已开启站点独立配置,配置信息:{brush_config}") + else: + logger.debug(f"没有开启站点独立配置,配置信息:{brush_config}") # 停止现有任务 self.stop_service() @@ -366,8 +365,6 @@ class BrushFlow(_PluginBase): # 如果开启&存在站点时,才需要启用后台任务 self._task_brush_enable = brush_config.enabled and brush_config.brushsites - # brush_config.onlyonce = True - # 检查是否启用了一次性任务 if brush_config.onlyonce: self._scheduler = BackgroundScheduler(timezone=settings.TZ) @@ -974,11 +971,6 @@ class BrushFlow(_PluginBase): 'component': 'VWindow', 'props': { 'model': '_tabs' - # VWindow设置paddnig会导致切换Tab时页面高度变动,调整为修改VRow的方案 - # 'style': { - # 'padding-top': '24px', - # 'padding-bottom': '24px', - # }, }, 'content': [ { @@ -1140,6 +1132,25 @@ class BrushFlow(_PluginBase): } } ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'auto_archive_days', + 'label': '自动归档记录天数', + 'placeholder': '超过此天数后自动归档', + 'type': 'number', + "min": "0" + } + } + ] } ] } @@ -1426,11 +1437,28 @@ class BrushFlow(_PluginBase): 'component': 'VTextField', 'props': { 'model': 'seed_inactivetime', - 'label': '未活动时间(分钟) ', + 'label': '未活动时间(分钟)', 'placeholder': '超过时删除任务' } } ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'delete_except_tags', + 'label': '删除排除标签', + 'placeholder': '如:MOVIEPILOT,H&R' + } + } + ] } ] } @@ -1476,8 +1504,8 @@ class BrushFlow(_PluginBase): { 'component': 'VSwitch', 'props': { - 'model': 'except_tags', - 'label': '删种排除MoviePilot任务', + 'model': 'except_subscribe', + 'label': '排除订阅(实验性功能)', } } ] @@ -1492,8 +1520,8 @@ class BrushFlow(_PluginBase): { 'component': 'VSwitch', 'props': { - 'model': 'except_subscribe', - 'label': '排除订阅(实验性功能)', + 'model': 'qb_first_last_piece', + 'label': '优先下载首尾文件块', } } ] @@ -1640,43 +1668,6 @@ class BrushFlow(_PluginBase): } } ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'qb_first_last_piece', - 'label': '优先下载首尾文件块', - } - } - ] - } - ] - }, - { - 'component': 'VRow', - "content": [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'log_more', - 'label': '记录更多日志', - } - } - ] } ] } @@ -1742,7 +1733,7 @@ class BrushFlow(_PluginBase): 'props': { 'type': 'error', 'variant': 'tonal', - 'text': '注意:排除H&R并不保证能完全适配所有站点(部分站点在列表页不显示H&R标志,但实际上是有H&R的),请注意核对使用!' + 'text': '注意:排除H&R并不保证能完全适配所有站点(部分站点在列表页不显示H&R标志,但实际上是有H&R的),请注意核对使用' } } ] @@ -1849,7 +1840,7 @@ class BrushFlow(_PluginBase): "onlyonce": False, "clear_task": False, "archive_task": False, - "except_tags": True, + "delete_except_tags": f"{settings.TORRENT_TAG},H&R" if settings.TORRENT_TAG else "H&R", "except_subscribe": True, "brush_sequential": False, "proxy_download": False, @@ -1857,7 +1848,6 @@ class BrushFlow(_PluginBase): "freeleech": "free", "hr": "yes", "enable_site_config": False, - "log_more": False, "downloader_monitor": False, "auto_qb_category": False, "qb_first_last_piece": False, @@ -2055,9 +2045,6 @@ class BrushFlow(_PluginBase): if brush_config.site_hr_active: logger.info(f"站点 {siteinfo.name} 已开启全站H&R选项,所有种子设置为H&R种子") - # 由于缓存原因,这里不能直接改torrents,在后续加入任务中调整 - # for torrent in torrents: - # torrent.hit_and_run = True # 排除包含订阅的种子 if brush_config.except_subscribe: @@ -2068,7 +2055,7 @@ class BrushFlow(_PluginBase): torrents_size = self.__calculate_seeding_torrents_size(torrent_tasks=torrent_tasks) - logger.info(f"正在准备种子刷流,数量:{len(torrents)}") + logger.info(f"正在准备种子刷流,数量 {len(torrents)}") # 过滤种子 for torrent in torrents: @@ -2078,6 +2065,8 @@ class BrushFlow(_PluginBase): if not pre_condition_passed: return False + logger.debug(f"种子详情:{torrent}") + # 判断能否通过保种体积刷流条件 size_condition_passed, reason = self.__evaluate_size_condition_for_brush(torrents_size=torrents_size, add_torrent_size=torrent.size) @@ -2098,8 +2087,8 @@ class BrushFlow(_PluginBase): logger.warn(f"{torrent.title} 添加刷流任务失败!") continue - # 保存任务信息 - torrent_tasks[hash_string] = { + # 触发刷流下载时间并保存任务信息 + torrent_task = { "site": siteinfo.id, "site_name": siteinfo.name, "title": torrent.title, @@ -2134,6 +2123,13 @@ class BrushFlow(_PluginBase): "time": time.time() } + self.eventmanager.send_event(etype=EventType.PluginAction, data={ + "action": "brushflow_download_added", + "hash": hash_string, + "data": torrent_task + }) + torrent_tasks[hash_string] = torrent_task + # 统计数据 torrents_size += torrent.size statistic_info["count"] += 1 @@ -2306,7 +2302,8 @@ class BrushFlow(_PluginBase): return True, None - def __log_brush_conditions(self, passed: bool, reason: str, torrent: Any = None): + @staticmethod + def __log_brush_conditions(passed: bool, reason: str, torrent: Any = None): """ 记录刷流日志 """ @@ -2314,9 +2311,7 @@ class BrushFlow(_PluginBase): if not torrent: logger.warn(f"没有通过前置刷流条件校验,原因:{reason}") else: - brush_config = self.__get_brush_config() - if brush_config.log_more: - logger.warn(f"种子没有通过刷流条件校验,原因:{reason} 种子:{torrent.title}|{torrent.description}") + logger.debug(f"种子没有通过刷流条件校验,原因:{reason} 种子:{torrent.title}|{torrent.description}") # endregion @@ -2331,10 +2326,6 @@ class BrushFlow(_PluginBase): if not brush_config.downloader: return - if not self.__is_current_time_in_range(): - logger.info(f"当前不在指定的刷流时间区间内,检查操作将暂时暂停") - return - with lock: logger.info("开始检查刷流下载任务 ...") torrent_tasks: Dict[str, dict] = self.get_data("torrents") or {} @@ -2372,34 +2363,58 @@ class BrushFlow(_PluginBase): # 更新刷流任务列表中在下载器中删除的种子为删除状态 self.__update_undeleted_torrents_missing_in_downloader(torrent_tasks, torrent_check_hashes, check_torrents) - # 排除MoviePilot种子 - if check_torrents and brush_config.except_tags: - check_torrents = self.__filter_torrents_by_tag(torrents=check_torrents, - exclude_tag=settings.TORRENT_TAG) + # 根据配置的标签进行种子排除 + if check_torrents: + logger.info(f"当前刷流任务共 {len(check_torrents)} 个有效种子,正在准备按设定的种子标签进行排除") + # 初始化一个空的列表来存储需要排除的标签 + tags_to_exclude = set() + # 如果 delete_except_tags 非空且不是纯空白,则添加到排除列表中 + if brush_config.delete_except_tags and brush_config.delete_except_tags.strip(): + tags_to_exclude.update(tag.strip() for tag in brush_config.delete_except_tags.split(',')) + # 将所有需要排除的标签组合成一个字符串,每个标签之间用逗号分隔 + combined_tags = ",".join(tags_to_exclude) + if combined_tags: # 确保有标签需要排除 + pre_filter_count = len(check_torrents) # 获取过滤前的任务数量 + check_torrents = self.__filter_torrents_by_tag(torrents=check_torrents, exclude_tag=combined_tags) + post_filter_count = len(check_torrents) # 获取过滤后的任务数量 + excluded_count = pre_filter_count - post_filter_count # 计算被排除的任务数量 + logger.info( + f"有效种子数 {pre_filter_count},排除标签 '{combined_tags}' 后," + f"剩余种子数 {post_filter_count},排除种子数 {excluded_count}") + else: + logger.info("没有配置有效的排除标签,所有种子均参与后续处理") - need_delete_hashes = [] - - # 如果配置了动态删除以及删种阈值,则根据动态删种进行分组处理 - if brush_config.proxy_delete and brush_config.delete_size_range: - logger.info("已开启动态删种,按系统默认动态删种条件开始检查任务") - proxy_delete_hashes = self.__delete_torrent_for_proxy(torrents=check_torrents, - torrent_tasks=torrent_tasks) or [] - need_delete_hashes.extend(proxy_delete_hashes) - # 否则均认为是没有开启动态删种 + # 种子删除检查 + if not check_torrents: + logger.info("没有需要检查的任务,跳过") else: - logger.info("没有开启动态删种,按用户设置删种条件开始检查任务") - not_proxy_delete_hashes = self.__delete_torrent_for_evaluate_conditions(torrents=check_torrents, - torrent_tasks=torrent_tasks) or [] - need_delete_hashes.extend(not_proxy_delete_hashes) + need_delete_hashes = [] - if need_delete_hashes: - # 如果是QB,则重新汇报Tracker - if brush_config.downloader == "qbittorrent": - self.__qb_torrents_reannounce(torrent_hashes=need_delete_hashes) - # 删除种子 - if downloader.delete_torrents(ids=need_delete_hashes, delete_file=True): - for torrent_hash in need_delete_hashes: - torrent_tasks[torrent_hash]["deleted"] = True + # 如果配置了动态删除以及删种阈值,则根据动态删种进行分组处理 + if brush_config.proxy_delete and brush_config.delete_size_range: + logger.info("已开启动态删种,按系统默认动态删种条件开始检查任务") + proxy_delete_hashes = self.__delete_torrent_for_proxy(torrents=check_torrents, + torrent_tasks=torrent_tasks) or [] + need_delete_hashes.extend(proxy_delete_hashes) + # 否则均认为是没有开启动态删种 + else: + logger.info("没有开启动态删种,按用户设置删种条件开始检查任务") + not_proxy_delete_hashes = self.__delete_torrent_for_evaluate_conditions(torrents=check_torrents, + torrent_tasks=torrent_tasks) or [] + need_delete_hashes.extend(not_proxy_delete_hashes) + + if need_delete_hashes: + # 如果是QB,则重新汇报Tracker + if brush_config.downloader == "qbittorrent": + self.__qb_torrents_reannounce(torrent_hashes=need_delete_hashes) + # 删除种子 + if downloader.delete_torrents(ids=need_delete_hashes, delete_file=True): + for torrent_hash in need_delete_hashes: + torrent_tasks[torrent_hash]["deleted"] = True + torrent_tasks[torrent_hash]["deleted_time"] = time.time() + + # 归档数据 + self.__auto_archive_tasks(torrent_tasks=torrent_tasks) self.__update_and_save_statistic_info(torrent_tasks) @@ -2618,8 +2633,7 @@ class BrushFlow(_PluginBase): reason=reason) logger.info(f"站点:{site_name},{reason},删除种子:{torrent_title}|{torrent_desc}") else: - if brush_config.log_more: - logger.info(f"站点:{site_name},{reason},不删除种子:{torrent_title}|{torrent_desc}") + logger.debug(f"站点:{site_name},{reason},不删除种子:{torrent_title}|{torrent_desc}") return delete_hashes @@ -2657,8 +2671,7 @@ class BrushFlow(_PluginBase): reason=reason) logger.info(f"站点:{site_name},{reason},删除种子:{torrent_title}|{torrent_desc}") else: - if brush_config.log_more: - logger.info(f"站点:{site_name},{reason},不删除种子:{torrent_title}|{torrent_desc}") + logger.debug(f"站点:{site_name},{reason},不删除种子:{torrent_title}|{torrent_desc}") return delete_hashes @@ -2829,6 +2842,7 @@ class BrushFlow(_PluginBase): torrent_task = torrent_tasks[hash_value] # 标记为已删除 torrent_task["deleted"] = True + torrent_task["deleted_time"] = time.time() # 处理日志相关内容 delete_tasks.append(torrent_task) site_name = torrent_task.get("site_name", "") @@ -2914,7 +2928,7 @@ class BrushFlow(_PluginBase): "active_downloaded": active_downloaded }) - logger.info(f"刷流任务统计数据:总任务数:{total_count},活跃任务数:{active_count},已删除:{total_deleted}," + logger.info(f"刷流任务统计数据,总任务数:{total_count},活跃任务数:{active_count},已删除:{total_deleted}," f"待归档:{total_unarchived}," f"活跃上传量:{StringUtils.str_filesize(active_uploaded)}," f"活跃下载量:{StringUtils.str_filesize(active_downloaded)}," @@ -2954,7 +2968,8 @@ class BrushFlow(_PluginBase): "seed_avgspeed": "平均上传速度", "seed_inactivetime": "未活动时间", "up_speed": "单任务上传限速", - "dl_speed": "单任务下载限速" + "dl_speed": "单任务下载限速", + "auto_archive_days": "自动清理记录天数" } config_range_number_attr_to_desc = { @@ -3026,15 +3041,15 @@ class BrushFlow(_PluginBase): "delete_size_range": brush_config.delete_size_range, "up_speed": brush_config.up_speed, "dl_speed": brush_config.dl_speed, + "auto_archive_days": brush_config.auto_archive_days, "save_path": brush_config.save_path, "clear_task": brush_config.clear_task, "archive_task": brush_config.archive_task, - "except_tags": brush_config.except_tags, + "delete_except_tags": brush_config.delete_except_tags, "except_subscribe": brush_config.except_subscribe, "brush_sequential": brush_config.brush_sequential, "proxy_download": brush_config.proxy_download, "proxy_delete": brush_config.proxy_delete, - "log_more": brush_config.log_more, "active_time_range": brush_config.active_time_range, "downloader_monitor": brush_config.downloader_monitor, "qb_category": brush_config.qb_category, @@ -3131,7 +3146,7 @@ class BrushFlow(_PluginBase): data = data.get(key) if not data: return None - logger.info(f"获取到下载地址:{data}") + logger.debug(f"获取到下载地址:{data}") return data return None @@ -3201,8 +3216,7 @@ class BrushFlow(_PluginBase): # 获取种子Hash torrent_hash = self.qb.get_torrent_id_by_tag(tags=tag) if not torrent_hash: - logger.error(f"{brush_config.downloader} 获取种子Hash失败" - f"{',请尝试启用「代理下载种子」配置项' if not brush_config.proxy_download else ''}") + logger.error(f"{brush_config.downloader} 获取种子Hash失败,详细信息请查看 README") return None return torrent_hash return None @@ -3654,12 +3668,21 @@ class BrushFlow(_PluginBase): """ 获取正在下载的任务数量 """ - brush_config = self.__get_brush_config() - downloader = self.__get_downloader(brush_config.downloader) - if not downloader: + try: + brush_config = self.__get_brush_config() + downloader = self.__get_downloader(brush_config.downloader) + if not downloader: + return 0 + + torrents = downloader.get_downloading_torrents(tags=brush_config.brush_tag) + if torrents is None: + logger.warn("获取下载数量失败,可能是下载器连接发生异常") + return 0 + + return len(torrents) + except Exception as e: + logger.error(f"获取下载数量发生异常: {e}") return 0 - torrents = downloader.get_downloading_torrents() - return len(torrents) or 0 @staticmethod def __get_pubminutes(pubdate: str) -> float: @@ -3705,14 +3728,21 @@ class BrushFlow(_PluginBase): def __filter_torrents_by_tag(self, torrents: List[Any], exclude_tag: str) -> List[Any]: """ - 根据标签过滤torrents + 根据标签过滤torrents,排除标签格式为逗号分隔的字符串,例如 "MOVIEPILOT, H&R" """ + # 如果排除标签字符串为空,则返回原始列表 + if not exclude_tag: + return torrents + + # 将 exclude_tag 字符串分割成一个集合,并去除每个标签两端的空白,忽略空白标签并自动去重 + exclude_tags = set(tag.strip() for tag in exclude_tag.split(',') if tag.strip()) + filter_torrents = [] for torrent in torrents: # 使用 __get_label 方法获取每个 torrent 的标签列表 labels = self.__get_label(torrent) - # 如果排除的标签不在这个列表中,则添加到过滤后的列表 - if exclude_tag not in labels: + # 检查是否有任何一个排除标签存在于标签列表中 + if not any(exclude in labels for exclude in exclude_tags): filter_torrents.append(torrent) return filter_torrents @@ -3752,7 +3782,8 @@ class BrushFlow(_PluginBase): doubanid=subscribe.doubanid, cache=True) if mediainfo: - logger.info(f"subscribe {subscribe.name} {mediainfo.to_dict()}") + logger.info(f"订阅 {subscribe.name} 已识别到媒体信息") + logger.debug(f"subscribe {subscribe.name} {mediainfo.to_dict()}") subscribe_titles.extend(mediainfo.names) subscribe_titles = [title.strip() for title in subscribe_titles if title and title.strip()] self._subscribe_infos[subscribe_key] = subscribe_titles @@ -3766,7 +3797,8 @@ class BrushFlow(_PluginBase): for key in set(self._subscribe_infos) - current_keys: del self._subscribe_infos[key] - logger.info(f"订阅标题匹配完成,当前订阅的标题集合为:{self._subscribe_infos}") + logger.info("订阅标题匹配完成") + logger.debug(f"当前订阅的标题集合为:{self._subscribe_infos}") unique_titles = {title for titles in self._subscribe_infos.values() for title in titles} return unique_titles @@ -3833,6 +3865,45 @@ class BrushFlow(_PluginBase): """ return sum(task.get("size", 0) for task in torrent_tasks.values() if not task.get("deleted", False)) + def __auto_archive_tasks(self, torrent_tasks: Dict[str, dict]) -> None: + """ + 自动归档已经删除的种子数据 + """ + if not self._brush_config.auto_archive_days or self._brush_config.auto_archive_days <= 0: + logger.info("自动归档记录天数小于等于0,取消自动归档") + return + + # 用于存储已删除的数据 + archived_tasks: Dict[str, dict] = self.get_data("archived") or {} + + current_time = time.time() + archive_threshold_seconds = self._brush_config.auto_archive_days * 86400 # 将天数转换为秒数 + + # 准备一个列表,记录所有需要从原始数据中删除的键 + keys_to_delete = set() + + # 遍历所有 torrent 条目 + for key, value in torrent_tasks.items(): + deleted_time = value.get("deleted_time") + # 场景 1: 检查任务是否已被标记为删除且超出保留天数 + if (value.get("deleted") and isinstance(deleted_time, (int, float)) and + current_time - deleted_time > archive_threshold_seconds): + keys_to_delete.add(key) + archived_tasks[key] = value + continue + + # 场景 2: 检查没有明确删除时间的历史数据 + if value.get("deleted") and deleted_time is None: + keys_to_delete.add(key) + archived_tasks[key] = value + continue + + # 从原始字典中移除已删除的条目 + for key in keys_to_delete: + del torrent_tasks[key] + + self.save_data("archived", archived_tasks) + def __archive_tasks(self): """ 归档已经删除的种子数据 @@ -3843,7 +3914,7 @@ class BrushFlow(_PluginBase): archived_tasks: Dict[str, dict] = self.get_data("archived") or {} # 准备一个列表,记录所有需要从原始数据中删除的键 - keys_to_delete = [] + keys_to_delete = set() # 遍历所有 torrent 条目 for key, value in torrent_tasks.items(): @@ -3852,7 +3923,7 @@ class BrushFlow(_PluginBase): # 如果是,加入到归档字典中 archived_tasks[key] = value # 记录键,稍后删除 - keys_to_delete.append(key) + keys_to_delete.add(key) # 从原始字典中移除已删除的条目 for key in keys_to_delete: diff --git a/plugins/customhosts/__init__.py b/plugins/customhosts/__init__.py index 849159f..ae0fe6a 100644 --- a/plugins/customhosts/__init__.py +++ b/plugins/customhosts/__init__.py @@ -18,7 +18,7 @@ class CustomHosts(_PluginBase): # 插件图标 plugin_icon = "hosts.png" # 插件版本 - plugin_version = "1.1" + plugin_version = "1.2" # 插件作者 plugin_author = "thsrite" # 作者主页 @@ -235,6 +235,12 @@ class CustomHosts(_PluginBase): for host in hosts: if not host: continue + host = host.strip() + if host.startswith('#'): # 检查是否为注释行 + host_entry = HostsEntry(entry_type='comment', comment=host) + new_entrys.append(host_entry) + continue + host_arr = str(host).split() try: host_entry = HostsEntry(entry_type='ipv4' if IpUtils.is_ipv4(str(host_arr[0])) else 'ipv6', diff --git a/plugins/dingdingmsg/__init__.py b/plugins/dingdingmsg/__init__.py new file mode 100644 index 0000000..280548f --- /dev/null +++ b/plugins/dingdingmsg/__init__.py @@ -0,0 +1,269 @@ +import re +import time +import hmac +import hashlib +import base64 +import urllib.parse + +from app.plugins import _PluginBase +from app.core.event import eventmanager, Event +from app.schemas.types import EventType, NotificationType +from app.utils.http import RequestUtils +from typing import Any, List, Dict, Tuple +from app.log import logger + + +class DingdingMsg(_PluginBase): + # 插件名称 + plugin_name = "钉钉机器人" + # 插件描述 + plugin_desc = "支持使用钉钉机器人发送消息通知。" + # 插件图标 + plugin_icon = "Dingding_A.png" + # 插件版本 + plugin_version = "1.12" + # 插件作者 + plugin_author = "nnlegenda" + # 作者主页 + author_url = "https://github.com/nnlegenda" + # 插件配置项ID前缀 + plugin_config_prefix = "dingdingmsg_" + # 加载顺序 + plugin_order = 25 + # 可使用的用户级别 + auth_level = 1 + + # 私有属性 + _enabled = False + _token = None + _secret = None + _msgtypes = [] + + def init_plugin(self, config: dict = None): + if config: + self._enabled = config.get("enabled") + self._token = config.get("token") + self._secret = config.get("secret") + self._msgtypes = config.get("msgtypes") or [] + + def get_state(self) -> bool: + return self._enabled and (True if self._token else False) and (True if self._secret else False) + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + pass + + def get_api(self) -> List[Dict[str, Any]]: + pass + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + # 编历 NotificationType 枚举,生成消息类型选项 + MsgTypeOptions = [] + for item in NotificationType: + MsgTypeOptions.append({ + "title": item.value, + "value": item.name + }) + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'token', + 'label': '钉钉机器人token', + 'placeholder': 'xxxxxx', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'secret', + 'label': '加签', + 'placeholder': 'SECxxx', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'multiple': True, + 'chips': True, + 'model': 'msgtypes', + 'label': '消息类型', + 'items': MsgTypeOptions + } + } + ] + } + ] + }, + ] + } + ], { + "enabled": False, + 'token': '', + 'msgtypes': [] + } + + def get_page(self) -> List[dict]: + pass + + @eventmanager.register(EventType.NoticeMessage) + def send(self, event: Event): + """ + 消息发送事件 + """ + if not self.get_state(): + return + + if not event.event_data: + return + + msg_body = event.event_data + # 渠道 + channel = msg_body.get("channel") + if channel: + return + # 类型 + msg_type: NotificationType = msg_body.get("type") + # 标题 + title = msg_body.get("title") + # 文本 + text = msg_body.get("text") + # 封面 + cover = msg_body.get("image") + + if not title and not text: + logger.warn("标题和内容不能同时为空") + return + + if (msg_type and self._msgtypes + and msg_type.name not in self._msgtypes): + logger.info(f"消息类型 {msg_type.value} 未开启消息发送") + return + + sc_url = self.url_sign(self._token, self._secret) + + try: + + if text: + # 对text进行Markdown特殊字符转义 + text = re.sub(r"([_`])", r"\\\1", text) + else: + text = "" + + if cover: + data = { + "msgtype": "markdown", + "markdown": { + "title": title, + "text": "### %s\n\n" + "\n\n" + "> %s\n\n > MoviePilot %s\n" % (title, cover, text, msg_type.value) + } + } + else: + data = { + "msgtype": "markdown", + "markdown": { + "title": title, + "text": "### %s\n\n" + "> %s\n\n > MoviePilot %s\n" % (title, text, msg_type.value) + } + } + res = RequestUtils(content_type="application/json").post_res(sc_url, json=data) + if res and res.status_code == 200: + ret_json = res.json() + errno = ret_json.get('errcode') + error = ret_json.get('errmsg') + if errno == 0: + logger.info("钉钉机器人消息发送成功") + else: + logger.warn(f"钉钉机器人消息发送失败,错误码:{errno},错误原因:{error}") + elif res is not None: + logger.warn(f"钉钉机器人消息发送失败,错误码:{res.status_code},错误原因:{res.reason}") + else: + logger.warn("钉钉机器人消息发送失败,未获取到返回信息") + except Exception as msg_e: + logger.error(f"钉钉机器人消息发送失败,{str(msg_e)}") + + def stop_service(self): + """ + 退出插件 + """ + pass + + def url_sign(self, access_token: str, secret: str) -> str: + """ + 加签 + """ + # 生成时间戳和签名 + timestamp = str(round(time.time() * 1000)) + secret_enc = secret.encode('utf-8') + string_to_sign = '{}\n{}'.format(timestamp, secret) + string_to_sign_enc = string_to_sign.encode('utf-8') + hmac_code = hmac.new(secret_enc, string_to_sign_enc, digestmod=hashlib.sha256).digest() + sign = urllib.parse.quote_plus(base64.b64encode(hmac_code)) + # 组合请求的完整 URL + full_url = f'https://oapi.dingtalk.com/robot/send?access_token={access_token}×tamp={timestamp}&sign={sign}' + return full_url diff --git a/plugins/dirmonitor/__init__.py b/plugins/dirmonitor/__init__.py index 2159523..0f0c957 100644 --- a/plugins/dirmonitor/__init__.py +++ b/plugins/dirmonitor/__init__.py @@ -330,7 +330,7 @@ class DirMonitor(_PluginBase): return # 不是媒体文件不处理 - if file_path.suffix not in settings.RMT_MEDIAEXT: + if file_path.suffix.casefold() not in map(str.casefold, settings.RMT_MEDIAEXT): logger.debug(f"{event_path} 不是媒体文件") return diff --git a/plugins/doubansync/__init__.py b/plugins/doubansync/__init__.py index 173367d..1c3508d 100644 --- a/plugins/doubansync/__init__.py +++ b/plugins/doubansync/__init__.py @@ -34,7 +34,7 @@ class DoubanSync(_PluginBase): # 插件图标 plugin_icon = "douban.png" # 插件版本 - plugin_version = "1.8" + plugin_version = "1.9.1" # 插件作者 plugin_author = "jxxghp" # 作者主页 @@ -498,6 +498,11 @@ class DoubanSync(_PluginBase): """ if not self._users: return + # 版本 + if hasattr(settings, 'VERSION_FLAG'): + version = settings.VERSION_FLAG # V2 + else: + version = "v1" # 读取历史记录 if self._clearflag: history = [] @@ -509,7 +514,12 @@ class DoubanSync(_PluginBase): continue logger.info(f"开始同步用户 {user_id} 的豆瓣想看数据 ...") url = self._interests_url % user_id - results = self.rsshelper.parse(url) + if version == "v2": + results = self.rsshelper.parse(url, headers={ + "User-Agent": settings.USER_AGENT + }) + else: + results = self.rsshelper.parse(url) if not results: logger.warn(f"未获取到用户 {user_id} 豆瓣RSS数据:{url}") continue diff --git a/plugins/dynamicwechat/__init__.py b/plugins/dynamicwechat/__init__.py new file mode 100644 index 0000000..c6d9dd5 --- /dev/null +++ b/plugins/dynamicwechat/__init__.py @@ -0,0 +1,1152 @@ +import io +import random +import re +import time +import base64 +from datetime import datetime, timedelta +from typing import Optional +from typing import Tuple, List, Dict, Any + +import pytz +import requests +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger +from playwright.sync_api import sync_playwright + +from app.core.config import settings +from app.core.event import eventmanager, Event +from app.helper.cookiecloud import CookieCloudHelper +from app.log import logger +from app.plugins import _PluginBase +from app.schemas.types import EventType + +from app.plugins.dynamicwechat.helper import PyCookieCloud, MySender + + +class DynamicWeChat(_PluginBase): + # 插件名称 + plugin_name = "动态企微可信IP" + # 插件描述 + plugin_desc = "修改企微应用可信IP,详细说明查看'作者主页',支持第三方通知。验证码以?结尾发送到企业微信应用" + # 插件图标 + plugin_icon = "Wecom_A.png" + # 插件版本 + plugin_version = "1.6.0" + # 插件作者 + plugin_author = "RamenRa" + # 作者主页 + author_url = "https://github.com/RamenRa/MoviePilot-Plugins" + # 插件配置项ID前缀 + plugin_config_prefix = "dynamicwechat_" + # 加载顺序 + plugin_order = 47 + # 可使用的用户级别 + auth_level = 2 + # 检测间隔时间,默认10分钟 + _refresh_cron = '*/20 * * * *' + + # ------------------------------------------私有属性------------------------------------------ + _enabled = False # 开关 + _cron = None + _onlyonce = False + # IP更改成功状态 + _ip_changed = False + # 强制更改IP + _forced_update = False + # CloudCookie服务器 + _cc_server = None + # 本地扫码开关 + _local_scan = False + # 类初始化时添加标记变量 + _is_special_upload = False + # 聚合通知 + _my_send = None + # 保存cookie + _saved_cookie = None + # 通知方式token/api + _notification_token = '' + + # 匹配ip地址的正则 + _ip_pattern = r'\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b' + # 获取ip地址的网址列表 + _ip_urls = ["https://myip.ipip.net", "https://ddns.oray.com/checkip", "https://ip.3322.net", "https://4.ipw.cn"] + # 当前ip地址 + _current_ip_address = '0.0.0.0' + # 企业微信登录 + _wechatUrl = 'https://work.weixin.qq.com/wework_admin/loginpage_wx?from=myhome' + + # 输入的企业应用id + _input_id_list = '' + # 二维码 + _qr_code_image = None + # 用户消息 + text = "" + # 手机验证码 + _verification_code = '' + # 过期时间 + _future_timestamp = 0 + # 配置文件路径 + _settings_file_path = None + + # cookie有效检测 + _cookie_valid = True + # cookie存活时间 + _cookie_lifetime = 0 + # 使用CookieCloud开关 + _use_cookiecloud = True + # 登录cookie + _cookie_header = "" + _server = f'http://localhost:{settings.NGINX_PORT}/cookiecloud' + + _cookiecloud = CookieCloudHelper() + # 定时器 + _scheduler: Optional[BackgroundScheduler] = None + + if hasattr(settings, 'VERSION_FLAG'): + version = settings.VERSION_FLAG # V2 + else: + version = "v1" + + def init_plugin(self, config: dict = None): + # 清空配置 + self._notification_token = '' + self._cron = '*/10 * * * *' + self._ip_changed = True + self._forced_update = False + self._use_cookiecloud = True + self._local_scan = False + self._input_id_list = '' + self._cookie_header = "" + self._settings_file_path = self.get_data_path() / "settings.json" + if config: + self._enabled = config.get("enabled") + self._notification_token = config.get("notification_token") + self._cron = config.get("cron") + self._onlyonce = config.get("onlyonce") + self._input_id_list = config.get("input_id_list") + self._current_ip_address = config.get("current_ip_address") + self._forced_update = config.get("forced_update") + self._local_scan = config.get("local_scan") + self._use_cookiecloud = config.get("use_cookiecloud") + self._cookie_header = config.get("cookie_header") + self._ip_changed = config.get("ip_changed") + if self.version != "v1": + self._my_send = MySender(self._notification_token, func=self.post_message) + else: + self._my_send = MySender(self._notification_token) + if not self._my_send.init_success: # 没有输入通知方式,不通知 + self._my_send = None + _, self._current_ip_address = self.get_ip_from_url(self._input_id_list) + # 停止现有任务 + self.stop_service() + if (self._enabled or self._onlyonce) and self._input_id_list: + # 定时服务 + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + # 运行一次定时服务 + if self._onlyonce: + if not self._forced_update or not self._local_scan: + # logger.info("立即检测公网IP") + self._scheduler.add_job(func=self.check, trigger='date', + run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3), + name="检测公网IP") # 添加任务 + # 关闭一次性开关 + self._onlyonce = False + + if self._forced_update: + if not self._local_scan: + logger.info("使用Cookie,强制更新公网IP") + self._scheduler.add_job(func=self.forced_change, trigger='date', + run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3), + name="强制更新公网IP") # 添加任务 + self._forced_update = False + + if self._local_scan: + logger.info("使用本地扫码登陆") + self._scheduler.add_job(func=self.local_scanning, trigger='date', + run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3), + name="本地扫码登陆") # 添加任务 + self._local_scan = False + + # 固定半小时周期请求一次地址,防止cookie失效 + try: + self._scheduler.add_job(func=self.refresh_cookie, + trigger=CronTrigger.from_crontab(self._refresh_cron), + name="延续企业微信cookie有效时间") + except Exception as err: + logger.error(f"定时任务配置错误:{err}") + self.systemmessage.put(f"执行周期配置错误:{err}") + + # 启动任务 + if self._scheduler.get_jobs(): + self._scheduler.print_jobs() + self._scheduler.start() + self.__update_config() + + def _send_cookie_false(self): + self._cookie_valid = False + if self._my_send: + result = self._my_send.send( + title="cookie已失效,请及时更新", + content="请在企业微信应用发送/push_qr, 如有验证码以'?'结束发送到企业微信应用。 如果使用’微信通知‘请确保公网IP还没有变动", + image=None, force_send=False + ) + if result: + logger.info(f"cookie失效通知发送失败,原因:{result}") + + @eventmanager.register(EventType.PluginAction) + def forced_change(self, event: Event = None): + """ + 强制修改IP + """ + if not self._enabled: + logger.error("插件未开启") + return + if event: + event_data = event.event_data + if not event_data or event_data.get("action") != "dynamicwechat": + return + # 先尝试cookie登陆 + try: + with sync_playwright() as p: + browser = p.chromium.launch(headless=True, args=['--lang=zh-CN']) + context = browser.new_context() + cookie = self.get_cookie() + if cookie: + context.add_cookies(cookie) + page = context.new_page() + page.goto(self._wechatUrl) + time.sleep(3) + if self.check_login_status(page, task='forced_change'): + self.click_app_management_buttons(page) + else: + logger.error("cookie失效,强制修改IP失败:请使用'本地扫码修改IP'") + self._cookie_valid = False + browser.close() + except Exception as err: + logger.error(f"强制修改IP失败:{err}") + + logger.info("----------------------本次任务结束----------------------") + + @eventmanager.register(EventType.PluginAction) + def local_scanning(self, event: Event = None): + """ + 本地扫码 + """ + if not self._enabled: + logger.error("插件未开启") + return + if event: + event_data = event.event_data + if not event_data or event_data.get("action") != "dynamicwechat": + return + try: + with sync_playwright() as p: + browser = p.chromium.launch(headless=True, args=['--lang=zh-CN']) + context = browser.new_context() + page = context.new_page() + page.goto(self._wechatUrl) + time.sleep(3) # 页面加载等待时间 + if self.find_qrc(page): + current_time = datetime.now() + future_time = current_time + timedelta(seconds=110) + self._future_timestamp = int(future_time.timestamp()) + logger.info("请重新进入插件面板扫码! 每20秒检查登录状态,最大尝试5次") + max_attempts = 5 + attempt = 0 + while attempt < max_attempts: + attempt += 1 + # logger.info(f"第 {attempt} 次检查登录状态...") + time.sleep(20) # 每20秒检查一次 + if self.check_login_status(page, task='local_scanning'): + self._update_cookie(page, context) # 刷新cookie + self.click_app_management_buttons(page) + break + else: + logger.info("用户可能没有扫码或登录失败") + else: + logger.error("未找到二维码,任务结束") + logger.info("----------------------本次任务结束----------------------") + browser.close() + except Exception as e: + logger.error(f"本地扫码任务: 本地扫码失败: {e}") + + @eventmanager.register(EventType.PluginAction) + def check(self, event: Event = None): + """ + 检测函数 + """ + if not self._enabled: + logger.error("插件未开启") + return + + if event: + event_data = event.event_data + if not event_data or event_data.get("action") != "dynamicwechat": + return + + if self._cookie_valid: + logger.info("开始检测公网IP") + if self.CheckIP(): + self.ChangeIP() + self.__update_config() + logger.info("----------------------本次任务结束----------------------") + else: + logger.warning("cookie已失效请及时更新,本次不检查公网IP") + + def CheckIP(self): + url, ip_address = self.get_ip_from_url(self._input_id_list) + if url and ip_address: + logger.info(f"IP获取成功: {url}: {ip_address}") + + # 如果所有 URL 请求失败 + if ip_address == "获取IP失败" or not url: + logger.error("获取IP失败 不操作可信IP") + return False + + elif not self._ip_changed: # 上次修改IP失败 + logger.info("上次IP修改IP失败 继续尝试修改IP") + self._current_ip_address = ip_address + return True + + # 检查 IP 是否变化 + if ip_address != self._current_ip_address: + logger.info("检测到IP变化") + self._current_ip_address = ip_address + return True + else: + return False + + def try_connect_cc(self): + if not self._use_cookiecloud: # 不使用CookieCloud + self._cc_server = None + return + if not settings.COOKIECLOUD_KEY or not settings.COOKIECLOUD_PASSWORD: # 没有设置key和password + self._cc_server = None + logger.error("没有配置CookieCloud的用户KEY和PASSWORD") + return + if settings.COOKIECLOUD_ENABLE_LOCAL: + self._cc_server = PyCookieCloud(url=self._server, uuid=settings.COOKIECLOUD_KEY, + password=settings.COOKIECLOUD_PASSWORD) + logger.info("使用内建CookieCloud服务器") + else: # 使用设置里的cookieCloud + self._cc_server = PyCookieCloud(url=settings.COOKIECLOUD_HOST, uuid=settings.COOKIECLOUD_KEY, + password=settings.COOKIECLOUD_PASSWORD) + logger.info("使用自定义CookieCloud服务器") + if not self._cc_server.check_connection(): + self._cc_server = None + logger.error("没有可用的CookieCloud服务器") + + def get_ip_from_url(self, input_data) -> (str, str): + # 根据输入解析 URL 列表 + if isinstance(input_data, str) and "||" in input_data: + _, url_list = input_data.split("||", 1) + urls = url_list.split(",") + elif isinstance(input_data, list): + urls = input_data + else: + urls = self._ip_urls + + # 随机化 URL 列表 + random.shuffle(urls) + + for url in urls: + try: + response = requests.get(url, timeout=3) + if response.status_code == 200: + ip_address = re.search(self._ip_pattern, response.text) + if ip_address: + return url, ip_address.group() # 返回匹配的 IP 地址 + except Exception as e: + if "104" not in str(e) or 'Read timed out' not in str(e): # 忽略网络波动,都失败会返回None, "获取IP失败" + logger.warning(f"{url} 获取IP失败, Error: {e}") + return None, "获取IP失败" + + def find_qrc(self, page): + # 查找 iframe 元素并切换到它 + try: + page.wait_for_selector("iframe", timeout=5000) # 等待 iframe 加载 + iframe_element = page.query_selector("iframe") + frame = iframe_element.content_frame() + + # 查找二维码图片元素 + qr_code_element = frame.query_selector("img.qrcode_login_img") + if qr_code_element: + # logger.info("找到二维码图片元素") + # 保存二维码图片 + qr_code_url = qr_code_element.get_attribute('src') + if qr_code_url.startswith("/"): + qr_code_url = "https://work.weixin.qq.com" + qr_code_url # 补全二维码 URL + + qr_code_data = requests.get(qr_code_url).content + self._qr_code_image = io.BytesIO(qr_code_data) + refuse_time = (datetime.now() + timedelta(seconds=115)).strftime("%Y-%m-%d %H:%M:%S") + return qr_code_url, refuse_time + else: + logger.warning("未找到二维码") + return None, None + except Exception as e: + logger.debug(str(e)) + return None, None + + def ChangeIP(self): + logger.info("开始请求企业微信管理更改可信IP") + try: + with sync_playwright() as p: + # 启动 Chromium 浏览器并设置语言为中文 + browser = p.chromium.launch(headless=True, args=['--lang=zh-CN']) + context = browser.new_context() + cookie = self.get_cookie() + if cookie: + context.add_cookies(cookie) + page = context.new_page() + page.goto(self._wechatUrl) + time.sleep(3) + img_src, refuse_time = self.find_qrc(page) + if img_src: + if self._my_send: # 统一逻辑,只有用户发送'/push_qr'才会发生二维码 + self._ip_changed = False + self._send_cookie_false() + logger.info("已尝试发送cookie失效通知") + else: + self._ip_changed = False + self._cookie_valid = False + logger.info("cookie已失效,且没有配置通知方式,本次修改可信IP失败") + else: # 如果直接进入企业微信 + logger.info("尝试cookie登录") + if self.check_login_status(page, ""): + self.click_app_management_buttons(page) + else: + logger.info("发生了意料之外的错误,请附上配置信息到github反馈") + self._send_cookie_false() + self._ip_changed = False + browser.close() + except Exception as e: + self._ip_changed = False + logger.error(f"更改可信IP失败: {e}") + finally: + pass + + def _update_cookie(self, page, context): + self._future_timestamp = 0 # 标记二维码失效 + PyCookieCloud.save_cookie_lifetime(self._settings_file_path, 0) # 重置cookie存活时间 + if self._use_cookiecloud: + if not self._cc_server: # 连接失败返回 False + self.try_connect_cc() # 再尝试一次连接 + if self._cc_server is None: + return + logger.info("使用二维码登录成功,开始刷新cookie") + try: + if not self._cc_server.check_connection(): + logger.error("连接 CookieCloud 失败", self._server) + return + current_url = page.url + current_cookies = context.cookies(current_url) # 通过 context 获取 cookies + if current_cookies is None: + logger.error("无法从内置浏览器获取 cookies") + self._cookie_valid = False + return + self._saved_cookie = current_cookies + formatted_cookies = {} + for cookie in current_cookies: + domain = cookie.get('domain') # 使用 get() 方法避免 KeyError + if domain is None: + continue # 跳过没有 domain 的 cookie + + if domain not in formatted_cookies: + formatted_cookies[domain] = [] + formatted_cookies[domain].append(cookie) + if self._cc_server.update_cookie(formatted_cookies): + logger.info("更新 CookieCloud 成功") + self._cookie_valid = True + self._is_special_upload = True + else: + self._send_cookie_false() + self._is_special_upload = False + logger.error("更新 CookieCloud 失败") + + except Exception as e: + self._send_cookie_false() + self._is_special_upload = False + logger.error(f"CookieCloud更新 cookie 发生错误: {e}") + else: + try: + current_url = page.url + current_cookies = context.cookies(current_url) # 通过 context 获取 cookies + if current_cookies is None: + self._send_cookie_false() + logger.error("更新本地 Cookie失败") + return + else: + logger.info("更新本地 Cookie成功") + self._saved_cookie = current_cookies # 保存 + self._cookie_valid = True + except Exception as e: + self._send_cookie_false() + logger.error(f"更新本地 cookie 发生错误: {e}") + + def get_cookie(self): + if self._saved_cookie and self._cookie_valid: + return self._saved_cookie + try: + cookie_header = '' + if not self._use_cookiecloud: + return + cookies, msg = self._cookiecloud.download() + if not cookies: # CookieCloud获取cookie失败 + logger.error(f"CookieCloud获取cookie失败,失败原因:{msg}") + return + for domain, cookie in cookies.items(): + if domain == ".work.weixin.qq.com": + cookie_header = cookie + if '_upload_type=A' in cookie: + self._is_special_upload = True + else: + self._is_special_upload = False + break + if cookie_header == '': + cookie_header = self._cookie_header + cookie = self.parse_cookie_header(cookie_header) + return cookie + except Exception as e: + logger.error(f"从CookieCloud获取cookie错误,错误原因:{e}") + return + + @staticmethod + def parse_cookie_header(cookie_header): + cookies = [] + for cookie in cookie_header.split(';'): + name, value = cookie.strip().split('=', 1) + cookies.append({ + 'name': name, + 'value': value, + 'domain': '.work.weixin.qq.com', + 'path': '/' + }) + return cookies + + def refresh_cookie(self): # 保活 + try: + with sync_playwright() as p: + browser = p.chromium.launch(headless=True, args=['--lang=zh-CN']) + context = browser.new_context() + cookie_used = False + if self._saved_cookie: + # logger.info("尝试使用本地保存的 cookie") + context.add_cookies(self._saved_cookie) + page = context.new_page() + page.goto(self._wechatUrl) + time.sleep(3) + if self.check_login_status(page, task='refresh_cookie'): + # logger.info("本地保存的 cookie 有效") + self._cookie_valid = True + cookie_used = True + else: + # logger.warning("本地保存的 cookie 无效") + self._cookie_valid = False + self._saved_cookie = None # 清空无效的 cookie + + if not cookie_used and self._use_cookiecloud: + # logger.info("尝试从CookieCloud 获取新的 cookie") + cookie = self.get_cookie() + if not cookie: + self._send_cookie_false() + return + context.add_cookies(cookie) + page = context.new_page() + page.goto(self._wechatUrl) + time.sleep(3) + if self.check_login_status(page, task='refresh_cookie'): + # logger.info("新获取的 cookie 有效") + self._cookie_valid = True + self._saved_cookie = context.cookies() # 保存有效的 cookie + else: + # logger.warning("新获取的 cookie 无效") + self._send_cookie_false() + self._saved_cookie = None # 清空无效的 cookie + + if self._cookie_valid: + if self._my_send: + self._my_send.reset_limit() + PyCookieCloud.increase_cookie_lifetime(self._settings_file_path, 1200) + self._cookie_lifetime = PyCookieCloud.load_cookie_lifetime(self._settings_file_path) + browser.close() + except Exception as e: + self._send_cookie_false() + self._saved_cookie = None # 异常时清空 cookie + logger.error(f"cookie 校验过程中发生异常: {e}") + + # + def check_login_status(self, page, task): + # 等待页面加载 + time.sleep(3) + # 检查是否需要进行短信验证 + if task != 'refresh_cookie': + logger.info("检查登录状态...") + try: + # 先检查登录成功后的页面状态 + success_element = page.wait_for_selector('#check_corp_info', timeout=5000) # 检查登录成功的元素 + if success_element: + if task != 'refresh_cookie': + logger.info("登录成功!") + return True + except Exception as e: + logger.debug(str(e)) + pass + + try: + # 在这里使用更安全的方式来检查元素是否存在 + captcha_panel = page.wait_for_selector('.receive_captcha_panel', timeout=5000) # 检查验证码面板 + if captcha_panel: # 出现了短信验证界面 + if task == 'local_scanning': + time.sleep(6) + else: + logger.info("等待30秒,请将短信验证码请以'?'结束,发送到<企业微信应用> 如: 110301?") + time.sleep(30) # 多等30秒 + if self._verification_code: + # logger.info("输入验证码:" + self._verification_code) + for digit in self._verification_code: + page.keyboard.press(digit) + time.sleep(0.3) # 每个数字之间添加少量间隔以确保输入顺利 + confirm_button = page.wait_for_selector('.confirm_btn', timeout=5000) # 获取确认按钮 + confirm_button.click() # 点击确认 + time.sleep(3) # 等待处理 + # 等待登录成功的元素出现 + success_element = page.wait_for_selector('#check_corp_info', timeout=5000) + if success_element: + logger.info("验证码登录成功!") + return True + else: + logger.error("未收到短信验证码") + return False + except Exception as e: + # logger.debug(str(e)) # 基于bug运行,请不要将错误输出到日志 + # try: # 没有登录成功,也没有短信验证码 + if self.find_qrc( + page) and not task == 'refresh_cookie' and not task == 'local_scanning': # 延长任务找到的二维码不会被发送,所以不算用户没有扫码 + logger.warning(f"用户没有扫描二维码") + return False + + def click_app_management_buttons(self, page): + self._cookie_valid = True + bash_url = "https://work.weixin.qq.com/wework_admin/frame#apps/modApiApp/" + # 按钮的选择器和名称 + buttons = [ + # ("//span[@class='frame_nav_item_title' and text()='应用管理']", "应用管理"), + # ("//div[@class='app_index_item_title ' and contains(text(), 'MoviePilot')]", "MoviePilot"), + ( + "//div[contains(@class, 'js_show_ipConfig_dialog')]//a[contains(@class, '_mod_card_operationLink') and text()='配置']", + "配置") + ] + _, self._current_ip_address = self.get_ip_from_url(self._input_id_list) + if "||" in self._input_id_list: + parts = self._input_id_list.split("||", 1) + input_id_list = parts[0] + else: + input_id_list = self._input_id_list + id_list = input_id_list.split(",") + app_urls = [f"{bash_url}{app_id.strip()}" for app_id in id_list] + for app_url in app_urls: + app_id = app_url.split("/")[-1] + if app_id.startswith("100000") and len(app_id) == 6: + self._ip_changed = False + logger.warning(f"请根据 https://github.com/RamenRa/MoviePilot-Plugins 的说明进行配置应用ID") + return + page.goto(app_url) # 打开应用详情页 + time.sleep(2) + # 依次点击每个按钮 + for xpath, name in buttons: + # 等待按钮出现并可点击 + try: + button = page.wait_for_selector(xpath, timeout=5000) # 等待按钮可点击 + button.click() + # logger.info(f"已点击 '{name}' 按钮") + page.wait_for_selector('textarea.js_ipConfig_textarea', timeout=5000) + # logger.info(f"已找到文本框") + input_area = page.locator('textarea.js_ipConfig_textarea') + confirm = page.locator('.js_ipConfig_confirmBtn') + input_area.fill(self._current_ip_address) # 填充 IP 地址 + confirm.click() # 点击确认按钮 + time.sleep(3) # 等待处理 + self._ip_changed = True + except Exception as e: + logger.error(f"未能找打开{app_url}或点击 '{name}' 按钮异常: {e}") + self._ip_changed = False + if "disabled" in str(e): + logger.info(f"应用{app_id} 已被禁用,可能是没有设置接收api") + if self._ip_changed: + logger.info(f"应用: {app_id} 输入IP:" + self._current_ip_address) + ip_parts = self._current_ip_address.split('.') + masked_ip = f"{ip_parts[0]}.{len(ip_parts[1]) * '*'}.{len(ip_parts[2]) * '*'}.{ip_parts[3]}" + if self._my_send: + self._my_send.send(title="更新可信IP成功", + content='应用: ' + app_id + ' 输入IP:' + masked_ip, + force_send=True, diy_channel="WeChat") + + def __update_config(self): + """ + 更新配置 + """ + self.update_config({ + "enabled": self._enabled, + "onlyonce": self._onlyonce, + "cron": self._cron, + "notification_token": self._notification_token, + "current_ip_address": self._current_ip_address, + "ip_changed": self._ip_changed, + "forced_update": self._forced_update, + "local_scan": self._local_scan, + "input_id_list": self._input_id_list, + "cookie_header": self._cookie_header, + "use_cookiecloud": self._use_cookiecloud, + }) + + def get_state(self) -> bool: + return self._enabled + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,只保留必要的配置项,并添加 token 配置。 + """ + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'onlyonce', + 'label': '立即检测一次', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'forced_update', + 'label': '强制更新IP', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'use_cookiecloud', + 'label': '使用CookieCloud', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'local_scan', + 'label': '本地扫码修改IP', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'cron', + 'label': '[必填]检测周期', + 'placeholder': '0 * * * *' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'notification_token', + 'label': '[可选] 通知方式', + 'rows': 1, + 'placeholder': '支持微信、Server酱、PushPlus、AnPush等Token或API' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'input_id_list', + 'label': '[必填]应用ID', + 'rows': 1, + 'placeholder': '输入应用ID,多个ID用英文逗号分隔。在企业微信应用页面URL末尾获取' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '建议启用内建或自定义CookieCloud。支持微信、Server酱等第三方通知,具体请查看作者主页' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'text': 'Cookie失效时通知用户,用户使用/push_qr让插件推送二维码。使用第三方通知时填写对应Token/API' + } + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "cron": "", + "onlyonce": False, + "forceUpdate": False, + "use_cookiecloud": True, + "use_local_qr": False, + "cookie_header": "", + "notification_token": "", + "input_id_list": "" + } + + def get_page(self) -> List[dict]: + # 获取当前时间戳 + current_time = datetime.now().timestamp() + + # 判断二维码是否过期 + if current_time > self._future_timestamp: + vaild_text = "二维码已过期或没有扫码任务" + color = "#ff0000" if self._enabled else "#bbbbbb" + self._qr_code_image = None + else: + # 二维码有效,格式化过期时间为 年-月-日 时:分:秒 + expiration_time = datetime.fromtimestamp(self._future_timestamp).strftime('%Y-%m-%d %H:%M:%S') + vaild_text = f"二维码有效,过期时间: {expiration_time}" + color = "#32CD32" + + # 如果self._qr_code_image为None,返回提示信息 + if self._qr_code_image is None: + img_component = { + "component": "div", + "text": "登录二维码都会在此展示,二维码有6秒延时。 [适用于Docker版]", + "props": { + "style": { + "fontSize": "22px", + "color": "#ff0000", + "textAlign": "center", + "margin": "20px" + } + } + } + else: + # 获取二维码图片数据 + qr_image_data = self._qr_code_image.getvalue() + # 将图片数据转为 base64 编码 + base64_image = base64.b64encode(qr_image_data).decode('utf-8') + img_src = f"data:image/png;base64,{base64_image}" + + # 生成图片组件 + img_component = { + "component": "img", + "props": { + "src": img_src, + "style": { + "width": "auto", + "height": "auto", + "maxWidth": "100%", + "maxHeight": "100%", + "display": "block", + "margin": "0 auto" + } + } + } + if self._is_special_upload: + # 计算 cookie_lifetime 的天数、小时数和分钟数 + cookie_lifetime_days = self._cookie_lifetime // 86400 # 一天的秒数为 86400 + cookie_lifetime_hours = (self._cookie_lifetime % 86400) // 3600 # 计算小时数 + cookie_lifetime_minutes = (self._cookie_lifetime % 3600) // 60 # 计算分钟数 + bg_color = "#40bb45" if self._cookie_valid else "#ff0000" + cookie_lifetime_text = f"Cookie 已使用: {cookie_lifetime_days}天{cookie_lifetime_hours}小时{cookie_lifetime_minutes}分钟" + + cookie_lifetime_component = { + "component": "div", + "text": cookie_lifetime_text, + "props": { + "style": { + "fontSize": "18px", + "color": "#ffffff", + "backgroundColor": bg_color, + "padding": "10px", + "borderRadius": "5px", + "textAlign": "center", + "marginTop": "10px", + "display": "block" + } + } + } + else: + cookie_lifetime_component = None # 不生成该组件 + + base_content = [ + { + "component": "div", + "props": { + "style": { + "textAlign": "center" + } + }, + "content": [ + { + "component": "div", + "props": { + "style": { + "display": "flex", + "justifyContent": "center", + "alignItems": "center", + "flexDirection": "column", # 垂直排列 + "gap": "10px" # 控制间距 + } + }, + "content": [ + { + "component": "div", + "text": vaild_text, + "props": { + "style": { + "fontSize": "22px", + "fontWeight": "bold", + "color": "#ffffff", + "backgroundColor": color, + "padding": "8px", + "borderRadius": "5px", + "textAlign": "center", + "marginBottom": "10px", + "display": "inline-block" + } + } + }, + cookie_lifetime_component if cookie_lifetime_component else {}, + ] + }, + img_component # 二维码图片或提示信息 + ] + } + ] + + return base_content + + @eventmanager.register(EventType.PluginAction) + def push_qr_code(self, event: Event = None): + """ + 立即发送二维码 + """ + if not self._enabled: + return + if event: + event_data = event.event_data + if not event_data or event_data.get("action") != "push_qrcode": + return + try: + with sync_playwright() as p: + # 启动 Chromium 浏览器并设置语言为中文 + browser = p.chromium.launch(headless=True, args=['--lang=zh-CN']) + context = browser.new_context() + page = context.new_page() + page.goto(self._wechatUrl) + time.sleep(3) + image_src, refuse_time = self.find_qrc(page) + if image_src: + if self._my_send: + result = self._my_send.send("企业微信登录二维码", image=image_src) + if result: + logger.info(f"远程推送任务: 二维码发送失败,原因:{result}") + browser.close() + logger.info("----------------------本次任务结束----------------------") + return + logger.info("远程推送任务: 二维码发送成功,等待用户 90 秒内扫码登录。V2'微信通知'的用户,此消息并不准确") + # logger.info("远程推送任务: 如收到短信验证码请以?结束,发送到<企业微信应用> 如: 110301?") + time.sleep(90) + if self.check_login_status(page, 'push_qr_code'): + self._update_cookie(page, context) # 刷新cookie + # logger.info("远程推送任务: 没有可用的CookieCloud服务器,只修改可信IP") + self.click_app_management_buttons(page) + else: + logger.warning("远程推送任务: 没有找到可用的通知方式") + else: + logger.warning("远程推送任务: 未找到二维码") + browser.close() + logger.info("----------------------本次任务结束----------------------") + except Exception as e: + logger.error(f"远程推送任务: 推送二维码失败: {e}") + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + return [ + { + "cmd": "/push_qr", + "event": EventType.PluginAction, + "desc": "立即推送登录二维码", + "category": "", + "data": { + "action": "push_qrcode" + } + } + ] + + def get_api(self) -> List[Dict[str, Any]]: + pass + + @eventmanager.register(EventType.UserMessage) + def talk(self, event: Event): + """ + 监听用户消息 + """ + if not self._enabled: + return + self.text = event.event_data.get("text") + if self.text[:6].isdigit() and len(self.text) == 7: + self._verification_code = self.text[:6] + logger.info(f"收到验证码:{self._verification_code}") + + def get_service(self) -> List[Dict[str, Any]]: + """ + 注册插件公共服务 + [{ + "id": "服务ID", + "name": "服务名称", + "trigger": "触发器:cron/interval/date/CronTrigger.from_crontab()", + "func": self.xxx, + "kwargs": {} # 定时器参数 + }] + """ + if self._enabled and self._cron: + # logger.info(f"{self.plugin_name}定时服务启动,时间间隔 {self._cron} ") + return [{ + "id": self.__class__.__name__, + "name": f"{self.plugin_name}服务", + "trigger": CronTrigger.from_crontab(self._cron), + "func": self.check, + "kwargs": {} + }] + + def stop_service(self): + """ + 退出插件 + """ + try: + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._scheduler.shutdown() + self._scheduler = None + except Exception as e: + logger.error(str(e)) diff --git a/plugins/dynamicwechat/helper.py b/plugins/dynamicwechat/helper.py new file mode 100644 index 0000000..0df72d4 --- /dev/null +++ b/plugins/dynamicwechat/helper.py @@ -0,0 +1,296 @@ +import re +import requests +from app.modules.wechat import WeChat +from app.schemas.types import NotificationType,MessageChannel + +import os +import json +import requests +import base64 +import hashlib +from typing import Dict, Any +from Crypto import Random +from Crypto.Cipher import AES + + +def bytes_to_key(data: bytes, salt: bytes, output=48) -> bytes: + # 兼容v2 将bytes_to_key和encrypt导入 + assert len(salt) == 8, len(salt) + data += salt + key = hashlib.md5(data).digest() + final_key = key + while len(final_key) < output: + key = hashlib.md5(key + data).digest() + final_key += key + return final_key[:output] + + +def encrypt(message: bytes, passphrase: bytes) -> bytes: + """ + CryptoJS 加密原文 + + This is a modified copy of https://stackoverflow.com/questions/36762098/how-to-decrypt-password-from-javascript-cryptojs-aes-encryptpassword-passphras + """ + salt = Random.new().read(8) + key_iv = bytes_to_key(passphrase, salt, 32 + 16) + key = key_iv[:32] + iv = key_iv[32:] + aes = AES.new(key, AES.MODE_CBC, iv) + length = 16 - (len(message) % 16) + data = message + (chr(length) * length).encode() + return base64.b64encode(b"Salted__" + salt + aes.encrypt(data)) + + +class PyCookieCloud: + def __init__(self, url: str, uuid: str, password: str): + self.url: str = url + self.uuid: str = uuid + self.password: str = password + + def check_connection(self) -> bool: + """ + Test the connection to the CookieCloud server. + + :return: True if the connection is successful, False otherwise. + """ + try: + resp = requests.get(self.url, timeout=3) # 设置超时为3秒 + return resp.status_code == 200 + except Exception as e: + return False + + def update_cookie(self, formatted_cookies: Dict[str, Any]) -> bool: + """ + Update cookie data to CookieCloud. + + :param formatted_cookies: cookie value to update. + :return: if update success, return True, else return False. + """ + if '.work.weixin.qq.com' not in formatted_cookies: + formatted_cookies['.work.weixin.qq.com'] = [] + formatted_cookies['.work.weixin.qq.com'].append({ + 'name': '_upload_type', + 'value': 'A', + 'domain': '.work.weixin.qq.com', + 'path': '/', + 'expires': -1, + 'httpOnly': False, + 'secure': False, + 'sameSite': 'Lax' + }) + + cookie = {'cookie_data': formatted_cookies} + raw_data = json.dumps(cookie) + encrypted_data = encrypt(raw_data.encode('utf-8'), self.get_the_key().encode('utf-8')).decode('utf-8') + cookie_cloud_request = requests.post(self.url + '/update', + json={'uuid': self.uuid, 'encrypted': encrypted_data}) + if cookie_cloud_request.status_code == 200: + if cookie_cloud_request.json().get('action') == 'done': + return True + return False + + def get_the_key(self) -> str: + """ + Get the key used to encrypt and decrypt data. + + :return: the key. + """ + md5 = hashlib.md5() + md5.update((self.uuid + '-' + self.password).encode('utf-8')) + return md5.hexdigest()[:16] + + @staticmethod + def load_cookie_lifetime(settings_file: str = None): # 返回时间戳 单位秒 + if os.path.exists(settings_file): + with open(settings_file, 'r') as file: + settings = json.load(file) + return settings.get('_cookie_lifetime', 0) + else: + return 0 + + @staticmethod + def save_cookie_lifetime(settings_file, cookie_lifetime): # 传入时间戳 单位秒 + with open(settings_file, 'w') as file: + json.dump({'_cookie_lifetime': cookie_lifetime}, file) + + @staticmethod + def increase_cookie_lifetime(settings_file, seconds: int): + if os.path.exists(settings_file): + with open(settings_file, 'r') as file: + settings = json.load(file) + current_lifetime = settings.get('_cookie_lifetime', 0) + else: + current_lifetime = 0 + new_lifetime = current_lifetime + seconds + # 保存新的 _cookie_lifetime + PyCookieCloud.save_cookie_lifetime(settings_file, new_lifetime) + + +class MySender: + def __init__(self, token=None, func=None): + self.tokens = token.split('||') if token and '||' in token else [token] if token else [] + self.channels = [MySender._detect_channel(t) for t in self.tokens] + self.current_index = 0 # 当前使用的 token 和 channel 的索引 + self.first_text_sent = False # 是否已发送过纯文本消息 + self.init_success = bool(self.tokens) # 标识初始化是否成功 + self.post_message_func = func # V2 微信模式的 post_message 方法 + + @staticmethod + def _detect_channel(token): + """根据 token 确定通知渠道""" + if "WeChat" in token: + return "WeChat" + + letters_only = ''.join(re.findall(r'[A-Za-z]', token)) + if token.lower().startswith("sct"): + return "ServerChan" + elif letters_only.isupper(): + return "AnPush" + else: + return "PushPlus" + + def send(self, title, content=None, image=None, force_send=False, diy_channel=None): + """发送消息""" + if not self.init_success: + return + + # 对纯文本消息进行限制 + if not image and not force_send: + if self.first_text_sent: + return + self.first_text_sent = True + + # 如果指定了自定义通道,直接尝试发送 + if diy_channel: + return self._try_send(title, content, image, diy_channel) + + # 尝试按顺序发送,直到成功或遍历所有通道 + for i in range(len(self.tokens)): + token = self.tokens[self.current_index] + channel = self.channels[self.current_index] + try: + result = self._try_send(title, content, image, channel, token) + if result is None: # 成功时返回 None + return + except Exception as e: + pass # 忽略单个错误,继续尝试下一个通道 + self.current_index = (self.current_index + 1) % len(self.tokens) + return f"所有的通知方式都发送失败" + + def _try_send(self, title, content, image, channel, token=None): + """尝试使用指定通道发送消息""" + if channel == "WeChat" and self.post_message_func: + return self._send_v2_wechat(title, content, image, token) + elif channel == "WeChat": + return self._send_wechat(title, content, image, token) + elif channel == "ServerChan": + return self._send_serverchan(title, content, image) + elif channel == "AnPush": + return self._send_anpush(title, content, image) + elif channel == "PushPlus": + return self._send_pushplus(title, content, image) + else: + raise ValueError(f"Unknown channel: {channel}") + + @staticmethod + def _send_wechat(title, content, image, token): + wechat = WeChat() + if token and ',' in token: + channel, actual_userid = token.split(',', 1) + else: + actual_userid = None + if image: + send_status = wechat.send_msg(title='企业微信登录二维码', image=image, link=image, userid=actual_userid) + else: + send_status = wechat.send_msg(title=title, text=content, userid=actual_userid) + + if send_status is None: + return "微信通知发送错误" + return None + + def _send_serverchan(self, title, content, image): + tmp_tokens = self.tokens[self.current_index] + if ',' in tmp_tokens: + before_comma, after_comma = tmp_tokens.split(',', 1) + if before_comma.startswith('sctp') and image: + token = after_comma # 图片发到公众号 + else: + token = before_comma # 发到 server3 + else: + token = tmp_tokens + + if token.startswith('sctp'): + match = re.match(r'sctp(\d+)t', token) + if match: + num = match.group(1) + url = f'https://{num}.push.ft07.com/send/{token}.send' + else: + return '错误的Server3 Sendkey' + else: + url = f'https://sctapi.ftqq.com/{token}.send' + + params = {'title': title, 'desp': f'' if image else content} + headers = {'Content-Type': 'application/json;charset=utf-8'} + response = requests.post(url, json=params, headers=headers) + result = response.json() + if result.get('code') != 0: + return f"Server酱通知错误: {result.get('message')}" + return None + + def _send_anpush(self, title, content, image): + token = self.tokens[self.current_index] # 获取当前通道对应的 token + if ',' in token: + channel, token = token.split(',', 1) + else: + return "可能AnPush 没有配置消息通道ID" + url = f"https://api.anpush.com/push/{token}" + payload = { + "title": title, + "content": f"