前言 完整项目地址: https://github.com/chr233/XinjingdailyBot 因为项目之前是使用运行时反射来完成服务的动态注册，而源生成器是在编译前完成代码生成并参与编译，完全没有运行时的性能开销 运行时反射 - 旧的方案 标记特性 首先创建一个 Attribute, 用来标注需要扫描的服务类 namespace XinjingdailyBot.Infrastructure.Attribute; /// /// 标记服务 /// 1、如果服务是本身 直接在类上使用[AppService] /// 2、如果服务是接口 在类上使用 [AppService(ServiceType = typeof(实现接口))] /// [AttributeUsage(AttributeTargets.Class, Inherited = false)] public sealed class AppServiceAttribute : System.Attribute { /// /// 服务声明周期 /// 不给默认值的话注册的是AddSingleton /// public LifeTime ServiceLifetime { get; set; } = LifeTime.Scoped; /// /// 指定服务类型 /// public Type? ServiceType { get; set; } /// /// 是否可以从第一个接口获取服务类型 /// public bool InterfaceServiceType { get; set; } /// /// 标记服务 /// /// public AppServiceAttribute(LifeTime serviceLifetime) { ServiceLifetime = serviceLifetime; } /// /// 标记服务 /// /// /// public AppServiceAttribute(Type? serviceType, LifeTime serviceLifetime) { ServiceLifetime = serviceLifetime; ServiceType = serviceType; } /// /// 标记服务 /// /// /// /// public AppServiceAttribute(Type? serviceType, LifeTime serviceLifetime, bool interfaceServiceType) { ServiceLifetime = serviceLifetime; ServiceType = serviceType; InterfaceServiceType = interfaceServiceType; } } /// /// 生命周期 /// public enum LifeTime { /// /// 瞬时 /// Transient, /// /// 范围 /// Scoped, /// /// 单例 /// Singleton } 基于反射的服务扫描器 然后再主项目启动的时候使用反射扫描添加了该 Attribute 的所有 Class 并注册服务 using System.Diagnostics.CodeAnalysis; using System.Reflection; using XinjingdailyBot.Infrastructure.Attribute; namespace XinjingdailyBot.WebAPI.Extensions; /// /// 动态注册服务扩展 /// public static class AppServiceExtensions { private static readonly NLog.Logger _logger = NLog.LogManager.GetCurrentClassLogger(); /// /// 注册引用程序域中所有有AppService标记的类的服务 /// /// [RequiresUnreferencedCode("不兼容剪裁")] public static void AddAppService(this IServiceCollection services) { var cls = new string[] { "XinjingdailyBot.Repository", "XinjingdailyBot.Service", "XinjingdailyBot.Command", "XinjingdailyBot.WebAPI", }; foreach (var item in cls) { var assembly = Assembly.Load(item); Register(services, assembly); } } /// /// 动态注册服务 /// /// /// [RequiresUnreferencedCode("不兼容剪裁")] private static void Register(IServiceCollection services, Assembly assembly) { string? name = assembly.GetName().Name; _logger.Debug($"===== 注册 {name} 中的服务 ====="); uint count = 0; foreach (var type in assembly.GetTypes()) { var serviceAttribute = type.GetCustomAttribute<AppServiceAttribute>(); if (serviceAttribute != null) { count++; var serviceType = serviceAttribute.ServiceType; //情况1 适用于依赖抽象编程，注意这里只获取第一个 if (serviceType == null && serviceAttribute.InterfaceServiceType) { serviceType = type.GetInterfaces().FirstOrDefault(); } //情况2 不常见特殊情况下才会指定ServiceType，写起来麻烦 if (serviceType == null) { serviceType = type; } var lifetime = serviceAttribute.ServiceLifetime; switch (lifetime) { case LifeTime.Singleton: services.AddSingleton(serviceType, type); break; case LifeTime.Scoped: services.AddScoped(serviceType, type); break; case LifeTime.Transient: services.AddTransient(serviceType, type); break; default: services.AddTransient(serviceType, type); break; } _logger.Debug($"{lifetime} - {serviceType}"); } } _logger.Debug($"===== 注册了 {count} 个服务 ====="); } } 也不是不能用，就是感觉不够完美，而且因为使用了反射的相关特性，无法简单地启用程序集裁剪和应用 AOT，所有就想到利用 .Net 5.0 新加入的源生成器特性来实现相同的功能，避免使用反射特性和降低运行时的额外开销 源生成器 - 新的方案 源生成器官方文档 因为我的项目是按照命名空间分成了几个子项目，而源生成器只能分析当前项目的语法树，如果用文件 IO 的方式读取本地 .cs 文件也是可以实现的，但是会显示警告，因为官方并不推荐在源生成器中使用文件 IO，这会降低编译速度，而且服务类也不是经常新增，每次编译都扫描一遍也是完全没必要的 综上，我的解决方案是写一个 Powershell 脚本来扫描解决方案中的所有服务，并保存到一个文件中，然后在源生成器中读取该文件来生成服务注册的代码 服务扫描脚本 首先是服务扫描脚本 scan_service.ps1 # # @Author : Chr_ # @Date : 2024-01-18 14:25:15 # @LastEditors : Chr_ # @LastEditTime : 2024-01-18 20:15:18 # @Description : 生成依赖服务信息 # # 工作目录 $workDir = (Get-Location).Path; $outputPath = Join-Path -Path (Join-Path -Path $workDir -ChildPath "XinjingdailyBot.WebAPI") -ChildPath "Properties"; # 包名列表 $libNames = "XinjingdailyBot.Model", "XinjingdailyBot.Repository", "XinjingdailyBot.Interface", "XinjingdailyBot.Service", "XinjingdailyBot.Tasks", "XinjingdailyBot.Command"; # 输出路径 $appServicePath = Join-Path -Path $outputPath -ChildPath "appService.json"; # 扫描源文件列表 function GetSourceFileList { param ( [string]$Path ) $result = @(); # 去除 bin obj 目录 $dirs = Get-ChildItem -Path $Path -Directory -Exclude "bin", "obj" # 遍历所有 .cs 文件 foreach ($dir in $dirs) { $files = Get-ChildItem -Path $dir -Filter "*.cs" -File -Recurse # 遍历子目录文件 foreach ($file in $files) { $result += $file } } $files = Get-ChildItem -Path $Path -Filter "*.cs" -File # 遍历所有 .cs 文件 foreach ($file in $files) { $result += $file } Write-Output $result $result; } # 读取命名空间 function GetNamespace { param ( [string] $Content ) if ($Content -match "namespace\s+([^;{]+)") { $Matches[1]; } } # 读取类名/接口名 function GetClassName { param ( [string] $Content ) if ($Content -match "(?:class|interface|record)\s+([^({:\s]+)") { $Matches[1]; } } # 扫描AppServiceAttribute function ScanAppServiceAttribute { param ( [string] $Content ) if ($Content -match "\[AppService(?:Attribute)?\(([^\]]+)\)\]") { $result = @{} $arguments = $Matches[1].Split(","); foreach ($argument in $arguments) { if ($argument -match "LifeTime\.(Singleton|Transient|Scoped)") { $result["LifeTime"] = $Matches[1]; } elseif ($argument -match "typeof\(([^()]+)\)") { $result["Interface"] = $Matches[1]; } elseif ($argument -match "true|false") { $result["AutoInterface"] = $Matches[1]; } else { Write-Output $argument; } } $result; } } # 类全名表 $clsNamespaces = @{}; $appServiceAttributes = @{}; foreach ($libName in $libNames) { # 库文件路径 $libPath = Join-Path -Path $workDir -ChildPath $libName; # 获取所有源文件路径 $filePaths = GetSourceFileList -Path $libPath; # 读取Service信息和类命名空间; foreach ($filePath in $filePaths) { $fileContent = Get-Content -Path $filePath.FullName -Raw; $namespace = GetNameSpace -Content $fileContent; if ($null -eq $namespace -or $namespace -eq "" ) { continue; } $clsName = GetClassName -Content $fileContent; if ($null -eq $namespace -or $namespace -eq "" ) { continue; } $clsNamespaces[$clsName] = $namespace; $appService = ScanAppServiceAttribute -Content $fileContent; if ($null -ne $appService) { $appServiceAttributes[$clsName] = $appService; } } } $services = $appServiceAttributes.Clone(); # 生成服务信息 foreach ($service in $services.Keys) { $serviceInfo = $appServiceAttributes[$service]; $namespace = $clsNamespaces[$service]; $serviceInfo["Class"] = "$namespace.$service"; $interface = $serviceInfo["Interface"]; if ($null -ne $interface) { $ns = $clsNamespaces[$interface]; $serviceInfo["Interface"] = "$ns.$interface"; } $appServiceAttributes[$service] = $serviceInfo; } Write-Output "扫描到 $($appServiceAttributes.Count) 个服务"; $appServiceAttributes | ConvertTo-Json | Out-File -FilePath $appServicePath Write-Output "文件保存至 $appServicePath" 这个脚本的功能是遍历定义的子项目文件，然后扫描所有类和接口，以及所有带有 AppServiceAttribute 特性的服务类，最后生成一个 appService.json 运行脚本生成文件以后记得修改文件生成操作，不然在分析器里这个文件是不可见的 创建源生成器 跟创建类库一样，但是需要编辑 .csproj 文件，完整文件如下 <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFramework>netstandard2.0</TargetFramework> <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules> <IsRoslynComponent>true</IsRoslynComponent> <GetTargetPathDependsOn>$(GetTargetPathDependsOn);GetDependencyTargetPaths</GetTargetPathDependsOn> <NoWarn>CS1570;CS1587</NoWarn> </PropertyGroup> <ItemGroup> <None Include="..\.editorconfig" Link=".editorconfig" /> </ItemGroup> <ItemGroup> <PackageReference Include="Microsoft.CodeAnalysis.Analyzers"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> <PackageReference Include="Microsoft.CodeAnalysis.Common"> <PrivateAssets>all</PrivateAssets> </PackageReference> <PackageReference Include="Microsoft.CodeAnalysis.CSharp"> <PrivateAssets>all</PrivateAssets> </PackageReference> <PackageReference Include="Newtonsoft.Json"> <GeneratePathProperty>true</GeneratePathProperty> <PrivateAssets>all</PrivateAssets> </PackageReference> </ItemGroup> <ItemGroup> <Compile Update="Templates.Designer.cs"> <DesignTime>True</DesignTime> <AutoGen>True</AutoGen> <DependentUpon>Templates.resx</DependentUpon> </Compile> </ItemGroup> <ItemGroup> <EmbeddedResource Update="Templates.resx"> <Generator>ResXFileCodeGenerator</Generator> <LastGenOutput>Templates.Designer.cs</LastGenOutput> </EmbeddedResource> </ItemGroup> <ItemGroup> <None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" /> </ItemGroup> <Target Name="GetDependencyTargetPaths"> <ItemGroup> <TargetPathWithTargetPlatformMoniker Include="$(PKGNewtonsoft_Json)\lib\netstandard2.0\Newtonsoft.Json.dll" IncludeRuntimeDependency="false" /> </ItemGroup> </Target> </Project> 我所做的修改如下 将目标框架改为 netstandard2.0，目前源生成器必须用该版本 添加 EnforceExtendedAnalyzerRules 和 IsRoslynComponent 项，第一个是排除别的分析器，第二个是定义该项目是 Roslyn 组件，添加了以后会有额外的调试配置 添加 GetTargetPathDependsOn 项，意思是从定义的路径中寻找依赖库 添加 Microsoft.CodeAnalysis.Analyzers 和 Microsoft.CodeAnalysis.Common 和 Microsoft.CodeAnalysis.CSharp 库，原样从微软文档拷来的，生成器必须的依赖 添加 Newtonsoft.Json 库（可选），我使用了 Newtonsoft.Json 作为 Json 反序列化库，因此还需要额外配置引用信息（因为源生成器相当于是编译期的扩展，如果不做额外处理会报找不到Dll的错），需要在下面定义 <ItemGroup> <None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" /> </ItemGroup> <Target Name="GetDependencyTargetPaths"> <ItemGroup> <TargetPathWithTargetPlatformMoniker Include="$(PKGNewtonsoft_Json)\lib\netstandard2.0\Newtonsoft.Json.dll" IncludeRuntimeDependency="false" /> </ItemGroup> </Target> 然后再主项目中添加项目引用，并修改引用的属性，将被引用项目类型改成分析器，然后排除所有的生成资产 </ItemGroup> <ProjectReference Include="..\XinjingdailyBot.Generator\XinjingdailyBot.Generator.csproj"> <OutputItemType>Analyzer</OutputItemType> <ReferenceOutputAssembly>false</ReferenceOutputAssembly> </ProjectReference> </ItemGroup> 服务注册源生成器 代码模板存放在 Templates.resx 中，内容如图 可以根据输出的结果反推 using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Text; using Newtonsoft.Json; using System.Text; using XinjingdailyBot.Generator.Data; namespace XinjingdailyBot.Generator; [Generator] internal sealed class AppServiceGenerator : ISourceGenerator { const string InputFileName = "appService.json"; const string OutoutFileName = "GeneratedAppServiceExtensions.g.cs"; /// <inheritdoc/> public void Initialize(GeneratorInitializationContext context) { // 无需处理 } /// <inheritdoc/> public void Execute(GeneratorExecutionContext context) { try { var fileText = context.AdditionalFiles.Where(static x => x.Path.EndsWith(InputFileName)).FirstOrDefault() ?? throw new FileNotFoundException("缺少配置文件, 请使用 scan_service.ps1 生成"); ProcessSettingsFile(fileText, context); } catch (Exception e) { context.ReportDiagnostic( Diagnostic.Create( "XJB_01", nameof(AppServiceGenerator), $"生成注入代码失败，{e.Message}", defaultSeverity: DiagnosticSeverity.Error, severity: DiagnosticSeverity.Error, isEnabledByDefault: true, warningLevel: 0)); } } /// /// 生成文件 /// /// /// private void ProcessSettingsFile(AdditionalText xmlFile, GeneratorExecutionContext context) { var text = xmlFile.GetText(context.CancellationToken)?.ToString() ?? throw new FileLoadException("文件读取失败"); var json = JsonConvert.DeserializeObject<AppServiceData>(text) ?? throw new FileLoadException("文件读取失败"); var sb = new StringBuilder(); sb.AppendLine(Templates.AppServiceHeader); foreach (var kv in json) { var entry = kv.Value; var lifeTime = entry.LifeTime?.ToLowerInvariant() switch { "singleton" or "scoped" or "transient" => entry.LifeTime, _ => null, }; if (string.IsNullOrEmpty(lifeTime)) { continue; } if (!string.IsNullOrEmpty(entry.Class)) { if (string.IsNullOrEmpty(entry.Interface)) { sb.AppendLine(string.Format(Templates.AppServiceContent1, lifeTime, entry.Class)); } else { sb.AppendLine(string.Format(Templates.AppServiceContent2, lifeTime, entry.Interface, entry.Class)); } } } sb.AppendLine(Templates.AppServiceFooter); context.AddSource(OutoutFileName, SourceText.From(sb.ToString(), Encoding.UTF8)); } } 生成的代码示例，为避免使用 using，这里使用完整类名引用 using Microsoft.Extensions.DependencyInjection; namespace XinjingdailyBot.WebAPI.Extensions; /// /// 动态注册服务扩展 /// public static class GeneratedAppServiceExtensions { private static readonly NLog.Logger _logger = NLog.LogManager.GetCurrentClassLogger(); /// /// 注册引用程序域中所有有AppService标记的类的服务 /// /// public static void AddAppServiceGenerated(this IServiceCollection services) { services.AddTransient<XinjingdailyBot.Repository.OldPostRepository>(); services.AddScoped<XinjingdailyBot.Interface.Bot.Common.IDispatcherService, XinjingdailyBot.Service.Bot.Common.DispatcherService>(); services.AddSingleton<XinjingdailyBot.Repository.TagRepository>(); services.AddSingleton<XinjingdailyBot.Interface.Bot.Common.IChannelService, XinjingdailyBot.Service.Bot.Common.ChannelService>(); services.AddTransient<XinjingdailyBot.Interface.Data.IAttachmentService, XinjingdailyBot.Service.Data.AttachmentService>(); services.AddScoped<XinjingdailyBot.Command.ReviewCommand>(); services.AddScoped<XinjingdailyBot.Interface.Bot.Handler.IJoinRequestHandler, XinjingdailyBot.Service.Bot.Handler.JoinRequestHandler>(); services.AddTransient<XinjingdailyBot.Interface.Data.IBanRecordService, XinjingdailyBot.Service.Data.BanRecordService>(); services.AddSingleton<XinjingdailyBot.Repository.RejectReasonRepository>(); services.AddScoped<XinjingdailyBot.Interface.Bot.Common.IUpdateService, XinjingdailyBot.Service.Bot.Common.UpdateService>(); services.AddSingleton<XinjingdailyBot.Interface.Bot.Handler.IForwardMessageHandler, XinjingdailyBot.Service.Bot.Handler.ForwardMessageHandler>(); services.AddScoped<XinjingdailyBot.Command.CommonCommand>(); services.AddSingleton<XinjingdailyBot.Repository.LevelRepository>(); services.AddSingleton<XinjingdailyBot.Interface.Bot.Handler.ICommandHandler, XinjingdailyBot.Service.Bot.Handler.CommandHandler>(); services.AddTransient<XinjingdailyBot.Interface.Data.IDialogueService, XinjingdailyBot.Service.Data.DialogueService>(); services.AddSingleton<XinjingdailyBot.Interface.Data.IPostService, XinjingdailyBot.Service.Data.PostService>(); services.AddTransient<XinjingdailyBot.Interface.Data.IChannelOptionService, XinjingdailyBot.Service.Data.ChannelOptionService>(); services.AddSingleton<XinjingdailyBot.Interface.Bot.Handler.IChannelPostHandler, XinjingdailyBot.Service.Bot.Handler.ChannelPostHandler>(); services.AddSingleton<XinjingdailyBot.Interface.Data.IUserService, XinjingdailyBot.Service.Data.UserService>(); services.AddTransient<XinjingdailyBot.Interface.Helper.IImageHelperService, XinjingdailyBot.Service.Helper.ImageHelperService>(); services.AddSingleton<XinjingdailyBot.Interface.Data.INameHistoryService, XinjingdailyBot.Service.Data.NameHistoryService>(); services.AddScoped<XinjingdailyBot.Command.NormalCommand>(); services.AddSingleton<XinjingdailyBot.Interface.Data.IMediaGroupService, XinjingdailyBot.Service.Data.MediaGroupService>(); services.AddScoped<XinjingdailyBot.Command.ObsoleteCommand>(); services.AddScoped<XinjingdailyBot.Command.PostCommand>(); services.AddTransient<XinjingdailyBot.Interface.Data.IAdvertisePostService, XinjingdailyBot.Service.Data.AdvertisePostsService>(); services.AddTransient<XinjingdailyBot.Interface.Data.IAdvertiseService, XinjingdailyBot.Service.Data.AdvertiseService>(); services.AddSingleton<XinjingdailyBot.Repository.GroupRepository>(); services.AddTransient<XinjingdailyBot.Interface.Helper.IHttpHelperService, XinjingdailyBot.Service.Helper.HttpHelperService>(); services.AddTransient<XinjingdailyBot.Interface.Helper.ITextHelperService, XinjingdailyBot.Service.Helper.TextHelperService>(); services.AddSingleton<XinjingdailyBot.Interface.Bot.Handler.IMessageHandler, XinjingdailyBot.Service.Bot.Handler.MessageHandler>(); services.AddTransient<XinjingdailyBot.Interface.Data.ICmdRecordService, XinjingdailyBot.Service.Data.CmdRecordService>(); services.AddSingleton<XinjingdailyBot.Interface.Bot.Handler.IInlineQueryHandler, XinjingdailyBot.Service.Bot.Handler.InlineQueryHandler>(); services.AddTransient<XinjingdailyBot.Repository.PostRepository>(); services.AddSingleton<XinjingdailyBot.Interface.Data.IUserTokenService, XinjingdailyBot.Service.Data.UserTokenService>(); services.AddTransient<XinjingdailyBot.Interface.Helper.IMarkupHelperService, XinjingdailyBot.Service.Helper.MarkupHelperService>(); services.AddScoped<XinjingdailyBot.Command.AdminCommand>(); services.AddScoped<XinjingdailyBot.Command.SuperCommand>(); services.AddTransient<XinjingdailyBot.Interface.Data.IReviewStatusService, XinjingdailyBot.Service.Data.ReviewStatusService>(); services.AddSingleton<XinjingdailyBot.Interface.Bot.Handler.IGroupMessageHandler, XinjingdailyBot.Service.Bot.Handler.GroupMessageHandler>(); } } 调试源生成器 首先删除源生成器项目的所有调试目标，然后新增 Roslyn Component 目标，并在右边将 Target Project 设为主项目，然后将启动项目改为生成器 然后在生成器的 Initialize 方法中开启调试器 public void Initialize(GeneratorInitializationContext context) { // 无需处理 if (!Debugger.IsAttached) { Debugger.Launch(); } } 这样就可以动态调试生成器代码了 实际使用 将启动项目改为主功能，去掉源生成器的调试代码，然后重新生成一遍解决方案，如果一切正常，将会在分析器中看到生成的代码文件 如果后续有新的服务，重新执行 scan_service.ps1 即可更新配置文件
前言 消失大半年，其中一个原因是工作了，大部分时间在给公司做开发，业余时间在写外包赚米，另一个原因就是拖延症犯了，越拖越想拖 过年放了半个月假，稍微有点时间做点自己一直以来就很想做的事——给NGA去广告，当然，JS教程也会安排上的 [...]
【油猴脚本教程】P2 初识油猴脚本 & 油猴脚本的基本构成
P2 初识油猴脚本 教程索引 视频链接 光讲JS感觉不是特别直观，用两个不涉及网络请求的脚本进行讲解 第一个脚本 - Remove Live.js 脚本链接： https://github.com/chr233/GM_Scripts/blob/master/Bilibili/Remove_Live.js 脚本功能： 提供一个开关，用于移除直播播放器，并且带有记忆功能 源码： // ==UserScript== // @name Remove_Live // @name:zh-CN 去除B站直播间播放器 // @namespace https://blog.chrxw.com // @version 1.1 // @description 去除B站多余的直播播放器（滑稽） // @author Chr_ // @include https://live.bilibili.com/* // @license AGPL-3.0 // @icon https://blog.chrxw.com/favicon.ico // ==/UserScript== (() => { "use strict"; //去除播放器的开关 let VEnable = localStorage.getItem("VEnable") === "true"; if (VEnable) { setTimeout(() => { document.getElementById("live-player").remove(); }, 3000); } let btnArea = document.querySelector(".right-ctnr"); let btn = document.createElement("button"); btn.textContent = VEnable ? "恢复播放器" : "移除播放器"; btn.addEventListener("click", () => { VEnable = !VEnable; localStorage.setItem("VEnable", VEnable); btn.textContent = VEnable ? "恢复播放器" : "移除播放器"; if (VEnable) { document.getElementById("live-player").remove(); } else { location.reload(); } }); btnArea.insertBefore(btn, btnArea.children[0]); })(); 第二个脚本 - SteamDB CN.js 脚本链接： https://github.com/chr233/GM_Scripts/blob/master/SteamDB/SteamDB_CN.js 脚本功能： 汉化SteamDB 源码： // ==UserScript== // @name:zh-CN SteamDB汉化 // @name SteamDB_CN // @namespace https://blog.chrxw.com // @supportURL https://blog.chrxw.com/scripts.html // @contributionURL https://afdian.net/@chr233 // @version 1.32 // @description SteamDB汉化插件 // @description:zh-cn SteamDB汉化插件 // @author Chr_ // @match https://steamdb.info/* // @license AGPL-3.0 // @icon https://blog.chrxw.com/favicon.ico // @resource data https://cf.chrxw.com/SteamDB/SteamDB_CN.json // @grant GM_addStyle // @grant GM_getResourceText // @grant GM_registerMenuCommand // ==/UserScript== (() => { "use strict"; //是否打开调试模式（使用自定义汉化规则而不是在线规则） const DEBUG = localStorage["dbg_mode"] == "开"; //是否在控制台输出未汉化文本 const OUTPUT = localStorage["out_word"] == "开"; GM_registerMenuCommand(`调试汉化文本: 【${DEBUG ? "开" : "关"}】`, () => { window.localStorage["dbg_mode"] = DEBUG ? "关" : "开"; window.location.reload(); }); GM_registerMenuCommand(`在控制台输出未匹配文本: 【${OUTPUT ? "开" : "关"}】`, () => { window.localStorage["out_word"] = OUTPUT ? "关" : "开"; window.location.reload(); }); document.querySelector("html").setAttribute("lang", "zh-CN"); let Locales; if (DEBUG) { const template = `{"DOC":{"更新时间":"调试模式","贡献名单":["调试模式"]},\n"STATIC":\n{\n\n},\n"INPUT":\n{\n\n},\n"LABEL":\n{\n\n},\n"DYNAMIC":\n{\n\n}\n}`; const box = document.createElement("div"); box.className = "sdc"; const text = document.createElement("textarea"); box.appendChild(text); const action = document.createElement("div"); action.className = "sdc-links"; box.appendChild(action); const btnSave = document.createElement("a"); btnSave.innerText = "💾 保存并应用"; btnSave.addEventListener("click", () => { const raw = text.value.trim(); if (!raw) { alert("翻译文本不能为空!!!"); } else { try { JSON.parse(raw); window.localStorage["sdb_lang"] = raw; window.location.reload(); } catch (e) { alert("翻译文本不是有效的JSON格式!!!"); } } }); action.appendChild(btnSave); const btnReset = document.createElement("a"); btnReset.textContent = "🗑️ 清空文本"; btnReset.addEventListener("click", () => { window.localStorage["sdb_lang"] = template; window.location.reload(); }); action.appendChild(btnReset); const btnOnline = document.createElement("a"); btnOnline.textContent = "📄 当前在线文本"; btnOnline.addEventListener("click", () => { if (confirm("替换为在线版本后当前所做修改将会丢失, 确定要继续吗?")) { text.value = GM_getResourceText("data"); } }); action.appendChild(btnOnline); const about = document.createElement('a'); about.href = "https://blog.chrxw.com" about.innerText = "🔗 By Chr_ © 2022"; action.appendChild(about); const father = document.getElementById("main"); father.insertBefore(box, father.firstChild); const customLang = window.localStorage["sdb_lang"] ?? template; text.value = customLang; Locales = JSON.parse(customLang); } else { Locales = JSON.parse(GM_getResourceText("data")); } //计时 const Start = new Date().getTime(); {//静态元素 for (const [css, dic] of Object.entries(Locales.STATIC)) { if (OUTPUT) { console.log(`〖${css}〗`); } const elements = document.querySelectorAll(css); if (elements.length > 0) { for (let i = 0; i < elements.length; i++) { const element = elements[i]; if (element.childElementCount === 0) {//节点内部无其他元素 const raw = element.innerText?.trim(); if (!raw || raw.length <= 2) { continue; } const txt = dic[raw]; if (txt) { element.innerText = txt; } else if (OUTPUT) { console.log(`"${raw}": "",`); } } else {//节点内部有其他元素 const nodes = element.childNodes; for (let j = 0; j < nodes.length; j++) { const node = nodes[j]; if (node.nodeType === Node.TEXT_NODE) { const raw = node.textContent?.trim(); if (!raw || raw.length <= 2) { continue; } const txt = dic[raw]; if (txt) { node.textContent = txt; } else if (OUTPUT) { console.log(`"${raw}": "",`); } } } } } } else { if (OUTPUT) { console.warn(`CSS选择器未匹配到任何元素: ${css}`); } } } } {//输入框 const inputs = Locales.INPUT; if (OUTPUT) { console.log("〖输入框〗"); } const elements = document.querySelectorAll("input"); for (let i = 0; i < elements.length; i++) { const element = elements[i]; const raw = element.placeholder; if (!raw) { continue; } const txt = inputs[raw]; if (txt) { element.placeholder = txt; } else if (OUTPUT) { console.log(`"${raw}": "",`); } } } {//悬浮提示 const labels = Locales.LABEL; if (OUTPUT) { console.log("〖提示文本〗"); } const elements = document.querySelectorAll("*[aria-label]"); for (let i = 0; i < elements.length; i++) { const element = elements[i]; const raw = element.getAttribute("aria-label"); if (!raw) { continue; } const txt = labels[raw]; if (txt) { element.setAttribute("aria-label", txt); } else if (OUTPUT) { console.log(`"${raw}": "",`); } } } const { script: { version } } = GM_info; //const version = GM_info.script.version; const { DOC: { "更新时间": update, "贡献名单": contribution } } = Locales; const End = new Date().getTime(); // 统计耗时 console.log("执行耗时", `${End - Start} ms`); console.log("================================="); console.log(`插件版本: ${version}`); console.log(`更新时间: ${update}`); console.log(`贡献名单: ${contribution.join(", ")}`); console.log("================================="); console.log("迷茫同学:\n『没有恶意 请问直接用谷歌翻译整个网页不香吗』") // 添加按钮 const headerUl=document.querySelector(".header-menu-container>div>ul:nth-child(1)"); const footerUl=document.querySelector(".footer-container>div>ul:nth-child(1)"); const scriptLink = document.createElement("li"); scriptLink.innerHTML = `SteamDB 汉化 V${version}`; headerUl.appendChild(scriptLink); footerUl.appendChild(scriptLink.cloneNode(true)); // 添加样式 GM_addStyle(` .tabnav-tabs > a { min-width: 80px; } .sdc { display: flex; } .sdc > textarea { width: 100%; height: 200px; min-height: 200px; resize: vertical; } .sdc > div.sdc-links { width: 150px; margin: 5px; } .sdc > div.sdc-links > a { width: 100%; margin-bottom: 10px; background-color: #213145; color: white; font-size: 12px; border-radius: 0px; display: inline-block; height: 30px; } .sdc > div.sdc-links > a:last-child { width: 100%; margin-top: 30px; margin-bottom: 0px; color: #67c1f5; background-color: #273b4b; } `); })(); 油猴脚本基本构成 元信息 记录脚本的额外信息，供油猴插件以及 GreasyFork 使用 // ==UserScript== // @name:zh-CN 脚本名称(可选，可以使用语言代码来支持多语言) // @name 脚本名称 // @namespace 命名空间 // @version 脚本版本 // @description 脚本描述 // @description:zh-cn 脚本描述(可选，可以使用语言代码来支持多语言) // @author 作者 // @include 脚本生效域名 // @match 脚本生效域名 // @license 共享协议 // @icon 脚本图标(可选) // @resource 外部资源链接，以及访问名称(可选) // @grant 申请额外权限(可选) // ==/UserScript== 脚本正文 略
前言 本文仅供学习探讨之用，如果侵犯您的权益请联系我删除。 上文 [post cid="714"/] [post cid="1347"/] [post cid="1365"/] [post cid="1383"/] 前情提要 距离上一篇文章已经过去一年多了，前两天心血来潮找了最新版本研究了一下，熬了两天夜也没整明白，不打算继续研究下去了，做个笔记记录下分析过程。 @> 本文适用于小黑盒 1.3.191，小黑盒历史版本参考我的仓库 @> 全文评论后可见
油猴脚本不求人！零基础油猴脚本编写指南
前言 本文首发于我的博客和其乐论坛，未经授权禁止转载 本文全程使用原生JavaScript进行编程，不使用像jQuery这样的第三方库 视频链接 零基础油猴脚本编写指南【上】 （对应0x01~0x05） 0x00 前置知识 本节简单的介绍一下一些名词的概念，了解为主，直接跳过也是可以的 HTML、CSS和JS HTML即超文本标记语言，它用来描述各种网页元素，类似与一份清单，浏览器读取以后根据它来加载对应的资源 CSS即层叠样式表，它只用来描述网页元素的外观，还可以添加少许动画，但是不影响功能 JS即JavaScript，它是一种网页脚本语言，用来让静态的网页能够响应各种动作，还可以挖矿 如果把HTML比作木偶，那么CSS就是木偶的衣服，JS就是木偶的提线，用来操控木偶 油猴和浏览器 当你访问链接的时候，浏览器会先下载链接对应的文件（通常是一个HTML文件），然后根据HTML描述的对象列表，来下载图片、视频等资源，有的CSS和JS内嵌在HTML文件里，有的则是单独的文件，等必要的资源加载完成以后，浏览器才能生成可以交互的网页 油猴实际上是一个加载器，它把用户脚本安插在最开始获取到的HTML文件中，这样浏览器加载网页的时候，也会加载特定的用户脚本，这样我们就能够通过编写用户脚本来修改网页了 HTML5和DOM HTML5是现行的HTML标准，DOM即文档对象模型，是一用对象表示HTML标签的方法，通过DOM，我们可以简单的修改文档对象的属性，来轻松修改HTML标签的内容 0x01 初识开发者工具 工欲善其事必先利其器，本节简单的介绍一下开发者工具的使用。 本节以我的博客作为演示，地址：chrxw.com 个人比较喜欢Firefox的开发者工具，以下介绍均使用Firefox进行演示，如果使用Chrome的话可能布局会有细微差异，不过功能是类似的。 Chrome自带的开发者工具貌似没有汉化，建议使用别的Chromium内核的浏览器（比如新版Edge），功能都差不多。 在浏览器中按F12即可打开开发者工具 查看器，左侧可以查看页面的HTML内容，右侧显示的是选中的DOM元素的CSS 控制台，可以运行自己编写的JS代码。 也可以在设置里打开“分离式控制台”，这样就可以在所有TAB里都显示控制台了。 调试器，可以用来单步调试JS 网络，可以查看浏览器的网络请求 储存，在这个TAB中可以查看网站在本地保存的内容，最常用的是Cookie，其次是LocalStorge（本地储存），还有个由浏览器自动管理的Session（会话储存），JS可以访问的只有Cookie和LocalStorge。 0x02 初识DOM 本节通过使用开发者工具，简单的演示一下如何操作DOM元素，以我的博客作为演示，地址：chrxw.com 首先打开开发者工具，然后激活选择器，然后点击“标题”，这样在右边的查看器中就会显示我们点击的对象 右键高亮的元素，然后点击“在控制台使用”（如果是Chromium或者Chrome，点击“复制”/“Copy”->“复制JS路径”/“Copy JS path”，然后把复制结果粘贴到控制台中） Firefox自动创建了一个temp0变量，按一下回车，可以看到控制台输出了一个DOM元素，点一下左边的箭头展开，可以看到这个对象的各种属性。 接下来我们尝试修改一下它的内容 temp0.textContent = 'hello world'; 按回车执行，然后可以看到，网页的标题已经被修改了 接着我们修改一下CSS，让它居中显示 temp0.style.textAlign='center'; 接下来我们创建一个新的DOM元素，然后插入到网页中 let btn = document.createElement('button'); btn.textContent = '按钮'; btn.addEventListener('click',() => { alert('hello world'); }); temp0.appendChild(btn); 点一下新出现的按钮，可以看到消息弹窗 代码讲解： PS： document 也是DOM元素，它代表整个网页，其他DOM元素都是它的子集。 let btn = document.createElement('button'); btn.textContent = '按钮'; 这两行代码创建了一个button对象，它内部的文本为按钮，由浏览器自动生成的HTML表达式为 按钮 btn.addEventListener('click',() => { alert('hello world'); }); 这三行代码为btn对象添加了事件监听器，addEventListener的第一个参数是事件监听器的名称，第二个参数是接收到指定事件后调用的函数，()=>{ ... }是匿名函数的缩略写法，匿名函数还有另一种等效写法function(){ ... }，或者写成普通函数，也是可以的。 //匿名函数写法2 btn.addEventListener('click',function(){ alert('hello world'); }); //普通函数的写法 btn.addEventListener('click',foo);//foo后面不加() function foo(){ alert('hello world'); } 添加好事件监听器以后，点一下按钮，就会触发click事件，然后由浏览器调用我们绑定的方法alert('hello world');，弹出消息框 temp0.appendChild(btn); A.appendChild(B)的作用是把B元素插入到A元素的子元素的末尾，也就是把btn对象插到temp0中，由浏览器自动帮我们修改了HTML文本 //修改前 Chr_小屋 //修改后 Chr_小屋 按钮 //这是我们新创建的DOM元素 通过操作DOM元素的属性，就可以很方便的修改网页的显示效果，添加各种功能。 0x03 DOM进阶之获取DOM元素 通过修改DOM元素的属性，可以很方便地修改网页，但是首先我们得获取我们想要修改的DOM元素 本节将会介绍如何获通过代码获取现存的DOM元素，以我的博客作为演示，地址：about.html 1. 使用ID进行获取 WEB开发者设计网页的时候，会给一些经常需要修改的元素添加id属性，原则上每个元素的id都是不同的，所以如果某个元素具有id属性，我们就可以使用getElementById来获取 需要注意的是，如果元素没有id属性的话这个方法就行不通了 随便打开一篇博文，最下面的评论框是带有id属性的，使用开发者工具我们可以看到，它的id为comment 我们可以用document.getElementById( id名称 )的方式获取DOM元素 let box = document.getElementById('comment'); box.value = '通过ID获取DOM'; 代码非常好理解，先调用getElementById方法获取DOM，然后修改它的value属性，效果就是文本框的内容被修改了 PS： textContent 属性修改的是HTML文本，而 value 属性修改的是文本框中的内容，不影响HTML文本 2. 使用CSS选择器进行获取 在一个网页中，具有id属性的元素不会太多，大多数时候都是没有这个属性的，这时候可以根据元素的class属性，使用querySelector（获取符合条件的第一个元素）或者querySelectorAll(获取符合条件的全部元素数组)进行获取 本节只介绍常用的方法，更详细的介绍可以参考这个：CSS 选择器参考手册 根据class进行选择 标题元素的class为entry-title m-n font-thin text-black l-h，class选择器的语法为.+class名称，如果有多个class名称，则用.分隔 所以标题元素的选择器写法为.entry-title.m-n.font-thin.text-black.l-h 在CSS选择器语法中，空格代表子元素的意思，所以不能随意添加空格。 通过搜索，我们发现entry-title在HTML文档只出现了一次，因此选择器也可以简化写作.entry-title，代码如下 //两种写法等效 document.querySelector('.entry-title.m-n.font-thin.text-black.l-h'); document.querySelector('.entry-title'); 在实际运用中，没必要给出完整的class，只需要写独一无二的部分即可 根据id进行选择（可以用，但还是建议用getElementById） id选择器的语法为#+id名称，评论框的id是comment，所以选择器写法为#comment，代码如下 //两种写法等效，推荐第一种 document.getElementById('comment'); document.querySelector('#comment'); 根据元素类型选择 还可以根据DOM元素的类型进行选择，如果网页中只有一个特定种类的元素，那么直接写元素类型即可 使用开发者工具，我们可以知道，评论框的类型为textarea，而且整个网页只有一个textarea，因此选择器可以直接写成textarea，代码如下 document.querySelector('textarea'); 根据相对关系进行选择 有的时候，想要操作的元素可能没有独一无二的属性，那么就可以根据相对关系进行选择 A>B表示在A的一级子元素中查找B A B表示在A的内部查找B（查找范围不仅包括子元素，还包括孙元素等） A+B表示与A相邻的B元素（平级关系，非父子关系） 这里的 A 和 B 代表的是选择器，选择器可以嵌套使用。 A.B，不表示相对关系，它表示类型是A，class名是B的元素，这个要注意。 还是用标题元素举例子 先找到容易定位的元素，然后再根据相对关系寻找想要找的元素，方法有很多，下面举几个例子 //以下语句都是等效的 document.querySelector('main>div>div>header>h1');//一级子元素 document.querySelector('main header>h1');//所有子元素 document.querySelector('main h1');//这样也可以 document.querySelector('header>h1');//多个方法定位header对象 document.querySelector('#small_widgets>h1'); document.querySelector('.bg-light.lter.wrapper-md>h1'); document.querySelector('header.bg-light>h1'); 写成这个样子主要是为了举例子，实际使用中选择器越简洁越好 根据其他属性名称进行选择 [xxx]选择带有xxx属性的元素 [xxx=sss]选择xxx属性等于sss的元素 [xxx~=sss]选择xxx属性包含sss的元素（sss必须是整个单词） [xxx|=sss]选择xxx属性以sss开头的元素（sss必须是整个单词） [xxx^=sss]选择xxx属性以sss开头的元素（sss可以是单词的一部分） [xxx$=sss]选择xxx属性以sss结尾的元素（sss可以是单词的一部分） [xxx*=sss]选择xxx属性包含sss的元素（sss可以是单词的一部分） 通过开发者工具可以看出高亮的DOM元素具有for属性，属性值为comment，代码如下，感受一下几种用法的区别 document.querySelector('[for=comment]');//完整匹配 document.querySelector('[for|=co]');//匹配开头，匹配失败，for属性中没有co这个单词 document.querySelector('[for~=co]');//匹配任意位置，匹配失败，for属性中没有co这个单词 document.querySelector('[for^=co]');//匹配开头 document.querySelector('[for*=co]');//匹配任意位置 document.querySelector('[for*=mm]');//匹配任意位置 document.querySelector('[for$=nt]');//匹配结尾 组合选择器 上面介绍过的选择器可以组合使用，比如选择表情按钮中的矢量图片 代码如下，自己体会一下各个部分是什么意思，以及哪些部分是没有必要的 //两种写法效果一样 document.querySelector('textarea.form-control[name=text]+div.OwO>.OwO-logo svg'); document.querySelector('.OwO-logo svg'); 0x04 DOM进阶之修改DOM元素 拿到了DOM对象，接下来就可以进行各种操♂作了 本节将会介绍DOM元素的常用方法，以我的博客作为演示，地址：about.html 以标题元素为例，首先我们先获取DOM对象 let h1 = document.querySelector('h1'); console.log(h1); 使用开发者工具可以非常方便的看到DOM元素的方法和属性 枚举子元素 DOM.children 返回值为DOM元素的所有子元素数组，如果不含有子元素，返回的是空数组。 可以通过DOM.children[i]的方式获取某一个子元素 获取/修改文本内容 DOM.textContent 返回值为当前DOM元素内的显示字符（没有被 < > 包起来的所有字符），不仅包括自身，还包括所有子元素 DOM.innerText也有类似的效果，区别是它会去掉多余的换行符 也可以修改DOM.textContent和`DOM.innerText 不过要注意的是，这两个方法会直接替换掉元素内部的内容，如果元素内部有其他子元素的话，子元素会消失。 获取/修改元素CSS 只能修改DOM元素的内联CSS，全局CSS得修改Style元素才行。 CSS相关内容不做过多介绍，贴个简单的教程：菜鸟CSS教程 DOM.style，获取DOM元素的内联CSS样式 DOM.style.[css名称]，获取DOM元素的特定CSS属性，例如textAlign DOM.style.cssText，获取DOM元素的内联CSS样式，纯文本格式 同样的，也可以用类似的方法修改CSS样式 DOM.style.[css名称] = 'xxx'，修改DOM元素的特定CSS属性为xxx DOM.style.cssText = 'aaa: xxx;'，直接修改DOM元素的内联CSS文本，可以一次设置多个CSS 如果想隐藏某些元素的话，可以把它的CSS的display属性设置成none，就会被隐藏起来了 新增子元素 DOM.appendChild( [要插入的DOM对象] ) appendChild会将新插入的对象放在所有子元素的末尾 DOM.insertBefore( [要插入的DOM对象] , [DOM对象，将要插在这个对象之前]) insertBefore可以指定新插入的对象的位置 let h1 = document.querySelector('h1'); let b1 = document.createElement('button'); b1.textContent='按钮1'; let b2 = document.createElement('button'); b2.textContent='按钮2'; h1.appendChild(b1); h1.insertBefore(b2,h1.children[0]); 删除对象 DOM.remove() 这是个方法，所以要加()，调用后会在HTML文本中删除对象对应的内容 添加/删除事件监听器 DOM.addEventListener( [事件名称] , [回调函数])添加事件监听器 DOM.removeEventListener( [事件名称] , [回调函数])移除事件监听器 也可以用以前的写法（不推荐）： DOM.on[事件名称] = [回调函数]添加事件监听器 DOM.on[事件名称] = null移除事件监听器 比如要让h1对象响应click事件，代码如下 let h1 = document.querySelector('h1'); //比较推荐这种写法 h1.addEventListener('click',() => { alert('响应click事件'); }); //也可以用旧风格的写法 h1.onclick = () => { alert('响应click事件'); }; //一些解释： //() => { ... } 定义了一个匿名函数，相当于 function(){ ... } //也可以使用实名函数，比如定义了function foo(){ ... }以后，也可以这么绑定： //h1.addEventListener('click',foo); //h1.onclick = foo; 删除绑定的方法也是类似的，不再赘述 模拟鼠标点击 DOM.click() 相当于鼠标点了一下DOM对象，触发它的click事件（如果有的话） 0x05 第一个油猴脚本 讲了这么久，都是在介绍前端的基础知识，接下来开始正文 首先让我们新建一个用户脚本 按Ctrl+S即可保存 元信息 元信息是给油猴插件使用的，用来记录脚本的属性 新建脚本时给出的默认元信息如下： // ==UserScript== // @name New Userscript //名称 // @namespace http://tampermonkey.net/ //命名空间 // 说明：@namespace相同，@name也相同的脚本会被油猴当做是同一个脚本，这两个属性起到区分不同脚本的作用 // @version 0.1 //版本号 // @description try to take over the world! //说明 // @author You //作者 // @match http://*/* //在什么网址下启用该脚本 // @grant none //特殊权限（调用GM_开头的函数需要先申请权限） // ==/UserScript== 还有部分比较常见的元信息： // @include /https://example.com/.*/ //和@match差不多，但是支持正则表达式，我更喜欢用这个 // @connect example.com //如果脚本需要访问跨域资源，需要提前申明 // @license AGPL-3.0 //脚本的授权许可信息 // @icon https://blog.chrxw.com/favicon.ico //脚本的图标（显示在脚本列表里） 代码区域 (function() { 'use strict'; // Your code here... })(); 首先定义了一个匿名函数function() { ... }，也可以写成我常用的格式，都是等效的： (() => { 'use strict'; // Your code here... })(); 用()把匿名函数包起来，再在后面加一个()，表示直接执行这个匿名函数 其实完全可以把代码写到匿名函数的外部，都可以执行，只是定义域不同而已 'use strict';是伪代码，它告诉解释器，开启严格模式 开启严格模式以后，会进行更严格的语法检查，建议开启，有利于养成良好的编码习惯 // 非严格模式，允许访问未定义变量 (() => { y = 3.14; // 不报错 })(); // 严格模式，允许访问已定义变量 (() => { "use strict"; // 启用严格模式 let y = 3.14; // 不报错（y已定义） })(); // 严格模式，不允许访问未定义变量 (() => { "use strict"; // 启用严格模式 y = 3.14; // 报错（y未定义） })(); 实现功能 接下来我们为脚本编写一些功能，以我的博客作为演示，地址：about.html 首先我们需要修改元信息，让脚步只在特定的网页生效 然后添加作者名称和脚本名称（除了@match都可以随便写） // ==UserScript== // @name 第一个油猴脚本 // @namespace http://blog.chrxw.com/ // @version 0.1 // @description hello world // @author Chr_ // @match https://blog.chrxw.com/about.html // @grant none // ==/UserScript== 我们先不编写脚本，我们先使用开发者工具测试一下代码 首先我们找一个对象下手（如图） 它没有id属性，但是它的class名称独一无二，所以我们可以直接用document.querySelector('.item-thumb')获取到DOM元素 let a = document.querySelector('.item-thumb'); 修改元素的背景图 a.style.backgroundImage='url(/usr/customize/logo.png)'; 添加点击事件 a.addEventListener('click',() => { a.style.background='#f0f'; }); 点一下以后背景变成了品红色 最后我们编写油猴脚本，让这一切自动完成 // ==UserScript== // @name 第一个油猴脚本 // @namespace http://blog.chrxw.com/ // @version 0.1 // @description hello world // @author Chr_ // @match https://blog.chrxw.com/about.html // @grant none // ==/UserScript== (() => { 'use strict'; let a = document.querySelector('.item-thumb'); a.style.backgroundImage='url(/usr/customize/logo.png)'; a.addEventListener('click',() => { a.style.background='#f0f'; }); // Your code here... })(); 保存脚本以后，刷新页面，可以看到元素的背景图片已经改掉了，说明脚本运行成功了。 0x06 脚本实战，Auto_Sub3 脚本获取链接：Auto_Sub3 脚本功能 安装脚本以后，首页可以打开一个面板，主要功能是一键-3 元信息 // ==UserScript== // @name Auto_Sub3 // @namespace https://blog.chrxw.com // @version 2.1 // @description 一键快乐-3 // @author Chr_ // @include https://keylol.com/* // @license AGPL-3.0 // @icon https://blog.chrxw.com/favicon.ico // @grant GM_setValue // @grant GM_getValue // @grant GM_xmlhttpRequest // ==/UserScript== 元信息提供了这个脚本的基本信息，我们还可以知道，它申请了GM_setValue,GM_getValue和GM_xmlhttpRequest三个特殊权限 代码总览 // 上次-3时间 let VLast = 0; // 今天还能不能-3 let VCan3 = true; // 自动展开 let VShow = false; // 自动-3 let VAuto = false; // 音效载入 let sound = new Audio("https://blog.chrxw.com/usr/keylol/gas.mp3"); (function () { // 每次加载以后从这里运行 'use strict'; loadCFG(); addBtns(); if (VShow) { switchPanel(); } if (VCan3 && VAuto) { autoRoll(); } })(); // 添加GUI function addBtns() { ... } // 显示小轮盘 function showLiteRoll() { ... } // 自动打开面板 function fBtnShow() { ... } // 自动-3 function fBtnAuto() { ... } // 显示布尔 function bool2txt(bool) { ... } // 隐藏面板 function switchPanel() { ... } // 读取设置 function loadCFG() { ... } // 保存设置 function saveCFG() { ... } // 检查能否-3 function checkZP() { ... } // 禁止-3 function disableS3() { ... } // 自动-3 function autoRoll() { ... } 先不用太纠结代码，建议先用调试模式看一下代码流程 脚本调试模式 在设置里可以打开调试模式，开启以后在加载完脚本以后会自动暂停，可以用来动态调试脚本 打开调试模式以后，先打开开发者工具，然后刷新页面，可以看到暂停在debugger语句上了 给我们感兴趣的函数添加断点，然后点“继续运行（F8）” 可以自己尝试跟踪一下每个函数的运行过程，不再赘述 代码详解 脚本入口 // 上次-3时间 let VLast = 0; // 今天还能不能-3 let VCan3 = true; // 自动展开 let VShow = false; // 自动-3 let VAuto = false; // 音效载入 let sound = new Audio("https://blog.chrxw.com/usr/keylol/gas.mp3"); (function () { 'use strict'; loadCFG(); addBtns(); if (VShow) { switchPanel(); } if (VCan3 && VAuto) { autoRoll(); } })(); 首先定义了5个全局变量，然后运行了匿名函数，在这个匿名函数里完成了脚本的初始化工作 在匿名函数内部调用了这么几个函数：loadCFG，addBtns，switchPanel，autoRoll loadCFG/saveCFG - 读写配置 // 读取设置 function loadCFG() { let t = null; t = GM_getValue('VLast'); t = Number(t); if (t != t) { t = 0; } VLast = t; t = GM_getValue('VCan3'); VCan3 = Boolean(t); t = GM_getValue('VShow'); VShow = Boolean(t); t = GM_getValue('VAuto'); VAuto = Boolean(t); // 日期变更,重置VCan3 let d = new Date(); let day = d.getDate(); let hour = d.getHours(); if (day != VLast && hour >= 8) { VCan3 = true; VLast = day; } saveCFG(); } // 保存设置 function saveCFG() { GM_setValue('VLast', VLast); GM_setValue('VCan3', VCan3); GM_setValue('VShow', VShow); GM_setValue('VAuto', VAuto); } 脚本保存数据的方法有很多种，可以利用原生的Cookie，也可以利用HTML5标准中的LocalStorge，但是这两个地方只能保存文本，我们还需要自行完成类型转换，非常不方便，所以我用的是油猴自带的储存空间 // 需要在元信息里提前申明，才可以使用 // @grant GM_setValue // @grant GM_getValue GM_setValue( 键名 , 键值)，保存某个值 GM_getValue( 键名 )，如果键名不存在，返回null 可以在脚本编辑器的储存TAB看到当前脚本的储存空间 saveCFG非常简单，就是把4个全局变量保存起来 loadCFG也很简单，就是从储存空间读取4个变量，然后获取现在的时间和日期，判断日期有没有变更，如果日期变了，并且当前小时数大于8，就把Vcan3赋值成true，然后把VLast改成今天的日期，最后调用saveCFG保存4个全局变量 addBtns - 添加GUI // 添加GUI function addBtns() { function genButton(text, foo, id) { let b = document.createElement('button');//创建类型为button的DOM对象 b.textContent = text; //修改内部文本为text b.style.cssText = 'margin: 3px 5px;' //添加样式（margin可以让元素间隔开一定距离） b.addEventListener('click', foo); //绑定click的事件的监听器 if (id) { b.id = id; } //如果传入了id，就修改DOM对象的id return b; //返回修改好的DOM对象 } function genDiv(cls) { let d = document.createElement('div'); //创建类型为div的DOM对象 if (cls) { d.className = cls }; //如果传入了cls，就修改DOM对象的class return d; //返回修改好的DOM对象 } function genPanel(name, visiable) { let p = genDiv(name); //创建类型为div，class为name的DOM对象 p.id = name; //修改DOM对象的id为name p.style.cssText = 'width: 100%;height: 100%;';//修改DOM对象的样式（占满父容器） if (!visiable) { p.style.display = 'none'; }//修改DOM对象的样式（不可见） return p; //返回修改好的DOM对象 } //使用CSS选择器获取对象 let btnSwitch = document.querySelector('.index_left_title>div'); if (btnSwitch == null) { return; } btnSwitch.id = 'btnSwitch1'; btnSwitch.title = '点这里开启/隐藏控制面板'; btnSwitch.style.cssText = 'width: auto;padding: 0 5px;cursor: pointer;'; btnSwitch.addEventListener('click', switchPanel); let panelArea = document.querySelector('.index_navi_left'); let panelOri = document.querySelector('.index_left_detail'); panelOri.id = 'panelOri' let panel54 = genPanel('panel54'); let aLP = document.createElement('a'); aLP.href = 'https://keylol.com/t571093-1-1'; let img54 = document.createElement('img'); img54.src = 'https://gitee.com/chr_a1/gm_scripts/raw/master/index.png'; img54.alt = '总之这里是54的名言'; img54.style.cssText = 'float: right;margin-top: -28px;height: 100%;' aLP.appendChild(img54); let btnArea = genDiv('btnArea'); btnArea.style.cssText = 'width: 210px;text-align: center;margin-top: -10px;'; let btnS3 = genButton('【一键-3】', autoRoll, 'btnS3'); if (!VCan3) { btnS3.style.textDecoration = 'line-through'; btnS3.textContent = '今天已经不能-3了'; } let btnShow = genButton(bool2txt(VShow) + '自动打开面板', fBtnShow, 'btnShow'); let btnAuto = genButton(bool2txt(VAuto) + '自动每日-3', fBtnAuto, 'btnAuto'); btnArea.appendChild(btnS3); btnArea.appendChild(btnShow); btnArea.appendChild(btnAuto); panel54.appendChild(aLP); panel54.appendChild(btnArea); panelArea.insertBefore(panel54, panelArea.children[1]); } 看着很长，实际上很简单，首先定义了3和内部函数genButton，genDiv，genPanel，用来生成我们需要的DOM元素 然后尝试用选择器获取DOM对象 let btnSwitch = document.querySelector('.index_left_title>div');//使用CSS选择器获取DOM对象 if (btnSwitch == null) { return; } //如果页面中不存在这个对象，就直接返回，结束运行 btnSwitch.id = 'btnSwitch1'; //添加id属性 btnSwitch.title = '点这里开启/隐藏控制面板'; //添加title属性（鼠标悬停的时候显示的文本） btnSwitch.style.cssText = 'width: auto;padding: 0 5px;cursor: pointer;';//添加CSS样式 btnSwitch.addEventListener('click', switchPanel);//绑定click事件的回调函数为switchPanel 可以利用开发者工具看一下是哪个对象 剩下的部分也很好理解，创建3个button对象，1个img对象，依次添加到名为panel54的div对象里，最后再把panel54添加到panelArea中 let panelArea = document.querySelector('.index_navi_left'); let panelOri = document.querySelector('.index_left_detail'); panelOri.id = 'panelOri' let panel54 = genPanel('panel54'); let aLP = document.createElement('a'); aLP.href = 'https://keylol.com/t571093-1-1'; let img54 = document.createElement('img'); img54.src = 'https://gitee.com/chr_a1/gm_scripts/raw/master/index.png'; img54.alt = '总之这里是54的名言'; img54.style.cssText = 'float: right;margin-top: -28px;height: 100%;' aLP.appendChild(img54); let btnArea = genDiv('btnArea'); btnArea.style.cssText = 'width: 210px;text-align: center;margin-top: -10px;'; let btnS3 = genButton('【一键-3】', autoRoll, 'btnS3'); if (!VCan3) { btnS3.style.textDecoration = 'line-through'; btnS3.textContent = '今天已经不能-3了'; } let btnShow = genButton(bool2txt(VShow) + '自动打开面板', fBtnShow, 'btnShow'); let btnAuto = genButton(bool2txt(VAuto) + '自动每日-3', fBtnAuto, 'btnAuto'); btnArea.appendChild(btnS3); btnArea.appendChild(btnShow); btnArea.appendChild(btnAuto); panel54.appendChild(aLP); panel54.appendChild(btnArea); panelArea.insertBefore(panel54, panelArea.children[1]); 通过switchPanel，可以切换显示原来的元素和脚本的面板 // 隐藏面板 function switchPanel() { let panel1 = document.getElementById('panel54'); let panel2 = document.getElementById('panelOri'); if (panel1.style.display == 'none') { btnSwitch1.textContent = '今天你【-3】了吗 - By Chr_'; panel1.style.display = 'block'; panel2.style.display = 'none'; } else { btnSwitch1.textContent = '关注重点'; panel1.style.display = 'none'; panel2.style.display = 'block'; } } 运行效果： autoRoll - 自动-3 最后我们看一下核心功能，自动-3 // 自动-3 function autoRoll() { try { sound.play(); } catch (e) { console.error(e); } let v = 0; gethash(); function gethash() { GM_xmlhttpRequest({ method: "GET", url: 'https://keylol.com/plugin.php?id=steamcn_lottery:view&lottery_id=43', onload: function (response) { if (response.status == 200) { let m = response.responseText.match(/plugin\.php\?id=steamcn_lottery:view&lottery_id=43&hash=(.+)&roll/); let hash = m ? m[1] : null; console.log(hash); if (hash != null) { roll(hash); roll(hash); roll(hash); } else { disableS3(); saveCFG(); } } else { console.error('出错'); } } }); } function roll(hash) { GM_xmlhttpRequest({ method: "GET", url: 'https://keylol.com/plugin.php?id=steamcn_lottery:view&lottery_id=43&hash=' + hash + '&roll', onload: function (response) { if (response.status == 200) { console.log(response.responseText); } else { console.error('出错') } if (++v == 3) { disableS3(); saveCFG(); } } }); } } 乍一看非常复杂，实际上也很好理解 try { sound.play(); } catch (e) { console.error(e); } let v = 0; gethash(); 首先调用sound.play()，播放蒸汽泄漏的音效，sound是全局变量，会在网页载入的时候自动加载音频资源，如果遇到错误就忽略错误（比如网络问题加载音频失败的话就会报错） 然后调用了gethash方法 function gethash() { GM_xmlhttpRequest({ method: "GET", url: 'https://keylol.com/plugin.php?id=steamcn_lottery:view&lottery_id=43', onload: function (response) { if (response.status == 200) { let m = response.responseText.match(/plugin\.php\?id=steamcn_lottery:view&lottery_id=43&hash=(.+)&roll/); let hash = m ? m[1] : null; console.log(hash); if (hash != null) { roll(hash); roll(hash); roll(hash); } else { disableS3(); saveCFG(); } } else { console.error('出错'); } } }); } 这个函数使用了GM_xmlhttpRequest，这个东西是一个网络请求器，深入了解可以参考官方文档（英语），GM_xmlhttpRequest类似于xmlHttpRequest，两者用法几乎一样，只是GM_xmlhttpRequest不存在跨域的问题 // 需要先在元信息里申明，才可以使用 // @grant GM_xmlhttpRequest 简单的用法如下 GM_xmlhttpRequest({ method: '', //请求方法，可以是 GET POST HEAD 等 url: '', //请求路径，简单的说就是url onload: (response) => { //请求被响应时执行，response是接受到的响应的对象 //response.status，获取响应代码（200代表请求成功,4xx和5xx代表请求出错） //response.responseText，获取响应文本，本例中获取到的是HTML文本 //response.responseXML，获取XML格式的响应文本 } }); 看到onload的回调函数部分 onload: (response) => { if (response.status == 200) { let m = response.responseText.match(/plugin\.php\?id=steamcn_lottery:view&lottery_id=43&hash=(.+)&roll/); let hash = m ? m[1] : null; console.log(hash); if (hash != null) { roll(hash); roll(hash); roll(hash); } else { disableS3(); saveCFG(); } } else { console.error('出错'); } } 首先判断状态码是不是200（代表请求成功），请求成功才会处理响应的内容 可以在浏览器中打开链接，用开发者工具模拟一下处理过程 我们把响应的内容当做纯文本处理，使用正则表达式提取想要的内容 正则表达式深入学习可以参考这个：正则表达式 - 语法 let a = document.body.innerHTML; console.log(a.match(/plugin\.php\?id=steamcn_lottery:view&lottery_id=43&hash=(.+)&roll/)); 简单的讲一下上面的正则表达式是什么意思 /plugin\.php\?id=steamcn_lottery:view&lottery_id=43&hash=(.+)&roll/ // 两边的 / 就是告诉解释器里面是正则表达式，没别的意思 // 按照不同元素拆分，如下： // plugin - 匹配“plugin”这个字符串 // \. - 匹配“.”，“.”原来有别的意思，在前面加“\”转义以后就只有字面意思了 // id=steamcn_lottery:view&lottery_id=43&hash= - 匹配一整串字符串 // (.+) - “()”是子匹配的意思，“.+”意思是匹配至少一个字符 // - “(.+)”的意思是不仅要匹配至少一个字符，而且匹配到的文本需要返回 // &roll - 匹配“&roll”这个字符串 简单点说就是先找到plugin.php?id=steamcn_lottery:view&lottery_id=43&hash=[XXXXXXX]&roll这一串字符串，然后提取中间的XXXXXXX 执行效果如下，可以看到返回了一个数组，第一个元素是匹配到的字符串，第二个元素是子匹配项，我们只需要子匹配项，也就是8b51a2d3（这个值不同用户是不一样的，所以需要动态获取） 然后就是赋值操作，用到了三目运算符号 let hash = m ? m[1] : null; // D = A ? B : C // 等价于： if(A){ D = B; }else{ D = C; } // 当A的逻辑意义为true时，返回值为B // 当A的逻辑意义为false时，返回值为C // 类似这样 let a = true ? 1 : 2; // a = 1 let a = false ? 1 : 2; // a = 2 如果正则表达式没有匹配到合适的字符串，返回的内容是null，逻辑意义等于false，这时候hash的值就取null 如果正则表达式匹配到了合适的字符串，返回的内容是数组，逻辑意义等于true，这时候hash的值就取数组的第2个元素 然后根据hash决定是不是要继续运行 if (hash != null) { roll(hash); roll(hash); roll(hash); } else { disableS3(); saveCFG(); } 最后是roll函数，核心也是一个网络请求器 function roll(hash) { GM_xmlhttpRequest({ method: "GET", url: 'https://keylol.com/plugin.php?id=steamcn_lottery:view&lottery_id=43&hash=' + hash + '&roll', onload: function (response) { if (response.status == 200) { console.log(response.responseText); } else { console.error('出错') } if (++v == 3) { disableS3(); saveCFG(); } } }); } 实际上就是访问https://keylol.com/plugin.php?id=steamcn_lottery:view&lottery_id=43&hash=[XXXXXXX]&roll这个网站，然后把响应结果打印出来 可以根据上一步得到的hash在浏览器中试一下 调用以后，会自增v，如果v = 3，就调用disableS3和saveCFG，修改了按钮的名称，然后保存全局变量