Contents

油猴脚本

参考资料

简介

Tampermonkey(油猴) 是一款流行的用户脚本管理器浏览器扩展。它本身不直接提供功能,而是作为一个平台,用于管理和使用能增强网页体验的 JavaScript 脚本
脚本可以是自行编写的,也可以是脚本共享网站(GreasyFork、OpenUserJS)获取的

注意不要安装不可信的脚本
恶意脚本可能盗取 Cookies、会话信息,甚至登录凭证;监控我们的键盘输入;在后台占用我们计算机资源进行加密货币挖矿;将网页重定向至钓鱼网站

工作原理

脚本匹配:当我们访问一个网页时,Tampermonkey 会检查该页面的 URL 是否与已安装脚本的 @match 或 @include 规则匹配
脚本注入:如果匹配成功,Tampermonkey 会在页面加载时,将对应的 JavaScript 脚本代码注入到页面中
执行操作:被注入的脚本可以在其沙箱环境中运行。可以操作和修改页面的 DOM(文档对象模型);动态添加、删除或修改 CSS 样式;拦截、修改或观察 网络请求
客户端生效:所有这些操作都仅在我们的浏览器中完成,不会影响网站服务器。关闭插件或禁用脚本后,网页即恢复原状

主要用途

功能增强:为网页添加原生没有的功能,如“一键下载图片/视频”、“恢复默认字体”、“解除复制限制”等
内容移除:自动屏蔽网页广告、弹窗、推广信息等
界面美化:自定义网站的配色方案、布局、字体
数据提取:从网页中网页中批量抓取和整理公开信息

脚本基本结构

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// ==UserScriptUserScript==
// @name        我的脚本
// @namespace   http://your-domain.com
// @version     1.0.1
// @description 功能描述
// @author      作者
// @match       https://www.example.com/*
// @exclude     https://www.example.com/admin/*  // 排除特定页面
// @require     https://code.jquery.com/jquery-3.6.0.min.js  // 引入外部库
// @grant       GM_setValue
// @grant       GM_getValue
// @grant       GM_xmlhttpRequest  // 跨域请求
// @connect     api.example.com  // 允许连接的域名
// @run-at      document-end  // 注入时机
// @icon         https://example.com/icon.png  // 脚本图标
// @noframes  // 禁止在iframe中运行
// ==/UserScript==

(function () {
  "use strict";
  // 主要代码写在这里
  console.log("Hello, Tampermonkey!");
})();

@match 指定脚本生效的网址模式(支持通配符 *)
@grant:声明脚本需要使用的特殊 Tampermonkey API(如 GM_setValue 用于存储数据)
@require 在脚本运行前加载外部 JavaScript 库(如 jQuery)
@run-at 控制脚本注入的时机,如 document-start(页面刚开始加载)、document-end(DOM 准备就绪)、document-idle(默认,页面空闲时)

常用片段

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// 存储数据
GM_setValue("user_preference", "dark_mode");
const value = GM_getValue("user_preference", "default_value");

// 发送通知
GM_notification({
  title: "提示",
  text: "操作已完成",
  timeout: 3000,
});

// 跨域请求
function safeRequest(url) {
  return new Promise((resolve, reject) => {
    GM_xmlhttpRequest({
      method: "GET",
      url: url,
      timeout: 10000,
      onload: function (response) {
        if (response.status >= 200 && response.status < 300) {
          resolve(response.responseText);
        } else {
          reject(new Error(`HTTP ${response.status}: ${response.statusText}`));
        }
      },
      onerror: function (error) {
        reject(error);
      },
      ontimeout: function () {
        reject(new Error("Request timeout"));
      },
    });
  });
}

// 添加样式
GM_addStyle(`
  .my-custom-class { 
    color: red !important; 
  }
`);
  • 安全的错误处理
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// 用于包装主函数,避免整个脚本崩溃
function safelyExecute(callback, fallback = null) {
  try {
    return callback();
  } catch (error) {
    console.error('脚本执行出错:', error);
    if (fallback) fallback(error);
    return null;
  }
}

// 使用示例
safelyExecute(() => {
  // 代码主要逻辑
  document.querySelector document.querySelector('.target').remove();
}, (error) => {
  GM_notification({ title: '脚本错误', text: error.message });
});
  • 调试
1
2
3
4
5
6
7
8
// 开发环境调试开关
const DEBUG = true;

