diff --git a/f2/apps/__apps__.py b/f2/apps/__apps__.py index 0ef1a92..b752bcf 100644 --- a/f2/apps/__apps__.py +++ b/f2/apps/__apps__.py @@ -8,6 +8,7 @@ _twitch = ["twitch", "tv"] _neteasy_music = ["neteasy_music", "ntm"] _little_red_book = ["little_red_book", "lrb"] +_bark = ["bark", "bk"] __all__ = [ "_douyin", @@ -20,4 +21,5 @@ "_twitch", "_neteasy_music", "_little_red_book", + "_bark", ] diff --git a/f2/apps/bark/cli.py b/f2/apps/bark/cli.py new file mode 100644 index 0000000..18e11f4 --- /dev/null +++ b/f2/apps/bark/cli.py @@ -0,0 +1,359 @@ +# path: f2/apps/bark/cli.py + +import f2 +import click +import typing + +from pathlib import Path + +from f2 import helps +from f2.cli.cli_commands import set_cli_config +from f2.log.logger import logger +from f2.utils.utils import merge_config, get_resource_path, check_proxy_avail +from f2.utils.conf_manager import ConfigManager +from f2.i18n.translator import TranslationManager, _ +from f2.apps.bark.utils import ClientConfManager + + +def handler_help( + ctx: click.Context, + param: typing.Union[click.Option, click.Parameter], + value: typing.Any, +) -> None: + """ + 处理帮助信息 (Handle help messages) + + 根据提供的值显示帮助信息或退出上下文 + (Display help messages based on the provided value or exit the context) + + Args: + ctx: click的上下文对象 (Click's context object). + param: 提供的参数或选项 (The provided parameter or option). + value: 参数或选项的值 (The value of the parameter or option). + """ + + if not value or ctx.resilient_parsing: + return + helps.get_help("bark") + ctx.exit() + + +def handler_language( + ctx: click.Context, + param: typing.Union[click.Option, click.Parameter], + value: typing.Any, +) -> typing.Any: + """用于设置语言 (For setting the language) + + Args: + ctx: click的上下文对象 (Click's context object) + param: 提供的参数或选项 (The provided parameter or option) + value: 参数或选项的值 (The value of the parameter or option) + """ + + if not value or ctx.resilient_parsing: + return + TranslationManager.get_instance().set_language(value) + global _ + _ = TranslationManager.get_instance().gettext + return value + + +def validate_key_length( + ctx: click.Context, + param: typing.Union[click.Option, click.Parameter], + value: typing.Any, +) -> typing.Any: + """验证密钥长度 (Validate the length of the key) + + Args: + ctx: click的上下文对象 (Click's context object) + param: 提供的参数或选项 (The provided parameter or option) + value: 参数或选项的值 (The value of the parameter or option) + """ + + if value and len(value) != 22: + raise click.BadParameter(_("密钥长度应该为22位")) + return value + + +def validate_proxies( + ctx: click.Context, + param: typing.Union[click.Option, click.Parameter], + value: typing.Any, +) -> typing.Any: + """验证代理参数 (Validate proxy parameters) + + Args: + ctx: click的上下文对象 (Click's context object) + param: 提供的参数或选项 (The provided parameter or option) + value: 参数或选项的值 (The value of the parameter or option) + """ + + if value: + # 校验代理参数是否合法的代理参数 + if not all([value[0].startswith("http://"), value[1].startswith("http://")]): + raise click.BadParameter( + _( + "代理参数应该以'http://'和'http://'开头,在大多数情况下,https:// 应使用 http:// 方案" + ) + ) + # 校验代理服务器是否可用 + if not check_proxy_avail( + http_proxy=value[0], + https_proxy=value[1], + test_url="https://bark.day.app/", + ): + raise click.BadParameter(_("代理服务器不可用")) + + return value + + +@click.command(name="bark", help=_("Bark 是一个通知推送工具")) +@click.option( + "--config", + "-c", + type=click.Path(file_okay=True, dir_okay=False, readable=True), # exists=True + help=_("配置文件路径,最低优先"), +) +@click.option( + "--token", + "-k", + type=str, + help=_("Bark 的 Token"), + callback=validate_key_length, +) +@click.option( + "--mode", + "-M", + type=click.Choice(["get", "post"]), + # default="get", + # required=True, + help=_( + "选择发送模式,get:使用 GET 请求发送通知,post:使用 POST 请求发送通知,默认为 get" + ), +) +@click.option( + "--title", + "-t", + type=str, + help=_("推送的标题"), +) +@click.option( + "--body", + "-b", + type=str, + help=_("推送的内容"), +) +@click.option( + "--ciphertext", + "-ct", + type=str, + help=_("推送密文,需在 APP 设置中开启加密推送"), +) +@click.option( + "--call", + "-cl", + type=bool, + # default=False, + help=_("是否持续响铃,默认不响铃"), +) +@click.option( + "--level", + "-l", + type=click.Choice(["active", "timeSensitive", "passive"]), + # default="active", + help=_("推送中断级别。active:默认,timeSensitive:时效性通知,passive:被动通知"), +) +@click.option( + "--badge", + "-bd", + type=int, + # default=1, + help=_("推送的角标数量"), +) +@click.option( + "--autoCopy", + "-ac", + type=bool, + # default=True, + help=_("是否自动复制推送内容(iOS 14.5 及以上需手动长按复制)"), +) +@click.option( + "--copy", + "-cp", + type=str, + help=_("指定要复制的内容,若未指定则复制整个推送内容"), +) +@click.option( + "--sound", + "-s", + type=click.Choice( + [ + "birdsong", + "alarm", + "chord", + "dog", + "guitar", + "piano", + "ring", + "robot", + "siren", + "trumpet", + "vibrate", + "none", + ] + ), + # default="birdsong", + help=_("推送铃声,可选项请查看 APP 设置"), +) +@click.option( + "--icon", + "-i", + type=str, + help=_("推送图标 URL,相同的图标 URL 仅下载一次"), +) +@click.option( + "--group", + "-g", + type=str, + # default="默认", + help=_("推送分组,通知中心将按分组显示推送"), +) +@click.option( + "--isArchive", + "-a", + type=bool, + # default=True, + help=_("是否保存推送,默认保存"), +) +@click.option( + "--url", + "-u", + type=str, + help=_("点击推送时跳转的 URL,支持 URL Scheme 和 Universal Link"), +) +@click.option( + "--proxies", + "-P", + type=str, + nargs=2, + help=_( + "代理服务器,最多 2 个参数,http://与https://。空格区分 2 个参数 http://x.x.x.x http://x.x.x.x (没有拼写错误,某些情况下,https:// 应使用 http:// 方案)" + ), + callback=validate_proxies, +) +@click.option( + "--update-config", + type=bool, + is_flag=True, + help=_("使用命令行选项更新配置文件。需要先使用'-c'选项提供一个配置文件路径"), +) +@click.option( + "--init-config", type=str, help=_("初始化配置文件。不能同时初始化和更新配置文件") +) +@click.option( + "-h", + is_flag=True, + is_eager=True, + expose_value=False, + help=_("显示富文本帮助"), + callback=handler_help, +) +@click.pass_context +def bark( + ctx: click.Context, + config: str, + init_config: str, + update_config: bool, + **kwargs, +): + ################## + # f2 存在2个主配置文件,分别是app低频配置(app.yaml)和f2低频配置(conf.yaml) + # app低频配置存放app相关的参数 + # f2低频配置存放计算值所需的参数 + + # 其中cli参数具有最高优先,cli >= 自定义 >= 低频 + # 在f2低频配置中设置代理参数 + # 在app低频配置中设置好后端接口的url,加密参数等 + # 在自定义配置中可以设置不同用户的高频参数,如用户主页,原声下载,封面下载,文案下载,下载模式等 + # cli参数为配置文件的热修改,可以随时修改每一个参数。 + ################## + + # 读取低频主配置文件 + main_manager = ConfigManager(f2.APP_CONFIG_FILE_PATH) + main_conf_path = get_resource_path(f2.APP_CONFIG_FILE_PATH) + main_conf = main_manager.get_config("bark") + + # 更新主配置文件中的代理参数 + main_conf["proxies"] = ClientConfManager.proxies() + + # 如果初始化配置文件,则与更新配置文件互斥 + if init_config and not update_config: + main_manager.generate_config("bark", init_config) + return + elif init_config: + raise click.UsageError(_("不能同时初始化和更新配置文件")) + # 如果没有初始化配置文件,但是更新配置文件,则需要提供配置文件路径 + elif update_config and not config: + raise click.UsageError( + _("要更新配置,首先需要使用'-c'选项提供一个自定义配置文件路径") + ) + + # 读取自定义配置文件 + if config: + custom_manager = ConfigManager(config) + else: + custom_manager = main_manager + config = main_conf_path + + custom_conf = custom_manager.get_config("bark") + + if update_config: # 如果指定了 update_config,更新配置文件 + update_manger = ConfigManager(config) + update_manger.update_config_with_args("bark", **kwargs) + return + + # 将kwargs["proxies"]中的tuple转换为dict + if kwargs.get("proxies"): + kwargs["proxies"] = { + "http://": kwargs["proxies"][0], + "https://": kwargs["proxies"][1], + } + + # 从低频配置开始到高频配置再到cli参数,逐级覆盖,如果键值不存在使用父级的键值 + kwargs = merge_config(main_conf, custom_conf, **kwargs) + + # 从配置文件中获取 token,如果命令行没有传入 token + token = kwargs.get("token") or main_conf.get("token") + + # 验证 token 的长度(无论从命令行还是配置文件获取) + try: + validate_key_length(ctx, None, token) + except click.BadParameter as e: + logger.error(str(e)) + ctx.exit(1) + + kwargs["token"] = token + + logger.info(_("密钥:{0}").format(kwargs.get("token"))) + logger.info(_("主配置路径:{0}").format(main_conf_path)) + logger.info(_("自定义配置路径:{0}").format(Path.cwd() / config)) + logger.debug(_("主配置参数:{0}").format(main_conf)) + logger.debug(_("自定义配置参数:{0}").format(custom_conf)) + logger.debug(_("CLI参数:{0}").format(kwargs)) + + # 尝试从命令行参数或kwargs中获取token和body,mode + missing_params = [param for param in ["body", "mode"] if not kwargs.get(param)] + + if missing_params: + logger.error( + _( + "Bark CLI 缺乏必要参数:[cyan]{0}[/cyan]。详情请查看帮助,[yellow]f2 bark -h/--help[/yellow]" + ).format(",".join(missing_params)) + ) + handler_help(ctx, None, True) + + # 添加app_name到kwargs + kwargs["app_name"] = "bark" + ctx.invoke(set_cli_config, **kwargs) diff --git a/f2/apps/bark/crawler.py b/f2/apps/bark/crawler.py new file mode 100644 index 0000000..603e435 --- /dev/null +++ b/f2/apps/bark/crawler.py @@ -0,0 +1,47 @@ +# path: f2/apps/bark/crawler.py + +from typing import Dict + +from f2.log.logger import logger +from f2.i18n.translator import _ +from f2.crawlers.base_crawler import BaseCrawler +from f2.utils.utils import BaseEndpointManager +from f2.apps.bark.model import BarkModel +from f2.apps.bark.utils import ClientConfManager + + +class BarkCrawler(BaseCrawler): + def __init__( + self, + kwargs: Dict = ..., + ): + # 需要与cli同步 + proxies = kwargs.get("proxies", {"http://": None, "https://": None}) + self.server_endpoint = ClientConfManager.client().get("url") + kwargs.get( + "token" + ) + if ClientConfManager.encryption().get("enable"): + self.encryption = ClientConfManager.encryption() + super().__init__(proxies=proxies, crawler_headers=kwargs.get("headers", {})) + + async def fetch_bark_notification(self, params: BarkModel) -> Dict: + endpoint = BaseEndpointManager.model_2_endpoint( + self.server_endpoint, + params.model_dump(by_alias=True), + ) + logger.debug(_("Bark 通知接口地址(GET):{0}").format(endpoint)) + return await self._fetch_get_json(endpoint) + + async def post_bark_notification(self, params: BarkModel) -> Dict: + endpoint = BaseEndpointManager.model_2_endpoint( + self.server_endpoint, + params.model_dump(by_alias=True), + ) + logger.debug(_("Bark 通知接口地址(POST):{0}").format(endpoint)) + return await self._fetch_post_json(endpoint, params.model_dump()) + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + await self.close() diff --git a/f2/apps/bark/filter.py b/f2/apps/bark/filter.py new file mode 100644 index 0000000..ece9b65 --- /dev/null +++ b/f2/apps/bark/filter.py @@ -0,0 +1,25 @@ +# path: f2/apps/bark/filter.py + +from typing import Dict +from f2.utils.json_filter import JSONModel +from f2.utils.utils import timestamp_2_str + + +class BarkNotificationFilter(JSONModel): + @property + def code(self): + return self._get_attr_value("$.code") + + @property + def message(self): + return self._get_attr_value("$.message") + + @property + def timestamp(self): + return timestamp_2_str(str(self._get_attr_value("$.timestamp"))) + + def _to_raw(self) -> Dict: + return self._data + + def _to_dict(self) -> Dict: + return {k: v for k, v in self.__dict__.items() if not k.startswith("_")} diff --git a/f2/apps/bark/handler.py b/f2/apps/bark/handler.py new file mode 100644 index 0000000..a6cdc8e --- /dev/null +++ b/f2/apps/bark/handler.py @@ -0,0 +1,76 @@ +# path: f2/apps/bark/handler.py + +from typing import Dict + +from f2.log.logger import logger +from f2.i18n.translator import _ +from f2.utils.decorators import mode_handler, mode_function_map +from f2.apps.bark.crawler import BarkCrawler +from f2.apps.bark.model import BarkModel +from f2.apps.bark.filter import BarkNotificationFilter + + +class BarkHandler: + + def __init__(self, kwargs: Dict = ...) -> None: + self.kwargs = kwargs + + async def _send_bark_notification(self, send_method: str) -> BarkNotificationFilter: + """ + 发送Bark通知的辅助方法。 + + Args: + send_method (str): 调用的发送方法("fetch" 或 "post") + kwargs (Dict): 通知参数 + + Returns: + BarkNotificationFilter: 处理后的Bark通知过滤结果 + """ + logger.info(_("正在发送Bark通知")) + + # 获取并确保 body 存在 + self.kwargs["body"] = self.kwargs.get("body", "无内容") + + try: + async with BarkCrawler(self.kwargs) as crawler: + params = BarkModel(**self.kwargs) + # 动态调用发送方法 + if send_method == "fetch": + response = await crawler.fetch_bark_notification(params) + elif send_method == "post": + response = await crawler.post_bark_notification(params) + else: + raise ValueError(_("无效的发送方法:{0}").format(send_method)) + + bark = BarkNotificationFilter(response) + # 原本status_code应该放接口code中,但由于bark接口将响应的状态码直接设置为了响应的code + # 所以这里不判断code + logger.info( + _("Bark通知发送成功,内容:{0},时间:{1}").format( + self.kwargs["body"], bark.timestamp + ) + ) + return bark + + except Exception as e: + logger.error(_("Bark 通知发送失败,请检查token和网络连接:{0}").format(e)) + + return None + + @mode_handler("get") + async def fetch_bark_notification(self) -> BarkNotificationFilter: + """用于发送Bark通知 (fetch 方式)""" + return await self._send_bark_notification("fetch") + + @mode_handler("post") + async def post_bark_notification(self) -> BarkNotificationFilter: + """用于发送Bark通知 (post 方式)""" + return await self._send_bark_notification("post") + + +async def main(kwargs): + mode = kwargs.get("mode") + if mode in mode_function_map: + await mode_function_map[mode](BarkHandler(kwargs)) + else: + logger.error(_("不存在该模式:{0}").format(mode)) diff --git a/f2/apps/bark/help.py b/f2/apps/bark/help.py new file mode 100644 index 0000000..961b119 --- /dev/null +++ b/f2/apps/bark/help.py @@ -0,0 +1,94 @@ +# path: f2/apps/bark/help.py + +from rich.console import Console +from rich.panel import Panel +from rich.table import Table + +from f2.i18n.translator import _ + + +def help() -> None: + # 真彩 + console = Console(color_system="truecolor") + table = Table(highlight=True, box=None, show_header=False) + table.add_column("OPTIONS", no_wrap=True, justify="left", style="bold") + table.add_column("Type", no_wrap=True, justify="left", style="bold") + table.add_column("Description", no_wrap=True) + + options = [ + ("-c --config", "[dark_cyan]Path", _("配置文件的路径,[red]最低优先[/red]")), + ("-k --token", "[dark_cyan]str", _("Bark 的 token")), + ( + "-M --mode", + "[dark_cyan]Choice", + _( + "选择发送模式,get:使用 GET 请求发送通知,post:使用 POST 请求发送通知,默认为 get" + ), + ), + ("-t --title", "[dark_cyan]str", _("推送的标题")), + ("-b --body", "[dark_cyan]str", _("推送的内容")), + ( + "-ct --ciphertext", + "[dark_cyan]str", + _("推送密文,需在 APP 设置中开启加密推送"), + ), + ("-cl --call", "[dark_cyan]Bool", _("是否持续响铃,默认不响铃")), + ( + "-l --level", + "[dark_cyan]Choice", + _( + "推送中断级别。active:默认,timeSensitive:时效性通知,passive:被动通知" + ), + ), + ("-bd --badge", "[dark_cyan]str", _("推送的角标数量")), + ( + "-ac --autoCopy", + "[dark_cyan]Bool", + _("是否自动复制推送内容(iOS 14.5 及以上需手动长按复制)"), + ), + ( + "-cp --copy", + "[dark_cyan]Bool", + _("指定要复制的内容,若未指定则复制整个推送内容"), + ), + ("-s --sound", "[dark_cyan]str", _("推送铃声,可选项请查看 APP 设置")), + ("-i --icon", "[dark_cyan]str", _("推送图标 URL,相同的图标 URL 仅下载一次")), + ("-g --group", "[dark_cyan]str", _("推送分组,通知中心将按分组显示推送")), + ("-a --isArchive", "[dark_cyan]Bool", _("是否保存推送,默认保存")), + ( + "-u --url", + "[dark_cyan]str", + _("点击推送时跳转的 URL,支持 URL Scheme 和 Universal Link"), + ), + ( + "-P --proxies", + "[dark_cyan]str", + _( + "代理服务器,空格区分 2 个参数 http://x.x.x.x:xxxx http://x.x.x.x:xxxx (某些情况下,https:// 应使用 http:// 方案)" + ), + ), + ( + "--update-config", + "[dark_cyan]Bool", + _("使用命令行选项更新配置文件。需要先使用'-c'选项提供一个配置文件路径"), + ), + ( + "--init-config", + "[dark_cyan]Bool", + _("初始化配置文件。不能同时初始化和更新配置文件"), + ), + ("--help", "[dark_cyan]Flag", _("显示经典帮助信息")), + ("-h", "[dark_cyan]Bool", _("显示富文本帮助")), + ( + "", + "", + _( + "更加详细的参数说明请点击[link=https://johnserf-seed.github.io/f2/site-config.html][dark_violet]前往文档[/dark_violet][/]查看" + ), + ), + ] + + for option in options: + table.add_row(*option) + + console.print(Panel(table, border_style="bold", title="[Bark]", title_align="left")) diff --git a/f2/apps/bark/model.py b/f2/apps/bark/model.py new file mode 100644 index 0000000..cb16fc2 --- /dev/null +++ b/f2/apps/bark/model.py @@ -0,0 +1,26 @@ +# path:f2/apps/bark/model.py + +from typing import Optional, Literal +from pydantic import BaseModel, Field + + +# Base Model +class BarkModel(BaseModel): + title: Optional[str] + body: str + sound: Optional[str] + call: Optional[int] + isArchive: Optional[int] + icon: Optional[str] + group: Optional[str] + # ciphertext: Optional[str] = "" + level: Optional[Literal["active", "timeSensitive", "passive"]] + url: Optional[str] + copy_text: Optional[str] = Field( + "", alias="copy" + ) # 'copy' 使用别名 'copy_text',原因是关键词冲突 + badge: Optional[int] + autoCopy: Optional[int] + + class Config: + populate_by_name = True diff --git a/f2/apps/bark/utils.py b/f2/apps/bark/utils.py new file mode 100644 index 0000000..d962b5c --- /dev/null +++ b/f2/apps/bark/utils.py @@ -0,0 +1,30 @@ +# path: f2/apps/bark/utils.py + +import f2 + +from f2.utils.conf_manager import ConfigManager + + +class ClientConfManager: + """ + 用于管理客户端配置 (Used to manage client configuration) + """ + + client_conf = ConfigManager(f2.F2_CONFIG_FILE_PATH).get_config("f2") + bark_conf = client_conf.get("bark", {}) + + @classmethod + def client(cls) -> dict: + return cls.bark_conf + + @classmethod + def token(cls) -> str: + return cls.client().get("token", "") + + @classmethod + def proxies(cls) -> dict: + return cls.client().get("proxies", {}) + + @classmethod + def encryption(cls) -> dict: + return cls.client().get("encryption", {}) diff --git a/f2/conf/app.yaml b/f2/conf/app.yaml index a1ebc51..c366193 100644 --- a/f2/conf/app.yaml +++ b/f2/conf/app.yaml @@ -52,3 +52,22 @@ weibo: max_counts: 0 max_tasks: 5 page_counts: 20 + +bark: + + token: + mode: get + retry: 3 + ringtones: 1 + icon: https://johnserf-seed.github.io/f2/f2-logo-with-shadow.png + level: active + call: 0 + isArchive: 1 + sound: birdsong + autoCopy: 1 + title: F2 + body: "" + call: 0 + copy: "" + group: 下载统计 + badge: 1 \ No newline at end of file diff --git a/f2/conf/conf.yaml b/f2/conf/conf.yaml index 815c3d8..73c9d1f 100644 --- a/f2/conf/conf.yaml +++ b/f2/conf/conf.yaml @@ -3,9 +3,17 @@ f2: show_update: true bark: - url: https://api.day.app + url: https://api.day.app/ key: "" + encryption: + enable: false + algorithms: AES128 + mode: CBC + padding: PKCS7 + key: "" + iv: "" + douyin: encryption: ab diff --git a/f2/conf/defaults.yaml b/f2/conf/defaults.yaml index b40400b..8e99c7b 100644 --- a/f2/conf/defaults.yaml +++ b/f2/conf/defaults.yaml @@ -69,4 +69,22 @@ twitter: max_counts: max_tasks: page_counts: - languages: \ No newline at end of file + languages: + +bark: + token: + title: + body: + mode: + retry: + ringtones: + icon: + level: + call: + isArchive: + sound: + autoCopy: + call: + copy: + group: + badge: