网页脚本 bilibili003:字幕展示功能的脚本说明(笔记)

油猴脚本模块

  • 油猴脚本(Tampermonkey Script)通常由以下几个模块组成:

元信息注释:

  • 这一部分包含了脚本的基本信息,如名称、版本、描述、作者等。
// ==UserScript==
// @name         脚本名称
// @namespace    命名空间
// @version      版本号
// @description  描述脚本功能
// @author       作者
// @match        匹配的页面地址
// @grant        需要的权限  https://www.tampermonkey.net/documentation.php#meta:grant
// ==/UserScript==

权限:

指令 描述
@grant none 脚本不需要任何额外的权限,只能访问常规的油猴 API。这通常用于简单的脚本,不需要执行敏感操作或访问用户敏感信息。 默认的情况下,你的脚本运行在油猴给你创建的一个沙盒环境(沙盒机制的前世今生)下,这个沙河环境无法访问到前端的页面,也就无法操作前端的一些元素等.如果在页面最前方声明:“//@grant none”,那么油猴就会将你的脚本直接放在网页的上下文中执行,这是的脚本上下文(window)就是前端的上下文.但是这样的话就无法使用GM_*等函数,无法与油猴交互,使用一些更强的功能.
@grant GM_addStyle 表示脚本需要使用 GM_addStyle 函数,用于向页面添加自定义样式。如果脚本中使用了自定义样式,就需要声明这个权限。
@grant GM_setValue 允许脚本使用 GM_setValue 函数,用于在脚本之间存储数据。
@grant GM_getValue 允许脚本使用 GM_getValue 函数,用于在脚本之间检索存储的数据。
@grant GM.setValue GM_setValue,不同的写法。
@grant GM.getValue GM_getValue,不同的写法。
GM_xmlhttpRequest 表示脚本需要使用 GM_xmlhttpRequest 函数,用于发起跨域请求。如果脚本需要从其他域获取数据,就需要声明这个权限。
@grant GM_setClipboard 允许脚本使用 GM_setClipboard 函数,用于设置剪贴板内容。
@grant unsafeWindow 允许脚本访问 unsafeWindow 对象,这个对象提供了对页面 JavaScript 上下文的完全访问。
@grant window.close 允许脚本使用 window.close 函数,用于关闭当前窗口或标签。
@grant window.focus 允许脚本使用 window.focus 函数,用于使当前窗口或标签获得焦点。
@grant window.onurlchange 允许脚本使用 window.onurlchange,虽然这个权限名在油猴脚本中并不存在。
  • 检测是否有权限的例子
if (!GM_xmlhttpRequest) {
	alert('请升级到最新版本的 Greasemonkey.');
	return;
}

样式定义:

  • 使用 GM_addStyle 函数为页面添加一些自定义样式,用于美化页面或调整特定元素的样式
    GM_addStyle(`
        // 自定义样式的定义
        // ...
    `);
    

代码解释

  • 这个脚本的功能是在哔哩哔哩(Bilibili)视频页面上显示视频的字幕

元信息注释模块:

// ==UserScript==
// @name         在侧边显示 Bilibili 视频字幕/文稿
// @name:en      Show transcript of Bilibili video on the side
// @version      1.1.1
// @description:en  Automatically display Bilibili video subtitles/scripts by default, support click to jump, text selection, auto-scrolling.
// @description     默认自动显示Bilibili视频字幕/文稿,支持点击跳转、文本选中、自动滚动。
// @namespace    https://bilibili.com/
// @match        https://www.bilibili.com/video/*
// @icon         https://www.bilibili.com/favicon.ico
// @author       bowencool
// @license      MIT
// @homepageURL  https://greasyfork.org/scripts/482165
// @supportURL   https://github.com/bowencool/Tampermonkey-Scripts/issues
// @grant        GM_addStyle
// @downloadURL https://update.greasyfork.org/scripts/482165/%E5%9C%A8%E4%BE%A7%E8%BE%B9%E6%98%BE%E7%A4%BA%20Bilibili%20%E8%A7%86%E9%A2%91%E5%AD%97%E5%B9%95%E6%96%87%E7%A8%BF.user.js
// @updateURL https://update.greasyfork.org/scripts/482165/%E5%9C%A8%E4%BE%A7%E8%BE%B9%E6%98%BE%E7%A4%BA%20Bilibili%20%E8%A7%86%E9%A2%91%E5%AD%97%E5%B9%95%E6%96%87%E7%A8%BF.meta.js
// ==/UserScript==