function debugLog(...args) {
  if (DEBUG) {
    console.log("[MyScript]", ...args);
  }
}
  • 避免脚本重复注入

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    
    // 检查其他脚本
    if (window.myScriptNamespace) {
      console.warn("脚本已经运行,避免重复注入");
      return;
    }
    window.myScriptNamespace = { version: "1.0.0" };
    
    // 清理函数
    function cleanup() {
      // 移除添加的事件监听器、定时器等
      delete window.myScriptNamespace;
    }
    
    // 页面卸载时清理
    window.addEventListener("beforeunload", cleanup);
    
  • 等待元素加载

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    
    // 使用 MutationObserver 监听
    function waitForElement(selector, timeout = 10000) {
      return new Promise((resolve, reject) => {
        const element = document.querySelector(selector);
        // 元素已存在
        if (element) return resolve(element);
    
        // 监听 DOM 变化
        const observer = new MutationObserver(() => {
          const element = document.querySelector(selector);
          if (element) {
            observer.disconnect();
            resolve(element);
          }
        });
        observer.observe(document.body, {
          childList: true,
          subtree: true,
        });
    
        // 超时则断开监听
        setTimeout(() => {
          observer.disconnect();
          reject(new Error(`Element ${selector} not found`));
        }, timeout);
      });
    }
    
    // 使用示例
    waitForElement(".video-player")
      .then((player) => {
        player.style.border = "2px solid red";
      })
      .catch((err) => console.warn(err));
    
  • 解除复制限制

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    
    // 移除选择保护
    document.oncontextmenu = null;
    document.onselectstart = null;
    document.onmousedown = null;
    
    // 移除复制限制
    const elements = document.querySelectorAll("*");
    elements.forEach((el) => {
      el.style.userSelect = "text";
      el.style.webkitUserSelect = "text";
    });
    
  • 自动点击跳过按钮

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    
    function skipAd() {
      const skipBtn = document.querySelector(".skip-button, .ad-skip");
      if (skipBtn) {
        skipBtn.click();
        console.log("已跳过");
      }
    }
    
    // 每隔1秒检查一次
    setInterval(skipAd, 1000);
    
  • 性能优化

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// 使用防抖避免频繁操作
function debounce(func, delay) {
  let timeoutId;
  return function (...args) {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => func.apply(this, args), delay);
  };
}

// 使用 DocumentFragment 进行批量DOM操作
function batchAddElements(container, items) {
  const fragment = document.createDocumentFragment();
  items.forEach((item) => {
    const div = document.createElement("div");
    div.textContent = item;
    fragment.appendChild(div);
  });
  container.appendChild(fragment);
}

如果还需要深入研究,Tampermonkey 官方文档是查询 API 和特性的最佳来源
还可以在脚本共享网站上阅读、学习热门开源脚本的源代码

实例

B 站资源下载

  • 核心功能

    音频下载:提取并下载 B 站视频中的音频为 MP3 格式
    封面下载:直接打开视频封面图片
    视频下载:通过外部服务下载视频
    客户端启动:调用本地 bilidown 客户端

