脚本缓存与网页脚本注入
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 元数据头)。整体链路分两个阶段:
- 导入/缓存阶段(客户端进程):用户导入
.js脚本文件后,ScriptManager解析==UserScript==注释块中的@Name、@Version、@Match、@Require等元数据,计算内容哈希做去重,把原始文件复制到AppDataDirectory/{DirName}/{md5}.js,随后「编译」出一个可直接注入的缓存文件CacheDirectory/{DirName_Build}/{md5}.js(内联@require依赖并用闭包隔离 jQuery),最后把 DTO 映射为Script实体写入 SQLite。 - 注入阶段(反向代理):加速器开启时内置反向代理拦截页面响应,
HttpReverseProxyMiddleware用字节级倒序扫描在响应体中找到最后一个形如</body>或</head>的闭合标签(标签名恰为 4 字符且不区分大小写等于BODY/HEAD),将缓存中的脚本内容插到该位置之前,从而把脚本注入网页。
关键设计意图:
- 以内容哈希为主键命名文件:同一内容无论来源如何都归并为同一
{md5}.js,天然幂等且便于去重与缓存命中; - 双目录(原始 vs 编译缓存):
DirName保存用户导入的原始脚本,DirName_Build保存可注入的编译产物,二者分离使得重建缓存无需重新下载依赖; - 字节级扫描而非字符串解析:注入位置查找直接在
byte[]上倒序匹配</+>并解码 4 字符标签名,避免将整个响应体解码为字符串的性能开销; - 删除/校验均设计为幂等:文件不存在时返回成功,升级后自动修复缓存路径,保证客户端升级不产生脏数据。
Architecture
架构说明:
- 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) 逐项提取键值。核心设计点:
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 → 编译缓存 → 入库」:
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> 内联注入的产物:
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
设计意图(逐层解释包装结构):
- 最外层
(function () { ... })()IIFE——所有临时变量(依赖库、jq2)不泄漏到页面全局作用域,避免污染目标网站; - 依次内联
@require的依赖脚本(在 IIFE 内部形成"局部库环境"); try{var jq2 = $.noConflict(true);}catch{};——释放全局$/jQuery,把控制权还给页面自身的 jQuery(若页面没有 jQuery 则静默吞掉异常);(($, jQuery) => { ... })(jq2, jq2)——以参数形式把捕获的jq2注入用户脚本,兼容油猴脚本中常见的$/jQuery引用习惯;- 单个依赖下载失败不中断整体构建(catch 后仅 Toast + 日志),保证弱网下仍能生成缓存——但这也意味着缓存可能缺少某个依赖,属已知取舍;
build == false时直接Append(model.Content),即"纯缓存"模式(用于CheckFiles中仅刷新缓存不重新下载依赖的场景)。
4. 数据模型:Script 实体(SQLite)
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
实体要点:
| 字段 | 列名(混淆) | 说明 |
|---|---|---|
Id | E386BC36 | 自增主键,DTO 侧即 LocalId |
Order | 8061969F | 排序,默认 10;基础脚本强制 1 |
Name / Version | 7C85E5C4 / 8037C4C3 | SQLiteNotNull,来自 @Name/@Version |
Pid | F1DDBF5B | 服务器侧脚本 Guid,...0001 表示基础脚本 |
FilePath / CachePath | C9719845 / 997C6B59 | 原始/缓存文件的相对路径,SQLiteNotNull |
MatchDomainNames | 29296F97 | 分号分隔的匹配域名(来自 @match/@include) |
Enable | 6DB11594 | 是否启用,默认 false(导入时 DTO 侧 Disable=false) |
ExcludeDomainNames | 569086A0 | 分号分隔的排除域名(@exclude) |
注释 //TableName = ScriptV2 表明表名/列名被替换为哈希常量用于本地数据库列名混淆(V2 迁移产物);DependentGreasyForkFunction 字段被注释掉,说明该标记只保留在 DTO 层、不再持久化。
5. 网页注入位置查找:字节级倒序扫描
反向代理中间件在响应字节流上定位注入点,避免整段 HTML 解码:
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(幂等删除):
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(升级自愈):源码注释「版本升级 检查数据库 数据 是否正确 不正确删除重新打包缓存文件」:
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
时序说明:导入阶段所有写操作集中在 SaveScriptAsync 内(文件复制在构建之前完成,保证 BuildScriptAsync 失败时原始文件已就位,可重试);注入阶段完全只读缓存目录,无任何数据库/网络依赖,因此代理路径上没有额外 I/O 放大。
Usage Examples
读取并解析一个 UserScript 文件
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>"),//不读取至 ContentSource: ScriptManager.cs
调用方(UI ViewModel)只需传入磁盘路径即可得到完整 DTO;返回 null 表示文件缺少 ==UserScript== 块或内容为空。
导入并构建缓存(含旧记录更新)
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 绕过重复检测(覆盖安装)。
在响应字节流中定位注入点
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.DirName | string | (接口常量) | 原始脚本目录名,位于 Plugin.Instance.AppDataDirectory 之下 |
IScriptManager.DirName_Build | string | (接口常量) | 编译缓存目录名,位于 Plugin.Instance.CacheDirectory 之下 |
DescRegex | string | (?<={0})[\s\S]*?(?=\n) | 元数据键取值正则模板,{0} 填入键名 |
Script.TableName | string | CAEC0F80 | SQLite 表名(列名混淆为哈希常量) |
ApiConstants.GeneralSeparator | string | (全局常量) | @match/@require/@exclude 多值连接分隔符 |
| 基础脚本 Pid | Guid | 00000000-0000-0000-0000-000000000001 | 命中时 IsBasics=true、Order=1 |
| 默认 Order | int | 10 | 未显式指定且当前为 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 | 老版本数据迁移自愈 |
| 缓存文件长度为 0 | CheckFiles 中检测 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关联服务器记录)。
- 新增 UserScript 元数据键:在
Related Links
- ScriptManager.cs — 解析/去重/构建/删除/自愈全部实现
- IScriptManager.cs — 接口契约与
DirName/DirName_Build常量 - Script.cs — SQLite 实体(表
CAEC0F80) - ScriptRepository.cs — 数据访问
- ServiceCollectionExtensions.TryAddScriptManager.cs — DI 注册
- HttpReverseProxyMiddleware.FindScriptInjectInsertPosition.cs — 注入位置查找算法
- ScriptPageViewModel.cs — 本地脚本管理 UI
- ScriptStorePageViewModel.cs — 在线脚本商店 UI