Repository Wiki
BeyondDimension/SteamTools

脚本缓存与网页脚本注入

SteamTools(Watt Toolkit)加速器插件中「油猴脚本(UserScript)」能力的完整实现:负责用户脚本的元数据解析、MD5/SHA512 指纹去重、编译打包与本地缓存落盘,并由反向代理中间件在响应 HTML 字节流中查找 </body> / </head> 注入位置,把缓存中的脚本内容注入到被加速的网页中。

Purpose and Scope

本页覆盖以下端到端机制(以源码为证):

  • 脚本解析:ScriptManager.ReadScriptAsync 对 ==UserScript== 元数据头的正则解析;
  • 指纹与去重:基于内容 MD5 + SHA512 的重复检测、以 {md5}.js 命名的原始文件与缓存文件双副本管理;
  • 脚本构建(编译):BuildScriptAsync 下载 @require 依赖并使用 IIFE + $.noConflict 包装;
  • 持久化:Script 实体(SQLite 表 CAEC0F80,列名混淆为哈希常量)与 ScriptRepository 的读写;
  • 缓存自愈:CheckFiles 在版本升级后修复/重建缓存、清理失效记录;
  • 网页注入:HttpReverseProxyMiddleware.FindScriptInjectInsertPosition 在响应字节流上倒序扫描定位 </body> / </head>(含 GitHub 专用变体)。

不属于本页(由兄弟页面承接):反向代理 HTTP 服务器的整体管线与请求转发(见 accelerator.reverse-proxy 相关页面);脚本商店的在线列表与下载 UI 交互(ScriptStorePageViewModel,见加速器脚本商店页面);加速节点选择与 hosts/代理配置等加速器主体能力。

Overview

该能力允许用户在加速模式下向被加速的网站注入自定义 JavaScript(兼容 GreasyFork 风格的 UserScript 元数据头)。整体链路分两个阶段:

  1. 导入/缓存阶段(客户端进程):用户导入 .js 脚本文件后,ScriptManager 解析 ==UserScript== 注释块中的 @Name、@Version、@Match、@Require 等元数据,计算内容哈希做去重,把原始文件复制到 AppDataDirectory/{DirName}/{md5}.js,随后「编译」出一个可直接注入的缓存文件 CacheDirectory/{DirName_Build}/{md5}.js(内联 @require 依赖并用闭包隔离 jQuery),最后把 DTO 映射为 Script 实体写入 SQLite。
  2. 注入阶段(反向代理):加速器开启时内置反向代理拦截页面响应,HttpReverseProxyMiddleware 用字节级倒序扫描在响应体中找到最后一个形如 </body> 或 </head> 的闭合标签(标签名恰为 4 字符且不区分大小写等于 BODY/HEAD),将缓存中的脚本内容插到该位置之前,从而把脚本注入网页。

关键设计意图:

  • 以内容哈希为主键命名文件:同一内容无论来源如何都归并为同一 {md5}.js,天然幂等且便于去重与缓存命中;
  • 双目录(原始 vs 编译缓存):DirName 保存用户导入的原始脚本,DirName_Build 保存可注入的编译产物,二者分离使得重建缓存无需重新下载依赖;
  • 字节级扫描而非字符串解析:注入位置查找直接在 byte[] 上倒序匹配 </ + > 并解码 4 字符标签名,避免将整个响应体解码为字符串的性能开销;
  • 删除/校验均设计为幂等:文件不存在时返回成功,升级后自动修复缓存路径,保证客户端升级不产生脏数据。

Architecture

Loading diagram...

架构说明:

  • ScriptManager 是唯一业务入口,继承 GeneralHttpClientFactory 以获得 CreateClient(null, HttpHandlerCategory.Default) 能力——这是为了在 BuildScriptAsync 中下载 @require 声明的第三方依赖(如 jQuery)。
  • 依赖注入注册 通过 ServiceCollectionExtensions.TryAddScriptManager.cs 完成(IScriptManager → ScriptManager),构造函数注入 IScriptRepository、IMapper、IToast、IMicroServiceClient 等。
  • HttpReverseProxyMiddleware 位于独立项目 BD.WTTS.Client.Plugins.Accelerator.ReverseProxy,注入位置查找以 partial class 的静态算法拆分在 HttpReverseProxyMiddleware.FindScriptInjectInsertPosition.cs 中,与脚本缓存层解耦——中间件只关心"在响应字节流的哪个偏移插入",内容来源仍是缓存目录中的编译产物。