1
2
3
4
5
// DOM操作工具函数
const $ = (el) => document.querySelector(el);
const checkElExist = (c) => $(`.${c}`);
const mount = (el, containers) =>
  containers.forEach((item) => item.appendChild(el));
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
(function () {
  let printLog = true;
  const log = window.console.log;
  window.console.log = (...args) => printLog && log.apply(window.console, args);
  let audioState = "pending";
  let retryCounter = 0;
  let my_xhr = null;
  const currentVersion = GM_info.script.version;
  let remoteVersion = null;
  const updateUrl =
    "https://update.greasyfork.org/scripts/459377/Bilidown%E8%81%94%E5%8A%A8%E8%84%9A%E6%9C%AC%5B%E9%9F%B3%E9%A2%91mp3%F0%9F%8E%B5%EF%BC%8C%E8%A7%86%E9%A2%91mp4%F0%9F%93%B9%EF%BC%8C%E5%BC%B9%E5%B9%95xml%EF%BC%8Cass%F0%9F%8C%B4%EF%BC%8C%E5%B0%81%E9%9D%A2%F0%9F%8C%BE%5D.meta.js?t=" +
    new Date().getTime();
  const localStorageMap = {
    封面: "bilidown_script_cover",
    音频: "bilidown_script_audio",
    视频: "bilidown_script_video",
    bilidown客户端: "bilidown_script_launch",
  };

  // 定义一个符号函数,用于查询元素
  const $ = (el) => document.querySelector(el);
  // 根据 class 属性值查询
  const checkElExist = (c) => $(`.${c}`);
  // 获取视频的信息,包括播放量、发布时间等等
  const chooseEl = () =>
    document.querySelectorAll(".video-info-detail-list") ||
    document.querySelectorAll(".video-data");
  // 渲染脚本需要的按钮
  const renderBtn = () =>
    Object.keys(localStorageMap).forEach((k) =>
      renderBtnLogic(`bilidown_script_btn bilidown_script_${k}_btn`, k)
    );
  // 用于往容器中添加元素的函数
  const mount = (el, containers) =>
    containers.forEach((item) => item.appendChild(el));
  // 重置音频的文本和状态
  const resetAudioAbout = (btn) => {
    btn.textContent = "音频";
    audioState = "pending";
  };
  // 移除所有自定义的按钮
  const removeAllBtn = () =>
    Object.keys(localStorageMap).forEach(
      (k) =>
        $(`.bilidown_script_${k}_btn`) &&
        $(`.bilidown_script_${k}_btn`).remove()
    );
  // 为给定 class 属性值的元素注入 css
  const injectStyle = (className, css) => {
    if (checkElExist(className)) return;
    let style = document.createElement("style");
    style.className = className;
    style.innerText = css;
    document.head.appendChild(style);
  };
  // inject edit native style
  injectStyle(
    "bilidown_script_eidt_native_2024",
    ".video-info-container{margin-bottom:6px}.video-info-detail-list{flex-wrap:wrap!important;height:44px!important}"
  );

  const coverEvent = () =>
    window.open(__INITIAL_STATE__.videoData.pic, "_blank");
  const videoEvent = () =>
    location.href &&
    window.open(`http://zhouql.vip/bilibili/?${location.href}`, "_blank");
  const launchEvent = () =>
    location.href && window.open(`bilidown://parser?link=${location.href}`);
  // 音频下载
  const aduioEvent = (btn, e) => {
    e.preventDefault();
    if (audioState === "pending") {
      audioState = "active";
      const url = `https://api.bilibili.com/x/player/playurl?avid=${
        __INITIAL_STATE__.aid
      }&bvid=${__INITIAL_STATE__.bvid}&cid=${
        __INITIAL_STATE__.cidMap[__INITIAL_STATE__.bvid].cids[
          __INITIAL_STATE__.p
        ]
      }&fnval=4048`;
      fetch(url)
        .then((resp) => resp.json())
        .then((i) => {
          my_xhr = new XMLHttpRequest();
          my_xhr.responseType = "blob";
          my_xhr.open("GET", i.data.dash.audio[0].base_url, true);
          my_xhr.onprogress = (event) =>
            (btn.textContent = `下载中 ${parseInt(
              (event.loaded / event.total) * 100
            )}%`);
          my_xhr.onload = () => {
            if (my_xhr.status !== 200) resetAudioAbout(btn);
            const reader = new FileReader();
            reader.readAsDataURL(my_xhr.response);
            reader.onload = (e) => {
              const a = document.createElement("a");
              a.download = `${
                $(".video-title").textContent || "Hello World"
              }.mp3`;
              a.href = e.target.result;
              document.documentElement.appendChild(a);
              a.click();
              a.remove();
              my_xhr = null;
              resetAudioAbout(btn);
            };
          };
          my_xhr.onerror = () => resetAudioAbout(btn);
          my_xhr.onabort = () => resetAudioAbout(btn);
          my_xhr.ontimeout = () => resetAudioAbout(btn);
          my_xhr.send();
        });
    } else {
      my_xhr.abort();
      btn.textContent = "已取消下载";
      setTimeout(() => resetAudioAbout(btn), 1000);
    }
  };

  // 检查是否有新的远程版本
  let updateCheck = async () => {
    try {
      const resp = await fetch(updateUrl);
      const text = await resp.text();
      if (!text) return false;
      let arr = text.split("\n");
      for (let i = 0; i < arr.length; i++) {
        if (!arr[i].includes("@version")) continue;
        remoteVersion = arr[i].split("version")[1].trim();
        if (currentVersion != remoteVersion) return true;
      }
      return false;
    } catch (e) {
      return false;
    }
  };

  // 切换对话框
  let toggleDialog = () => {
    let dialog = $(".bilidown_script_system_dialog");
    dialog.style.display = dialog.style.display === "block" ? "none" : "block";
    if (dialog.style.display === "block") echoDialogChecked();
  };

  // 添加 checked 属性?
  let echoDialogChecked = () => {
    // 遍历字典的键 v 和下标 i
    // 从 localStorage 中获取数据
    Object.values(localStorageMap).forEach((v, i) => {
      let value = JSON.parse(localStorage.getItem(v));
      if (value && value === true) {
        document.querySelectorAll(
          ".bilidown_script_system__dialog__content label input"
        )[i].checked = true;
      }
    });
  };

  // 渲染按钮的逻辑
  let renderBtnLogic = (className, text) => {
    let key = JSON.parse(localStorage.getItem(localStorageMap[text]));
    if (key && key === true) {
      let btn = document.createElement("button");
      registerEvent(btn, text);
      btn.textContent = text;
      btn.className = className;
      mount(btn, chooseEl());
    }
  };

  // 为按钮注册对应的事件
  let registerEvent = (btn, text) => {
    if (!localStorageMap.hasOwnProperty(text)) return;
    btn.addEventListener("click", (e) => {
      if (text === "封面") {
        coverEvent(e);
      } else if (text === "音频") {
        aduioEvent(btn, e);
      } else if (text === "视频") {
        videoEvent(e);
      } else if (text === "bilidown客户端") {
        launchEvent(e);
      }
    });
  };

  // 渲染 bilidown_script_system_dialog 对话框
  let renderDialog = () => {
    if (checkElExist("bilidown_script_system_dialog")) return;
    let target = document.createElement("div");
    target.className = "bilidown_script_system_dialog";
    target.innerHTML = `<div class="bilidown_script_system__dialog__header"><div class="title"><img src="data:image/webp;base64,UklGRkoEAABXRUJQVlA4WAoAAAAwAAAAPwAAPwAASUNDUMgBAAAAAAHIAAAAAAQwAABtbnRyUkdCIFhZWiAH4AABAAEAAAAAAABhY3NwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAA9tYAAQAAAADTLQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAlkZXNjAAAA8AAAACRyWFlaAAABFAAAABRnWFlaAAABKAAAABRiWFlaAAABPAAAABR3dHB0AAABUAAAABRyVFJDAAABZAAAAChnVFJDAAABZAAAAChiVFJDAAABZAAAAChjcHJ0AAABjAAAADxtbHVjAAAAAAAAAAEAAAAMZW5VUwAAAAgAAAAcAHMAUgBHAEJYWVogAAAAAAAAb6IAADj1AAADkFhZWiAAAAAAAABimQAAt4UAABjaWFlaIAAAAAAAACSgAAAPhAAAts9YWVogAAAAAAAA9tYAAQAAAADTLXBhcmEAAAAAAAQAAAACZmYAAPKnAAANWQAAE9AAAApbAAAAAAAAAABtbHVjAAAAAAAAAAEAAAAMZW5VUwAAACAAAAAcAEcAbwBvAGcAbABlACAASQBuAGMALgAgADIAMAAxADZBTFBIoAAAAAFfoDaSDTr9kEn/UrmORERgqJdeT4zgpta2N3kpvwT2vncJ2etDlRAJkRAdkdAAefX7doaI/k+AJK1zTKOR3ieYT97EcH+aVhBe4GkOkmI6u5IgUT8dr2hK51EyjHm6EuLG5xYyr0N+96kins78P8p7nyrKGp/bIfYKMT6EKZ1HwVDUHlekmM6uIEgCO5CkGHqbCsKLUsxXer/ObaKRJAFWUDggtAEAAFAMAJ0BKkAAQAA/gbrNXrYxKSaoGAyqwDAJQBqv5i4QYRFzT/ewe1mJ22KucUYMTsK3jwwkf8stqz7QBvQGk6vMjaTmq2ViEiSLAR1tyBqGQgiVl9OQAsS9pi/vVcuzN4zM37Byu/vzBSbAANtKZ5uayFBaFvRGroBkEeijKjuBHPFsLGenK/cmNDrTzifuCSIJ/7i/iN3mTpvr+VDTxfR+AndcIgd4ITIALC+kAtGhEY+84lcBrJGGTL8sctYdjSwyxEymZswKkLHIQau61+hOR8OfHc9zjTsE1th9tHAMR4HsE2DBQYVAXK74u6HvEXONvCPNf+Ah+DAPYogluF+in3FCd0ceF2p704DGdxYqBDrVldeuslKulMg8w50VpbgiNKl4kAQ18A4yjKYOMrTZuNCp11UNCtSvjM9tCe1GXxDQY3fuAy24FtQaL12glbmxTCFblu/ZpuLBTgSCu1LCzsOLWdEZQAgrnNmoJkd4DzQjKGVrf4d1lQJiEfYQUxaBrK6syg07hhCi2hajnkjUg/XGWBJiQX5OvF7FLrdmlLZeCOW3tese1rensTisK4VgAAA="                alt=""><a href="https://greasyfork.org/zh-CN/scripts/459377" target="_blank">bilidown联动脚本</a></div><div class="close"><svg t="1721872624289" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"                p-id="5584" width="64" height="64"><path                    d="M240.512 180.181333l271.530667 271.488 271.530666-271.488a42.666667 42.666667 0 0 1 56.32-3.541333l4.010667 3.541333a42.666667 42.666667 0 0 1 0 60.330667l-271.530667 271.530667 271.530667 271.530666a42.666667 42.666667 0 0 1-56.32 63.872l-4.010667-3.541333-271.530666-271.530667-271.530667 271.530667-4.010667 3.541333a42.666667 42.666667 0 0 1-56.32-63.872l271.488-271.530666-271.488-271.530667a42.666667 42.666667 0 0 1 60.330667-60.330667z"                    fill="#000000" p-id="5585"></path></svg></div></div><div class="bilidown_script_system__dialog__content"><ul><li><label id="cover"><span>封面</span><input type="checkbox" name="封面"></label></li><li><label id="audio"><span>音频</span><input type="checkbox" name="音频"></label></li><li><label id="video"><span>视频</span><input type="checkbox" name="视频"></label></li><li><label id="bilidown-pc"><span>bilidown客户端<a href="https://zhouql.vip/bilibili/pc/"                            target="_blank">[安装]</a></span><input type="checkbox" name="bilidown客户端"></label></li></ul></div>`;
    mount(target, chooseEl());
    let closeDialog = $(".bilidown_script_system__dialog__header .close");
    closeDialog.addEventListener("click", (e) => toggleDialog());
    document
      .querySelectorAll(".bilidown_script_system__dialog__content label input")
      .forEach((label) => {
        label.addEventListener("click", (e) => {
          localStorage.setItem(
            localStorageMap[e.target.name],
            e.target.checked
          );
          removeAllBtn();
          renderBtn();
        });
      });
  };

  // 渲染 bilidown_script_system_btn 按钮
  let renderSystemBtn = () => {
    if (checkElExist("bilidown_script_system_btn")) return;
    let btn = document.createElement("button");
    btn.textContent = "设置";
    btn.className = "bilidown_script_btn bilidown_script_system_btn";
    btn.addEventListener("click", (e) => toggleDialog());
    mount(btn, chooseEl());
  };

  // 最长等待 15 秒,重试 5 次
  let render = (retryNum = 5) => {
    (async function check(c) {
      if (c < 1) {
        console.error("bilidown script render timeout, stopped.");
        return;
      }
      if (checkElExist("bpx-player-ctrl-playbackrate-menu")) {
        retryCounter === 0 && console.log("bilidown script rendering...");
        injectStyle(
          "bilidown_script_style_2024",
          `.bilidown_script_btn{background-color:#0071e3;border:0;color:#fff;border-radius:100px;cursor:pointer;padding:0 6px;font-size:12px;margin:0 2px}.bilidown_script_system_dialog{width:280px;border:1px solid #ccc;padding:12px 16px;position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);background-color:#fafafa;border-radius:4px;user-select:none;display:none;z-index:99999}.bilidown_script_system__dialog__header{display:flex;align-items:center;justify-content:space-between}.bilidown_script_system__dialog__header .title{display:flex;align-items:center;flex:1}.bilidown_script_system__dialog__header a{font-size:15px;text-decoration:none;color:#232323;transition:all .1s;flex:1;margin-right:30px}.bilidown_script_system__dialog__header img{height:18px;width:18px;margin-right:4px}.bilidown_script_system__dialog__header a:hover{color:#0071e3}.bilidown_script_system__dialog__header .close{display:flex;align-items:center;justify-content:center;cursor:pointer}.bilidown_script_system__dialog__header .close svg{width:16px;height:16px}.bilidown_script_system__dialog__content{margin-top:10px}.bilidown_script_system__dialog__content ul li{list-style:none;font-size:13px;margin:16px 0}.bilidown_script_system__dialog__content ul li:last-child{margin:0}.bilidown_script_system__dialog__content ul li label{display:flex;align-items:center;justify-content:space-between;cursor:pointer;color:#454545;}.bilidown_script_system__dialog__content ul li label a{color:#0071e3;pointer-events:all}.bilidown_script_system__dialog__content ul li span{pointer-events:none}.bilidown_script_system__dialog__content ul li input{margin-right:2px}.new_version{position:relative}.new_version::after{content:'';display:inline-block;position:absolute;width:9px;height:9px;border-radius:50%;background-color:#fc3c4a;top:0;left:-10px}.system_btn_new_version::after{right:0;left:initial}`
        );
        renderDialog();
        renderSystemBtn();
        removeAllBtn();
        renderBtn();
        console.log("bilidown script render success.");
        if (retryCounter === 0) {
          const hasNewVersion = await updateCheck();
          console.log(`bilidown new version result: ${hasNewVersion}`);
          if (hasNewVersion) {
            $(
              ".bilidown_script_system__dialog__header a"
            ).textContent = `[新版本${remoteVersion},点我更新]`;
            $(".bilidown_script_system__dialog__header a").classList.add(
              "new_version"
            );
            $(".bilidown_script_system_btn").classList.add("new_version");
            $(".bilidown_script_system_btn").classList.add(
              "system_btn_new_version"
            );
          }
        }
        if (retryCounter++ < 6) {
          console.log(`bilidown script render confirm${retryCounter}.`);
          setTimeout(() => render(), 1000);
        }
      } else {
        console.log(`bilidown script waiting render${c}...`);
        setTimeout(() => check(c - 1), 1000);
      }
    })(retryNum);
  };
  window.onload = () => render(15);
})();
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
const videoEvent = (btn, e) => {
  e.preventDefault();
  const videoUrl = `https://api.bilibili.com/x/player/playurl?cid=${
    __INITIAL_STATE__.cidMap[__INITIAL_STATE__.bvid].cids[__INITIAL_STATE__.p]
  }&bvid=${__INITIAL_STATE__.bvid}&qn=80&type=&otype=json`; // 这里假设我们已经有了视频的CID和BVID
  fetch(videoUrl)
    .then((resp) => resp.json())
    .then((data) => {
      const videoBlobUrl = data.data.dash.video[0].base_url; // 获取视频的Blob URL
      const a = document.createElement("a");
      a.href = videoBlobUrl;
      a.download = `${$(".video-title").textContent || "Video Title"}.mp4`; // 设置下载文件名
      document.body.appendChild(a);
      a.click();
      a.remove();
    })
    .catch((error) => {
      console.error("Error downloading video:", error);
      btn.textContent = "下载失败";
    });
};

