很荣幸能收到然哥的邀请，共同完善HexoHub项目。一个基于 Electron + Next.js 构建的Hexo博客管理桌面应用程序，提供图形化界面来替代传统的命令行操作正如前文所述，HexoHUB使用的是Electron + Next.js的架构，但是由于Electron应用打包出来的体积非常大（大概安装后286M，安装包85M左右），根据一通优化，成功把安装后的体积缩小到254M左右，但还是非常大。查询了一下主要体积都是Electron里面的浏览器框架太大了，于是我们一通商量，能不能再把Electron删掉呢？我想到了Tauri，这是一个跨端的、和Electron的框架，果不其然，在我们之后的新框架选型中，我和然哥都一致认为他相当优秀。这是我们打的第一个包，由我在原先的Electron版本，迁移到Rust的后端完成的基于Tauri版本。好吧，我们都小小的震惊了一把，那时候感觉似乎Tauri也挺简单的……吃到了甜头，我们正式发起了Tauri分支，在往后的半周里，我们有反反复复发现了好多BUG……尤其是Tauri的文件拖曳、本地文件的获取、本地的文件操作、联网操作、Shell操作，都和Electron的不一样，而且但凡是加载程序内部以外的东西，都要提前申请Permission，我们苦不堪言……为了避免Tauri版本出问题，我们依然保留了Electron，设计了多平台的打包自动工作流。Tauri的文件拖曳是一个需要注意的点，默认其实Tauri是会拦截你的文件拖曳的……具体的，你需要在tauri.conf.json中，在windows项关掉它的拦截行为，不然你的Html是收不到拖曳信号的。文件操作、联网操作、Shell操作都需要用到专门的组建来完成。还有Tauri的环境是不能直接以 file:// 形式载入本地资源，需要用convertFileSrc转换，etc...相关文章不过我们还是很成功的完成了大部分的测试，相对于之前的v2.6.0版本，有以下更新：新架构新增Tauri框架，实现Electron+Tauri双选择包体积大幅减小，支持Win/Mac/Linux服务器渲染预览方式、本地壁纸文件读取、本地文件复制、联网操作、Shell操作、进程残留修复问题在Tauri上重构新功能图片拖拽自动复制到资源文件夹部署失败详细提示，点击跳转日志页面权限错误自动修复更新日志支持Markdown渲染更新页面显示当前环境安装程序上会对Win下的node.js环境自动检测并提示跨平台优化Windows中文在部分机器的乱码问题多平台多架构的路径/时间显示代码和工作流优化统一API接口清理重复代码自动化发布流程其中，图片拖拽自动复制在双框架上的实现,由我负责了Tauri端，然哥完成了Electron端。Tauri端的大部分代码是我这边完成（毕竟我这边更熟悉Tauri一点），然后很打一方面的测试是然哥完成的~有点累哇，成就感满满~
博客服务器迁徙 —— 至三年来腾讯云陪伴的青春
最近腾讯云服务器快到期了，把博客从腾讯云搬到了阿里云。其实，我挺想继续留在腾讯云，但不得不说，钱包顶不住。续费价格翻了好几倍，轻量云的配置又有些鸡肋。相比之下，阿里云我现有机子直接用还更划算，于是就下了搬家的决定。 为了怀念，我翻了下支付记录，发现当初这台机子只花了两百多块，性价比真的没话说。 现在回头看看，真是“互联网早年红利”的见证者。腾讯云的稳定性确实让我很满意，阿里云虽然划算，但99一年的机子有IO限流的问题让我有些无奈，有时跑个npm都会卡到要重启机子…… 最后，打开宝塔面板，看一眼承载了许多回忆的服务器： 它曾经跑过不少项目，每一个都记录着学习和成长的点滴： 虽然有些不舍，但这次搬家是新的开始。再见，腾讯云。
六零导航页-file-任意文件上传漏洞复现和修复
我先援引一下其它博主的内容：lylme_spage六零导航页，未授权webshell上传。php 的文件上传的漏洞，使用$_FILE['type']来判断文件类型是否在白名单内，该值是由浏览器提供的，不是可靠的文件类型验证方法。概况与复现lylme_spage，六零导航页，“六零导航页 (LyLme Spage) 致力于简洁高效无广告的上网导航和搜索入口，支持后台添加链接、自定义搜索引擎，沉淀最具价值链接，全站无商业推广，简约而不简单。”，项目地址：https://github.com/LyLme/lylme_spage。影响版本v1.9.5实际搭建六零导航页后，关闭收录功能，发现漏洞并没有关闭。主要问题在于 /include/file.php 。漏洞概述六零导航页是一款开源的导航页面，设计简洁、无广告，具备可自定义的搜索引擎和隐私链接功能。然而，由于其 /include/file.php 接口存在任意文件上传漏洞，未经授权的攻击者可以通过这个接口上传任意文件。利用此漏洞，攻击者可能将恶意代码上传至服务器，并取得执行权限，进而控制系统。漏洞利用的 Proof of Concept (PoC)可以通过以下的 HTTP POST 请求来利用此漏洞：POST /include/file.php HTTP/1.1 Host: x.x.x.x User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:125.0) Gecko/20100101 Firefox/125.0 Content-Type: multipart/form-data; boundary=---------------------------575673989461736 Content-Length: 234 -----------------------------575673989461736 Content-Disposition: form-data; name="file"; filename="test.php" Content-Type: image/png <?php echo "HelloWorldtest"; unlink(__FILE__); ?> -----------------------------575673989461736--解释：这个请求向 /include/file.php 发送了带有 .php 扩展名的文件 test.php，文件内容包含简单的 PHP 代码：<?php echo "HelloWorldtest"; unlink(__FILE__); ?>。此代码在上传后执行时会显示 HelloWorldtest，然后删除自身 (unlink(__FILE__))。若成功上传并执行，则证明此漏洞存在。漏洞检测脚本可以使用 Python 检测脚本来验证系统是否存在该漏洞： GitHub，通过下列指令批量检测：url.txt 包含需要测试的目标 URL 清单。python 60NavigationPage_CVE-2024-34982_ArbitraryFileUploads.py -f url.txt或python 60NavigationPage_CVE-2024-34982_ArbitraryFileUploads.py -u http://xxxx.xxx.top修复方法网上都说官方已针对此漏洞发布补丁，然而并没有找到。所以让我们手动来修复吧~将上传目录设置为不可执行首先，一个很直接的方法就是直接禁止文件上传目录的php执行，这样就算能上传，也不能执行恶意脚本~在Nginx下对站点加上如下配置：location /files/upload/ { disable_symlinks on; autoindex off; location ~ \.php$ { return 403; } }如果用的是Apache服务器，则在上传目录中添加 .htaccess 文件（针对 Apache 服务器）：php_flag engine off RemoveHandler .php .phtml .php3修复漏洞文件file.php 的主要问题包括：文件类型验证不足：仅依赖文件扩展名和用户提供的 MIME 类型，容易被绕过。缺乏对上传文件内容的验证：未确认文件实际内容是否为有效图片。那我们据此开始修复漏洞1. 验证上传的 URL在 download_img 函数中，添加对传入 URL 的合法性验证，防止非法 URL 导致的安全问题。修改前：function download_img($url) { // 原有代码 }修改后：function download_img($url) { // 验证 URL 是否有效 if (!filter_var($url, FILTER_VALIDATE_URL)) { exit(json_encode(['code' => '-2', 'msg' => '无效的URL'])); } // 继续原有代码 }2. 严格验证文件扩展名和 MIME 类型在 validate_file_type 函数中，同时验证文件的扩展名和实际的 MIME 类型，防止通过修改扩展名或 MIME 类型绕过验证。修改前：function validate_file_type($type) { switch ($type) { case 'jpeg': case 'jpg': $type = 'image/jpeg'; break; case 'png': $type = 'image/png'; break; case 'gif': $type = 'image/gif'; break; case 'ico': $type = 'image/x-icon'; break; } $allowed_types = array("image/jpeg", "image/png", "image/gif", "image/x-icon"); return in_array($type, $allowed_types); }修改后：function validate_file_type($extension, $mime_type = null) { $allowed_extensions = ['jpg', 'jpeg', 'png', 'gif', 'ico']; $allowed_mime_types = [ 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', 'gif' => 'image/gif', 'ico' => 'image/x-icon', ]; // 验证扩展名 if (!in_array(strtolower($extension), $allowed_extensions)) { return false; } // 如果提供了 MIME 类型，进一步验证 if ($mime_type) { return isset($allowed_mime_types[strtolower($extension)]) && $allowed_mime_types[strtolower($extension)] === $mime_type; } return true; }3. 使用 finfo 获取真实的 MIME 类型在 upload_img 函数中，使用 finfo_file 获取上传文件的真实 MIME 类型，而不是依赖用户提供的 $_FILES["type"]。修改前：$type = $upfile["type"];修改后：// 使用 finfo 获取真实的 MIME 类型 $finfo = finfo_open(FILEINFO_MIME_TYPE); $mime_type = finfo_file($finfo, $upfile["tmp_name"]); finfo_close($finfo);并在调用 validate_file_type 时传入 $mime_type：if (!validate_file_type($img_ext, $mime_type)) { exit(json_encode(['code' => '-4', 'msg' => '上传的图片类型不支持'])); }4. 验证文件内容是否为有效图片在 upload_img 和 download_img 函数中，使用 getimagesize 确认文件实际内容是有效的图片。在 upload_img 函数中添加：// 验证文件内容是否为有效图片 $image_info = getimagesize($upfile["tmp_name"]); if ($image_info === false) { exit(json_encode(['code' => '-4', 'msg' => '上传的文件不是有效图片'])); }在 download_img 函数中添加：// 验证下载的文件是否为有效图片 $tmp_file = tempnam(sys_get_temp_dir(), 'img_'); file_put_contents($tmp_file, $data); $image_info = getimagesize($tmp_file); if ($image_info === false) { unlink($tmp_file); exit(json_encode(['code' => '-4', 'msg' => '抓取的文件不是有效图片'])); } unlink($tmp_file);5. 限制文件大小确保上传和下载的文件大小不超过设定的最大值（如5MB）。在 download_img 函数中：if ($size > $maxsize) { exit(json_encode([ 'code' => '-1', 'msg' => '抓取的图片超过' . ($maxsize / pow(1024, 2)) . 'M，当前为：' . round($size / pow(1024, 2), 2) . 'M' ])); }在 upload_img 函数中：if ($upfile["size"] > $maxsize) { exit(json_encode(['code' => '-1', 'msg' => '图片不能超过' . ($maxsize / pow(1024, 2)) . 'M'])); }完整修改<?php header('Content-Type: application/json'); require_once("common.php"); define('SAVE_PATH', 'files/'); // 保存路径 // 1. 下载图片函数 function download_img($url) { if (!filter_var($url, FILTER_VALIDATE_URL)) { exit(json_encode(['code' => '-2', 'msg' => '无效的URL'])); } $IMG_NAME = uniqid("img_"); // 文件名 $maxsize = pow(1024, 2) * 5; // 文件大小5M $size = remote_filesize($url); // 文件大小 if ($size > $maxsize) { exit(json_encode([ 'code' => '-1', 'msg' => '抓取的图片超过' . ($maxsize / pow(1024, 2)) . 'M，当前为：' . round($size / pow(1024, 2), 2) . 'M' ])); } $img_ext = strtolower(pathinfo(parse_url($url, PHP_URL_PATH), PATHINFO_EXTENSION)); if (!validate_file_type($img_ext)) { exit(json_encode(['code' => '-4', 'msg' => '抓取的图片类型不支持'])); } $img_name = $IMG_NAME . '.' . $img_ext; $dir = ROOT . SAVE_PATH . 'download/'; $save_to = $dir . $img_name; if (!is_dir($dir)) { mkdir($dir, 0755, true); } // 使用 curl 下载图片 // ... (保持原有 curl 代码不变) // 验证下载的文件是否为有效图片 $tmp_file = tempnam(sys_get_temp_dir(), 'img_'); file_put_contents($tmp_file, $data); $image_info = getimagesize($tmp_file); if ($image_info === false) { unlink($tmp_file); exit(json_encode(['code' => '-4', 'msg' => '抓取的文件不是有效图片'])); } unlink($tmp_file); // 保存文件 if (file_put_contents($save_to, $data) === false) { exit(json_encode(['code' => '-1', 'msg' => '保存图片失败'])); } $fileurl = '/' . SAVE_PATH . 'download/' . $img_name; echo json_encode([ 'code' => '200', 'msg' => '抓取图片成功', 'url' => $fileurl, 'size' => round($fileSize / 1024, 2) . 'KB' ]); return $save_to; } // 2. 上传图片函数 function upload_img($upfile) { if ($upfile['error'] !== UPLOAD_ERR_OK) { exit(json_encode(['code' => '-1', 'msg' => '文件上传错误'])); } $IMG_NAME = uniqid("img_"); // 文件名 $maxsize = pow(1024, 2) * 5; // 文件大小5M $dir = ROOT . SAVE_PATH . 'upload/'; if (!is_dir($dir)) { mkdir($dir, 0755, true); } // 获取真实的 MIME 类型 $finfo = finfo_open(FILEINFO_MIME_TYPE); $mime_type = finfo_file($finfo, $upfile["tmp_name"]); finfo_close($finfo); // 获取文件扩展名 $img_ext = strtolower(pathinfo($upfile["name"], PATHINFO_EXTENSION)); // 验证文件类型 if (!validate_file_type($img_ext, $mime_type)) { exit(json_encode(['code' => '-4', 'msg' => '上传的图片类型不支持'])); } // 验证文件大小 if ($upfile["size"] > $maxsize) { exit(json_encode(['code' => '-1', 'msg' => '图片不能超过' . ($maxsize / pow(1024, 2)) . 'M'])); } // 验证文件内容是否为有效图片 $image_info = getimagesize($upfile["tmp_name"]); if ($image_info === false) { exit(json_encode(['code' => '-4', 'msg' => '上传的文件不是有效图片'])); } $img_name = $IMG_NAME . '.' . $img_ext; $save_to = $dir . $img_name; $url = '/' . SAVE_PATH . 'upload/' . $img_name; if (!move_uploaded_file($upfile["tmp_name"], $save_to)) { exit(json_encode(['code' => '-1', 'msg' => '上传失败'])); } echo json_encode(['code' => '200', 'msg' => '上传成功', 'url' => $url]); return $save_to; } // 3. 文件验证函数 function validate_file_type($extension, $mime_type = null) { $allowed_extensions = ['jpg', 'jpeg', 'png', 'gif', 'ico']; $allowed_mime_types = [ 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', 'gif' => 'image/gif', 'ico' => 'image/x-icon', ]; if (!in_array($extension, $allowed_extensions)) { return false; } if ($mime_type) { return isset($allowed_mime_types[$extension]) && $allowed_mime_types[$extension] === $mime_type; } return true; } // 4. 主逻辑处理 if (empty($_POST["url"]) && !empty($_FILES["file"])) { $filename = upload_img($_FILES["file"]); if (isset($islogin) && $islogin == 1 && isset($_GET["crop"]) && $_GET["crop"] == "no") { // 不压缩图片 exit(); } } elseif (!empty($_POST["url"])) { $filename = download_img($_POST["url"]); } else { exit(json_encode(['code' => '0', 'msg' => 'error'])); } imagecropper($filename, 480, 480); ?>效果只进行上传目录执行限制时，上传文件后访问其文件：修复文件漏洞后，尝试用60NavigationPage_CVE-2024-34982_ArbitraryFileUploads.py对漏洞文件发起攻击。
项目背景赛氪（Saikr）是一家专注于大学生竞赛活动的平台，致力于为全国大学生提供高含金量的竞赛信息、报名和成绩查询服务。 平台涵盖英语竞赛、数学竞赛、编程挑战赛、知识竞赛等多种赛事，知名竞赛如全国大学生英语竞赛、蓝桥杯、互联网+大学生创新创业大赛等均可在赛氪上报名和查询成绩。说白了，赛氪是一个有一堆比赛的平台，应该涵盖了我们在大学期间能接触到的至少70%的校级以上比赛。当然，里面的水赛也特别多……我们学院参加各类竞赛并获奖可以申报专项奖学金，虽然金额不多，但是至少可以回本。参加的比赛必须满足：除优秀奖之外，总获奖率≤50%。赛氪是一个很好的比赛信息来源之一。截止24年11月3日笔者撰写本文，赛氪现在共登记有51032个比赛，除去不能报名的，至少也有上百个比赛，再除去不合申报要求的比赛，恐怕更少了。需求拆解标记看过的比赛。屏蔽水赛或不满足我要求的比赛。标记报名了的比赛，提醒我参赛。方案选型数据爬取: 使用 Python 和 requests 库爬取赛氪网的比赛信息。数据存储: 采用 MySQL 数据库存储爬取到的比赛信息。后端处理: 使用 PHP 作为后端语言进行数据处理与接口管理。前端展示: 使用 H5 和 Bootstrap 框架构建前端用户界面。数据更新: 青龙面板设置每日定时任务，自动更新和同步比赛信息。效果展示源码展示在这里，我先开源MySQL版的赛氪比赛抓取器。剩下的内容整理优化后再开源到gitee或者github.Python和数据库部分使用前，请建立一个MYSQL数据库，并输入以下建表指令：表1：contests（如果只想让Python爬虫跑起来，建立这个表就行）CREATE TABLE `contests` ( `contest_id` INT(11) NOT NULL AUTO_INCREMENT, `contest_url` VARCHAR(255) NOT NULL, `contest_name` VARCHAR(255) DEFAULT NULL, `org_real_name` VARCHAR(255) DEFAULT NULL, `org_avatar` VARCHAR(255) DEFAULT NULL, `org_univs_name` VARCHAR(255) DEFAULT NULL, `contest_start_time` DATETIME DEFAULT NULL, `contest_end_time` DATETIME DEFAULT NULL, `regist_start_time` DATETIME DEFAULT NULL, `regist_end_time` DATETIME DEFAULT NULL, `is_valid` TINYINT(1) DEFAULT NULL, `is_contest_status` TINYINT(1) DEFAULT NULL, `look_count` INT(11) DEFAULT 0, `focus_num` INT(11) DEFAULT 0, `content` TEXT DEFAULT NULL, `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`contest_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8_general_ci;表2：contest_tagsCREATE TABLE `contest_tags` ( `tag_id` INT(11) NOT NULL AUTO_INCREMENT, `user_id` INT(11) NOT NULL, `contest_id` INT(11) NOT NULL, `tag_type` ENUM('like', 'block') NOT NULL, `is_registered` TINYINT(1) DEFAULT 0, `next_contest_time` DATETIME DEFAULT NULL, `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`tag_id`), KEY `user_id` (`user_id`), KEY `tag_type` (`tag_type`), KEY `contest_id` (`contest_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8_general_ci;第一个表contests存储竞赛相关的信息，第二个表contest_tags用于用户与竞赛之间的标签关系，支持多种类型标签（如点赞和屏蔽）。Python代码：import requests import mysql.connector from mysql.connector import errorcode from datetime import datetime # 配置数据库连接 config = { 'user': '数据库用户名', 'password': '数据库密码', 'host': '数据库IP地址', # 通常为localhost 'database': '数据库名', 'raise_on_warnings': True } # 连接到数据库 try: cnx = mysql.connector.connect(**config) cursor = cnx.cursor() except mysql.connector.Error as err: if err.errno == errorcode.ER_ACCESS_DENIED_ERROR: print("用户名或密码错误") elif err.errno == errorcode.ER_BAD_DB_ERROR: print("数据库不存在") else: print(err) exit(1) # 按报名时间降序排列的比赛列表，相当于是先抓取最新的，如果要更改抓取的条数只需要更改limit参数为别的数值，具体对应https://new.saikr.com/contests页面 LIST_API_URL = "https://apiv4buffer.saikr.com/api/pc/contest/lists?page=1&limit=150&univs_id=&class_id=&level=0&sort=0" def convert_unix_to_datetime(timestamp): return datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d %H:%M:%S') def fetch_contest_list(): try: response = requests.get(LIST_API_URL) response.raise_for_status() data = response.json() if data['code'] == 200: return data['data']['list'] else: print(f"列表API返回错误代码: {data['code']}, 信息: {data['msg']}") return [] except requests.RequestException as e: print(f"请求列表API失败: {e}") return [] #抓取详细的比赛信息，甚至比赛内容都在里面，所以拿到这个内容之后甚至可以自己复刻一个赛氪了 def fetch_contest_info(contest_url_suffix): info_api_url = f"https://apiv4buffer.saikr.com/api/pc/contest/info?contest_url={contest_url_suffix}&isp=" try: response = requests.get(info_api_url) response.raise_for_status() data = response.json() if data['code'] == 200: return data['data'] else: print(f"信息API返回错误代码: {data['code']}, 信息: {data['msg']}") return None except requests.RequestException as e: print(f"请求信息API失败: {e}") return None #解析比赛内容 def insert_or_update_contest(contest_summary, contest_info): contest_id = contest_summary['contest_id'] contest_url_suffix = contest_summary['contest_url'].replace("vse/", "") complete_contest_url = f"https://new.saikr.com/vse/{contest_url_suffix}" # Convert UNIX timestamps to datetime strings regist_start_time = convert_unix_to_datetime(contest_summary['regist_start_time']) regist_end_time = convert_unix_to_datetime(contest_summary['regist_end_time']) contest_start_time = convert_unix_to_datetime(contest_summary['contest_start_time']) contest_end_time = convert_unix_to_datetime(contest_summary['contest_end_time']) is_valid = 1 if contest_summary.get('is_exam', 0) == 0 else 0 is_contest_status = 1 if contest_summary.get('is_contest_status', 0) == 1 else 0 # 从 contest_info 中获取 look_count 和 focus_num look_count = contest_info.get('look_count', 0) focus_num = contest_info.get('focus_num', 0) content = contest_info.get('content', '') insert_query = """ INSERT INTO contests (contest_id, contest_url, contest_name, org_real_name, org_avatar, org_univs_name, contest_start_time, contest_end_time, regist_start_time, regist_end_time, is_valid, is_contest_status, look_count, focus_num, content) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) ON DUPLICATE KEY UPDATE contest_url=VALUES(contest_url), contest_name=VALUES(contest_name), org_real_name=VALUES(org_real_name), org_avatar=VALUES(org_avatar), org_univs_name=VALUES(org_univs_name), contest_start_time=VALUES(contest_start_time), contest_end_time=VALUES(contest_end_time), regist_start_time=VALUES(regist_start_time), regist_end_time=VALUES(regist_end_time), is_valid=VALUES(is_valid), is_contest_status=VALUES(is_contest_status), look_count=VALUES(look_count), focus_num=VALUES(focus_num), content=VALUES(content) """ contest_data = ( contest_id, complete_contest_url, contest_summary['contest_name'], contest_info.get('org_real_name', ''), # 从 contest_info 获取 contest_info.get('org_avatar', ''), contest_info.get('org_univs_name', ''), # 从 contest_info 获取 contest_start_time, contest_end_time, regist_start_time, regist_end_time, is_valid, is_contest_status, look_count, focus_num, content ) try: cursor.execute(insert_query, contest_data) cnx.commit() print(f"已插入或更新比赛ID {contest_id}") except mysql.connector.Error as err: print(f"插入或更新比赛ID {contest_id} 失败: {err}") def main(): contest_list = fetch_contest_list() if not contest_list: print("没有获取到任何比赛数据。") return for contest_summary in contest_list: contest_id = contest_summary['contest_id'] contest_url_suffix = contest_summary['contest_url'].replace("vse/", "") contest_info = fetch_contest_info(contest_url_suffix) if contest_info: insert_or_update_contest(contest_summary, contest_info) else: print(f"跳过比赛ID {contest_id} 由于无法获取详细信息。") # 关闭数据库连接 cursor.close() cnx.close() print("数据抓取完成。") if __name__ == "__main__": main()
如何修改基于thinter的程序的消息框图标
最近在研究Python GUI，现在主要在用一个thinter的库。今天遇到一个问题：在我已经设置了主程序图标的情况下，消息框的图标还是Thinter的默认“小羽毛”，有点点不好看……参考了这篇资料change-icon-for-tkinter-messageboxIf you set a custom icon for the root window, then this same icon will be set as the title bar icon for all other child window(s) including the message boxes from messagebox module.如果您为根窗口设置了自定义图标，那么该图标将会被设置为所有其他子窗口（包括 messagebox 模块中的消息框）的标题栏图标。于是我尝试看看是不是所有弹出框都是默认小羽毛图标，然后发现不是这样的：一定是漏了什么。回到这个警告框的定义：诶，对照代码的话，他们似乎没有区别。修改和尝试发现，一个小细节可能会解释这个默认图标的问题：在 tkinter 中，如果消息框是从根窗口 (Tk) 中弹出的，它通常会继承根窗口的图标。但如果消息框是从其他窗口（如 Toplevel）中弹出，tkinter 会将该窗口视为一个独立窗口，所以它不会自动继承根窗口的图标，除非我们在调用消息框时，显式指定其父窗口。我们刚刚看到的默认图标的窗口，是一个Toplevel窗口。那么，如何确保所有弹出的消息框都能显示自定义图标？经过一些调试和查阅资料，总结出以下几种方法：1. 确保主窗口和所有 Toplevel 窗口都有图标首先，在创建根窗口时可以直接设置图标，代码如下：import tkinter as tk from tkinter import messagebox root = tk.Tk() root.iconbitmap("your_icon.ico") # 设置主窗口图标如果你的程序还创建了 Toplevel 窗口（例如登录、注册等独立窗口），也需要为这些窗口单独设置图标，这样就不会出现默认图标的小羽毛。top = tk.Toplevel(root) top.iconbitmap("your_icon.ico") # 设置 Toplevel 窗口图标2. 使用 parent 参数来指定消息框的父窗口当消息框弹出时，通常会继承其“父窗口”的图标。如果不指定 parent，消息框默认关联到根窗口。如果我们在 Toplevel 窗口中调用消息框，则需要在消息框函数中添加 parent 参数，以确保它继承正确的图标（重点！！！）。messagebox.showwarning("警告", "这是一条警告信息", parent=top)这种方法可以确保所有消息框都正确地继承父窗口的图标，避免了默认图标的出现。代码示例为了更直观地展示这些方法，以下是一个完整示例：import tkinter as tk from tkinter import messagebox def open_warning(): if messagebox.askokcancel("退出", "您确定要退出吗？", parent=root): root.destroy() def open_toplevel(): top = tk.Toplevel(root) top.iconbitmap("your_icon.ico") # 设置Toplevel图标 tk.Button(top, text="显示警告", command=lambda: messagebox.showwarning("警告", "在Toplevel中弹出", parent=top)).pack() root = tk.Tk() root.iconbitmap("your_icon.ico") # 设置主窗口图标 tk.Button(root, text="弹出警告", command=open_warning).pack() tk.Button(root, text="打开Toplevel", command=open_toplevel).pack() root.mainloop()在这个示例中，我创建了一个主窗口（Tk）并设置了图标，同时添加了一个 Toplevel 窗口并为其单独设置了图标。这样，无论是主窗口中的消息框，还是在 Toplevel 中弹出的消息框，都会显示自定义图标，避免了默认的“小羽毛”问题。顺利解决：
ChatGPT o1不支持发送文件，但是有很长的上下文，那么我们可以做一个小工具，合并代码文件和目录，一同发给ChatGPT，并在后文附上问题，免去一个个复制代码内容的麻烦。效果方案选型• PyQt5：用于创建图形用户界面。• clipboard：用于处理剪贴板操作。• chardet：用于检测文件编码。• python-docx：用于生成 Word 文档。• qtmodern：用于美化用户界面。打包使用Pyinstaller，压缩使用UPX，经过UPX压缩后，软件包大小由39MB减小至31MB.主要功能使用 chardet 库自动检测并处理不同编码的文件，确保能够正确读取和合并来自不同平台的代码文件。支持将合并后的内容导出为 Markdown 和 Word 格式。源码及下载源码后续在Github上同步。下载：代码合并小工具
前言看到舍友最近对爬虫感兴趣，然后尝试写一个自动签到脚本，但是他的方案好麻烦呀，遂写一篇文章分析之。网页分析 - 登录首先，目标是咸鱼单机-单机游戏交流分享平台，我们需要分析他的登录机制。F12打开浏览器的开发者工具，这里有一个小技巧，勾选保留日志，因为登录行为往往伴随页面刷新，如果不勾选保存日志，登录的具体日志就会一闪而过，不利于分析。这登录也忒简单了吧……！没有验证码，非常nice.登录一下看看日志具体是怎么样的。发了一个POST请求，包含明文的username和password,然后从响应中通过“set-cookies”返回cookies信息给浏览器。方案选型这里有两种方法：①我们主动存储cookies信息，然后下一次访问签到API的时候在请求头中带上②使用requests.Session，一套搞定。③使用selenium，模拟浏览器真实操作这里介绍方法②。什么是requests.Session对象？ 在Python的requests库中，requests.Session对象是一个用于发送HTTP请求的实例。与直接使用requests.get()或requests.post()发送单独的请求不同，使用Session对象可以在多个请求之间保留一些状态信息，例如cookies、headers等，从而实现更高效的HTTP通信。说白了就是，它帮我们存储cookies和header这一堆拉杂东西，极大简化代码量。哎，人生苦短，我用Python.代码实现import requests # 模拟登录 session = requests.Session() login_url = "https://www.xianyudanji.net/wp-admin/admin-ajax.php" login_data = { 'action': 'user_login', 'username': '', #用户名 'password': '', #密码 'rememberme': '1' } response = session.post(login_url, data=login_data) #post和get的时候调用前文定义的对象就可以了 if response.status_code == 200 and '"status":"1"' in response.text: print("登录成功") else: print("登录失败") exit()现在，session对象内就已经存有了cookies等一系列信息了，我们只要继续找一下签到的方法，然后下一次继续调用session.post()或者session.get()就好。然后是签到。网页分析 - 签到单击按钮后发起一个POST请求。这里的“nonce”有点意思，大概率是一个动态变化的值，我们检查一下它是怎么来的吧。Ctrl+F激活搜索工具，输入我们刚刚的nonce e91b5f2d29：找到了。代码如下： 今日已签到 我们需要使用bs库来拿到这个动态的nonce值：qiandao_button = soup.find('button', {'class': 'go-user-qiandao'})另外查了一下这个nonce的用途：这是 WordPress 的安全机制之一，称为 "nonce"（数字一次性使用密码），用于防止跨站请求伪造 (CSRF) 攻击。这个值确保请求来自合法用户而非恶意脚本。nonce 值是由服务器生成的，并在页面中通过 JavaScript 提供代码实现from bs4 import BeautifulSoup #...这里就是前文的内容，我省略掉了 # 获取包含签到按钮的页面 user_page = session.get('https://www.xianyudanji.net/user') # 解析页面获取签到按钮的状态和 nonce soup = BeautifulSoup(user_page.text, 'html.parser') qiandao_button = soup.find('button', {'class': 'go-user-qiandao'}) # 检查签到按钮是否被禁用 if 'disabled' in qiandao_button.attrs: print("今日已签到，无法重复签到") else: # 提取 nonce 值 nonce = qiandao_button['data-nonce'] print(f"获取到的 nonce 值: {nonce}") # 构建签到请求 qiandao_url = "https://www.xianyudanji.net/wp-admin/admin-ajax.php" qiandao_data = { 'action': 'user_qiandao', 'nonce': nonce } # 发送签到请求 qiandao_response = session.post(qiandao_url, data=qiandao_data) # 检查签到是否成功 if qiandao_response.status_code == 200 and '"status":"1"' in qiandao_response.text: print("签到成功:", qiandao_response.json().get('msg')) else: print("签到失败") 结尾最后我优化了一下，下面是成品import requests from bs4 import BeautifulSoup import math # 目标赞助币 goal_coin = 49 # 每次签到获得的赞助币 coin_per_signin = 0.3 # 模拟登录 session = requests.Session() login_url = "https://www.xianyudanji.net/wp-admin/admin-ajax.php" login_data = { 'action': 'user_login', 'username': ' ', #用户名 'password': ' ', #密码 'rememberme': '1' } response = session.post(login_url, data=login_data) if response.status_code == 200 and '"status":"1"' in response.text: print("登录成功") else: print("登录失败") exit() # 获取用户页面，包含余额和签到按钮 user_page = session.get('https://www.xianyudanji.net/user/aff') # 解析页面，获取当前余额和签到按钮状态 soup = BeautifulSoup(user_page.text, 'html.parser') # 获取当前余额（假设余额在 badge-warning-lighten 类中） balance_span = soup.find('span', {'class': 'badge-warning-lighten'}) if balance_span: current_balance = float(balance_span.text.strip()) # 提取当前余额，转换为 float 类型 print(f"当前赞助币余额: {current_balance}") else: print("无法获取当前余额") exit() # 计算到达目标的剩余天数 remaining_coin = goal_coin - current_balance if remaining_coin <= 0: print("已达到或超过目标金额") else: remaining_days = math.ceil(remaining_coin / coin_per_signin) print(f"距离目标 {goal_coin} 赞助币，还需 {remaining_days} 天") # 获取签到按钮并检查是否可以签到 qiandao_button = soup.find('button', {'class': 'go-user-qiandao'}) if 'disabled' in qiandao_button.attrs: print("今日已签到，无法重复签到") else: # 提取 nonce 值 nonce = qiandao_button['data-nonce'] print(f"获取到的 nonce 值: {nonce}") # 构建签到请求 qiandao_url = "https://www.xianyudanji.net/wp-admin/admin-ajax.php" qiandao_data = { 'action': 'user_qiandao', 'nonce': nonce } # 发送签到请求 qiandao_response = session.post(qiandao_url, data=qiandao_data) # 检查签到是否成功 if qiandao_response.status_code == 200 and '"status":"1"' in qiandao_response.text: print("签到成功:", qiandao_response.json().get('msg')) else: print("签到失败")收获熟悉了一下request.Session的基本使用~
最近发现了一个名为 “音视频内容 AI 一键总结丨BibiGPT 学习助理” 的网站，感觉非常有趣。这个工具能够直接总结B站、油管的在线视频内容，甚至支持百度网盘的视频。稍微深入研究了一下后，我原本以为它是通过转录视频内容再进行总结，所以我自己动手部署了一下语音转文字服务，使用的是基于fast-whisper的工具（GitHub链接：jianchang512/stt: Voice Recognition to Text Tool）。然而，后来我发现即使使用最小的tiny模型，转录大约20分钟的视频也需要90秒左右，且识别准确率大约在90%以上。如果想要更高的准确率，就需要使用更大的模型。但这样一来，辛辛苦苦地转录还不如直接使用B站的AI字幕！B站的AI字幕准确率估计在98%以上，而且几乎所有视频都配有AI字幕，何乐而不为呢？因此，最初我还编写了一个Python脚本来爬取AI字幕（脚本附在文末），但B站的逻辑有些一言难尽，必须先登录才能获取字幕，否则API里面返回的字幕那部分内容是空的。后来，我发现油猴（Tampermonkey）有现成的字幕下载脚本，这样一来就无需重复造轮子了。接下来，对现有的脚本进行了以下调整：修改快捷键功能：原先的Ctrl+下载键是直接下载字幕，我将其改为直接复制字幕内容。更改下载格式：默认下载格式是SRT，我将其修改为TXT格式，便于后续处理。添加AI总结提示词：在复制字幕内容之前，加入了一段用于引导GPT进行总结的提示词：请阅读以下视频字幕文本，并总结出视频中的所有关键要点。总结应涵盖视频的主要观点、重要信息和核心内容，使读者能够理解获取到视频的全部内容。请按照以下要求进行总结： 1. **结构清晰**：使用分段或项目符号列表，使总结条理分明，易于阅读。 2. **简明扼要**：每个要点应简洁明了，避免冗长的描述。 3. **全面覆盖**：确保不遗漏视频中的重要信息和关键细节。 4. **语言简练**：使用准确且简练的语言表达，保持专业性。 5. **保持原意**：总结内容应忠实于原视频的意思，如果必要可以进行解释，便于理解。 6. **开头添加视频标题及链接**：在总结开头使用Markdown格式，将视频标题作为可点击的链接（去除“哔哩哔哩”字样），便于存档。这样一来，可以平替它的总结功能。附：Python爬取B站AI字幕脚本import requests import json import re import sys import os from datetime import timedelta import browser_cookie3 # 定义Cookies缓存文件路径 COOKIES_CACHE_FILE = 'cookies_cache.json' def load_cached_cookies(): """ 从缓存文件加载Cookies """ if os.path.exists(COOKIES_CACHE_FILE): with open(COOKIES_CACHE_FILE, 'r', encoding='utf-8') as f: try: cookies = json.load(f) print("从缓存加载Cookies。") return cookies except json.JSONDecodeError: print("Cookies缓存文件损坏，忽略缓存。") return None return None def save_cookies_to_cache(cookies_dict): """ 将Cookies保存到缓存文件 """ with open(COOKIES_CACHE_FILE, 'w', encoding='utf-8') as f: json.dump(cookies_dict, f) print("Cookies已保存到缓存。") def extract_cookies(): """ 从浏览器中提取Cookies并保存到缓存 """ print("正在从浏览器中提取Cookies...") cj = browser_cookie3.edge(domain_name='bilibili.com') # 如果使用Firefox，请使用以下行代替： # cj = browser_cookie3.firefox(domain_name='bilibili.com') # 将Cookies转换为字典 cookies_dict = requests.utils.dict_from_cookiejar(cj) # 保存到缓存 save_cookies_to_cache(cookies_dict) return cookies_dict def build_headers(bv, cookies=None): """ 构建请求头，包括必要的头信息和Cookies """ headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", "Referer": f"https://www.bilibili.com/video/{bv}", "Accept": "application/json, text/javascript, */*; q=0.01", "Origin": "https://www.bilibili.com" } if cookies: headers["Cookie"] = "; ".join([f"{k}={v}" for k, v in cookies.items()]) return headers def bv_to_aid(bv, headers): """ 将BV号转换为AV号 """ # 确保BV号以'BV'开头 if not bv.startswith('BV'): bv = 'BV' + bv url = f"https://api.bilibili.com/x/web-interface/view?bvid={bv}" response = requests.get(url, headers=headers) if response.status_code != 200: raise Exception(f"获取视频信息失败，状态码：{response.status_code}") data = response.json() # 如果需要调试，可以取消下面的注释 # print(json.dumps(data, indent=2, ensure_ascii=False)) if data['code'] != 0: raise Exception(f"API返回错误：{data['message']}") aid = data['data']['aid'] cid = data['data']['cid'] return aid, cid def get_subtitle_list(aid, cid, bv, headers): """ 获取字幕列表 """ url = f"https://api.bilibili.com/x/player/v2?cid={cid}&aid={aid}&bvid={bv}" response = requests.get(url, headers=headers) if response.status_code != 200: raise Exception(f"获取字幕信息失败，状态码：{response.status_code}") data = response.json() # 如果需要调试，可以取消下面的注释 # print(json.dumps(data, indent=2, ensure_ascii=False)) if data['code'] != 0: raise Exception(f"API返回错误：{data['message']}") # 获取 'subtitles' 而不是 'list' subtitles = data['data'].get('subtitle', {}).get('subtitles', []) if not subtitles: raise Exception("该视频没有字幕。") return subtitles def download_subtitle(subtitle_url, headers): """ 下载字幕内容 """ # 确保URL以协议开头 if subtitle_url.startswith('//'): subtitle_url = 'https:' + subtitle_url elif subtitle_url.startswith('/'): subtitle_url = 'https://www.bilibili.com' + subtitle_url response = requests.get(subtitle_url, headers=headers) if response.status_code != 200: raise Exception(f"下载字幕失败，状态码：{response.status_code}") return response.json() def convert_to_srt(subtitles): """ 将Bilibili的字幕JSON转换为SRT格式 """ srt_content = "" for idx, item in enumerate(subtitles, 1): start = seconds_to_srt_time(item['from']) end = seconds_to_srt_time(item['to']) # 处理HTML实体和换行符 text = re.sub(r'"', '"', item['content']) text = re.sub(r''', "'", text) text = re.sub(r' ', '\n', text) srt_content += f"{idx}\n{start} --> {end}\n{text}\n\n" return srt_content def seconds_to_srt_time(seconds): """ 将秒数转换为SRT时间格式 """ td = timedelta(seconds=seconds) total_seconds = td.total_seconds() hours = int(total_seconds // 3600) minutes = int((total_seconds % 3600) // 60) secs = int(total_seconds % 60) milliseconds = int((total_seconds - int(total_seconds)) * 1000) return f"{hours:02}:{minutes:02}:{secs:02},{milliseconds:03}" def main(bv): try: print(f"正在处理BV号：{bv}") # 第一次尝试：不使用Cookies headers_no_cookies = build_headers(bv) try: aid, cid = bv_to_aid(bv, headers_no_cookies) print(f"获取到的AV号：{aid}，CID：{cid}") subtitles = get_subtitle_list(aid, cid, bv, headers_no_cookies) print("可用的字幕列表：") for idx, sub in enumerate(subtitles, 1): lan = sub.get('lan_doc', sub.get('lan', '未知语言')) print(f"{idx}. {lan}") # 检查字幕数量 if len(subtitles) == 1: selected_sub = subtitles[0] print(f"只有一个字幕，自动选择：{selected_sub.get('lan_doc', '未知语言')}") else: choice = int(input("请选择要下载的字幕编号：")) if choice < 1 or choice > len(subtitles): print("无效的选择。") return selected_sub = subtitles[choice - 1] subtitle_url = selected_sub.get('subtitle_url') if not subtitle_url: print("选择的字幕没有有效的下载URL。") return print(f"正在下载字幕：{selected_sub.get('lan_doc', '未知语言')}") subtitle_data = download_subtitle(subtitle_url, headers_no_cookies) if 'body' not in subtitle_data: print("字幕数据格式错误。") return srt_content = convert_to_srt(subtitle_data['body']) # 确保文件名中不包含非法字符 lan_doc = re.sub(r'[\\/:"*?<>|]+', '_', selected_sub.get('lan_doc', 'subtitle')) filename = f"{bv}_{lan_doc}.srt" with open(filename, 'w', encoding='utf-8') as f: f.write(srt_content) print(f"字幕已成功下载并保存为 {filename}") return # 成功下载，结束程序 except Exception as e_no_cookies: print(f"首次尝试失败：{e_no_cookies}") print("将尝试使用Cookies重新获取字幕。") # 第二次尝试：使用Cookies # 先尝试从缓存加载Cookies cookies = load_cached_cookies() if not cookies: # 如果缓存不存在或加载失败，提取Cookies并缓存 cookies = extract_cookies() headers_with_cookies = build_headers(bv, cookies) try: aid, cid = bv_to_aid(bv, headers_with_cookies) print(f"获取到的AV号：{aid}，CID：{cid}") subtitles = get_subtitle_list(aid, cid, bv, headers_with_cookies) print("可用的字幕列表：") for idx, sub in enumerate(subtitles, 1): lan = sub.get('lan_doc', sub.get('lan', '未知语言')) print(f"{idx}. {lan}") # 检查字幕数量 if len(subtitles) == 1: selected_sub = subtitles[0] print(f"只有一个字幕，自动选择：{selected_sub.get('lan_doc', '未知语言')}") else: choice = int(input("请选择要下载的字幕编号：")) if choice < 1 or choice > len(subtitles): print("无效的选择。") return selected_sub = subtitles[choice - 1] subtitle_url = selected_sub.get('subtitle_url') if not subtitle_url: print("选择的字幕没有有效的下载URL。") return print(f"正在下载字幕：{selected_sub.get('lan_doc', '未知语言')}") subtitle_data = download_subtitle(subtitle_url, headers_with_cookies) if 'body' not in subtitle_data: print("字幕数据格式错误。") return srt_content = convert_to_srt(subtitle_data['body']) # 确保文件名中不包含非法字符 lan_doc = re.sub(r'[\\/:"*?<>|]+', '_', selected_sub.get('lan_doc', 'subtitle')) filename = f"{bv}_{lan_doc}.srt" with open(filename, 'w', encoding='utf-8') as f: f.write(srt_content) print(f"字幕已成功下载并保存为 {filename}") except Exception as e_with_cookies: print(f"使用Cookies尝试下载字幕失败：{e_with_cookies}") print("请检查您的Cookies是否有效，或手动更新Cookies缓存。") if __name__ == "__main__": if len(sys.argv) != 2: print("用法：python download_sub.py <BV号>") sys.exit(1) bv_number = sys.argv[1] main(bv_number) 修改后的油猴脚本（已关闭更新）：// ==UserScript== // @name Bilibili CC字幕工具 // @namespace indefined // @version 0.5.44 // @description 可下载B站的CC字幕，旧版B站播放器可启用CC字幕 // @author indefined // @supportURL https://github.com/indefined/UserScripts/issues // @match http*://www.bilibili.com/video/* // @match http*://www.bilibili.com/bangumi/play/ss* // @match http*://www.bilibili.com/bangumi/play/ep* // @match https://www.bilibili.com/cheese/play/ss* // @match https://www.bilibili.com/cheese/play/ep* // @match http*://www.bilibili.com/list/watchlater* // @match https://www.bilibili.com/medialist/play/watchlater/* // @match http*://www.bilibili.com/medialist/play/ml* // @match http*://www.bilibili.com/blackboard/html5player.html* // @license MIT // @grant none // @downloadURL https://update.greasyfork.org/scripts/378513/Bilibili%20CC%E5%AD%97%E5%B9%95%E5%B7%A5%E5%85%B7.user.js // @updateURL // ==/UserScript== function copyToClipboard(text) { if (navigator.clipboard && window.isSecureContext) { // 使用 Clipboard API return navigator.clipboard.writeText(text); } else { // 使用旧的方法 let textArea = document.createElement("textarea"); textArea.value = text; // 确保 textarea 不会被显示 textArea.style.position = "fixed"; textArea.style.left = "-999999px"; document.body.appendChild(textArea); textArea.focus(); textArea.select(); return new Promise((res, rej) => { // 执行复制操作 document.execCommand('copy') ? res() : rej(); textArea.remove(); }); } } ( function() { 'use strict'; const elements = { subtitleStyle:` `, oldEnableIcon:` <path id="svg_1" fill-rule="evenodd" fill="#99a2aa" d="m4.07787,6.88102l14,0a2,2 0 0 1 2,2l0,10a2,2 0 0 \ 1 -2,2l-14,0a2,2 0 0 1 -2,-2l0,-10a2,2 0 0 1 2,-2zm5,5.5a1,1 0 1 0 0,-2l-3,0a2,2 0 0 0 -2,2l0,3a2,2 0 0 0 \ 2,2l3,0a1,1 0 0 0 0,-2l-2,0a1,1 0 0 1 -1,-1l0,-1a1,1 0 0 1 1,-1l2,0zm8,0a1,1 0 0 0 0,-2l-3,0a2,2 0 0 0 -2,2l0\ ,3a2,2 0 0 0 2,2l3,0a1,1 0 0 0 0,-2l-2,0a1,1 0 0 1 -1,-1l0,-1a1,1 0 0 1 1,-1l2,0z"/> `, oldDisableIcon:` <path id="svg_1" fill-rule="evenodd" fill="#99a2aa" d="m15.172,21.87103l-11.172,0a2,2 0 0 1 -2,-2l0,-10c0,\ -0.34 0.084,-0.658 0.233,-0.938l-0.425,-0.426a1,1 0 1 1 1.414,-1.414l15.556,15.556a1,1 0 0 1 -1.414,1.414l-2.192,\ -2.192zm-10.21,-10.21c-0.577,0.351 -0.962,0.986 -0.962,1.71l0,3a2,2 0 0 0 2,2l3,0a1,1 0 0 0 0,-2l-2,0a1,1 0 0 1 -1,\ -1l0,-1a1,1 0 0 1 0.713,-0.958l-1.751,-1.752zm1.866,-3.79l11.172,0a2,2 0 0 1 2,2l0,10c0,0.34 -0.084,0.658 -0.233,\ 0.938l-2.48,-2.48a1,1 0 0 0 -0.287,-1.958l-1.672,0l-1.328,-1.328l0,-0.672a1,1 0 0 1 1,-1l2,0a1,1 0 0 0 0,-2l-3,\ 0a2,2 0 0 0 -1.977,1.695l-5.195,-5.195z"/> `, newDisableIcon:` <g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> <path d="M6.998,4 L10.118,7.123 L9.5605,7.1235 C9.4135,6.777 9.151,6.3465 8.8885,6 L7.933,6.3045 C8.101,6.546 8.269,6.8505 8.4055,7.1235 L4.3945,7.1235 L4.3945,9.3705 L5.35,9.3705 L5.35,8.0475 L11.042,8.047 L12.206,9.212 L12.2065,9.3705 L12.364,9.37 L14.494,11.502 L14.389,11.502 L14.389,12.2685 L15.259,12.268 L15.7076026,12.7152226 C15.273892,12.9780418 14.772314,13.2154154 14.2,13.413 C14.3785,13.5705 14.641,13.9275 14.746,14.148 C15.2185,13.959 15.6385,13.7595 16.027,13.5285 L16.027,15.5025 L16.9615,15.5025 L16.961,13.971 L18.536,15.547 L18.5365,15.7125 L18.701,15.712 L20.987,18 L4,18 C2.8954305,18 2,17.1045695 2,16 L2,6 C2,4.8954305 2.8954305,4 4,4 L6.998,4 Z M24,4 C25.1045695,4 26,4.8954305 26,6 L26,16 C26,17.1045695 25.1045695,18 24,18 L23.814,18 L21.2866753,15.470484 C21.499408,15.4571242 21.672579,15.4281871 21.8125,15.366 C22.096,15.24 22.1695,15.0405 22.1695,14.631 L22.1695,13.5915 C22.5475,13.812 22.957,13.98 23.3665,14.106 C23.482,13.8855 23.7445,13.539 23.944,13.3815 C23.0725,13.161 22.201,12.762 21.5605,12.2685 L23.7025,12.2685 L23.7025,11.502 L18.2635,11.502 C18.3685,11.3445 18.4735,11.187 18.568,11.019 L22.6,11.019 L22.6,8.079 L15.565,8.079 L15.564,9.743 L13.204,7.381 L13.204,7.1235 L12.946,7.123 L9.825,4 L24,4 Z M11.0725,9.045 L10.852,9.0975 L6.043,9.0975 L6.043,10.0005 L9.865,10.0005 C9.3925,10.3995 8.815,10.809 8.2795,11.0715 L8.2795,11.6805 L4.3,11.6805 L4.3,12.615 L8.2795,12.615 L8.2795,14.547 C8.2795,14.673 8.23321429,14.7295714 8.10096939,14.7431633 L7.788625,14.7522422 C7.4696875,14.7556875 6.938125,14.75175 6.442,14.736 C6.5995,14.988 6.799,15.429 6.862,15.7125 L7.348864,15.710148 C7.95904,15.70242 8.416,15.6705 8.752,15.5445 C9.1825,15.3975 9.319,15.1245 9.319,14.5785 L9.319,12.615 L13.2985,12.615 L13.2985,11.6805 L9.319,11.6805 L9.319,11.397 C10.2115,10.8825 11.0935,10.2 11.734,9.549 L11.0725,9.045 Z M21.235,13.77 L21.235,14.6205 C21.235,14.7255 21.193,14.757 21.0775,14.757 L20.574025,14.7533985 L20.569,14.753 L19.587,13.77 L21.235,13.77 Z M20.5105,12.2685 C20.731,12.531 20.9935,12.7725 21.2875,13.0035 L19.4815,13.0035 L19.4815,12.4575 L18.5365,12.4575 L18.536,12.718 L18.087,12.268 L20.5105,12.2685 Z M16.839,11.019 L17.497,11.019 C17.4212405,11.1536835 17.3319842,11.2816187 17.2292312,11.4082156 L16.839,11.019 Z M21.6235,9.822 L21.6235,10.4205 L16.4995,10.4205 L16.4995,9.822 L21.6235,9.822 Z M21.6235,8.6775 L21.6235,9.255 L16.4995,9.255 L16.4995,8.6775 L21.6235,8.6775 Z M17.791,6.084 L16.8355,6.084 L16.8355,6.7035 L14.452,6.7035 L14.452,7.491 L16.8355,7.491 L16.8355,7.89 L17.791,7.89 L17.791,7.491 L20.269,7.491 L20.269,7.89 L21.2245,7.89 L21.2245,7.491 L23.6605,7.491 L23.6605,6.7035 L21.2245,6.7035 L21.2245,6.084 L20.269,6.084 L20.269,6.7035 L17.791,6.7035 L17.791,6.084 Z" id="形状结合" fill="#FFFFFF"></path> <path d="M4.08046105,2.26754606 L4.08516738,2.26283972 C4.4730925,1.8749146 5.10204339,1.8749146 5.48996851,2.26283972 C5.49153201,2.26440322 5.49309028,2.26597193 5.4946433,2.26754583 L21.6870833,18.6777025 C22.0731601,19.0689703 22.0710589,19.6984951 21.6823787,20.0871769 L21.6776695,20.0918861 C21.2897453,20.479812 20.6607945,20.4798133 20.2728686,20.091889 C20.2713046,20.090325 20.2697458,20.0887558 20.2681922,20.0871813 L4.07575482,3.67702186 C3.68967757,3.28575345 3.69177955,2.65622755 4.08046105,2.26754606 Z" id="Path" fill="#FFFFFF"></path> </g> `, newEnableIcon:` <g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> <g transform="translate(2.000000, 0.000000)" fill="#FFFFFF"> <path d="M22,3.5 C23.1045695,3.5 24,4.3954305 24,5.5 L24,16.5 C24,17.6045695 23.1045695,18.5 22,18.5 L2,18.5 C0.8954305,18.5 1.3527075e-16,17.6045695 0,16.5 L0,5.5 C-1.3527075e-16,4.3954305 0.8954305,3.5 2,3.5 L22,3.5 Z M9.018,9.1515 L8.7975,9.204 L3.9885,9.204 L3.9885,10.107 L7.8105,10.107 C7.338,10.506 6.7605,10.9155 6.225,11.178 L6.225,11.787 L2.2455,11.787 L2.2455,12.7215 L6.225,12.7215 L6.225,14.6535 C6.225,14.8005 6.162,14.853 5.973,14.853 C5.9065,14.8565 5.78166667,14.8588333 5.62027778,14.8596111 L5.3535,14.8595625 C5.06475,14.85825 4.71825,14.853 4.3875,14.8425 C4.545,15.0945 4.7445,15.5355 4.8075,15.819 C5.6685,15.819 6.2775,15.8085 6.6975,15.651 C7.128,15.504 7.2645,15.231 7.2645,14.685 L7.2645,12.7215 L11.244,12.7215 L11.244,11.787 L7.2645,11.787 L7.2645,11.5035 C8.157,10.989 9.039,10.3065 9.6795,9.6555 L9.018,9.1515 Z M20.799,8.1855 L13.764,8.1855 L13.764,11.1255 L15.696,11.1255 C15.6015,11.2935 15.486,11.451 15.3495,11.6085 L12.588,11.6085 L12.588,12.375 L14.5515,12.375 C13.995,12.816 13.281,13.215 12.399,13.5195 C12.5775,13.677 12.84,14.034 12.945,14.2545 C13.4175,14.0655 13.8375,13.866 14.226,13.635 L14.226,15.609 L15.1605,15.609 L15.1605,13.8765 L16.7355,13.8765 L16.7355,15.819 L17.6805,15.819 L17.6805,13.8765 L19.434,13.8765 L19.434,14.727 C19.434,14.832 19.392,14.8635 19.2765,14.8635 L19.15575,14.8633359 C18.9962813,14.8628437 18.7305,14.860875 18.447,14.853 C18.552,15.0735 18.657,15.357 18.699,15.588 C19.308,15.588 19.728,15.5985 20.0115,15.4725 C20.295,15.3465 20.3685,15.147 20.3685,14.7375 L20.3685,13.698 C20.7465,13.9185 21.156,14.0865 21.5655,14.2125 C21.681,13.992 21.9435,13.6455 22.143,13.488 C21.2715,13.2675 20.4,12.8685 19.7595,12.375 L21.9015,12.375 L21.9015,11.6085 L16.4625,11.6085 C16.5675,11.451 16.6725,11.2935 16.767,11.1255 L20.799,11.1255 L20.799,8.1855 Z M18.7095,12.375 C18.93,12.6375 19.1925,12.879 19.4865,13.11 L17.6805,13.11 L17.6805,12.564 L16.7355,12.564 L16.7355,13.11 L15.0135,13.11 C15.318,12.879 15.591,12.6375 15.8325,12.375 L18.7095,12.375 Z M19.8225,9.9285 L19.8225,10.527 L14.6985,10.527 L14.6985,9.9285 L19.8225,9.9285 Z M6.834,6.1065 L5.8785,6.411 C6.0465,6.6525 6.2145,6.957 6.351,7.23 L2.34,7.23 L2.34,9.477 L3.2955,9.477 L3.2955,8.154 L10.152,8.154 L10.152,9.477 L11.1495,9.477 L11.1495,7.23 L7.506,7.23 C7.359,6.8835 7.0965,6.453 6.834,6.1065 Z M19.8225,8.784 L19.8225,9.3615 L14.6985,9.3615 L14.6985,8.784 L19.8225,8.784 Z M15.99,6.1905 L15.0345,6.1905 L15.0345,6.81 L12.651,6.81 L12.651,7.5975 L15.0345,7.5975 L15.0345,7.9965 L15.99,7.9965 L15.99,7.5975 L18.468,7.5975 L18.468,7.9965 L19.4235,7.9965 L19.4235,7.5975 L21.8595,7.5975 L21.8595,6.81 L19.4235,6.81 L19.4235,6.1905 L18.468,6.1905 L18.468,6.81 L15.99,6.81 L15.99,6.1905 Z" id="形状结合"></path> </g> </g> `, createAs(nodeType,config,appendTo){ const element = document.createElement(nodeType); config&&this.setAs(element,config); appendTo&&appendTo.appendChild(element); return element; }, setAs(element,config,appendTo){ config&&Object.entries(config).forEach(([key, value])=>{ element[key] = value; }); appendTo&&appendTo.appendChild(element); return element; }, getAs(selector,config,appendTo){ if(selector instanceof Array) { return selector.map(item=>this.getAs(item)); } const element = document.body.querySelector(selector); element&&config&&this.setAs(element,config); element&&appendTo&&appendTo.appendChild(element); return element; }, createSelector(config,appendTo){ const selector = this.createAs('div',{ className:"bilibili-player-block-string-type bpui-component bpui-selectmenu selectmenu-mode-absolute", style:"width:"+config.width },appendTo), selected = config.datas.find(item=>item.value==config.initValue), label = this.createAs('div',{ className:'bpui-selectmenu-txt', innerHTML: selected?selected.content:config.initValue },selector), arraw = this.createAs('div',{ className:'bpui-selectmenu-arrow bpui-icon bpui-icon-arrow-down' },selector), list = this.createAs('ul',{ className:'bpui-selectmenu-list bpui-selectmenu-list-left', style:`max-height:${config.height||'100px'};overflow:hidden auto;white-space:nowrap;`, onclick:e=>{ label.dataset.value = e.target.dataset.value; label.innerHTML = e.target.innerHTML; config.handler(e.target.dataset.value); } },selector); config.datas.forEach(item=>{ this.createAs('li',{ className:'bpui-selectmenu-list-row', innerHTML:item.content },list).dataset.value = item.value; }); return selector; }, createRadio(config,appendTo){ this.createAs('input',{ ...config,type: "radio",style:"cursor:pointer;5px;vertical-align: middle;" },appendTo); this.createAs('label',{ style:"margin-right: 5px;cursor:pointer;vertical-align: middle;", innerText:config.value },appendTo).setAttribute('for',config.id); } }; function fetch(url, option = {}) { return new Promise((resolve, reject) => { const req = new XMLHttpRequest(); req.onreadystatechange = ()=> { if (req.readyState === 4) { resolve({ ok: req.status>=200&&req.status<=299, status: req.status, statusText: req.statusText, body: req.response, // 与原fetch定义的ReadableStream类型不同，无用 json: ()=>Promise.resolve(JSON.parse(req.responseText)), text: ()=>Promise.resolve(req.responseText) }); } }; if (option.credentials == 'include') req.withCredentials = true; req.onerror = reject; req.open('GET', url); req.send(); }); } //编码器，用于将B站BCC字幕编码为常见字幕格式下载 const encoder = { instructionText: `请阅读以下视频字幕文本，并总结出视频中的所有关键要点。总结应涵盖视频的主要观点、重要信息和核心内容，使读者能够理解获取到视频的全部内容。请按照以下要求进行总结： 1. **结构清晰**：使用分段或项目符号列表，使总结条理分明，易于阅读。 2. **简明扼要**：每个要点应简洁明了，避免冗长的描述。 3. **全面覆盖**：确保不遗漏视频中的重要信息和关键细节。 4. **语言简练**：使用准确且简练的语言表达，保持专业性。 5. **保持原意**：总结内容应忠实于原视频的意思，如果必要可以进行解释，便于理解。 6. **开头添加视频标题及链接**：在总结开头使用Markdown格式，将视频标题作为可点击的链接（去除“哔哩哔哩”字样），便于存档。 `, //内嵌ASS格式头 assHead : [ '[Script Info]', `Title: ${document.title}`, 'ScriptType: v4.00+', 'Collisions: Reverse', 'PlayResX: 1280', 'PlayResY: 720', 'WrapStyle: 3', 'ScaledBorderAndShadow: yes', '; ----------------------', `; 字幕来源${document.location}`, '; 设置了字幕过长自动换行，但若字幕中没有空格换行将无效', '; 字体大小依据720p 48号字体等比缩放', '; 如显示不正常请尝试使用SRT格式', '','[V4+ Styles]', 'Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, ' +'BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, ' +'BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding', 'Style: Default,Segoe UI,48,&H00FFFFFF,&HF0000000,&H00000000,&HF0000000,1,0,0,0,100,100,0,0.00,1,1,3,2,30,30,20,1', '','[Events]', 'Format: Layer, Start, End, Style, Actor, MarginL, MarginR, MarginV, Effect, Text' ], showDialog(data, download){ if(!data||!(data.body instanceof Array)){ throw '数据错误'; } this.data = data; const settingDiv = elements.createAs('div',{ style :'position: fixed;top: 0;bottom: 0;left: 0;right: 0;background: rgba(0,0,0,0.4);z-index: 1048576;'+(download?'display:none':'') },document.body), panel = elements.createAs('div',{ style:'left: 50%;top: 50%;position: absolute;padding: 15px;background:white;' + 'border-radius: 8px;margin: auto;transform: translate(-50%,-50%);', innerHTML: ' 字幕下载 ' + '' + `当前版本：${typeof(GM_info)!="undefined"&&GM_info.script.version||'unknow'}` },settingDiv), textArea = this.textArea = elements.createAs('textarea',{ style: 'width: 400px;min-width: 300px;height: 400px;resize: both;padding: 5px;line-height: normal;border: 1px solid #e5e9ef;margin: 0px;' },panel), bottomPanel = elements.createAs('div',{style:'font-size:14px; padding-top: 10px;'},panel); textArea.setAttribute('readonly',true); const type = localStorage.defaultSubtitleType || 'TXT'; elements.createAs('select', { style: 'height: 24px; margin-right: 5px;', innerHTML:['ASS', 'SRT', 'LRC', 'VTT', 'TXT', 'BCC'].map(type=>` ${type} `).join(''), value: type, onchange:(ev)=>this.updateDownload(ev.target.value) },bottomPanel); //下载 this.actionButton = elements.createAs('a',{ title: '按住Ctrl键点击字幕列表的下载可不打开预览直接下载当前格式', innerText: "下载",style: 'height: 24px;margin-right: 5px;background: #00a1d6;color: #fff;padding: 7px;', onclick: function(){event.stopPropagation();}, oncontextmenu: function(){event.stopPropagation();}, },bottomPanel); //在新标签页中打开 this.openTabButton = elements.createAs('a',{ innerText: "在新标签页中打开",style: 'height: 24px;margin-right: 5px;background: #00a1d6;color: #fff;padding: 7px;', target: '_blank',onclick: function(){event.stopPropagation();}, oncontextmenu: function(){event.stopPropagation();}, },bottomPanel); //关闭 this.closeButton = elements.createAs('a',{ innerText: "关闭",style:'height: 24px;margin-right: 5px;background: #00a1d6;color: #fff;padding: 7px;cursor: pointer;', onclick: ()=>document.body.removeChild(settingDiv) },bottomPanel); //默认转换SRT格式 this.updateDownload(type, download); }, updateDownload(type='LRC', download){ let result; let blobResult; switch(type) { case 'LRC': result = this.encodeToLRC(this.data.body); break; case 'SRT': result = this.encodeToSRT(this.data.body); break; case 'ASS': result = this.encodeToASS(this.data.body); break; case 'VTT': result = this.encodeToVTT(this.data.body); break; case 'TXT': result = this.data.body.map(item=>item.content).join('\r\n'); break; case 'BCC': result = JSON.stringify(this.data, undefined, 2); break; default: result = '错误：无法识别的格式 ' + type; break; } this.textArea.value = result; localStorage.defaultSubtitleType = type; type = type.toLowerCase(); URL.revokeObjectURL(this.actionButton.href); this.actionButton.classList.remove('bpui-state-disabled','bui-button-disabled'); blobResult = new Blob([result], {type:'text/'+type+';charset=utf-8'}); this.actionButton.href = URL.createObjectURL(blobResult); this.openTabButton.href = URL.createObjectURL(blobResult); this.actionButton.download = `${bilibiliCCHelper.getInfo('h1Title') || document.title}.${type}`; if (download) { // 按住Ctrl键时，合并指令文本与字幕内容后复制到剪贴板 const combinedText = `${this.instructionText}\n\n视频标题: ${document.title}\n视频链接: ${document.location}\n\n${result}`; copyToClipboard(combinedText).then(() => { console.log('字幕内容及指令已复制到剪贴板'); this.closeButton.click(); // 关闭下载对话框 }).catch(err => { console.error('复制到剪贴板失败:', err); }); } else { // 未按住Ctrl键时，执行下载操作 this.actionButton.click(); this.closeButton.click(); } }, encodeToLRC(data){ return data.map(({from,to,content})=>{ return `${this.encodeTime(from,'LRC')} ${content.replace(/\n/g,' ')}`; }).join('\r\n'); }, encodeToSRT(data){ return data.map(({from,to,content},index)=>{ return `${index+1}\r\n${this.encodeTime(from)} --> ${this.encodeTime(to)}\r\n${content}`; }).join('\r\n\r\n'); }, encodeToVTT(data){ return 'WEBVTT \r\n\r\n' + data.map(({from,to,content},index)=>{ return `${index+1}\r\n${this.encodeTime(from, 'VTT')} --> ${this.encodeTime(to, 'VTT')}\r\n${content}`; }).join('\r\n\r\n'); }, encodeToASS(data){ this.assHead[1] = `Title: ${document.title}`; this.assHead[10] = `; 字幕来源${document.location}`; return this.assHead.concat(data.map(({from,to,content})=>{ return `Dialogue: 0,${this.encodeTime(from,'ASS')},${this.encodeTime(to,'ASS')},*Default,NTP,0000,0000,0000,,${content.replace(/\n/g,'\\N')}`; })).join('\r\n'); }, encodeTime(input,format='SRT'){ let time = new Date(input*1000), ms = time.getMilliseconds(), second = time.getSeconds(), minute = time.getMinutes(), hour = Math.floor(input/60/60); if (format=='SRT'||format=='VTT'){ if (hour<10) hour = '0'+hour; if (minute<10) minute = '0'+minute; if (second<10) second = '0'+second; if (ms<10) ms = '00'+ms; else if (ms<100) ms = '0'+ms; return `${hour}:${minute}:${second}${format=='SRT'?',':'.'}${ms}`; } else if(format=='ASS'){ ms = (ms/10).toFixed(0); if (minute<10) minute = '0'+minute; if (second<10) second = '0'+second; if (ms<10) ms = '0'+ms; return `${hour}:${minute}:${second}.${ms}`; } else{ ms = (ms/10).toFixed(0); minute += hour*60; if (minute<10) minute = '0'+minute; if (second<10) second = '0'+second; if (ms<10) ms = '0'+ms; return `[${minute}:${second}.${ms}]`; } } }; //解码器，用于读取常见格式字幕并将其转换为B站可以读取BCC格式字幕 const decoder = { srtReg:/(?:(\d+):)?(\d{1,2}):(\d{1,2})[,\.](\d{1,3})\s*(?:-->|,)\s*(?:(\d+):)?(\d{1,2}):(\d{1,2})[,\.](\d{1,3})\r?\n([.\s\S]+)/, assReg:/Dialogue:.*,(\d+):(\d{1,2}):(\d{1,2}\.?\d*),\s*?(\d+):(\d{1,2}):(\d{1,2}\.?\d*)(?:.*?,){7}(.+)/, encodings:['UTF-8','GB18030','BIG5','UNICODE','JIS','EUC-KR'], encoding:'UTF-8', dialog:undefined, reader:undefined, file:undefined, data:undefined, statusHandler:undefined, show(handler){ this.statusHandler = handler; if(!this.dialog){ this.moveAction = ev=>this.dialogMove(ev); this.dialog = elements.createAs('div',{ id :'subtitle-local-selector', style :'position:fixed;z-index:1048576;padding:10px;top:50%;left:calc(50% - 185px);' +'box-shadow: 0 0 4px #e5e9ef;border: 1px solid #e5e9ef;background:white;border-radius:5px;color:#99a2aa', innerHTML:' ' },elements.getAs('#bilibiliPlayer')); //标题栏，可拖动对话框 elements.createAs('div',{ style:"margin-bottom: 5px;cursor:move;user-select:none;line-height:1;", innerText:'本地字幕选择', onmousedown:this.moveAction },this.dialog); //选择字幕，保存文件对象并调用处理文件 elements.createAs('input',{ style: "margin-bottom: 5px;width: 370px;", innerText: '选择字幕', type: 'file',accept:'.lrc,.ass,.ssa,.srt,.bcc,.sbv,.vtt', oninput: ({target})=> this.readFile(this.file = target.files&&target.files[0]) },this.dialog); elements.createAs('br',{},this.dialog); //文件编码选择，保存编码并调用处理文件 elements.createAs('label',{style: "margin-right: 10px;",innerText: '字幕编码'},this.dialog); elements.createAs('select',{ style: "width: 80px;height: 20px;border-radius: 4px;line-height: 20px;border:1px solid #ccd0d7;", title:'如果字幕乱码可尝试更改编码', innerHTML:this.encodings.reduce((result,item)=>`${result} ${item} `,''), oninput: ({target})=> this.readFile(this.encoding = target.value) },this.dialog); //字幕偏移，保存DOM对象以便修改显示偏移，更改时调用字幕处理显示 elements.createAs('label',{ style: "margin-left: 10px;",innerText: '时间偏移(s)',title:'字幕相对于视频的时间偏移，双击此标签复位时间偏移', ondblclick:()=> +this.offset.value&&this.handleSubtitle(this.offset.value=0) },this.dialog); this.offset = elements.createAs('input',{ style: "margin-left: 10px;width: 50px;border: 1px solid #ccd0d7;border-radius: 4px;line-height: 20px;", type:'number', step:0.5, value:0, title:'负值表示将字幕延后，正值将字幕提前', oninput: ()=> this.handleSubtitle() },this.dialog); //关闭按钮 elements.createAs('button',{ style: "margin-left: 10px;border:none;width:max-content;",innerText: '关闭面板', className:'bpui-button bui bui-button bui-button-blue', onclick: ()=> elements.getAs('#bilibiliPlayer').removeChild(this.dialog) },this.dialog); //文件读取器，载入文件 this.reader = new FileReader(); this.reader.onloadend = ()=> this.decodeFile() this.reader.onerror = e=> bilibiliCCHelper.toast('载入字幕失败',e); } else{ elements.getAs('#bilibiliPlayer').appendChild(this.dialog); this.handleSubtitle(); } }, dialogMove(ev){ if (ev.type=='mousedown'){ this.offsetT = ev.pageY-this.dialog.offsetTop; this.offsetL = ev.pageX-this.dialog.offsetLeft; document.body.addEventListener('mouseup',this.moveAction); document.body.addEventListener('mousemove',this.moveAction); } else if (ev.type=='mouseup'){ document.body.removeEventListener('mouseup',this.moveAction); document.body.removeEventListener('mousemove',this.moveAction); } else{ this.dialog.style.top = ev.pageY - this.offsetT + 'px'; this.dialog.style.left = ev.pageX - this.offsetL +'px'; } }, readFile(){ if(!this.file) { this.data = undefined; return bilibiliCCHelper.toast('没有文件'); } this.reader.readAsText(this.file,this.encoding) }, handleSubtitle(){ if(!this.data) return; const offset = +this.offset.value; bilibiliCCHelper.updateLocal(!offset?this.data:{ body:this.data.body.map(({from,to,content})=>({ from:from - offset, to:to - offset, content })) }).then(()=>{ if('function'==typeof(this.statusHandler)) this.statusHandler(true); bilibiliCCHelper.toast(`载入本地字幕:${this.file.name},共${this.data.body.length}行,偏移:${offset}s`); }).catch(e=>{ bilibiliCCHelper.toast('载入字幕失败',e); }); }, decodeFile(){ try{ const type = this.file.name.split('.').pop().toLowerCase(); switch(type){ case 'lrc':this.data = this.decodeFromLRC(this.reader.result);break; case 'ass':case 'ssa': this.data = this.decodeFromASS(this.reader.result);break; case 'srt':case 'sbv':case 'vtt': this.data = this.decodeFromSRT(this.reader.result);break; case 'bcc':this.data = JSON.parse(this.reader.result);break; default:throw('未知文件类型'+type);break; } console.log(this.data); this.handleSubtitle(); } catch(e){ bilibiliCCHelper.toast('解码字幕文件失败',e); }; }, decodeFromLRC(input){ if(!input) return; const data = []; input.split('\n').forEach(line=>{ let match = line.match(/((\[\d+:\d+\.?\d*\])+)(.*)/); if (!match) { if(match=line.match(/\[offset:(\d+)\]/i)) { this.offset.value = +match[1]/1000; } //console.log('跳过非正文行',line); return; } const times = match[1].match(/\d+:\d+\.?\d*/g); times.forEach(time=>{ const t = time.split(':'); data.push({ time:t[0]*60 + (+t[1]), content:match[3].trim().replace('\r','') }); }); }); return { body:data.sort((a,b)=>a.time-b.time).map((item,index)=>( item.content!=''&&{ from:item.time, to:index==data.length-1?item.time+20:data[index+1].time, content:item.content } )).filter(item=>item) }; }, decodeFromSRT(input){ if(!input) return; const data = []; let split = input.split('\n\n'); if(split.length==1) split = input.split('\r\n\r\n'); split.forEach(item=>{ const match = item.match(this.srtReg); if (!match){ //console.log('跳过非正文行',item); return; } data.push({ from:(match[1]*60*60||0) + match[2]*60 + (+match[3]) + (match[4]/1000), to:(match[5]*60*60||0) + match[6]*60 + (+match[7]) + (match[8]/1000), content:match[9].trim().replace(/{\\.+?}/g,'').replace(/\\N/gi,'\n').replace(/\\h/g,' ') }); }); return {body:data.sort((a,b)=>a.from-b.from)}; }, decodeFromASS(input){ if(!input) return; const data = []; let split = input.split('\n'); split.forEach(line=>{ const match = line.match(this.assReg); if (!match){ //console.log('跳过非正文行',line); return; } data.push({ from:match[1]*60*60 + match[2]*60 + (+match[3]), to:match[4]*60*60 + match[5]*60 + (+match[6]), content:match[7].trim().replace(/{\\.+?}/g,'').replace(/\\N/gi,'\n').replace(/\\h/g,' ') }); }); return {body:data.sort((a,b)=>a.from-b.from)}; } };//decoder END //旧版播放器CC字幕助手，需要维护整个设置面板和字幕逻辑 const oldPlayerHelper = { setting:undefined, subtitle:undefined, selectedLan:undefined, isclosed:true, resizeRate: 100, configs:{ color:[ {value:'16777215',content:'白色'}, {value:'16007990',content:'红色'}, {value:'10233776',content:'紫色'}, {value:'6765239',content:'深紫色'}, {value:'4149685',content:'靛青色'}, {value:'2201331',content:'蓝色'}, {value:'240116',content:'亮蓝色'} ], position:[ {value:'bl',content:'左下角'}, {value:'bc',content:'底部居中'}, {value:'br',content:'右下角'}, {value:'tl',content:'左上角'}, {value:'tc',content:'顶部居中'}, {value:'tr',content:'右上角'} ], shadow:[ {value:'0',content:'无描边',style:''}, {value:'1',content:'重墨',style:`text-shadow: #000 1px 0px 1px, #000 0px 1px 1px, #000 0px -1px 1px,#000 -1px 0px 1px;`}, {value:'2',content:'描边',style:`text-shadow: #000 0px 0px 1px, #000 0px 0px 1px, #000 0px 0px 1px;`}, {value:'3',content:'45°投影',style:`text-shadow: #000 1px 1px 2px, #000 0px 0px 1px;`} ] }, saveSetting(){ try{ const playerSetting = localStorage.bilibili_player_settings?JSON.parse(localStorage.bilibili_player_settings):{}; playerSetting.subtitle = this.setting; localStorage.bilibili_player_settings = JSON.stringify(playerSetting); }catch(e){ bilibiliCCHelper.toast('保存字幕设置错误',e); } }, changeStyle(){ this.fontStyle.innerHTML = `span.subtitle-item-background{opacity: ${this.setting.backgroundopacity};}` + `span.subtitle-item-text {color:#${("000000"+this.setting.color.toString(16)).slice(-6)};}` + `span.subtitle-item {font-size: ${this.setting.fontsize*this.resizeRate}%;line-height: 110%;}` + `span.subtitle-item {${this.configs.shadow[this.setting.shadow].style}}`; }, changePosition(){ this.subtitleContainer.className = 'subtitle-position subtitle-position-' +(this.setting.position||'bc'); this.subtitleContainer.style = ''; }, changeResize(){ this.resizeRate = this.setting.scale?bilibiliCCHelper.window.player.getWidth()/1280*100:100; this.changeStyle(); }, changeSubtitle(value=this.subtitle.subtitles[0].lan){ this.selectedLanguage.innerText = bilibiliCCHelper.getSubtitleInfo(value).lan_doc; if(value=='close'){ if(!this.isclosed) { this.isclosed = true; bilibiliCCHelper.loadSubtitle(value); //更换本地字幕不切换设置中的字幕开关状态 if(this.selectedLan!='local') this.setting.isclosed = true; } this.downloadBtn.classList.add('bpui-state-disabled','bpui-button-icon'); this.icon.innerHTML = elements.oldDisableIcon; } else if(value=='local') { //本地字幕解码器产生加载事件后再切换状态 decoder.show((status)=>{ if(status==true){ this.downloadBtn.classList.remove('bpui-state-disabled','bpui-button-icon'); this.isclosed = false; this.selectedLan = value; this.icon.innerHTML = elements.oldEnableIcon; } }); } else{ this.isclosed = false; this.selectedLan = value; this.icon.innerHTML = elements.oldEnableIcon; this.setting.lan = value; this.setting.isclosed = false; bilibiliCCHelper.loadSubtitle(value); this.downloadBtn.classList.remove('bpui-state-disabled','bpui-button-icon'); } }, toggleSubtitle(){ if(this.isclosed) { this.changeSubtitle(this.selectedLan); } else{ this.changeSubtitle('close'); } }, initSubtitle(){ if(this.setting.isclosed) { this.changeSubtitle('close'); } else{ //查找本视频是否有之前选择过的语言，如果有则选择之前的语言 const lan = bilibiliCCHelper.getSubtitleInfo(this.setting.lan)&&this.setting.lan this.changeSubtitle(lan); } //没有字幕时设置下一次切换字幕行为为本地字幕 if(!this.subtitle.count) this.selectedLan = 'local'; this.changeResize(); }, initUI(){ const preBtn = elements.getAs('.bilibili-player-video-btn-quality'); if(!preBtn) throw('没有找到视频清晰度按钮'); this.subtitleContainer = elements.getAs('.bilibili-player-video-subtitle>div'); const btn = preBtn.insertAdjacentElement('afterEnd',elements.createAs('div',{ className:"bilibili-player-video-btn", id:'bilibili-player-subtitle-btn', style:"display: block;", innerHTML:elements.subtitleStyle, onclick:(e)=>{ if(!this.panel.contains(e.target)) this.toggleSubtitle(); } })); //按钮 this.icon = elements.createAs('span',{ innerHTML: this.setting.isclosed?elements.oldDisableIcon:elements.oldEnableIcon },btn); //字幕样式表 this.fontStyle = elements.createAs('style',{type:"text/css"},btn); const panel = this.panel = elements.createAs('div',{ id:'subtitle-setting-panel', style:'position: absolute;bottom: 28px;right: 30px;background: white;border-radius: 4px;' +'text-align: left;padding: 13px;display: none;cursor:default;' },btn), languageDiv = elements.createAs('div',{innerHTML:' 字幕 '},panel), sizeDiv = elements.createAs('div',{innerHTML:' 字体大小 '},panel), colorDiv = elements.createAs('div',{innerHTML:'字幕颜色'},panel), shadowDiv = elements.createAs('div',{innerHTML:'字幕描边'},panel), positionDiv = elements.createAs('div',{innerHTML:'字幕位置'},panel), opacityDiv = elements.createAs('div',{innerHTML:' 背景不透明度 '},panel); //选择字幕 this.selectedLanguage = elements.createSelector({ width:'100px',height:'180px',initValue:'close', handler:(value)=>this.changeSubtitle(value), datas:this.subtitle.subtitles.map(({lan,lan_doc})=>({content:lan_doc,value:lan})) },languageDiv).firstElementChild; //下载字幕 this.downloadBtn = elements.createAs('button',{ className: "bpui-button",style: 'padding:0 8px;', innerText: "下载", onclick: (ev)=>{ if(this.selectedLan=='close') return; bilibiliCCHelper.downloadSubtitle(this.selectedLan, undefined, ev.ctrlKey); } },languageDiv); //上传字幕 elements.createAs('a',{ className: this.subtitle.allow_submit?'bpui-button':'bpui-button bpui-state-disabled', innerText: '添加字幕', href: !this.subtitle.allow_submit?'javascript:' :`https://member.bilibili.com/v2#/zimu/my-zimu/zimu-editor?cid=${window.cid}&${window.aid?`aid=${window.aid}`:`bvid=${window.bvid}`}`, target: '_blank', style: 'margin-right: 0px;height: 24px;padding:0 6px;', title: this.subtitle.allow_submit?'':'本视频无法添加字幕，可能原因是:\r\n·UP主未允许观众投稿字幕\r\n·您未达到UP主设置的投稿字幕条件', },languageDiv); //字体大小 elements.createAs('input',{ style:"width: 70%;",type:"range",step:"25", value: (this.setting.fontsize==0.6?0 :this.setting.fontsize==0.8?25 :this.setting.fontsize==1.3?75 :this.setting.fontsize==1.6?100:50), oninput:(e)=>{ const v = e.target.value/25; this.setting.fontsize = v>2?(v-2)*0.3+1:v*0.2+0.6; this.changeStyle(); } },sizeDiv); //自动缩放 elements.createAs('input',{ id:'subtitle-auto-resize', type:"checkbox", checked:this.setting.scale, onchange:(e)=>this.changeResize(this.setting.scale = e.target.checked) },sizeDiv); elements.createAs('label',{ style:"cursor:pointer", innerText:'自动缩放' },sizeDiv).setAttribute('for','subtitle-auto-resize'); //字幕颜色 elements.createSelector({ width:'74%',height:'120px', initValue:this.setting.color, handler:(value)=>this.changeStyle(this.setting.color=parseInt(value)), datas:this.configs.color },colorDiv); //字幕阴影 elements.createSelector({ width:'74%',height:'120px', initValue:this.setting.shadow, handler:(value)=>this.changeStyle(this.setting.shadow=value), datas:this.configs.shadow },shadowDiv); //字幕位置 elements.createSelector({ width:'74%',initValue:this.setting.position, handler:(value)=>this.changePosition(this.setting.position=value), datas:this.configs.position },positionDiv); //背景透明度 elements.createAs('input',{ style:"width: 100%;", type:"range", value: this.setting.backgroundopacity*100, oninput:(e)=>{ this.changeStyle(this.setting.backgroundopacity = e.target.value/100); } },opacityDiv); //播放器缩放 bilibiliCCHelper.window.player.addEventListener('video_resize', (event) => { this.changeResize(event); }); //退出页面保存配置 bilibiliCCHelper.window.addEventListener("beforeunload", (event) => { this.saveSetting(); }); //初始化字幕 this.initSubtitle(); console.log('init cc helper button done'); }, init(subtitle){ this.subtitle = subtitle; this.selectedLan = undefined; try { if (!localStorage.bilibili_player_settings) throw '当前播放器没有设置信息'; this.setting = JSON.parse(localStorage.bilibili_player_settings).subtitle; if (!this.setting) throw '当前播放器没有字幕设置'; }catch (e) { bilibiliCCHelper.toast('bilibili CC字幕助手读取设置出错,将使用默认设置:', e); this.setting = {backgroundopacity: 0.5,color: 16777215,fontsize: 1,isclosed: false,scale: true,shadow: "0", position: 'bc'}; } this.initUI(); } };//oldPlayerHelper END //2.x播放器CC字幕助手，需要维护下载按钮/本地字幕选项/关闭选项/需要时监听CC字幕按钮 const player2x = { iconBtn:undefined, icon:undefined, panel:undefined, downloadBtn:undefined, selectedLan:undefined, selectedLocal:false, hasSubtitles:false, updateDownloadBtn(value='close'){ this.selectedLan = value; if(value=='close'){ this.downloadBtn.classList.add('bui-button-disabled','bpui-button-icon'); } else{ this.selectedLocal = false; this.downloadBtn.classList.remove('bui-button-disabled','bpui-button-icon'); } }, initUI(){ const downloadBtn = this.downloadBtn = this.panel.nextElementSibling.cloneNode(), selector = this.panel.querySelector('ul'), selectedItem = selector.querySelector('li.bui-select-item.bui-select-item-active'), closeItem = selector.querySelector('li.bui-select-item[data-value="close"]'), localItem = closeItem.cloneNode(); elements.setAs(downloadBtn,{ style: 'min-width:unset!important',innerText: '下载', onclick: (ev)=>{ if(this.selectedLan=='close') return; bilibiliCCHelper.downloadSubtitle(this.selectedLan, undefined, ev.ctrlKey); } }); this.panel.insertAdjacentElement('afterend',downloadBtn); this.updateDownloadBtn(selectedItem&&selectedItem.dataset.value); //本地字幕 elements.setAs(localItem,{ innerText: '本地字幕', onclick: ()=> { decoder.show((status)=>{ if(status==true){ this.selectedLocal = true; this.updateDownloadBtn('local'); this.icon.innerHTML = elements.newEnableIcon; } }); } },selector); //选中本地字幕后关闭需要手动执行 closeItem.addEventListener('click',()=>{ if(!this.selectedLocal) return; this.selectedLocal = false; bilibiliCCHelper.loadSubtitle('close'); this.icon.innerHTML = elements.newDisableIcon; }); //视频本身没有字幕时，点击CC字幕按钮切换本地字幕和关闭 //视频本身有字幕时播放器自身会切换到视频自身字幕 if(!this.hasSubtitles && this.icon){ this.icon.innerHTML = elements.newDisableIcon; this.icon.addEventListener('click',({target})=>{ if(!this.selectedLocal) localItem.click(); else closeItem.click(); }); } new MutationObserver((mutations,observer)=>{ mutations.forEach(mutation=>{ if(!mutation.target||mutation.type!='attributes') return; if(mutation.target.classList.contains('bui-select-item-active')&&mutation.target.dataset.value){ this.updateDownloadBtn(mutation.target.dataset.value); } }); }).observe(selector,{ subtree: true, attributes: true, attributeFilter: ['class'] }); console.log('Bilibili CC Helper init new UI success.'); }, //2.75版UI initUI275(){ //下载标识 if (this.localPanel = this.panel.querySelector('.bilibili-player-video-subtitle-setting-item-body')) { if (!(this.localButton = this.localPanel.querySelector('.bilibili-player-video-subtitle-setting-title'))) { this.localPanel.insertAdjacentElement('afterbegin', elements.createAs('div', { innerText: '字幕', className: 'bilibili-player-video-subtitle-setting-title', onclick:()=> decoder.show(status=>(status && (this.icon.innerHTML = elements.newEnableIcon))) })); } else { this.localButton.onclick = ()=> decoder.show(status=>{ if (status) { this.selectedLocal = true; this.icon.innerHTML = elements.newEnableIcon; } }) } } if (this.lngPanel = this.panel.querySelector('.bilibili-player-video-subtitle-setting-lan-majorlist')) { this.lngPanel.addEventListener('click', function(ev) { if (!(ev.target instanceof HTMLLIElement) || ev.target.lastChild.data=='本地字幕') return; const rect = ev.target.getBoundingClientRect().right; if (rect ==0 || rect -ev.x > 30) return;// 仅当点击字幕右侧30像素内的下载标识区域时触发下载 bilibiliCCHelper.downloadSubtitle(undefined, ev.target.lastChild.data, ev.ctrlKey); return false; }); } elements.createAs('style', { innerHTML:'.bilibili-player-video-subtitle-setting-lan-majorlist>li.bilibili-player-video-subtitle-setting-lan-majorlist-item:after {content: "下载";right: 12px;position: absolute;}' +'.bilibili-player-video-subtitle-setting-title {cursor:pointer}.bilibili-player-video-subtitle-setting-title:before {content: "本地"}' }, this.panel); if(!this.hasSubtitles) { this.icon.onclick = ()=>{ if (this.selectedLocal) { this.selectedLocal = false; bilibiliCCHelper.loadSubtitle('close'); this.icon.innerHTML = elements.newDisableIcon; } else { this.localButton.click(); } }; this.icon.innerHTML = elements.newDisableIcon; // 没有字幕时关闭按钮 } console.log('Bilibili CC Helper init new 2.75 UI success.'); }, init(subtitle){ this.hasSubtitles = subtitle.count; this.selectedLan = undefined; this.selectedLocal = false; this.iconBtn = elements.getAs('.bilibili-player-video-btn-subtitle'); this.panel = elements.getAs('.bilibili-player-video-subtitle-setting-lan'); this.icon = this.iconBtn.querySelector('.bilibili-player-iconfont-subtitle span'); //提高字幕位置高度，避免被遮挡无法拖动 elements.createAs('style', {innerHTML:'.bilibili-player-video-subtitle {z-index: 20;}'}, document.head); if(this.panel){ this.initUI(); //设置ID标记视频为已注入，防止二次初始化 this.iconBtn.id = 'bilibili-player-subtitle-btn'; } else if(this.iconBtn){ //强制显示新版播放器CC字幕按钮，不管视频有没有字幕，反正可以选择本地字幕 this.iconBtn.style = 'display:block'; //视频本身没有字幕时把按钮图标设置成关闭状态 if(!this.hasSubtitles&&this.icon) this.icon.innerHTML = elements.newDisableIcon; //设置ID标记视频为已注入，防止二次初始化 this.iconBtn.id = 'bilibili-player-subtitle-btn'; new MutationObserver((mutations,observer)=>{ //console.log(mutations); for (const mutation of mutations){ if(!mutation.target) continue; if (mutation.target.classList.contains('bilibili-player-video-subtitle-setting-left')){ observer.disconnect(); if (this.panel = mutation.target.querySelector('.bilibili-player-video-subtitle-setting-lan')) { this.initUI(); } else { this.panel = mutation.target; this.initUI275(); } return; } } }).observe(this.iconBtn,{ childList: true, subtree: true }); } else{ throw('找不到新播放器按钮'); } }, };//player2x END // 3.15新版播放器，只有下载功能 const player315 = { panel:undefined, initUI(){ //下载标识 elements.createAs('style',{ innerHTML:'.bpx-player-ctrl-subtitle-major-inner>.bpx-player-ctrl-subtitle-language-item:after {content: "下载";position:absolute;right:12px}' }, this.panel); this.panel.addEventListener('click', function(ev) { if (!(ev.target || !ev.target.classList.contains('bpx-player-ctrl-subtitle-language-item'))) return; const rect = ev.target.getBoundingClientRect().right; if (rect ==0 || rect -ev.x > 30) return;// 仅当点击字幕右侧30像素内的下载标识区域时触发下载 bilibiliCCHelper.downloadSubtitle(ev.target.dataset.lan, ev.target.lastChild.data, ev.ctrlKey); return false; }); //设置ID标记视频为已注入，防止二次初始化 this.panel.id = 'bilibili-player-subtitle-btn'; console.log('3.15 Bilibili CC Helper init new Bangumi UI success.'); }, init(subtitle){ this.panel = elements.getAs('.bpx-player-ctrl-subtitle-major-content'); if (!this.panel) { throw('无字幕'); } this.initUI(); }, }; //player315end //3.14版番剧播放器，仅下载功能 const player314 = { iconBtn:undefined, icon:undefined, panel:undefined, selectedLan:undefined, selectedLocal:false, hasSubtitles:false, updateBtnIcon(value) { if (value) { this.icon.classList.add('squirtle-subtitle-show-state'); this.icon.classList.remove('squirtle-subtitle-hide-state'); } else { this.icon.classList.add('squirtle-subtitle-hide-state'); this.icon.classList.remove('squirtle-subtitle-show-state'); } }, initUI(){ //下载标识 elements.createAs('style', {innerHTML:'.squirtle-subtitle-select-list>li.squirtle-select-item:after {content: "下载";}'}, document.head); this.panel.addEventListener('click', function(ev) { if (!(ev.target instanceof HTMLLIElement)) return; const rect = ev.target.getBoundingClientRect().right; if (rect ==0 || rect -ev.x > 30) return;// 仅当点击字幕右侧30像素内的下载标识区域时触发下载 bilibiliCCHelper.getSubtitle(undefined, ev.target.lastChild.data).then(data=>{ encoder.showDialog(data,ev.ctrlKey); }).catch(e=>{ bilibiliCCHelper.toast('获取字幕失败',e); }); return false; }); //设置ID标记视频为已注入，防止二次初始化 this.panel.id = 'bilibili-player-subtitle-btn'; //if(!this.hasSubtitles) this.updateBtnIcon(status); // 没有字幕时关闭按钮 console.log('Bilibili CC Helper init new Bangumi UI success.'); }, init(subtitle){ this.hasSubtitles = subtitle.count; this.selectedLan = undefined; this.selectedLocal = false; this.iconBtn = elements.getAs('.squirtle-subtitle-wrap'); this.panel = elements.getAs('.squirtle-subtitle-select-list'); this.icon = this.iconBtn.querySelector('.squirtle-subtitle-icon'); if (!this.iconBtn) { throw('找不到新播放器按钮'); } if(this.panel) this.initUI(); }, };//player314 END //启动器 const bilibiliCCHelper = { window:"undefined"==typeof(unsafeWindow)?window:unsafeWindow, player:undefined, cid:undefined, subtitle:undefined, datas:undefined, toast(msg,error){ if(error) console.error(msg,error); if(!this.toastDiv){ this.toastDiv = document.createElement('div'); this.toastDiv.className = 'bilibili-player-video-toast-item'; } const panel = elements.getAs('.bilibili-player-video-toast-top'); if(!panel) return; clearTimeout(this.removeTimmer); this.toastDiv.innerText = msg + (error?`:${error}`:''); panel.appendChild(this.toastDiv); this.removeTimmer = setTimeout(()=>{ panel.contains(this.toastDiv)&&panel.removeChild(this.toastDiv) },3000); }, async updateLocal(data){ this.datas.local = data; return this.updateSubtitle(data); }, async updateSubtitle(data){ this.window.player.updateSubtitle(data); }, loadSubtitle(lan){ this.getSubtitle(lan) .catch(()=>this.setupData(true)).then(()=>this.getSubtitle(lan)) //下载失败时，重新获取一遍字幕信息，因为自动字幕可能有过期时间 .then(data=>this.updateSubtitle(data)) .then(()=>this.toast(lan=='close'?'字幕已关闭':`载入字幕:${this.getSubtitleInfo(lan).lan_doc}`)) .catch(e=>this.toast('载入字幕失败',e)); }, downloadSubtitle(lan, name, direct){ this.getSubtitle(lan,name) .catch(()=>this.setupData(true)).then(()=>this.getSubtitle(lan, name)) //下载失败时，重新获取一遍字幕信息，因为自动字幕可能有过期时间 .then(data=>encoder.showDialog(data,direct)).catch(e=>bilibiliCCHelper.toast('获取字幕失败',e)); }, async getSubtitle(lan, name){ if(this.datas[lan]) return this.datas[lan]; const item = this.getSubtitleInfo(lan, name); if(!item) throw('找不到所选语言字幕'+lan); if(this.datas[item.lan]) return this.datas[item.lan]; return fetch(item.subtitle_url) .then(res=>res.json()) .then(data=>(this.datas[item.lan] = data)); }, getSubtitleInfo(lan, name){ return this.subtitle.subtitles.find(item=>item.lan==lan || item.lan_doc==name); }, getInfo(name) { return this.window[name] || this.window.__INITIAL_STATE__ && this.window.__INITIAL_STATE__[name] || this.window.__INITIAL_STATE__ && this.window.__INITIAL_STATE__.epInfo && this.window.__INITIAL_STATE__.epInfo[name] || this.window.__INITIAL_STATE__ && this.window.__INITIAL_STATE__.videoData && this.window.__INITIAL_STATE__.videoData[name]; }, getEpid(){ return this.getInfo('id') || /ep(\d+)/.test(location.pathname) && +RegExp.$1 || /ss\d+/.test(location.pathname); // ss\d+当季第一集未知epid }, getEpInfo(){ const bvid = this.getInfo('bvid'), epid = this.getEpid(), cidMap = this.getInfo('cidMap'), page = this?.window?.__INITIAL_STATE__?.p; let ep = cidMap?.[bvid]; if (ep) { this.aid = ep.aid; this.bvid = ep.bvid; this.cid = ep.cids[page]; return this.cid; } ep = this.window.__NEXT_DATA__?.props?.pageProps?.dehydratedState?.queries ?.find(query=>query?.queryKey?.[0] == "pgc/view/web/season") ?.state?.data; ep = (ep?.seasonInfo??ep)?.mediaInfo?.episodes ?.find(ep=>epid == true || ep.ep_id == epid); if (ep) { this.epid = ep.ep_id; this.cid = ep.cid; this.aid = ep.aid; this.bvid = ep.bvid; return this.cid; } ep = this.window.__INITIAL_STATE__?.epInfo; if (ep){ this.epid = ep.id; this.cid = ep.cid; this.aid = ep.aid; this.bvid = ep.bvid; return this.cid; } ep = this.window.playerRaw?.getManifest(); if (ep){ this.epid = ep.episodeId; this.cid = ep.cid; this.aid = ep.aid; this.bvid = ep.bvid; return this.cid; } }, async setupData(force){ if(this.subtitle && (this.pcid == this.getEpInfo()) && !force) return this.subtitle; if(location.pathname=='/blackboard/html5player.html') { let match = location.search.match(/cid=(\d+)/i); if(!match) return; this.window.cid = match[1]; match = location.search.match(/aid=(\d+)/i); if(match) this.window.aid = match[1]; match = location.search.match(/bvid=(\d+)/i); if(match) this.window.bvid = match[1]; } this.pcid = this.getEpInfo(); if((!this.cid&&!this.epid)||(!this.aid&&!this.bvid)) return; this.player = this.window.player; this.subtitle = {count:0,subtitles:[{lan:'close',lan_doc:'关闭'},{lan:'local',lan_doc:'本地字幕'}]}; if (!force) this.datas = {close:{body:[]},local:{body:[]}}; decoder.data = undefined; return fetch(`https://api.bilibili.com/x/player/v2?${this.cid?`cid=${this.cid}`:`&ep_id=${this.epid}`}${this.aid?`&aid=${this.aid}`:`&bvid=${this.bvid}`}`, {credentials: 'include'}).then(res=>{ if (res.status==200) { return res.json().then(ret=>{ if (ret.code == -404) { return fetch(`//api.bilibili.com/x/v2/dm/view?${this.aid?`aid=${this.aid}`:`bvid=${this.bvid}`}&oid=${this.cid}&type=1`, {credentials: 'include'}).then(res=>{ return res.json() }).then(ret=>{ if (ret.code!=0) throw('无法读取本视频APP字幕配置'+ret.message); this.subtitle = ret.data && ret.data.subtitle || {subtitles:[]}; this.subtitle.count = this.subtitle.subtitles.length; this.subtitle.subtitles.forEach(item=>(item.subtitle_url = item.subtitle_url.replace(/https?:\/\//,'//'))) this.subtitle.subtitles.push({lan:'close',lan_doc:'关闭'},{lan:'local',lan_doc:'本地字幕'}); this.subtitle.allow_submit = false; return this.subtitle; }); } if(ret.code!=0||!ret.data||!ret.data.subtitle) throw('读取视频字幕配置错误:'+ret.code+ret.message); this.subtitle = ret.data.subtitle; this.subtitle.count = this.subtitle.subtitles.length; this.subtitle.subtitles.push({lan:'close',lan_doc:'关闭'},{lan:'local',lan_doc:'本地字幕'}); return this.subtitle; }); } else { throw('请求字幕配置失败:'+res.statusText); } }) }, tryInit(){ this.setupData().then(subtitle=>{ if(!subtitle) return; if(elements.getAs('#bilibili-player-subtitle-btn')) { console.log('CC助手已初始化'); } else if(elements.getAs('.bilibili-player-video-btn-color')){ oldPlayerHelper.init(subtitle); } else if(elements.getAs('.bilibili-player-video-danmaku-setting')){ player2x.init(subtitle); } else if (elements.getAs('.bpx-player-ctrl-subtitle-major-content')){ player315.init(subtitle); } else if(elements.getAs('.squirtle-subtitle-wrap')){ player314.init(subtitle); } else { console.log('bilibili cc未发现可识别版本播放器') } }).catch(e=>{ this.toast('CC字幕助手配置失败',e); }); }, init(){ this.tryInit(); new MutationObserver((mutations, observer)=>{ //console.log(mutations) for (const mutation of mutations){ if(!mutation.target) return; if(mutation.target.getAttribute('stage')==1 // 2.x版本播放器 || mutation.target.classList.contains('bpx-player-subtitle-wrap') || mutation.target.classList.contains('tit') // 3.22+版本播放器 || mutation.target.classList.contains('bpx-player-ctrl-subtitle-bilingual') // 4.712播放器初始化 || mutation.target.classList.contains('squirtle-quality-wrap')){ // 3.14版本番剧播放器 this.tryInit(); break; } } }).observe(document.body,{ childList: true, subtree: true, }); } }; bilibiliCCHelper.init(); })();
今天我终终终于拿到我的2080Ti显卡啦~！也是另一个广州的老哥发顺丰给我的，钱包呼的一下不见2200……你可能好奇这个显存这么这么大，实际上原卡应该是11G的显存，但是咧……原版RTX2080ti显卡配备的是11GB GDDR6显存，通常包含11个1GB的显存芯片。为了增加显存容量，魔改者会替换这些显存芯片，使用更大容量的显存芯片，比如2GB的芯片，从而将总显存提升至22GB。RTX2080ti显卡能够魔改至22G显存，而其他显卡不行的原因，主要在于RTX2080ti是基于NVIDIA的Turing架构，它与NVIDIA的专业级显卡共享相同的核心，因此在驱动和硬件兼容性方面可以进行魔改。听说早些时候这个卡只要2000出头，现在都涨到2500上下了……我在闲鱼逛了好久找到一个2200的，遂入手选购2080ti主要就是想用大显存跑大模型，用我之前那张GTX 970来跑大模型实在太憋屈了……只能勉强跑动qwen_7b，再大的就再跑不起了，换了大显存卡之后，qwen2.5:32b随便跑都没问题。刚刚翻了下我这台机子的配置，我印象里面我是花了1500多的，原来实际上更少吗…… CPUE3 2696V3240显卡RX560XT 8G225内存三星 32G*2142主板精粤X99M-PRO D3291电源振华冰山战斗蝶550W160机箱笨牛E1589风扇杂牌125.5硬盘自个笔记本滴0显示器AOC115 合计 1388.5显卡比整台电脑都贵……然后我在这个平台上利用ollma + open-webui搭建了个大模型平台，可以来找我要网址看看效果：我的总体感觉是3.5之上，4未满，但是现在的硬件平台应该跑不起更大参数的模型了，我估计要自己喂点数据给他进行微调应该会更好。
在 Typecho 的解析器中添加对 GitHub 风格任务列表的支持
问题场景和效果对比测试代码- [ ] 未完成的任务 - [x] 已完成的任务 1. [ ] 有序列表中的未完成任务 2. [x] 有序列表中的已完成任务 原效果：修复效果： 未完成的任务 已完成的任务 有序列表中的未完成任务 有序列表中的已完成任务原始代码概览<?php if (!defined('__TYPECHO_ROOT_DIR__')) exit; /** * Markdown解析 * * @package Markdown * @copyright Copyright (c) 2014 Typecho team (http://www.typecho.org) * @license GNU General Public License 2.0 */ class Markdown { /** * convert * * @param string $text * @return string */ public static function convert($text) { static $parser; if (empty($parser)) { $parser = new HyperDown(); $parser->hook('afterParseCode', function ($html) { return preg_replace("//i", "", $html); }); $parser->hook('beforeParseInline', function ($html) use ($parser) { return preg_replace_callback("/^\s*<!\-\-\s*more\s*\-\->\s*$/s", function ($matches) use ($parser) { return $parser->makeHolder(' '); }, $html); }); $parser->enableHtml(true); $parser->_commonWhiteList .= '|img|cite|embed|iframe'; $parser->_specialWhiteList = array_merge($parser->_specialWhiteList, array( 'ol' => 'ol|li', 'ul' => 'ul|li', 'blockquote' => 'blockquote', 'pre' => 'pre|code' )); } return str_replace(' ', ' ', $parser->makeHtml($text)); } /** * transerCodeClass * * @param string $html * @return string */ public static function transerCodeClass($html) { return preg_replace("//i", "", $html); } /** * @param $html * @return mixed */ public static function transerComment($html) { return preg_replace_callback("/<!\-\-(.+?)\-\->/s", array('Markdown', 'transerCommentCallback'), $html); } /** * @param $matches * @return string */ public static function transerCommentCallback($matches) { return self::$parser->makeHolder($matches[0]); } } ?>二、问题发现与详细分析1. 任务列表（Task Lists）支持不足问题描述在使用现有的Markdown解析器时，发现无法正确渲染任务列表（如- [ ]和- [x]）。分析疑问疑问1：当前的解析器是否支持任务列表的语法？疑问2：如果不支持，是什么原因导致任务列表无法正确渲染？疑问3：是否有现有的代码部分可以复用或修改以支持任务列表？发现原因在原始代码中，解析器主要关注代码块（标签）和特定注释的处理，没有针对任务列表的专门处理。具体表现为：缺乏任务列表的正则匹配：现有的convert方法中，使用了preg_replace来替换 标签，但没有对任务列表的- [ ]或- [x]进行任何处理。代码重复：即使在后续的transerCodeClass和transerComment方法中进行了一些处理，但依然没有覆盖到任务列表的转换。2. 代码结构冗余与可维护性差问题描述在原始代码中，处理任务列表的逻辑（尽管未实现）如果需要添加，可能会涉及到重复的正则表达式处理。例如，无序列表（ul）和有序列表（ol）的处理可能会重复，增加代码的复杂性和维护难度。分析疑问疑问1：现有代码中是否存在重复的逻辑？疑问2：是否可以将重复的部分抽象为独立的函数，以提高代码的可读性和维护性？疑问3：代码结构是否符合单一职责原则，是否有优化空间？发现原因重复正则表达式：原始代码中的任务列表处理逻辑可能需要在无序列表和有序列表中分别进行匹配和替换，导致相同或相似的正则表达式在多个地方重复出现。函数职责不明确：convert方法负责多个任务，包括初始化解析器、处理代码块、注释以及其他HTML标签的转换，职责过于集中，难以维护和扩展。3. 解析器初始化效率低下问题描述在原始代码中，解析器实例使用了static变量$parser，并在每次调用convert方法时检查是否已初始化。这种方式在高频调用的情况下，可能会带来性能上的浪费，尤其是在多线程或高并发环境下。分析疑问疑问1：每次调用convert方法时，解析器的初始化是否会带来性能开销？疑问2：是否有更高效的方式来管理解析器实例，避免重复初始化？疑问3：当前的单例模式是否符合最佳实践，是否存在改进空间？发现原因性能开销：虽然使用static变量可以避免每次调用都初始化解析器，但在代码层面仍然存在潜在的性能瓶颈，尤其是在并发环境下。单例模式局限：当前的实现方式简单，但缺乏灵活性，如无法在不同的上下文中使用不同的解析器配置。三、详细的解决方案针对上述发现的问题，我们制定了以下详细的解决方案，具体到每个疑问和对应的改进措施。1. 增加任务列表的支持改进措施新增任务列表处理函数：编写一个专门处理任务列表的函数，通过正则表达式匹配- [ ]和- [x]，并将其转换为HTML的复选框。集成到转换流程中：在convert方法中，解析完成后调用新增的任务列表处理函数，确保任务列表在最终HTML中正确渲染。具体实现在改进后的代码中，新增了convertTaskLists方法，专门负责将Markdown中的任务列表语法转换为HTML复选框：/** * 处理任务列表，将 - [ ] 和 - [x] 转换为HTML复选框 * * @param string $html 解析后的HTML * @return string 处理后的HTML */ private static function convertTaskLists($html) { // 定义正则表达式模式，匹配任务列表项 $pattern = '/ \s*\[(x| )\]\s+(.*?)<\/li>/i'; // 回调函数，用于替换匹配的任务列表项 $callback = function ($matches) { // 判断是否为选中的复选框 $checked = strtolower($matches[1]) === 'x' ? 'checked' : ''; // 对任务文本进行HTML实体编码，防止XSS攻击 $taskText = htmlspecialchars($matches[2], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); // 返回带有复选框的列表项HTML return ' ' . $taskText . ' '; }; // 使用正则表达式替换所有匹配的任务列表项 $html = preg_replace_callback($pattern, $callback, $html); return $html; }在convert方法中调用该函数：// 处理任务列表，将 - [ ] 和 - [x] 转换为复选框 $html = self::convertTaskLists($html);2. 优化代码结构，提高可维护性改进措施抽象重复逻辑：将任务列表的处理逻辑抽象为独立的函数，避免在不同类型的列表中重复编写相似的正则表达式。单一职责原则：将convert方法的职责划分明确，解析器初始化、任务列表处理、特定标签替换等功能分别由独立的方法负责。提高代码可读性：通过清晰的函数命名和注释，使代码逻辑更加直观，便于后续维护和扩展。具体实现在改进后的代码中，convertTaskLists方法独立处理任务列表，convert方法则专注于整体的转换流程：/** * 将Markdown文本转换为HTML * * @param string $text Markdown文本 * @return string 转换后的HTML */ public static function convert($text) { // 检查解析器是否已初始化 if (empty(self::$parser)) { self::$parser = new HyperDown(); // 钩子：在解析代码块后处理 self::$parser->hook('afterParseCode', function ($html) { // 将代码块的class属性从"language-xxx"改为"lang-xxx" return self::transerCodeClass($html); }); // 钩子：在解析内联元素前处理 self::$parser->hook('beforeParseInline', function ($html) { // 将特定的注释标记转换为占位符 return self::transerComment($html); }); // 启用HTML标签解析 self::$parser->enableHtml(true); // 扩展允许的通用白名单标签 self::$parser->_commonWhiteList .= '|img|cite|embed|iframe'; // 扩展特殊白名单标签及其子标签 self::$parser->_specialWhiteList = array_merge(self::$parser->_specialWhiteList, array( 'ol' => 'ol|li', 'ul' => 'ul|li', 'blockquote' => 'blockquote', 'pre' => 'pre|code' )); } // 使用 HyperDown 解析 Markdown 文本 $html = self::$parser->makeHtml($text); // 替换特定的占位符标签 $html = str_replace(' ', ' ', $html); // 处理任务列表，将 - [ ] 和 - [x] 转换为复选框 $html = self::convertTaskLists($html); return $html; }3. 提升解析器初始化的效率改进措施使用私有静态变量：将解析器实例$parser设为私有静态变量，确保在整个应用生命周期中只初始化一次，避免多次初始化带来的性能开销。单例模式优化：采用更严格的单例模式，防止在其他地方意外创建多个解析器实例，确保资源的高效利用。懒加载：仅在需要时初始化解析器，避免不必要的资源占用。具体实现在改进后的代码中，解析器实例被设为私有静态变量$parser，并通过检查是否为空来决定是否初始化：/** * Markdown解析器实例 * * @var HyperDown */ private static $parser;在convert方法中进行初始化：if (empty(self::$parser)) { self::$parser = new HyperDown(); // 初始化钩子和白名单 // ... }四、改进后的代码<?php if (!defined('__TYPECHO_ROOT_DIR__')) exit; /** * Markdown解析 * * @package Markdown * @copyright Copyright (c) 2014 Typecho team (http://www.typecho.org) * @license GNU General Public License 2.0 */ class Markdown { /** * Markdown解析器实例 * * @var HyperDown */ private static $parser; /** * 将Markdown文本转换为HTML * * @param string $text Markdown文本 * @return string 转换后的HTML */ public static function convert($text) { // 检查解析器是否已初始化 if (empty(self::$parser)) { self::$parser = new HyperDown(); // 钩子：在解析代码块后处理 self::$parser->hook('afterParseCode', function ($html) { // 将代码块的class属性从"language-xxx"改为"lang-xxx" return self::transerCodeClass($html); }); // 钩子：在解析内联元素前处理 self::$parser->hook('beforeParseInline', function ($html) { // 将特定的注释标记转换为占位符 return self::transerComment($html); }); // 启用HTML标签解析 self::$parser->enableHtml(true); // 扩展允许的通用白名单标签 self::$parser->_commonWhiteList .= '|img|cite|embed|iframe'; // 扩展特殊白名单标签及其子标签 self::$parser->_specialWhiteList = array_merge(self::$parser->_specialWhiteList, array( 'ol' => 'ol|li', 'ul' => 'ul|li', 'blockquote' => 'blockquote', 'pre' => 'pre|code' )); } // 使用 HyperDown 解析 Markdown 文本 $html = self::$parser->makeHtml($text); // 替换特定的占位符标签 $html = str_replace(' ', ' ', $html); // 处理任务列表，将 - [ ] 和 - [x] 转换为复选框 $html = self::convertTaskLists($html); return $html; } /** * 处理任务列表，将 - [ ] 和 - [x] 转换为HTML复选框 * * @param string $html 解析后的HTML * @return string 处理后的HTML */ private static function convertTaskLists($html) { // 定义正则表达式模式，匹配任务列表项 $pattern = '/ \s*\[(x| )\]\s+(.*?)<\/li>/i'; // 回调函数，用于替换匹配的任务列表项 $callback = function ($matches) { // 判断是否为选中的复选框 $checked = strtolower($matches[1]) === 'x' ? 'checked' : ''; // 对任务文本进行HTML实体编码，防止XSS攻击 $taskText = htmlspecialchars($matches[2], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); // 返回带有复选框的列表项HTML return ' ' . $taskText . ' '; }; // 使用正则表达式替换所有匹配的任务列表项 $html = preg_replace_callback($pattern, $callback, $html); return $html; } /** * 转换代码块的class属性 * * @param string $html 解析后的HTML * @return string 转换后的HTML */ public static function transerCodeClass($html) { return preg_replace("//i", "", $html); } /** * 转换HTML注释为占位符 * * @param string $html 解析后的HTML * @return mixed 转换后的HTML */ public static function transerComment($html) { return preg_replace_callback("/<!\-\-(.+?)\-\->/s", array('Markdown', 'transerCommentCallback'), $html); } /** * 注释转换的回调函数 * * @param array $matches 正则表达式匹配结果 * @return string 占位符字符串 */ public static function transerCommentCallback($matches) { return self::$parser->makeHolder($matches[0]); } } ?>五、改进后的代码运行流程初始化解析器在第一次调用convert方法时，检查self::$parser是否为空。若为空，实例化HyperDown解析器，并设置相关钩子和白名单。Markdown转换使用self::$parser->makeHtml($text)将Markdown文本转换为HTML。特定标签替换替换 为 ，确保 标签的正确位置。任务列表处理调用convertTaskLists函数，将HTML中的任务列表语法转换为复选框。返回最终HTML返回处理后的HTML，供前端展示或其他用途。