等待元素加载的函数

  • 这个函数用于等待页面中指定选择器的元素加载完成。它返回一个 Promise,当元素加载完成时,Promise 解析。
function waitForElementToExist(selector) {
  // 返回一个 Promise 对象
  return new Promise((resolve) => {
    // 如果文档中已经存在指定选择器的元素
    if (document.querySelector(selector)) {
      // 直接解析 Promise,将找到的元素作为解析值
      return resolve(document.querySelector(selector));
    }

    // 如果元素尚未存在,使用 MutationObserver 监听文档变化
    const observer = new MutationObserver(() => {
      // 当找到指定选择器的元素时
      if (document.querySelector(selector)) {
        // 解析 Promise,并传递找到的元素作为解析值
        resolve(document.querySelector(selector));
        // 停止监听文档变化
        observer.disconnect();
      }
    });

    // 开始监听文档变化,监测子节点的添加或删除
    observer.observe(document.body, {
      subtree: true,
      childList: true,
    });
  });
}

发起请求的函数(重要)

  • 这个函数使用 fetch 函数向指定的 URL 发起请求,并处理响应,返回解析后的数据。
async function request(url, options) {
// 使用 fetch 发起请求的逻辑
return fetch(`https://api.bilibili.com${url}`, {
 ...options,
 credentials: "include",
})
 .then((res) => res.json())
 .then((data) => {
   if (data.code != 0) {
     throw new Error(data.message);
   }
   return data.data;
 });
}

sleep

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

样式定义

GM_addStyle(`
.transcript-box {
  border: 1px solid #e1e1e1;
  border-radius: 6px;
  padding: 12px 16px;
  max-height: 50vh;
  overflow: scroll;
  margin-bottom: 20px;
  pointer-events: initial;
}
.transcript-line {
    display: flex;
}
.transcript-line:hover {
  background-color: #0002;
}
.transcript-line.active {
  font-weight: bold;
  background-color: #0002;
}

.transcript-line-time {
    flex: none;
    overflow: hidden;
    width:66px;
    user-select: none;
    corsur: pointer;
    color: var(--bpx-fn-hover-color,#00b5e5);
}

.transcript-line-content {
    // white-space: nowrap;
}

`);

工具函数模块:

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function fixNumber(n) {
    // 数字格式化的逻辑
    // ...
}
function parseTime(t) {
    // 时间解析的逻辑
    // ...
}

const MUSIC_FILTER_RATE = 0.85;

function fixNumber(n) {
return n.toLocaleString("en-US", {
 minimumIntegerDigits: 2,
 useGrouping: false,
});
}

function parseTime(t) {
t = parseInt(t);
return `${fixNumber(parseInt(t / 60))}:${fixNumber(t % 60)}`;
}

这一模块包含一些用于处理通用任务的工具函数,如睡眠函数、数字格式化、时间解析等。

showTranscript(重要)

  • 这里才最终得到了字幕

网页脚本 bilibili003:字幕展示功能的脚本说明(笔记)_第1张图片