AI 改进

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
// ==UserScript==
// @name         B站资源下载助手 - 增强版
// @namespace    http://tampermonkey.net/
// @version      2.0.0
// @description  一键下载B站视频、音频、封面等资源,支持多画质选择
// @author       YourName
// @match        https://www.bilibili.com/video/*
// @match        https://m.bilibili.com/video/*
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_download
// @connect      bilibili.com
// @connect      api.bilibili.com
// @require      https://cdn.jsdelivr.net/npm/file-saver@2.0.5/dist/FileSaver.min.js
// @license      MIT
// ==/UserScript==

(function () {
  "use strict";

  // 配置项
  const CONFIG = {
    scriptName: "B站资源下载助手",
    version: "2.0.0",
    author: "YourName",
    updateUrl: "https://example.com/your-script.meta.js",
  };

  // 本地存储键名
  const STORAGE_KEYS = {
    COVER: "bili_downloader_cover_enabled",
    AUDIO: "bili_downloader_audio_enabled",
    VIDEO: "bili_downloader_video_enabled",
    QUALITY: "bili_downloader_preferred_quality",
  };

  // 视频质量选项
  const QUALITY_OPTIONS = [
    { value: 120, label: "超清 4K" },
    { value: 116, label: "高清 1080P60" },
    { value: 112, label: "高清 1080P+" },
    { value: 80, label: "高清 1080P" },
    { value: 64, label: "清晰 720P" },
    { value: 32, label: "流畅 480P" },
    { value: 16, label: "流畅 360P" },
  ];

  // DOM 工具函数
  const $ = (selector, parent = document) => parent.querySelector(selector);
  const $$ = (selector, parent = document) => parent.querySelectorAll(selector);
  const createElement = (tag, className = "", innerHTML = "") => {
    const el = document.createElement(tag);
    if (className) el.className = className;
    if (innerHTML) el.innerHTML = innerHTML;
    return el;
  };

  class BiliDownloader {
    constructor() {
      this.isInitialized = false;
      this.currentTask = null;
      this.init();
    }

    init() {
      if (this.isInitialized) return;
      this.injectStyles();
      this.waitForPageLoad().then(() => {
        this.renderUI();
        this.checkUpdate();
        this.isInitialized = true;
        console.log(`${CONFIG.scriptName} 初始化完成`);
      });
    }

    // 注入CSS样式
    injectStyles() {
      if ($(".bili-downloader-styles")) return;
      const styles = `
        .bili-downloader-btn {
          background: linear-gradient(135deg, #00a1d6, #0084b4);
          border: none;
          color: white;
          border-radius: 20px;
          cursor: pointer;
          padding: 6px 12px;
          font-size: 12px;
          margin: 0 4px;
          transition: all 0.3s ease;
          font-weight: 500;
        }
        
        .bili-downloader-btn:hover {
          transform: translateY(-2px);
          box-shadow: 0 4px 12px rgba(0, 161, 214, 0.4);
        }
        
        .bili-downloader-btn:disabled {
          background: #cccccc;
          cursor: not-allowed;
          transform: none;
        }
        
        .bili-downloader-btn.downloading {
          background: linear-gradient(135deg, #ff9500, #ff6600);
        }
        
        .bili-downloader-btn.success {
          background: linear-gradient(135deg, #34c759, #28a745);
        }
        
        .bili-downloader-btn.error {
          background: linear-gradient(135deg, #ff3b30, #dc3545);
        }
        
        .bili-downloader-settings-btn {
          background: transparent;
          border: 1px solid #00a1d6;
          color: #00a1d6;
        }
        
        .bili-downloader-dialog {
          position: fixed;
          top: 50%;
          left: 50%;
          transform: translate(-50%, -50%);
          background: white;
          border-radius: 12px;
          padding: 20px;
          box-shadow: 0 10px 40px rgba(0,0,0,0.2);
          z-index: 10000;
          min-width: 300px;
          max-width: 90vw;
        }
        
        .bili-downloader-dialog-overlay {
          position: fixed;
          top: 0;
          left: 0;
          right: 0;
          bottom: 0;
          background: rgba(0,0,0,0.5);
          z-index: 9999;
        }
        
        .bili-downloader-header {
          display: flex;
          justify-content: space-between;
          align-items: center;
          margin-bottom: 20px;
          padding-bottom: 15px;
          border-bottom: 1px solid #eee;
        }
        
        .bili-downloader-title {
          font-size: 18px;
          font-weight: bold;
          color: #00a1d6;
        }
        
        .bili-downloader-close {
          background: none;
          border: none;
          font-size: 20px;
          cursor: pointer;
          color: #666;
        }
        
        .bili-downloader-option {
          display: flex;
          justify-content: space-between;
          align-items: center;
          margin: 12px 0;
          padding: 8px 0;
        }
        
        .bili-downloader-quality-select {
          padding: 4px 8px;
          border: 1px solid #ddd;
          border-radius: 6px;
          font-size: 12px;
        }
        
        .bili-downloader-status {
          font-size: 11px;
          color: #666;
          margin-top: 4px;
        }`;

      const styleEl = createElement("style", "bili-downloader-styles");
      styleEl.textContent = styles;
      document.head.appendChild(styleEl);
    }

    // 等待页面加载完成
    waitForPageLoad() {
      return new Promise((resolve) => {
        const checkReady = () => {
          if (window.__INITIAL_STATE__ && $(".video-data")) {
            resolve();
          } else {
            setTimeout(checkReady, 500);
          }
        };
        checkReady();
      });
    }

    // 获取视频信息
    getVideoInfo() {
      if (!window.__INITIAL_STATE__) return null;
      const state = window.__INITIAL_STATE__;
      return {
        aid: state.aid,
        bvid: state.bvid,
        title: $(".video-title")?.textContent?.trim() || "B站视频",
        cover: state.videoData?.pic,
        cid: state.cidMap?.[state.bvid]?.cids?.[state.p || 0],
      };
    }

    // 下载封面
    async downloadCover() {
      const info = this.getVideoInfo();
      if (!info?.cover) {
        this.showMessage("无法获取封面地址", "error");
        return;
      }

      try {
        const response = await fetch(info.cover);
        const blob = await response.blob();
        const link = createElement("a");
        link.href = URL.createObjectURL(blob);
        link.download = `${info.title}_封面.jpg`;
        document.body.appendChild(link);
        link.click();
        document.body.removeChild(link);
        URL.revokeObjectURL(link.href);
        this.showMessage("封面下载成功", "success");
      } catch (error) {
        this.showMessage("封面下载失败", "error");
        console.error("封面下载错误:", error);
      }
    }

    // 下载音频
    async downloadAudio(btn) {
      if (this.currentTask) {
        this.currentTask.abort();
        this.currentTask = null;
        btn.textContent = "音频";
        btn.classList.remove("downloading");
        return;
      }
      const info = this.getVideoInfo();
      if (!info) {
        this.showMessage("无法获取视频信息", "error");
        return;
      }

      btn.textContent = "准备中...";
      btn.classList.add("downloading");
      try {
        const audioUrl = `https://api.bilibili.com/x/player/playurl?avid=${info.aid}&bvid=${info.bvid}&cid=${info.cid}&fnval=4048`;
        const response = await fetch(audioUrl);
        const data = await response.json();
        if (data.code !== 0 || !data.data?.dash?.audio?.[0]) {
          throw new Error("无法获取音频信息");
        }
        const audioInfo = data.data.dash.audio[0];
        const xhr = new XMLHttpRequest();
        this.currentTask = xhr;
        xhr.responseType = "blob";
        xhr.open("GET", audioInfo.base_url, true);
        xhr.onprogress = (event) => {
          if (event.lengthComputable) {
            const percent = Math.round((event.loaded / event.total) * 100);
            btn.textContent = `下载中 ${percent}%`;
          }
          xhr.onload = () => {
            if (xhr.status === 200) {
              const blob = xhr.response;
              const link = createElement("a");
              link.href = URL.createObjectURL(blob);
              link.download = `${info.title}.mp3`;
              document.body.appendChild(link);
              link.click();
              document.body.removeChild(link);
              URL.revokeObjectURL(link.href);
              btn.textContent = "音频";
              btn.classList.remove("downloading");
              btn.classList.add("success");
              setTimeout(() => btn.classList.remove("success"), 2000);
              this.showMessage("音频下载成功", "success");
            }
            this.currentTask = null;
          };
          xhr.onerror = () => {
            btn.textContent = "下载失败";
            btn.classList.remove("downloading");
            btn.classList.add("error");
            setTimeout(() => {
              btn.textContent = "音频";
              btn.classList.remove("error");
            }, 2000);
            this.currentTask = null;
          };
          xhr.send();
        };
      } catch (error) {
        console.error("音频下载错误:", error);
        btn.textContent = "下载失败";
        btn.classList.remove("downloading");
        btn.classList.add("error");
        setTimeout(() => {
          btn.textContent = "音频";
          btn.classList.remove("error");
        }, 2000);
        this.showMessage("音频下载失败", "error");
      }
    }

    // 下载视频
    async downloadVideo(btn) {
      const info = this.getVideoInfo();
      if (!info) {
        this.showMessage("无法获取视频信息", "error");
        return;
      }

      const quality = GM_getValue(STORAGE_KEYS.QUALITY, 80);
      btn.textContent = "准备中...";
      btn.disabled = true;
      try {
        // 这里可以使用我们自己的视频解析服务
        const videoUrl = `https://api.bilibili.com/x/player/playurl?cid=${info.cid}&bvid=${info.bvid}&qn=${quality}`;
        const response = await fetch(videoUrl);
        const data = await response.json();
        if (data.code !== 0) {
          throw new Error("无法获取视频地址");
        }
        this.showMessage("正在获取视频链接...", "info");
        // 实际项目中这里应该处理视频流
        // 这里简化处理,实际使用时需要完整实现
        window.open(
          `https://your-video-service.com/download?url=${encodeURIComponent(
            window.location.href
          )}`
        );
      } catch (error) {
        console.error("视频下载错误:", error);
        this.showMessage("视频下载失败,请稍后重试", "error");
      } finally {
        btn.textContent = "视频";
        btn.disabled = false;
      }
    }

    // 显示消息
    showMessage(message, type = "info") {
      const messageEl = createElement("div", `bili-downloader-message ${type}`);
      messageEl.textContent = message;
      messageEl.style.cssText = `
          position: fixed;
          top: 20px;
          right: 20px;
          padding: 12px 20px;
          background: ${
            type === "error"
              ? "#ff3b30"
              : type === "success"
              ? "#34c759"
              : "#007aff"
          };
          color: white;
          border-radius: 8px;
          z-index: 10001;
          animation: slideInRight 0.3s ease;
        `;
      document.body.appendChild(messageEl);
      setTimeout(() => {
        if (messageEl.parentNode) {
          messageEl.parentNode.removeChild(messageEl);
        }
      }, 3000);
    }

    // 渲染用户界面
    renderUI() {
      const container = $(".video-data") || $(".video-info-detail-list");
      if (!container) {
        console.warn("找不到视频信息容器");
        return;
      }
      // 渲染设置按钮
      this.renderSettingsButton(container);
      // 渲染功能按钮
      this.renderFunctionButtons(container);
    }

    // 渲染设置按钮
    renderSettingsButton(container) {
      if ($(".bili-downloader-settings-btn")) return;
      const settingsBtn = createElement(
        "button",
        "bili-downloader-btn bili-downloader-settings-btn"
      );
      settingsBtn.textContent = "设置";
      settingsBtn.title = "B站下载助手设置";
      settingsBtn.addEventListener("click", () => this.showSettingsDialog());
      container.appendChild(settingsBtn);
    }

    // 渲染功能按钮
    renderFunctionButtons(container) {
      // 移除旧按钮
      $$(".bili-downloader-func-btn").forEach((btn) => btn.remove());
      // 封面按钮
      if (GM_getValue(STORAGE_KEYS.COVER, true)) {
        const coverBtn = createElement(
          "button",
          "bili-downloader-btn bili-downloader-func-btn"
        );
        coverBtn.textContent = "封面";
        coverBtn.addEventListener("click", () => this.downloadCover());
        container.appendChild(coverBtn);
      }

      // 音频按钮
      if (GM_getValue(STORAGE_KEYS.AUDIO, true)) {
        const audioBtn = createElement(
          "button",
          "bili-downloader-btn bili-downloader-func-btn"
        );
        audioBtn.textContent = "音频";
        audioBtn.addEventListener("click", () => this.downloadAudio(audioBtn));
        container.appendChild(audioBtn);
      }

      // 视频按钮
      if (GM_getValue(STORAGE_KEYS.VIDEO, true)) {
        const videoBtn = createElement(
          "button",
          "bili-downloader-btn bili-downloader-func-btn"
        );
        videoBtn.textContent = "视频";
        videoBtn.addEventListener("click", () => this.downloadVideo(videoBtn));
        container.appendChild(videoBtn);
      }
    }

    // 显示设置对话框
    showSettingsDialog() {
      // 移除现有对话框
      $(".bili-downloader-dialog-overlay")?.remove();
      $(".bili-downloader-dialog")?.remove();

      const overlay = createElement("div", "bili-downloader-dialog-overlay");
      const dialog = createElement("div", "bili-downloader-dialog");

      dialog.innerHTML = `
              <div class="bili-downloader-header">
                  <div class="bili-downloader-title">${CONFIG.scriptName}</div>
                  <button class="bili-downloader-close">&times;</button>
              </div>
              <div class="bili-downloader-content">
                  <div class="bili-downloader-option">
                      <label>启用封面下载</label>
                      <input type="checkbox" id="cover-enabled" ${
                        GM_getValue(STORAGE_KEYS.COVER, true) ? "checked" : ""
                      }>
                  </div>
                  <div class="bili-downloader-option">
                      <label>启用音频下载</label>
                      <input type="checkbox" id="audio-enabled" ${
                        GM_getValue(STORAGE_KEYS.AUDIO, true) ? "checked" : ""
                      }>
                  </div>
                  <div class="bili-downloader-option">
                      <label>启用视频下载</label>
                      <input type="checkbox" id="video-enabled" ${
                        GM_getValue(STORAGE_KEYS.VIDEO, true) ? "checked" : ""
                      }>
                  </div>
                  <div class="bili-downloader-option">
                      <label>默认画质</label>
                      <select class="bili-downloader-quality-select" id="quality-select">
                          ${QUALITY_OPTIONS.map(
                            (opt) =>
                              `<option value="${opt.value}" ${
                                GM_getValue(STORAGE_KEYS.QUALITY, 80) ===
                                opt.value
                                  ? "selected"
                                  : ""
                              }>${opt.label}</option>`
                          ).join("")}
                      </select>
                  </div>
              </div>
          `;

      overlay.appendChild(dialog);
      document.body.appendChild(overlay);

      // 关闭按钮事件
      $(".bili-downloader-close", dialog).addEventListener("click", () => {
        overlay.remove();
      });

      // 点击遮罩关闭
      overlay.addEventListener("click", (e) => {
        if (e.target === overlay) {
          overlay.remove();
        }
      });

      // 设置项变更事件
      $("#cover-enabled", dialog).addEventListener("change", (e) => {
        GM_setValue(STORAGE_KEYS.COVER, e.target.checked);
        this.refreshButtons();
      });

      $("#audio-enabled", dialog).addEventListener("change", (e) => {
        GM_setValue(STORAGE_KEYS.AUDIO, e.target.checked);
        this.refreshButtons();
      });

      $("#video-enabled", dialog).addEventListener("change", (e) => {
        GM_setValue(STORAGE_KEYS.VIDEO, e.target.checked);
        this.refreshButtons();
      });

      $("#quality-select", dialog).addEventListener("change", (e) => {
        GM_setValue(STORAGE_KEYS.QUALITY, parseInt(e.target.value));
      });
    }

    // 刷新按钮
    refreshButtons() {
      const container = $(".video-data") || $(".video-info-detail-list");
      if (container) {
        this.renderFunctionButtons(container);
      }
    }

    // 检查更新
    async checkUpdate() {
      try {
        const response = await fetch(CONFIG.updateUrl + "?t=" + Date.now());
        const text = await response.text();

        // 简化的版本检查逻辑
        const versionMatch = text.match(/@version\s+([\d.]+)/);
        if (versionMatch && versionMatch[1] !== CONFIG.version) {
          this.showMessage(`发现新版本 ${versionMatch[1]},请前往更新`, "info");
        }
      } catch (error) {
        console.log("检查更新失败:", error);
      }
    }
  }

  // 初始化脚本
  let downloader;

  const initScript = () => {
    if (!downloader) {
      downloader = new BiliDownloader();
    }
  };

  // 页面加载完成后初始化
  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", initScript);
  } else {
    initScript();
  }

  // 监听URL变化(单页应用)
  let lastUrl = location.href;
  new MutationObserver(() => {
    const url = location.href;
    if (url !== lastUrl) {
      lastUrl = url;
      setTimeout(initScript, 1000);
    }
  }).observe(document, { subtree: true, childList: true });
})();