Main Content

1. 脚本解析:ReadScriptAsync

ReadScriptAsync 读取整个 .js 文件,用 Substring("==UserScript==", "==/UserScript==") 截取元数据头,再用常量正则 (?<={0})[\s\S]*?(?=\n) 逐项提取键值。核心设计点:

csharp
1var userScript = content.Substring("==UserScript==", "==/UserScript=="); 2if (!string.IsNullOrEmpty(userScript)) 3{ 4 var script = new ScriptDTO 5 { 6 FilePath = path, 7 Content = content.Replace("</script>", "<\\/script>"),//不读取至 Content 8 Name = Regex.Match(userScript, string.Format(DescRegex, "@Name"), RegexOptions.IgnoreCase).GetValue(s => s.Success == true), 9 Version = Regex.Match(userScript, string.Format(DescRegex, "@Version"), RegexOptions.IgnoreCase).GetValue(s => s.Success == true), 10 Describe = Regex.Match(userScript, string.Format(DescRegex, "@Description"), RegexOptions.IgnoreCase).GetValue(s => s.Success == true), 11 AuthorName = Regex.Match(userScript, string.Format(DescRegex, "@Author"), RegexOptions.IgnoreCase).GetValue(s => s.Success == true),

Source: ScriptManager.cs

意图解释:

  • Content = content.Replace("</script>", "<\\/script>"):把脚本中出现的字符串 </script> 转义为 <\/script>,防止脚本内容被注入到 HTML 内联 <script> 标签时提前闭合标签(注释「不读取至 Content」说明后续 IPC/进程间传递会清理该字段);
  • @Match 与 @Include 回退关系:script.MatchDomainNames = string.IsNullOrEmpty(matchs) ? includes : matchs;——优先使用 @match 声明的域名,没有 @match 时回退到 @include;
  • 默认启用:script.Disable = false;(源码注释「忽略脚本 Enable 启动标签默认启动」),即导入后默认启用;
  • DependentGreasyForkFunction 通过检测是否存在任意 @grant 声明来判定脚本是否依赖油猴(GreasyFork)函数。

2. 指纹去重与文件落盘:SaveScriptAsync

SaveScriptAsync 是整个缓存机制的枢纽,完整执行「哈希 → 去重 → 复制原始文件 → 合并 oldInfo → 编译缓存 → 入库」:

csharp
1if (info.Content != null) 2{ 3 var md5 = Hashs.String.MD5(info.Content); 4 var sha512 = Hashs.String.SHA512(info.Content); 5 if (!ignoreCache) 6 { 7 if (await scriptRepository.ExistsScriptAsync(md5, sha512)) 8 { 9 return ApiRspHelper.Fail<ScriptDTO?>(AppResources.Script_FileRepeat); 10 } 11 } 12 var jsFileName = md5 + FileEx.JS; 13 var jsRelativePath = Path.Combine(IScriptManager.DirName, jsFileName); 14 var jsBuildRelativePath = Path.Combine(IScriptManager.DirName_Build, jsFileName); 15 var jsSavePath = Path.Combine(Plugin.Instance.AppDataDirectory, IScriptManager.DirName, jsFileName);

Source: ScriptManager.cs

关键逻辑:

  • 去重策略:以 (MD5, SHA512) 二元组查库去重(ignoreCache 参数可绕过,用于在线商店覆盖安装);用双哈希而非单一 MD5 是为了降低碰撞概率。
  • oldInfo 分支(L146-L177):若 oldInfo.LocalId > 0 表示是本地已存在脚本的更新——保留原 LocalId/Id/Order/IconUrl,并在新旧文件路径不同(isNoRepeat)时调用 DeleteScriptAsync(oldInfo, false) 删除旧文件但不删库(保留记录待更新);否则视为在线 DTO,把服务器返回的 SourceLink/DownloadLink/Version 等覆盖到新 info。
  • pid 与基础脚本:if (pid.HasValue) info.Id = pid.Value;,且当 entity.Pid == Guid.Parse("00000000-0000-0000-0000-000000000001") 时标记 IsBasics = true 并强制 order = 1——内置基础脚本(如商店预置)排序置顶。
  • Order 归一化(L195-L198):order.Value > int.MaxValue ? int.MaxValue : ... 夹紧到 int 区间,未指定且当前为 0 时默认 10。
  • 入库结果:(await scriptRepository.InsertOrUpdateAsync(entity, ...)).rowCount > 0 决定返回 Script_SaveDbSuccess 或 Script_SaveDBError,并把自增 entity.Id 回填 info.LocalId。

3. 编译缓存:BuildScriptAsync(IIFE + noConflict 包装)

BuildScriptAsync 把「油猴脚本 + @require 依赖」打包成可直接 <script> 内联注入的产物:

csharp
1if (build) 2{ 3 scriptContent.AppendLine("(function () {"); 4 foreach (var item in model.RequiredJsArray) 5 { 6 try 7 { 8 var scriptInfo = await GetAsync<string>(item); 9 scriptContent.AppendLine(scriptInfo); 10 } 11 catch (Exception e) 12 { 13 var errorMsg = AppResources.Script_BuildDownloadError__.Format(model.Name, item); 14 logger.LogError(e, errorMsg); 15 toast.Show(ToastIcon.Error, errorMsg); 16 } 17 } 18 scriptContent.AppendLine("try{var jq2 = $.noConflict(true);}catch{};(($, jQuery) => {"); 19 scriptContent.AppendLine(model.Content); 20 scriptContent.AppendLine("})(jq2, jq2)})()"); 21}

Source: ScriptManager.cs

设计意图(逐层解释包装结构):

  1. 最外层 (function () { ... })() IIFE——所有临时变量(依赖库、jq2)不泄漏到页面全局作用域,避免污染目标网站;
  2. 依次内联 @require 的依赖脚本(在 IIFE 内部形成"局部库环境");
  3. try{var jq2 = $.noConflict(true);}catch{}; ——释放全局 $/jQuery,把控制权还给页面自身的 jQuery(若页面没有 jQuery 则静默吞掉异常);
  4. (($, jQuery) => { ... })(jq2, jq2) ——以参数形式把捕获的 jq2 注入用户脚本,兼容油猴脚本中常见的 $/jQuery 引用习惯;
  5. 单个依赖下载失败不中断整体构建(catch 后仅 Toast + 日志),保证弱网下仍能生成缓存——但这也意味着缓存可能缺少某个依赖,属已知取舍;
  6. build == false 时直接 Append(model.Content),即"纯缓存"模式(用于 CheckFiles 中仅刷新缓存不重新下载依赖的场景)。

4. 数据模型:Script 实体(SQLite)

csharp
1[SQLiteTable(TableName)] 2[DebuggerDisplay("{DebuggerDisplay(),nq}")] 3public sealed class Script : IEntity<int> 4{ 5 //TableName = ScriptV2 6 public const string TableName = "CAEC0F80"; 7 public const string ColumnName_Id = "E386BC36"; 8 public const string ColumnName_Enable = "6DB11594"; 9 public const string ColumnName_CachePath = "997C6B59"; 10 11 [Column(ColumnName_Id)] 12 [PrimaryKey] 13 [AutoIncrement] 14 public int Id { get; set; }

Source: Script.cs

实体要点:

字段列名(混淆)说明
IdE386BC36自增主键,DTO 侧即 LocalId
Order8061969F排序,默认 10;基础脚本强制 1
Name / Version7C85E5C4 / 8037C4C3SQLiteNotNull,来自 @Name/@Version
PidF1DDBF5B服务器侧脚本 Guid,...0001 表示基础脚本
FilePath / CachePathC9719845 / 997C6B59原始/缓存文件的相对路径,SQLiteNotNull
MatchDomainNames29296F97分号分隔的匹配域名(来自 @match/@include)
Enable6DB11594是否启用,默认 false(导入时 DTO 侧 Disable=false)
ExcludeDomainNames569086A0分号分隔的排除域名(@exclude)

注释 //TableName = ScriptV2 表明表名/列名被替换为哈希常量用于本地数据库列名混淆(V2 迁移产物);DependentGreasyForkFunction 字段被注释掉,说明该标记只保留在 DTO 层、不再持久化。

5. 网页注入位置查找:字节级倒序扫描

反向代理中间件在响应字节流上定位注入点,避免整段 HTML 解码:

csharp
1for (int i = buffer_.Length - 1; i >= 0; i--) // 倒序匹配,对应之前的 LastIndexOf(string 2{ 3 var item = buffer_[i]; 4 if (index_name_end == 0) 5 { 6 var index = mark_end.Length - 1 - match_mark_end_index; 7 if (index >= 0 && index < mark_end.Length && item == mark_end[index]) // 匹配末尾 8 { 9 ... 10 var bytes = buffer.Span[index_name_start..index_name_end]; 11 var charCount = encoding.GetCharCount(bytes); 12 if (charCount == matchCharCount) 13 { 14 var body = "BODY"u8; 15 var head = "HEAD"u8; 16 if ((bytes.Length == body.Length && 17 bytes.SequenceEqual(body, comparer)) || 18 (bytes.Length == head.Length && 19 bytes.SequenceEqual(head, comparer))) 20 { 21 insertPosition = index_name_start - mark_start.Length; 22 return true; 23 } 24 }

Source: HttpReverseProxyMiddleware.FindScriptInjectInsertPosition.cs

算法分析(状态机两阶段):

  • 阶段一(尚未找到 >):从缓冲区末尾向前寻找 >(mark_end),找到后记录 index_name_end = i 进入阶段二——这等价于字符串 LastIndexOf 的语义("最后一个闭合标签"),源码注释明确说明该循环是对原先 LastIndexOf(string) 的重写;
  • 阶段二(已找到 >):继续向前寻找 </(mark_start),命中后取中间的标签名字节 buffer.Span[index_name_start..index_name_end];
  • matchCharCount = 4:只接受恰为 4 个字符的标签名,且与 BODY/HEAD 不区分大小写(Utf8StringComparerOrdinalIgnoreCase)相等,才把 insertPosition 设为 < 之前的位置并返回 true;否则跳到 reset 标签清零三个计数器继续向前扫描(跳过如 </div>、</html> 等非目标标签);
  • encoding.GetCharCount(bytes):用响应编码确认标签名解码后正好 4 个字符,兼容多字节编码下"字节数 ≠ 字符数"的页面;
  • 未找到:insertPosition = -1; return false;,调用方据此放弃注入(响应保持原样透传)。

该文件还提供 GitHub 专用变体 FindScriptInjectInsertPositionForGithub(以 <script 为搜索标记定位最后一个带 src 的脚本标签起点),用于 GitHub 页面这类结构特殊的响应。

6. 删除与缓存自愈:DeleteScriptAsync / CheckFiles

DeleteScriptAsync(幂等删除):

csharp
1// 对于删除操作,应当为幂等,当不存在时候应当返回成功,除非删除失败否则不应该有错误 2if (item.LocalId > 0) 3{ 4 var info = await scriptRepository.FirstOrDefaultAsync(x => x.Id == item.LocalId); 5 if (info != null) 6 { 7 var fileName = info.MD5 + FileEx.JS; 8 var cachePath = Path.Combine(Plugin.Instance.CacheDirectory, IScriptManager.DirName_Build, fileName);

Source: ScriptManager.cs

删除顺序为「编译缓存 → 原始文件 → 数据库记录」,每一步单独 try/catch 返回失败信息;库中查不到记录或 LocalId <= 0 时直接返回成功——保证幂等。SaveScriptAsync 更新流程中复用该方法并传 removeByDataBase: false,实现"只删文件保留记录"。

CheckFiles(升级自愈):源码注释「版本升级 检查数据库 数据 是否正确 不正确删除重新打包缓存文件」:

csharp
1//检查缓存文件夹如果不是 IScriptManager.DirName_Build 替换成 IScriptManager.DirName_Build 开头 2if (!item.CachePath.StartsWith($"{IScriptManager.DirName_Build}{Path.DirectorySeparatorChar}")) 3{ 4 var oldCachePath = Path.Combine(Plugin.Instance.CacheDirectory, item.CachePath); 5 item.CachePath = Path.Combine(IScriptManager.DirName_Build, item.FileName!); 6 if (File.Exists(oldCachePath)) 7 { 8 File.Delete(oldCachePath); 9 } 10 await TryReadFileAsync(item, true); 11 //清理 脚本内容 后续 IPC 传递 插件进程读取。 12 //item.Content = string.Empty; 13 await scriptRepository.SaveScriptCachePathAsync(item, default); 14}

Source: ScriptManager.cs

该迁移逻辑处理旧版本缓存路径不以 DirName_Build 开头的历史数据:删除旧缓存、修正 CachePath、重建并回写数据库;对文件校验失败(CheckFile 为 false)的记录则调用 DeleteScriptAsync 移除并 Toast 提示 Script_NoFile_ / Script_NoFileDeleteError_。

Core Flow

Loading diagram...

时序说明:导入阶段所有写操作集中在 SaveScriptAsync 内(文件复制在构建之前完成,保证 BuildScriptAsync 失败时原始文件已就位,可重试);注入阶段完全只读缓存目录,无任何数据库/网络依赖,因此代理路径上没有额外 I/O 放大。

Usage Examples

读取并解析一个 UserScript 文件

csharp
1public async Task<ScriptDTO?> ReadScriptAsync(string path) 2{ 3 4 var content = await File.ReadAllTextAsync(path); 5 if (!string.IsNullOrEmpty(content)) 6 { 7 var userScript = content.Substring("==UserScript==", "==/UserScript=="); 8 if (!string.IsNullOrEmpty(userScript)) 9 { 10 var script = new ScriptDTO 11 { 12 FilePath = path, 13 Content = content.Replace("</script>", "<\\/script>"),//不读取至 Content

Source: ScriptManager.cs

调用方(UI ViewModel)只需传入磁盘路径即可得到完整 DTO;返回 null 表示文件缺少 ==UserScript== 块或内容为空。

导入并构建缓存(含旧记录更新)

csharp
1public async Task<IApiRsp<ScriptDTO?>> AddScriptAsync(string filePath, ScriptDTO? oldInfo = null, bool isCompile = true, long? order = null, bool deleteFile = false, Guid? pid = null, bool ignoreCache = false) 2{ 3 var fileInfo = new FileInfo(filePath); 4 if (fileInfo.Exists) 5 { 6 var info = await ReadScriptAsync(filePath); 7 return await SaveScriptAsync(fileInfo, info, oldInfo, isCompile, order, deleteFile, pid, ignoreCache); 8 } 9 else 10 { 11 var msg = AppResources.Script_NoFile_.Format(filePath); 12 logger.LogError(msg); 13 return ApiRspHelper.Fail<ScriptDTO?>(msg); 14 } 15}

Source: ScriptManager.cs

参数语义:oldInfo 用于更新已有脚本(本地或在线);isCompile=false 仅落盘不打包依赖;deleteFile=true 入库成功后删除用户导入的源文件(导入临时目录场景);pid 关联服务器脚本 Guid;ignoreCache=true 绕过重复检测(覆盖安装)。

在响应字节流中定位注入点

csharp
1internal static bool FindScriptInjectInsertPosition(byte[] buffer_, Encoding encoding, out ReadOnlyMemory<byte> buffer, out int insertPosition) 2{ 3 buffer = buffer_.AsMemory(); 4 5 // 匹配 </...> 60 47 ... 62 6 var mark_start = "</"u8.ToArray(); 7 var mark_end = ">"u8.ToArray(); 8 if (mark_start.Length <= 0 || mark_end.Length <= 0) goto notfound;

Source: HttpReverseProxyMiddleware.FindScriptInjectInsertPosition.cs

返回 true 时 insertPosition 即插入偏移,buffer 为同一内存的只读视图,调用方零拷贝完成拼接。

Configuration Options

本能力无独立配置文件键,行为由常量与目录约定决定:

常量 / 目录类型默认值说明
IScriptManager.DirNamestring(接口常量)原始脚本目录名,位于 Plugin.Instance.AppDataDirectory 之下
IScriptManager.DirName_Buildstring(接口常量)编译缓存目录名,位于 Plugin.Instance.CacheDirectory 之下
DescRegexstring(?<={0})[\s\S]*?(?=\n)元数据键取值正则模板,{0} 填入键名
Script.TableNamestringCAEC0F80SQLite 表名(列名混淆为哈希常量)
ApiConstants.GeneralSeparatorstring(全局常量)@match/@require/@exclude 多值连接分隔符
基础脚本 PidGuid00000000-0000-0000-0000-000000000001命中时 IsBasics=true、Order=1
默认 Orderint10未显式指定且当前为 0 时使用

API Reference

Task<ScriptDTO?> ReadScriptAsync(string path)

解析磁盘上的 UserScript 文件并返回 DTO。

Parameters:

  • path (string): 脚本文件绝对路径。

Returns: 解析成功返回填充了元数据与转义后 Content 的 ScriptDTO;文件为空或缺少 ==UserScript== 块时返回 null。

Task<IApiRsp<ScriptDTO?>> AddScriptAsync(string filePath, ScriptDTO? oldInfo = null, bool isCompile = true, long? order = null, bool deleteFile = false, Guid? pid = null, bool ignoreCache = false)

导入脚本的对外入口,先校验文件存在再委托 SaveScriptAsync。

Parameters: 见上文「Usage Examples」中的参数语义说明。

Returns: ApiRspHelper.Code(ApiRspCode.OK, Script_SaveDbSuccess, info) 成功;文件不存在/重复/构建失败/入库失败分别返回带本地化消息的失败结果。

Task<IApiRsp<ScriptDTO?>> SaveScriptAsync(FileInfo fileInfo, ScriptDTO? info, ScriptDTO? oldInfo = null, bool isCompile = true, long? order = null, bool deleteFile = false, Guid? pid = null, bool ignoreCache = false)

缓存枢纽方法:指纹去重、复制原始文件、合并 oldInfo、构建缓存、映射入库。

Returns: 成功时回填 info.LocalId = entity.Id;BuildScriptAsync 失败返回 Script_BuildError_;info == null 返回 Script_ReadFileError_。

Task<bool> BuildScriptAsync(ScriptDTO model, FileInfo fileInfo, bool build = true)

生成可注入的编译产物。

Returns: 成功写入返回 true;RequiredJsArray == null、下载异常或写盘异常返回 false。单个 @require 下载失败不会导致返回 false(仅 Toast+日志)。

Task<IApiRsp> DeleteScriptAsync(ScriptDTO item, bool removeByDataBase = true)

幂等删除脚本:缓存文件 → 原始文件 → 数据库记录。

Returns: 记录不存在或 LocalId <= 0 时也返回成功 Script_DeleteSuccess;任一文件删除异常返回 Script_CacheDeleteError_ / Script_FileDeleteError_。

Task<IEnumerable<ScriptDTO>> CheckFiles(IEnumerable<ScriptDTO> list)

启动/升级时的缓存自愈校验,剔除文件丢失项并修正历史 CachePath。

internal static bool FindScriptInjectInsertPosition(byte[] buffer_, Encoding encoding, out ReadOnlyMemory<byte> buffer, out int insertPosition)

字节级倒序扫描定位 </body>/</head> 注入点。

Parameters:

  • buffer_ (byte[]): 响应体字节;
  • encoding (Encoding): 响应编码,用于 GetCharCount 校验标签名字符数;
  • buffer (out ReadOnlyMemory<byte>): 同一缓冲的只读视图;
  • insertPosition (out int): 插入偏移,未找到时为 -1。

Returns: 找到合法 BODY/HEAD(4 字符、忽略大小写)闭合标签返回 true。

Throws:

  • ArgumentOutOfRangeException(源码 XML 注释声明):索引/切片越界时。

Failure Modes, Edge Cases & Concurrency

场景源码行为意图
导入文件不存在AddScriptAsync 返回 Fail(Script_NoFile_) 并 LogError提前校验,避免后续空引用
内容重复(MD5+SHA512 命中)返回 Fail(Script_FileRepeat);ignoreCache=true 可绕过防止同内容多份文件占用双倍空间
脚本缺少 ==UserScript== 块ReadScriptAsync 返回 null,SaveScriptAsync 走 info == null 分支返回 Script_ReadFileError_非油猴格式脚本被拒绝
脚本内含 </script> 字面量导入时被转义为 <\/script>防止内联注入时提前闭合标签
@require 依赖下载失败单项 catch → Toast Script_BuildDownloadError__ + 日志,继续构建其余部分弱网可用性优先,接受缺失依赖
编译失败SaveScriptAsync 返回 Script_BuildError_ 并 Toast原始文件已复制,用户可重试
删除时库中无记录直接返回成功幂等删除约定(源码注释明确声明)
删除文件被占用/失败单独 catch 返回 Script_CacheDeleteError_ / Script_FileDeleteError_ 并记录路径区分缓存与原始文件失败便于定位
升级后 CachePath 非法CheckFiles 删除旧缓存、修正路径、重建并 SaveScriptCachePathAsync老版本数据迁移自愈
缓存文件长度为 0CheckFiles 中检测 file.Length == 0 后触发刷新重建防止空文件注入空内容
响应体无 </body>/</head>insertPosition = -1,返回 false,响应原样透传不破坏无标准闭合标签的响应
多字节编码标签名encoding.GetCharCount(bytes) == 4 校验字符数而非字节数避免按字节误判标签长度
更新旧脚本时新旧路径相同(isNoRepeat == false)跳过文件删除与覆盖复制,仅重建缓存与入库同名同哈希更新不产生自删竞态

并发注意:ScriptManager 无显式锁;安全边界来自「文件以内容哈希命名 + ExistsScriptAsync 先查后插」与 SQLite 侧 InsertOrUpdateAsync 的 upsert 语义。理论上并发导入同一脚本存在微小 TOCTOU 窗口(两个调用都通过存在性检查后各自复制/入库),但因文件名相同最终收敛为同一 {md5}.js,数据库 upsert 亦收敛为单条记录。

Performance / Operational Notes & Extension Points

  • 注入路径零解码:FindScriptInjectInsertPosition 只在 byte[] 上做常量标记匹配(</ 与 >),标签名仅在命中时按需 GetCharCount,整体 O(n) 单遍倒序扫描,不分配响应体大小的字符串;
  • u8 字面量 + Utf8StringComparerOrdinalIgnoreCase:标记与比较均为 UTF-8 字节级常量,避免每次调用分配新数组(仅 mark_start/mark_end 各一次 ToArray);
  • 缓存与原始文件分离:重建缓存(CheckFiles/build:false)不触发依赖重新下载,降低运营期网络开销;
  • [MethodImpl(AggressiveInlining)] 的 GetAsync<T>:所有微服务/依赖下载共用同一帮助方法,统一 UserAgent 与 HttpHandlerCategory.Default 通道;
  • 可扩展点:
    • 新增 UserScript 元数据键:在 ReadScriptAsync 中按 string.Format(DescRegex, "@Key") 模式追加一行正则即可(源码中 @SupportURL、@NameSpace、@Enable 即以注释形式保留的扩展示例);
    • 新增注入目标站点特化:参照 FindScriptInjectInsertPositionForGithub 在同一 partial class 中新增静态定位算法,由中间件按响应来源分发;
    • 脚本分发:IMicroServiceClient csc 已注入 ScriptManager 构造函数,用于脚本商店在线数据(Pid 关联服务器记录)。

Sources

(3 files)
src/BD.WTTS.Client.Plugins.Accelerator.ReverseProxy/Services.Implementation/HttpServer/Middleware
src/BD.WTTS.Client.Plugins.Accelerator/Entities
src/BD.WTTS.Client.Plugins.Accelerator/Services.Implementation