const transcriptBox = document.createElement("div");
transcriptBox.className = "transcript-box";
# 这里才最终得到了字幕
async function showTranscript(subtitleInfo) {
  console.log("showTranscript", subtitleInfo);
  const { body: lines } = await fetch(
    subtitleInfo.subtitle_url.replace(/^\/\//, "https://")
  ).then((res) => res.json());
  
  console.log("lines", lines);
  transcriptBox.innerHTML = "";
  for (let line of lines) {
    if (line.music && line.music > MUSIC_FILTER_RATE) {
      continue;
    }
    let timeLink = document.createElement("a");
    timeLink.className = "transcript-line-time";
    // timeLink.setAttribute("data-index", line.index);
    timeLink.textContent = parseTime(line.from);
    timeLink.addEventListener("click", () => {
      video.currentTime = line.from;
    });
    let lineDiv = document.createElement("div");
    lineDiv.className = "transcript-line";
    lineDiv.setAttribute("data-from", line.from);
    lineDiv.setAttribute("data-to", line.to);
    lineDiv.appendChild(timeLink);
    let span = document.createElement("span");
    span.className = "transcript-line-content";
    span.textContent = line.content;

    lineDiv.appendChild(span);
    transcriptBox.appendChild(lineDiv);
  }
}

getBvid

  • 例子: “https://www.bilibili.com/video/BV16v421i7z3?p=2”,调用 getBvid() 将返回 { bvid: ‘BV16v421i7z3’, curPage: 1 }
  • 此函数会被getTranscript和路由调用
function getBvid(route /* : string|undefined */) {
  let url;
  if (route) {
    url = new URL(window.location.origin + route);// new URL(...) 是 JavaScript 中用于创建 URL 对象的构造函数。这个构造函数允许你方便地操作和解析 URL 的各个部分
  } else {
    url = new URL(window.location.href);
  }
  const bvid = url.pathname.match(/\/video\/(\w+)/)?.[1];
  // if (!bvid) throw new Error("没有找到 bvid");
  let curPage = url.searchParams.get("p") - 1;
  if (!curPage || curPage == -1) {
    curPage = 0;
  }
  return { bvid, curPage };
}

getTranscript(重要)

接口说明 videoInfo

  • http://api.bilibili.com/x/web-interface/view?bvid=%s

  • 例如:https://api.bilibili.com/x/web-interface/view?bvid=BV1pt421a7gF,返回

{
    "code": 0,
    "message": "0",
    "ttl": 1,
    "data": {
        "bvid": "BV1pt421a7gF",
        "aid": 1800515935,
        "videos": 1,
        "tid": 207,
        "tname": "财经商业",
        "copyright": 1,
        "pic": "http://i2.hdslb.com/bfs/archive/c4710709e7afe5e13bc36c11e59e06fccff08ddf.jpg",
        "title": "春晚赞助商兴衰,见证中国商业变迁",
        "pubdate": 1707635876,
        "ctime": 1707635876,
        "desc": "京东五粮液是今年春晚的主角,没办法,毕竟赞助商给的钱太多了。站在商业博主视角,盘点一下春晚赞助商。首先要说,央视春晚真的是太赚钱了,成本低,赞助多,利润高。央视舞台演员们抢着上,片酬很低,赵本山冯巩刘谦也才几千块钱,但是冠名费一亿起。不管你看不看,这钱是必须赚。",
        "desc_v2": [
            {
                "raw_text": "京东五粮液是今年春晚的主角,没办法,毕竟赞助商给的钱太多了。站在商业博主视角,盘点一下春晚赞助商。首先要说,央视春晚真的是太赚钱了,成本低,赞助多,利润高。央视舞台演员们抢着上,片酬很低,赵本山冯巩刘谦也才几千块钱,但是冠名费一亿起。不管你看不看,这钱是必须赚。",
                "type": 1,
                "biz_id": 0
            }
        ],
        "state": 0,
        "duration": 127,
        "mission_id": 4008109,
        "rights": {
            "bp": 0,
            "elec": 0,
            "download": 1,
            "movie": 0,
            "pay": 0,
            "hd5": 1,
            "no_reprint": 1,
            "autoplay": 1,
            "ugc_pay": 0,
            "is_cooperation": 0,
            "ugc_pay_preview": 0,
            "no_background": 0,
            "clean_mode": 0,
            "is_stein_gate": 0,
            "is_360": 0,
            "no_share": 0,
            "arc_pay": 0,
            "free_watch": 0
        },
        "owner": {
            "mid": 1196901933,
            "name": "娄哥商业创业",
            "face": "https://i0.hdslb.com/bfs/face/7ddad6531f47e567aab7c4fa7df306c938944f97.jpg"
        },
        "stat": {
            "aid": 1800515935,
            "view": 24956,
            "danmaku": 4,
            "reply": 47,
            "favorite": 24,
            "coin": 10,
            "share": 3,
            "now_rank": 0,
            "his_rank": 0,
            "like": 272,
            "dislike": 0,
            "evaluation": "",
            "vt": 0
        },
        "argue_info": {
            "argue_msg": "",
            "argue_type": 0,
            "argue_link": ""
        },
        "dynamic": "",
        "cid": 1436874734,
        "dimension": {
            "width": 1920,
            "height": 1080,
            "rotate": 0
        },
        "premiere": null,
        "teenage_mode": 0,
        "is_chargeable_season": false,
        "is_story": false,
        "is_upower_exclusive": false,
        "is_upower_play": false,
        "is_upower_preview": false,
        "enable_vt": 0,
        "vt_display": "",
        "no_cache": false,
        "pages": [
            {
                "cid": 1436874734,
                "page": 1,
                "from": "vupload",
                "part": "春晚赞助商兴衰,见证中国商业变迁",
                "duration": 127,
                "vid": "",
                "weblink": "",
                "dimension": {
                    "width": 1920,
                    "height": 1080,
                    "rotate": 0
                },
                "first_frame": "http://i1.hdslb.com/bfs/storyff/n240211sasoz7kgbz9p3a1l6u6dl7zlj_firsti.jpg"
            }
        ],
        "subtitle": {
            "allow_submit": false,
            "list": [
                {
                    "id": 1421746887056598800,
                    "lan": "ai-zh",
                    "lan_doc": "中文(自动生成)",
                    "is_lock": false,
                    "subtitle_url": "",
                    "type": 1,
                    "id_str": "1421746887056598784",
                    "ai_type": 0,
                    "ai_status": 2,
                    "author": {
                        "mid": 0,
                        "name": "",
                        "sex": "",
                        "face": "",
                        "sign": "",
                        "rank": 0,
                        "birthday": 0,
                        "is_fake_account": 0,
                        "is_deleted": 0,
                        "in_reg_audit": 0,
                        "is_senior_member": 0
                    }
                }
            ]
        },
        "is_season_display": false,
        "user_garb": {
            "url_image_ani_cut": ""
        },
        "honor_reply": {},
        "like_icon": "",
        "need_jump_bv": false,
        "disable_show_up_info": false,
        "is_story_play": 1
    }
}

接口说明 videoInfo

  • https://api.bilibili.com/x/player/v2?aid=1800515935&cid=1436874734 会返回一个带有私人信息和"subtitle_url": “//aisubtitle.hdslb.com/bfs/ai_subtitle/prod/18005********?auth_key=*********************”,的对象

代码解读

async function getTranscript(route /* : string|undefined */) {
  const { bvid, curPage } = getBvid(route);
  if (!bvid) throw new Error("没有找到 bvid");
  const videoInfo = await request("/x/web-interface/view?bvid=" + bvid);
  const {
    subtitle: { subtitles = [] },
  } = await request(
    `/x/player/v2?aid=${videoInfo.aid}&cid=${videoInfo.pages[curPage].cid}`
  );
  console.log("subtitles", subtitles);
  transcriptBox.innerHTML = "没有字幕";
  if (subtitles.length == 0) throw new Error("没有字幕");
  return subtitles;
}

在这里插入图片描述

main

async function main() {
  "use strict";
  const subtitles = await getTranscript();

  // B站页面是SSR(服务器端渲染Server-Side Rendering)的,如果插入过早,页面 js 检测到实际 Dom 和期望 Dom 不一致,会导致重新渲染
  await waitForElementToExist("img.bili-avatar-img");
  const video = await waitForElementToExist("video");
  // const oldfanfollowEntry = await waitForElementToExist("#oldfanfollowEntry");

  // 为DOM 元素注册事件处理函数:
  // 当 video 元素触发 timeupdate 事件(视频时间更新时)时,字幕的高亮显示。
  video.addEventListener("timeupdate", () => {
    const currentTime = video.currentTime;
    const lastActiveLine = document.querySelector(".transcript-line.active");
    const lineBoxes = lastActiveLine
      ? [lastActiveLine, lastActiveLine.nextSibling]
      : document.querySelectorAll(".transcript-line");

    for (let i = 0; i < lineBoxes.length; i++) {
      const currentLine = lineBoxes[i];
      const from = +currentLine.getAttribute("data-from");
      const to = +currentLine.getAttribute("data-to");
      // console.log({ i, from, to, currentTime }, currentLine);
      if (currentTime >= to || currentTime <= from) {
        // Remove the 'active' class
        if (currentLine.classList.contains("active")) {
          currentLine.classList.remove("active");
        }
      }
      if (currentTime > from && currentTime < to) {
        const targetPosition =
          currentLine.offsetTop - transcriptBox.clientHeight * 0.5;
        transcriptBox.scrollTo(0, targetPosition);
        // Add the 'active' class to the current line
        currentLine.classList.add("active");
        break;
      }
    }
  });
  await showTranscript(subtitles[0]);
  const danmukuBox = await waitForElementToExist("#danmukuBox");
  // B站页面是SSR的,如果插入过早,页面 js 检测到实际 Dom 和期望 Dom 不一致,会导致重新渲染
  danmukuBox.parentNode.insertBefore(transcriptBox, danmukuBox);
}

updateTranscript

async function updateTranscript(route /* : string|undefined */) {
  const subtitles = await getTranscript(route);
  await showTranscript(subtitles[0]);
}

调用main函数

main();

路由监听并调用:

  • 这个函数用于监听浏览器历史变化和页面路由变化,当路由发生变化时,触发相应的处理逻辑。
function getCurrentState(route) {
  const { bvid, curPage } = getBvid(route);
  return `${bvid}?p=${curPage}`;
}
let lastState = getCurrentState();
traceRoute();

function traceRoute() {
  // popstate 可以监测到 hashchange
  window.addEventListener("popstate", (evt) => {
    const to = getCurrentState();
    if (to !== lastState) {
      console.log("bvid changed when popstate", lastState, to);
      updateTranscript();
    }
  });
  let theHistory /* History */ = history || window.history;
  if (!theHistory) return;

  const replacement = (originFn /* History['pushState'] */) => {
    return (data /* any */, t /* string */, route /* string | undefined */) => {
      const from = getCurrentState();
      const to = getCurrentState(route);
      if (route && from !== to) {
        console.log("bvid changed when pushState", from, to, route);
        updateTranscript(route);
      }
      const ret = originFn.call(theHistory, data, t, route);
      if (to) {
        lastState = to;
      }
      return ret;
    };
  };
  overrideMethod(
    /*  */ theHistory,
    "pushState",
    replacement
  );
  overrideMethod(
    /*  */ theHistory,
    "replaceState",
    replacement
  );
}
function overrideMethod /*  */(
  target /* : { [key: string]: any } */,
  key /* : string */,
  replacement /* : (f: F) => F */
) {
  if (!(key in target)) return;
  const originFn /* : F */ = target[key];
  const wrapped /* : F */ = replacement(originFn);
  if (wrapped instanceof Function) {
    target[key] = wrapped;
  }
}

CG

  • https://www.gitbook.com/?

  • https://www.appinn.com/category/android/

  • 如何开发一个油猴脚本- 从零开始编写一个油猴脚本

  • 油猴安装、编写及添加脚本 笔记

  • 油猴开发指南

  • 深入浅出 Greasemonkey

  • 最经常遇到的麻烦之一是在用户脚本里定义的变量和函数不能被别的脚本访问。事实上,只要用户脚本运行完了,所有的变量和函数就都不能使用了。如果您需要引用用户脚本中的变量或者函数,应该显式的把它们定义为window对象的属性,它是始终存在的。

window.helloworld = function() {
	alert('Hello world!');
	}

window.setTimeout("helloworld()", 60);
// 目的达到了!页面完成加载一秒后,一个提示框骄傲的弹了出来,写着:“Hello world!”
// 然而,这有点像用全局变量来做局部变量该做的事。最佳的解决方案是定义匿名函数,把它作为第一个参数传递给 window.setTimeout。

你可能感兴趣的:(硬件和移动端,笔记)