B站一键填充
BV码和标题

复制 B 站视频 BV 码与标题,创作中心一次粘贴自动填入两个输入框

作者:小丁猫同学(Garry Jung)

一键抓取 B 站视频 BV 码与标题,跳转创作投稿页面,单次点击自动把内容分别填入两个输入框。告别来回切换、手动复制粘贴的繁琐。

免费开源
无数据上传
Chrome / Edge / Firefox
bilibili.com/video/BV133411V714
步骤 1 / 2 · 视频页复制
学了三年动画,动画做成这样能毕业么?《十点五十九》动画片段。
BV133411V714
BV 号
视频标题
已自动填充两个输入框
已复制 BV 码 + 标题
CORE FEATURES

核心功能

做二创、搬视频、整理素材时,BV 码和标题要分别复制、分别粘贴,来回切换页面烦不胜烦。喵贴把这两步合成一次点击。

一键复制

在 B 站视频播放页,一键同时抓取 BV 码和视频标题,写入系统剪贴板。

智能双填

跳转创作中心后,单次触发自动识别两个输入框,分别填入 BV 码和标题。

纯本地运行

所有操作在浏览器本地完成,不连接任何服务器,不上传你的浏览数据。

持续适配

跟进 B 站页面 DOM 变动,大版本改版后快速更新,保持脚本稳定可用。

INSTALL

安装 BiliClip 喵贴

从安装脚本管理器到开始使用,只需不到一分钟。

安装步骤

1

安装脚本管理器

在浏览器扩展商店安装 Tampermonkey 或 Violentmonkey(暴力猴)

2

新建脚本,粘贴代码

复制右侧 v7.2.1 脚本源码,替换编辑器全部内容,Ctrl+S 保存

3

刷新 B 站页面

打开 B 站视频页,喵贴悬浮按钮出现即表示脚本已生效

v7.2.1 脚本源码预览

// ==UserScript==
// @name         BiliClip 喵贴  B站一键填充BV码和标题
// @namespace    https://www.bilibili.com/
// @version      7.2.1
// @description  复制B站视频BV码和标题,在创作中心按一次粘贴自动填入两个输入框。
// @author       Garry Jung
// @match        http://*/*
// @match        https://*/*
// @run-at       document-start
// @grant        GM_setClipboard
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_addValueChangeListener
// @grant        GM_xmlhttpRequest
// @connect      api.bilibili.com
// ==/UserScript==
(function () {
  'use strict';
  const KEY = 'biliclip_pair_v72';
  const isBili =
    /(^|\.)bilibili\.com$/i.test(location.hostname);
  const isMember =
    /(^|\.)member\.bilibili\.com$/i.test(location.hostname);
  let pair = validPair(
    GM_getValue(KEY, null)
  );
  let step = 0;
  let ui = null;
  let writingClipboard = false;
  let reminderKey = '';
  GM_addValueChangeListener(
    KEY,
    (_key, _old, value) => {
      pair = validPair(value);
      step = 0;
      if (pair) {
        setTimeout(
          checkReminder,
          500
        );
      }
    }
  );
  document.addEventListener(
    'keydown',
    onPaste,
    true
  );
  document.addEventListener(
    'copy',
    () => {
      if (!writingClipboard) {
        clearPair();
      }
    },
    true
  );
  document.addEventListener(
    'cut',
    () => {
      if (!writingClipboard) {
        clearPair();
      }
    },
    true
  );
  document.addEventListener(
    'keydown',
    event => {
      if (
        pair &&
        (event.ctrlKey || event.metaKey) &&
        ['c', 'x'].includes(
          event.key.toLowerCase()
        )
      ) {
        clearPair();
      }
    },
    true
  );
  ready(() => {
    if (pair) {
      checkReminder();
    }
    if (isBili && !isMember) {
      createBiliUI();
      setInterval(
        createBiliUI,
        1500
      );
    }
  });
  setInterval(
    checkReminder,
    1000
  );
  function ready(callback) {
    if (
      document.readyState ===
      'loading'
    ) {
      document.addEventListener(
        'DOMContentLoaded',
        callback,
        { once: true }
      );
    } else {
      callback();
    }
  }
  function clean(value) {
    return String(value || '')
      .replace(/\s+/g, ' ')
      .trim();
  }
  function validPair(value) {
    if (
      !value ||
      typeof value !== 'object'
    ) {
      return null;
    }
    const bv = clean(value.bv);
    const title = clean(value.title);
    return (
      /^BV[0-9A-Za-z]{10}$/.test(bv) &&
      title
    )
      ? {
          bv,
          title,
          savedAt:
            Number(value.savedAt) || 0
        }
      : null;
  }
  function savePair(bv, title) {
    const value = {
      bv,
      title: clean(title),
      savedAt: Date.now()
    };
    GM_setValue(KEY, value);
    pair = validPair(value);
    step = 0;
    writeClipboard(bv);
  }
  function clearPair() {
    if (!pair) return;
    pair = null;
    step = 0;
    GM_setValue(KEY, null);
  }
  function writeClipboard(text) {
    writingClipboard = true;
    try {
      GM_setClipboard(
        text,
        'text'
      );
    } catch (_) {
      navigator.clipboard
        ?.writeText(text)
        .catch(() => {});
    }
    setTimeout(() => {
      writingClipboard = false;
    }, 200);
  }
  /* ==================== 粘贴与自动填充 ==================== */
 function onPaste(event) {
  // 只允许 B 站页面接管粘贴
  if (!isBili) return;
  if (!pair) return;
    if (
      !event.ctrlKey &&
      !event.metaKey
    ) {
      return;
    }
    if (
      event.altKey ||
      event.shiftKey ||
      event.repeat
    ) {
      return;
    }
    if (
      event.key.toLowerCase() !==
      'v'
    ) {
      return;
    }
    const fields =
      findCreatorFields();
    if (fields) {
      event.preventDefault();
      event.stopImmediatePropagation();
      const current = pair;
      const bvDone =
        replaceValue(
          fields.bv,
          current.bv
        );
      const titleDone =
        replaceValue(
          fields.title,
          current.title
        );
      if (
        bvDone &&
        titleDone
      ) {
        fields.title.focus();
        clearPair();
        toast(
          '✓ 已自动填充「关联视频ID/链接」和「描述文案」',
          'blue',
          2200
        );
      } else {
        toast(
          '自动填充失败,请重新打开关联视频后再试',
          'pink',
          2300
        );
      }
      return;
    }
    if (isCreatorPage()) {
      event.preventDefault();
      event.stopImmediatePropagation();
      toast(
        '请先打开「关联视频」,等两个输入框出现后再粘贴',
        'pink',
        2300
      );
      return;
    }
    const editor =
      getEventEditor(event);
    if (!editor) return;
    const text =
      step === 0
        ? pair.bv
        : pair.title;
    event.preventDefault();
    event.stopImmediatePropagation();
    if (
      !insertAtCursor(
        editor,
        text
      )
    ) {
      return;
    }
    step =
      step === 0
        ? 1
        : 0;
    writeClipboard(
      step === 0
        ? pair.bv
        : pair.title
    );
    toast(
      step === 1
        ? '✓ BV 已粘贴 · 下次标题'
        : '✓ 标题已粘贴 · 下次 BV'
    );
  }
  function isCreatorPage() {
    if (isMember) {
      return true;
    }
    const heading =
      document.querySelector(
        'h1, [class*="page-title"]'
      );
    const pageText = clean(
      `${document.title} ${
        heading?.textContent || ''
      }`
    );
    return /创作中心/.test(pageText);
  }
  function checkReminder() {
    if (
      !pair ||
      !document.body ||
      !isCreatorPage()
    ) {
      return;
    }
    const key =
      `${pair.savedAt}:${location.href}`;
    if (key === reminderKey) {
      return;
    }
    reminderKey = key;
    toast(
      '已复制 BV + 标题|打开「关联视频」后按 Ctrl+V,自动填充两个输入框',
      'blue',
      4300
    );
  }
  /* ==================== 输入框识别 ==================== */
  function visibleEditors() {
    return [
      ...document.querySelectorAll(
        [
          'input',
          'textarea',
          '[contenteditable="true"]',
          '[contenteditable=""]',
          '[role="textbox"]'
        ].join(',')
      )
    ].filter(editor => {
      if (
        editor instanceof
        HTMLInputElement
      ) {
        if (
          !/^(text|search|url|tel|email)$/i
            .test(
              editor.type || 'text'
            )
        ) {
          return false;
        }
        if (
          editor.disabled ||
          editor.readOnly
        ) {
          return false;
        }
      }
      if (
        editor instanceof
          HTMLTextAreaElement &&
        (
          editor.disabled ||
          editor.readOnly
        )
      ) {
        return false;
      }
      const rect =
        editor.getBoundingClientRect();
      const style =
        getComputedStyle(editor);
      return (
        rect.width > 30 &&
        rect.height > 10 &&
        style.display !== 'none' &&
        style.visibility !== 'hidden' &&
        style.opacity !== '0'
      );
    });
  }
  function fieldText(editor) {
    const parts = [
      editor.getAttribute('placeholder'),
      editor.getAttribute('aria-label'),
      editor.getAttribute('title'),
      editor.getAttribute('name'),
      editor.id
    ];
    if (editor.id) {
      const label =
        document.querySelector(
          `label[for="${
            CSS.escape(editor.id)
          }"]`
        );
      if (label) {
        parts.push(
          label.textContent
        );
      }
    }
    let node =
      editor.parentElement;
    for (
      let i = 0;
      i < 5 && node;
      i += 1
    ) {
      const text =
        clean(node.textContent);
      const editorCount =
        node.querySelectorAll(
          [
            'input',
            'textarea',
            '[contenteditable="true"]',
            '[contenteditable=""]',
            '[role="textbox"]'
          ].join(',')
        ).length;
      if (
        editorCount === 1 &&
        text.length <= 220
      ) {
        parts.push(text);
        break;
      }
      node =
        node.parentElement;
    }
    return clean(
      parts.join(' ')
    );
  }
  function findCreatorFields() {
    const editors =
      visibleEditors();
    if (
      editors.length < 2
    ) {
      return null;
    }
    const bvPattern =
      /(关联视频\s*(?:ID)?\s*[//]?\s*链接|关联视频|视频\s*ID\s*[//]?\s*链接|BV号|BV\s*ID)/i;
    const titlePattern =
      /(描述文案|关联描述|视频描述)/i;
    const bvByLabel =
      editorBelowLabel(
        bvPattern,
        editors
      );
    const titleByLabel =
      editorBelowLabel(
        titlePattern,
        editors,
        bvByLabel
      );
    if (
      bvByLabel &&
      titleByLabel &&
      bvByLabel !== titleByLabel
    ) {
      return {
        bv: bvByLabel,
        title: titleByLabel
      };
    }
    const bvList =
      editors.filter(
        editor =>
          bvPattern.test(
            fieldText(editor)
          )
      );
    const titleList =
      editors.filter(
        editor =>
          titlePattern.test(
            fieldText(editor)
          )
      );
    let best = null;
    for (
      const bv of bvList
    ) {
      for (
        const title of titleList
      ) {
        if (bv === title) {
          continue;
        }
        const common =
          commonAncestor(
            bv,
            title
          );
        if (!common) {
          continue;
        }
        const text =
          clean(
            common.textContent
          );
        if (
          !bvPattern.test(text) ||
          !titlePattern.test(text)
        ) {
          continue;
        }
        const distance =
          Math.abs(
            bv.getBoundingClientRect().top -
            title.getBoundingClientRect().top
          );
        const score =
          elementDepth(common) *
            100 -
          distance -
          text.length / 10;
        if (
          !best ||
          score > best.score
        ) {
          best = {
            bv,
            title,
            score
          };
        }
      }
    }
    return best
      ? {
          bv: best.bv,
          title: best.title
        }
      : null;
  }
  function editorBelowLabel(
    pattern,
    editors,
    excluded = null
  ) {
    const labels = [
      ...document.querySelectorAll(
        [
          'label',
          'div',
          'span',
          'p',
          'h1',
          'h2',
          'h3',
          'h4'
        ].join(',')
      )
    ].filter(element => {
      const text =
        clean(
          element.textContent
        );
      if (
        !text ||
        text.length > 45 ||
        !pattern.test(text)
      ) {
        return false;
      }
      const rect =
        element.getBoundingClientRect();
      const style =
        getComputedStyle(element);
      if (
        rect.width <= 1 ||
        rect.height <= 1 ||
        style.display === 'none' ||
        style.visibility === 'hidden'
      ) {
        return false;
      }
      return ![
        ...element.children
      ].some(child => {
        const childText =
          clean(
            child.textContent
          );
        return (
          childText.length <= 45 &&
          pattern.test(childText)
        );
      });
    });
    let best = null;
    for (
      const label of labels
    ) {
      if (
        label instanceof
          HTMLLabelElement &&
        label.htmlFor
      ) {
        const linked =
          document.getElementById(
            label.htmlFor
          );
        if (
          editors.includes(linked) &&
          linked !== excluded
        ) {
          return linked;
        }
      }
      const labelRect =
        label.getBoundingClientRect();
      for (
        const editor of editors
      ) {
        if (
          editor === excluded
        ) {
          continue;
        }
        const editorRect =
          editor.getBoundingClientRect();
        const verticalGap =
          editorRect.top -
          labelRect.bottom;
        const horizontalGap =
          Math.abs(
            editorRect.left -
            labelRect.left
          );
        const overlapsHorizontally =
          editorRect.right >=
            labelRect.left - 20 &&
          editorRect.left <=
            labelRect.right + 320;
        if (
          verticalGap < -10 ||
          verticalGap > 180 ||
          !overlapsHorizontally
        ) {
          continue;
        }
        const score =
          verticalGap +
          horizontalGap * 0.08;
        if (
          !best ||
          score < best.score
        ) {
          best = {
            editor,
            score
          };
        }
      }
    }
    return (
      best?.editor ||
      null
    );
  }
  function commonAncestor(
    first,
    second
  ) {
    const parents =
      new Set();
    let node = first;
    while (node) {
      parents.add(node);
      node =
        node.parentElement;
    }
    node = second;
    while (node) {
      if (
        parents.has(node)
      ) {
        return node;
      }
      node =
        node.parentElement;
    }
    return null;
  }
  function elementDepth(
    element
  ) {
    let depth = 0;
    while (
      element?.parentElement
    ) {
      depth += 1;
      element =
        element.parentElement;
    }
    return depth;
  }
  function getEventEditor(event) {
    const path =
      event.composedPath
        ? event.composedPath()
        : [event.target];
    const editors =
      visibleEditors();
    for (
      const node of path
    ) {
      if (
        !(node instanceof Element)
      ) {
        continue;
      }
      const editor =
        node.closest(
          [
            'input',
            'textarea',
            '[contenteditable="true"]',
            '[contenteditable=""]',
            '[role="textbox"]'
          ].join(',')
        );
      if (
        editor &&
        editors.includes(editor)
      ) {
        return editor;
      }
    }
    return null;
  }
  function nativeValue(
    editor,
    value
  ) {
    const prototype =
      editor instanceof
        HTMLTextAreaElement
        ? HTMLTextAreaElement.prototype
        : HTMLInputElement.prototype;
    const setter =
      Object.getOwnPropertyDescriptor(
        prototype,
        'value'
      )?.set;
    if (setter) {
      setter.call(
        editor,
        value
      );
    } else {
      editor.value = value;
    }
  }
  function fireInput(
    editor,
    text
  ) {
    try {
      editor.dispatchEvent(
        new InputEvent(
          'input',
          {
            bubbles: true,
            composed: true,
            inputType:
              'insertFromPaste',
            data: text
          }
        )
      );
    } catch (_) {
      editor.dispatchEvent(
        new Event(
          'input',
          {
            bubbles: true,
            composed: true
          }
        )
      );
    }
    editor.dispatchEvent(
      new Event(
        'change',
        {
          bubbles: true,
          composed: true
        }
      )
    );
  }
  function replaceValue(
    editor,
    text
  ) {
    if (
      !editor?.isConnected
    ) {
      return false;
    }
    editor.focus();
    if (
      editor instanceof
        HTMLInputElement ||
      editor instanceof
        HTMLTextAreaElement
    ) {
      nativeValue(
        editor,
        text
      );
      try {
        editor.setSelectionRange(
          text.length,
          text.length
        );
      } catch (_) {}
      fireInput(
        editor,
        text
      );
      return (
        editor.value === text
      );
    }
    const selection =
      getSelection();
    const range =
      document.createRange();
    range.selectNodeContents(
      editor
    );
    selection?.removeAllRanges();
    selection?.addRange(range);
    let done = false;
    try {
      done =
        document.execCommand(
          'insertText',
          false,
          text
        );
    } catch (_) {}
    if (!done) {
      editor.textContent = text;
    }
    fireInput(
      editor,
      text
    );
    return (
      clean(
        editor.textContent
      ) === clean(text)
    );
  }
  function insertAtCursor(
    editor,
    text
  ) {
    if (
      editor instanceof
        HTMLInputElement ||
      editor instanceof
        HTMLTextAreaElement
    ) {
      const start =
        editor.selectionStart ??
        editor.value.length;
      const end =
        editor.selectionEnd ??
        start;
      const value =
        editor.value.slice(
          0,
          start
        ) +
        text +
        editor.value.slice(end);
      nativeValue(
        editor,
        value
      );
      editor.setSelectionRange(
        start + text.length,
        start + text.length
      );
      fireInput(
        editor,
        text
      );
      return true;
    }
    editor.focus();
    const selection =
      getSelection();
    if (!selection) {
      return false;
    }
    if (
      !selection.rangeCount ||
      !editor.contains(
        selection.anchorNode
      )
    ) {
      const range =
        document.createRange();
      range.selectNodeContents(
        editor
      );
      range.collapse(false);
      selection.removeAllRanges();
      selection.addRange(range);
    }
    if (
      document.execCommand?.(
        'insertText',
        false,
        text
      )
    ) {
      return true;
    }
    const range =
      selection.getRangeAt(0);
    range.deleteContents();
    const node =
      document.createTextNode(text);
    range.insertNode(node);
    range.setStartAfter(node);
    range.collapse(true);
    selection.removeAllRanges();
    selection.addRange(range);
    fireInput(
      editor,
      text
    );
    return true;
  }
  /* ==================== 提醒 ==================== */
  function toast(
    message,
    color = 'blue',
    duration = 1800
  ) {
    let host =
      document.getElementById(
        'biliclip-toast-host'
      );
    if (!host) {
      host =
        document.createElement(
          'div'
        );
      host.id =
        'biliclip-toast-host';
      host.style.cssText = [
        'all:initial',
        'position:fixed',
        'inset:0',
        'width:0',
        'height:0',
        'z-index:2147483647',
        'pointer-events:none'
      ].join(';');
      const shadow =
        host.attachShadow({
          mode: 'open'
        });
      shadow.innerHTML = `
        
        
`; ( document.body || document.documentElement ).appendChild(host); } const box = host.shadowRoot .querySelector( '.toast' ); box.textContent = message; box.className = `toast ${color}`; box.style.display = 'block'; requestAnimationFrame( () => { box.classList.add( 'show' ); } ); clearTimeout( host._timer ); host._timer = setTimeout(() => { box.classList.remove( 'show' ); setTimeout(() => { box.style.display = 'none'; }, 220); }, duration); } /* ==================== B站按钮 ==================== */ function createBiliUI() { if ( !document.body || ui?.host?.isConnected ) { return; } document .getElementById( 'biliclip-ui-host' ) ?.remove(); const host = document.createElement( 'div' ); host.id = 'biliclip-ui-host'; host.style.cssText = [ 'all:initial', 'position:fixed', 'inset:0', 'width:100vw', 'height:100vh', 'z-index:2147483646', 'pointer-events:none' ].join(';'); const shadow = host.attachShadow({ mode: 'open' }); shadow.innerHTML = `
`; document.body .appendChild(host); ui = { host, hover: shadow.querySelector( '.hover' ), hoverText: shadow.querySelector( '.hover-text' ), bar: shadow.querySelector( '.bar' ), tip: shadow.querySelector( '.tip' ), active: null, title: null, frame: 0, tipTimer: 0 }; bindBiliUI(); syncVideoBar(); setInterval( syncVideoBar, 1000 ); addEventListener( 'scroll', positionUI, true ); addEventListener( 'resize', positionUI ); } function copyIcon() { return [ '', '', '', '' ].join(''); } function bindBiliUI() { document.addEventListener( 'pointermove', event => { if ( !ui || ui.frame ) { return; } const x = event.clientX; const y = event.clientY; ui.frame = requestAnimationFrame( () => { ui.frame = 0; if ( ui.hover.matches( ':hover' ) ) { return; } const cover = findCover(x, y); if (!cover) { ui.hover.style.display = 'none'; ui.active = null; return; } const bv = getBV( cover.href ); if (!bv) return; ui.active = { cover, bv, title: cardTitle( cover ) }; ui.hoverText .textContent = '复制 BV + 标题'; ui.hover.disabled = false; ui.hover.style.display = 'flex'; positionHover(); } ); }, true ); ui.hover.addEventListener( 'click', async event => { event.preventDefault(); event.stopPropagation(); const video = ui.active; if (!video) return; ui.hover.disabled = true; ui.hoverText .textContent = '读取中…'; const title = video.title || await fetchTitle( video.bv ); if (!title) { ui.hover.disabled = false; ui.hoverText .textContent = '读取失败'; buttonTip( ui.hover, '读取失败,请重试' ); return; } savePair( video.bv, title ); ui.hover.disabled = false; ui.hoverText .textContent = '已复制'; buttonTip( ui.hover, '✓ 已复制 · 创作中心一次粘贴' ); setTimeout(() => { if (ui?.hover) { ui.hoverText .textContent = '复制 BV + 标题'; } }, 1600); } ); ui.bar.addEventListener( 'click', async event => { const button = event.target.closest( 'button[data-action]' ); if (!button) return; event.preventDefault(); event.stopPropagation(); const bv = getBV( location.href ); const title = currentTitle() || ( bv ? await fetchTitle(bv) : '' ); if ( button.dataset.action === 'bv' ) { if (!bv) { return buttonTip( button, 'BV 读取失败' ); } clearPair(); writeClipboard(bv); return buttonTip( button, '✓ BV 已复制' ); } if ( button.dataset.action === 'title' ) { if (!title) { return buttonTip( button, '标题读取失败' ); } clearPair(); writeClipboard(title); return buttonTip( button, '✓ 标题已复制' ); } if ( !bv || !title ) { return buttonTip( button, '读取失败' ); } savePair( bv, title ); buttonTip( button, '✓ 已复制 · 创作中心一次粘贴' ); } ); } function findCover(x, y) { const elements = document.elementsFromPoint( x, y ); for ( const element of elements ) { if ( !(element instanceof Element) ) { continue; } const anchor = element.closest( 'a[href]' ); if ( !anchor || !getBV(anchor.href) ) { continue; } if ( !anchor.querySelector( [ 'img', 'picture', 'video', '[class*="cover"]', '[class*="pic"]' ].join(',') ) ) { continue; } const rect = anchor.getBoundingClientRect(); if ( rect.width >= 80 && rect.height >= 45 ) { return anchor; } } return null; } function positionHover() { const cover = ui?.active?.cover; if (!cover) return; const rect = cover.getBoundingClientRect(); const width = ui.hover.offsetWidth || 145; ui.hover.style.left = `${ Math.max( 8, Math.min( innerWidth - width - 8, rect.right - width - 8 ) ) }px`; ui.hover.style.top = `${ Math.max( 8, rect.top + 8 ) }px`; } function syncVideoBar() { if (!ui) return; if ( !/^\/video\/BV[0-9A-Za-z]{10}/i .test( location.pathname ) ) { ui.title = null; ui.bar.style.display = 'none'; return; } ui.title = document.querySelector( [ 'h1.video-title', 'h1[data-title]', '.video-info-title h1', '.video-title' ].join(',') ); if (!ui.title) { ui.bar.style.display = 'none'; return; } ui.bar.style.display = 'flex'; positionBar(); } function positionUI() { if (!ui) return; if ( ui.active && ui.hover.style.display !== 'none' ) { positionHover(); } if (ui.title) { positionBar(); } } function positionBar() { if ( !ui?.title?.isConnected ) { return; } const titleRect = ui.title .getBoundingClientRect(); const rows = [ ...document.querySelectorAll( [ '.video-info-detail-list', '.video-info-detail', '.video-data' ].join(',') ) ]; const row = rows.find(element => { const rect = element.getBoundingClientRect(); return ( rect.width > 40 && rect.top >= titleRect.top && rect.top <= titleRect.bottom + 100 ); }); if ( !row || titleRect.bottom < 58 ) { ui.bar.style.display = 'none'; return; } ui.bar.style.display = 'flex'; const rowRect = row.getBoundingClientRect(); const datePattern = /\d{4}\s*[-/.]\s*\d{1,2}\s*[-/.]\s*\d{1,2}/; const dateElements = [ ...row.querySelectorAll('*') ] .filter(element => { const text = clean( element.textContent ); if ( !datePattern.test(text) ) { return false; } const rect = element.getBoundingClientRect(); if ( rect.width <= 1 || rect.height <= 1 ) { return false; } return ![ ...element.children ].some(child => datePattern.test( clean( child.textContent ) ) ); }) .sort((a, b) => { const aRect = a.getBoundingClientRect(); const bRect = b.getBoundingClientRect(); return ( aRect.width * aRect.height - bRect.width * bRect.height ); }); const dateElement = row.querySelector( [ '.pubdate-ip-text', '.pubdate-text', '.pubdate-ip', '[class*="pubdate"]' ].join(',') ) || dateElements[0]; const anchorRect = dateElement ?.getBoundingClientRect() || rowRect; const width = ui.bar.offsetWidth || 275; const height = ui.bar.offsetHeight || 32; ui.bar.style.left = `${ Math.max( 12, Math.min( innerWidth - width - 12, anchorRect.right + 18 ) ) }px`; ui.bar.style.top = `${ Math.max( 62, anchorRect.top + ( anchorRect.height - height ) / 2 ) }px`; } function buttonTip( button, message ) { const tip = ui.tip; const rect = button.getBoundingClientRect(); tip.textContent = message; tip.style.display = 'block'; tip.classList.remove( 'show' ); requestAnimationFrame( () => { const width = tip.offsetWidth; tip.style.left = `${ Math.max( 10, Math.min( innerWidth - width - 10, rect.left + rect.width / 2 - width / 2 ) ) }px`; tip.style.top = `${ Math.max( 10, rect.top - tip.offsetHeight - 7 ) }px`; tip.classList.add( 'show' ); } ); clearTimeout( ui.tipTimer ); ui.tipTimer = setTimeout(() => { tip.classList.remove( 'show' ); setTimeout(() => { tip.style.display = 'none'; }, 200); }, 1100); } /* ==================== BV与标题 ==================== */ function getBV(url) { try { return ( new URL( url, location.href ) .pathname .match( /\/video\/(BV[0-9A-Za-z]{10})/i ) ?.[1] || '' ); } catch (_) { return ( String(url) .match( /\/video\/(BV[0-9A-Za-z]{10})/i ) ?.[1] || '' ); } } function currentTitle() { const element = document.querySelector( [ 'h1.video-title', 'h1[data-title]', '.video-info-title h1', '.video-title' ].join(',') ); return clean( element?.getAttribute( 'title' ) || element?.getAttribute( 'data-title' ) || element?.textContent ); } function cardTitle(cover) { const direct = clean( cover.getAttribute( 'title' ) || cover.getAttribute( 'aria-label' ) ); if ( direct && !/^BV[0-9A-Za-z]{10}$/i .test(direct) ) { return direct; } const card = cover.closest( [ '.bili-video-card', '.video-card', '.feed-card', '.small-item', '[class*="video-card"]', '[class*="videoCard"]', 'article', 'li' ].join(',') ) || cover .parentElement ?.parentElement; const element = card?.querySelector( [ '.bili-video-card__info--tit a', '.bili-video-card__info--tit', 'h3 a[title]', 'h3 a', 'h3', 'a[class*="title"][title]', 'a[class*="title"]' ].join(',') ); const title = clean( element?.getAttribute?.( 'title' ) || element?.textContent ); return ( /^BV[0-9A-Za-z]{10}$/i .test(title) ) ? '' : title; } function fetchTitle(bv) { return new Promise( resolve => { GM_xmlhttpRequest({ method: 'GET', url: 'https://api.bilibili.com/' + 'x/web-interface/view?bvid=' + encodeURIComponent(bv), timeout: 8000, onload(response) { try { const data = JSON.parse( response.responseText ); resolve( clean( data?.data?.title ) ); } catch (_) { resolve(''); } }, onerror() { resolve(''); }, ontimeout() { resolve(''); } }); } ); } })();
已复制脚本代码!到油猴编辑器粘贴保存即可。
CHANGELOG

BiliClip 喵贴|更新日志

BiliClip 喵贴并不是一次完成的脚本。它最初只是解决"B站 BV 号不好复制"的一个小工具,随后随着实际运营流程不断使用,逐渐加入标题提取、封面悬浮复制、交替粘贴、页面定位、创作中心识别和智能填充等功能。下面记录主要版本演进。

早期版本|BV 快速复制

最早期目标非常简单:不想每次进入视频页面以后再去地址栏手动复制 BV 号。

因此脚本首先实现:

识别 B站视频地址 提取 BVxxxxxxxxxx 一键复制

这一阶段主要解决:BV 获取步骤多、手动选择容易出错、复制效率低的问题。

后续版本|加入标题复制

随着使用场景从"只要 BV"变成:BV + 标题,脚本加入视频标题识别。

开始支持分别执行:复制 BV复制标题,后续又加入复制 BV+标题,为后面的智能粘贴机制提供基础。

5.0|视频卡片悬浮复制

这一阶段开始从"视频详情页工具"扩展到"B站浏览工具"。

加入:鼠标悬停视频封面 → 出现复制按钮 → 直接复制 BV+标题,无需进入视频详情页。

同时视频播放页保留:复制标题、复制 BV、复制 BV+标题三个按钮。这一阶段开始形成 BiliClip 后来的基本交互结构。

5.1|重新复制覆盖旧任务

实际使用中发现一个重要问题:如果已经复制过视频 A,随后发现复制错了,再复制视频 B,旧粘贴队列不能继续存在。

因此加入:新复制自动覆盖旧 pair、step 自动归零、下一次重新从 BV 开始,从而形成"最后一次复制永远优先"的原则。

5.2|悬浮按钮与提示位置调整

早期按钮和提示的位置影响正常浏览。随后进行交互调整:

按钮改为悬浮显示,不长期占据页面。提示从按钮旁边移动到按钮上方,避免提示跑得太靠右、远离用户当前视线、遮挡其他页面内容。

最终形成更加紧凑的"按钮 ↑ 短提示"交互方式。

6.0|解决按钮破坏 B站布局的问题

曾出现加入按钮以后,B站顶部导航或原页面布局异常。主要原因是脚本元素直接参与 B站原始页面布局。

这一阶段开始减少直接插入 B站 DOM 的方式,逐步转向:独立浮层、fixed 定位、页面坐标跟随,让按钮视觉上属于视频信息区域,但实际上不进入 B站原始布局。

6.1|Shadow DOM UI

为了进一步解决样式污染问题,UI 改为 Shadow DOM。加入独立的 BiliClip UI Host、Shadow Root、独立 CSS

主要解决:B站 CSS 覆盖按钮、按钮样式影响 B站、页面布局冲突、不同页面样式不一致的问题。

6.2|B站视觉适配

这一阶段开始强化产品感。按钮视觉逐步采用:B站蓝、B站粉、圆角按钮、轻阴影、悬浮反馈、点击缩放、短动画

尽量使脚本看起来不像浏览器外挂的小灰按钮,而更接近 B站页面本身的视觉语言。

6.4.0|BV / 标题循环粘贴体系稳定

这一阶段重点解决复制以后如何快速粘贴。加入完整的BV → 标题 → BV → 标题交替粘贴机制。

例如:

第一次 Ctrl+V BV133411V714
第二次 Ctrl+V 学了三年动画,动画做成这样能毕业么?《十点五十九》动画片段。
第三次 Ctrl+V BV133411V714
第四次 Ctrl+V 标题

并持续循环。这一修改解决早期存在的"第一次能够粘贴 BV,但第二次还是 BV"的问题。

6.4.1|系统剪贴板同步

仅在 JavaScript 内部维护 step = 0 / step = 1 并不足够稳定。因此每次智能粘贴完成后,也会同步更新真正的系统剪贴板。

第一次粘贴 BV 后:剪贴板 → 标题;粘贴标题后:剪贴板 → BV。让脚本内部状态与系统剪贴板保持更加一致。

6.5|复制错误后的重新开始

实际运营时还存在:第一次复制错视频 → 马上复制另外一个视频的场景。

因此逻辑进一步调整为:只要有新的 BiliClip 复制,旧 BV、旧标题、旧 step 全部作废。新的 pair 写入以后 step = 0,始终重新从 BV 开始。

7.0.0|工作流从"复制工具"升级为"填充工具"

7.x 开始后,BiliClip 的目标发生明显变化。以前:帮用户更方便地复制。后来变成:帮用户完成 B站运营工作流。

重点开始针对B站创作中心进行优化。

7.1.0|创作中心智能分流

这一阶段引入"页面场景判断"。脚本不再认为所有 Ctrl/Cmd+V 都应该执行 BV → 标题 循环。

如果当前页面是 B站创作中心,并检测到关联视频 ID / 链接、描述文案,则切换到"一次粘贴 → 自动填写两个字段"模式。

普通 B站输入区域 BV / 标题循环粘贴
创作中心关联视频 一次粘贴自动填两个框

正式形成"智能分流"。

7.1.1|创作中心字段识别增强

为了避免 B站页面更新后 CSS 类名变化导致功能失效,不再只绑定单一 class。增加基于 label、placeholder、aria-label、title、name、id、父级文字、位置关系进行字段判断。

支持识别:关联视频、关联视频 ID / 链接、视频 ID / 链接、BV号、BV ID、描述文案、关联描述、视频描述,并通过字段相对位置进一步确认。

7.1.2|未打开"关联视频"时增加提示

早期如果用户直接进入创作中心按 Ctrl+V,但"关联视频"区域还没有打开,脚本无法找到目标输入框。

后来加入明确提示:"请先打开「关联视频」,等两个输入框出现后再粘贴",避免用户误以为脚本已经失效。

7.1.3|自动填充成功后结束当前任务

当 BV、标题已经成功分别写入关联视频 ID / 链接、描述文案后,本次 pair 就已经完成任务。

因此自动执行 clearPair();,避免下一次普通粘贴时继续出现刚才的视频信息。

7.2.0|智能复制与创作中心流程整合

7.2.0 将前期多个逻辑正式整合,核心流程统一为:

视频卡片/详情页 复制 BV+标题 保存为 pair 创作中心识别 一次 Ctrl/Cmd+V 自动填入两个字段

页面提示调整为:"已复制 BV + 标题|打开「关联视频」后按 Ctrl+V,自动填充两个输入框"。这一版本基本形成当前 BiliClip 喵贴的工作流。

视频详情页按钮完善:稳定提供复制标题、复制 BV、复制 BV+标题三个按钮。单独复制标题/BV 属于普通系统剪贴板操作,会退出智能 pair 模式;复制 BV+标题才会进入 BiliClip 智能粘贴模式。

卡片悬浮复制完善:B站首页和视频流继续支持悬停封面显示「复制 BV + 标题」,并增加标题读取回退。如果卡片 DOM 无法获得标题,通过 BV 请求 Bilibili API 获得真实视频标题,提高不同推荐流页面下的成功率。

动态页面兼容:考虑到 Bilibili 属于大量动态渲染页面,加入周期性 UI 检查、视频栏同步、位置同步,避免 SPA 页面跳转以后按钮消失、页面切换以后位置失效、B站重新渲染以后元素被清掉等问题。

出现全站粘贴副作用:7.2.0 为了监听用户是否重新复制内容,脚本使用 // @match http://*/*// @match https://*/* 让脚本在所有网页运行。但当时的粘贴接管逻辑没有进一步限制"只能在 B站执行",因此可能导致 ChatGPT、飞书、知乎、其他后台、普通网页输入框也受到 BV/标题智能粘贴状态影响。这是 7.2.1 最主要需要解决的问题。

7.2.1|重要修复:作用域隔离与新复制优先

特殊粘贴仅限 B站:在 onPaste() 最前面增加 if (!isBili) return;。从此以后,bilibili.com 允许 BiliClip 智能粘贴,其他网站完全不处理 Ctrl/Cmd+V。这是一次重要的作用域隔离修改,解决 BiliClip 影响其他网站正常剪贴板操作的问题。

保留全站复制检测:虽然特殊粘贴只在 B站运行,但 // @match http://*/*// @match https://*/* 仍然保留。原因是用户可能执行"B站复制 BV+标题 → 去飞书复制一段文字 → 重新回 B站"的流程,如果 BiliClip 完全不运行在飞书,它就不知道用户已经产生了新的剪贴板内容。因此 7.2.1 将两种能力明确分开:全站只负责检测新的复制/剪切,B站允许执行智能粘贴。

任何新复制立即废弃旧 BV+标题:进一步明确,只要用户产生新的 copy、cut、Ctrl+C、Cmd+C、Ctrl+X、Cmd+X,就调用 clearPair(),旧的 BV、标题、step 立即失效。例如先复制 BV133411V714 + 标题,然后在飞书复制"下午三点开会",此时 BiliClip 状态立即退出,下一次 Ctrl+V 粘贴"下午三点开会",而不是之前的 BV 或标题。

重新明确剪贴板优先级:当前优先级变为"用户最新复制内容 > BiliClip 旧的 BV+标题任务"。BiliClip 不再尝试"守住"旧复制任务,只要用户做出新的复制行为,就认为用户的意图已经发生改变,直接让位于系统剪贴板。

最终行为模型:目前 BiliClip 的规则已经明确为——在 B站复制 BV+标题进入智能模式;在 B站普通区域粘贴执行 BV→标题循环;在 B站创作中心如果识别到关联视频字段,一次 Ctrl/Cmd+V 自动填入两个字段;在其他网站粘贴 BiliClip 不参与,系统剪贴板正常工作;在任何网站重新复制内容立即取消智能模式,之后完全以最新复制内容为准。

当前版本|BiliClip 喵贴 Version 7.2.1 最新

作者:小丁猫同学 Garry Jung

从最初的复制 BV,到现在已经形成:

视频识别 + 标题提取 + 卡片悬浮 + 详情页操作栏 + BV/标题双数据状态 + 循环粘贴 + 新复制覆盖 + 创作中心识别 + 双字段自动填充 + 跨页面状态 + Shadow DOM UI + 非 B站粘贴隔离

BiliClip 喵贴已经从一个简单的 BV 复制脚本,逐步变成针对 Bilibili 内容运营工作流设计的效率工具。

TECHNICAL

BiliClip 喵贴|技术说明

BiliClip 喵贴是一款针对 Bilibili 内容运营场景设计的 Tampermonkey 油猴脚本,核心目标是减少运营人员在"B站视频页面 → B站创作中心"之间重复复制 BV 号、标题、关联视频信息时产生的机械操作。它并不是单纯的"复制按钮增强",而是围绕 B 站视频信息提取、剪贴板状态管理、智能粘贴分流、创作中心自动填充等场景构建的一套轻量化工作流。

01

核心使用逻辑

BiliClip 喵贴主要解决这样一个高频操作流程:

  • 在 Bilibili 首页、推荐流、视频列表或视频详情页找到目标视频
  • 获取该视频的 BV 号和完整标题
  • 前往 B站创作中心,打开"关联视频"相关输入区域
  • 将 BV 号填写进"关联视频 ID / 链接",将视频标题填写进"描述文案"

传统操作需要多次进入页面、选择文字、复制、切换页面、粘贴。BiliClip 喵贴将其简化为:找到视频 → 复制 BV+标题 → 创作中心 Ctrl/Cmd+V → 自动填充

02

视频信息识别

脚本会识别标准 Bilibili BV 视频地址,例如:

https://www.bilibili.com/video/BV133411V714

并从 URL 中提取 BV133411V714

BV 识别规则基于:

/\/video\/(BV[0-9A-Za-z]{10})/i

因此脚本并不是通过页面中文字猜测 BV 号,而是优先从标准视频 URL 中提取。这使视频卡片、视频详情页以及不同入口中的识别逻辑保持统一。

03

标题提取机制

视频标题并不是单一来源读取。BiliClip 喵贴会按照页面环境采用多级提取策略。

1. 视频详情页:优先寻找 h1.video-titleh1[data-title].video-info-title h1.video-title,然后依次读取 title 属性、data-title 属性、textContent,并执行空格清洗与文本标准化。

2. 视频卡片:鼠标悬停视频封面时,脚本会尝试从当前视频卡片附近获取标题。识别范围包括 .bili-video-card.video-card.feed-card.small-itemarticleli,并进一步寻找标题类链接。因此不需要进入视频详情页,也可以直接获取 BV码 + 视频标题。

3. API 回退机制:如果页面本身无法稳定读取标题,BiliClip 喵贴会使用 Bilibili 官方 Web API x/web-interface/view?bvid= 通过 BV 号重新查询视频信息。标题获取采用页面读取优先 → API 查询兜底的方式。

04

视频卡片悬浮复制

BiliClip 喵贴支持在 Bilibili 视频列表中直接操作。当鼠标移动到可识别的视频封面区域时,脚本会检测鼠标位置下方的元素。

通过 document.elementsFromPoint() 获取鼠标所在区域的 DOM,并进一步寻找 a[href],然后判断链接中是否存在合法 BV 号。

如果该链接同时包含 imgpicturevideocoverpic 等封面相关元素,就会被识别为视频封面。识别成功后会显示"复制 BV + 标题"悬浮按钮。这样不需要进入视频详情页即可直接获取视频信息。

05

视频详情页操作栏

在标准 /video/BVxxxxxxxxxx 页面中,脚本会识别视频标题区域及发布时间/信息行,随后显示三个操作按钮:

  • 复制标题:只把当前视频标题写入系统剪贴板,同时取消之前可能存在的 BV+标题粘贴队列
  • 复制 BV:只复制 BV 号,同样退出之前的 BV+标题智能粘贴状态
  • 复制 BV+标题:进入 BiliClip 的智能粘贴模式,内部保存 { bv, title, savedAt },并将 BV 首先写入系统剪贴板,同时将粘贴步骤重置为 step = 0
06

BV + 标题双数据状态

BiliClip 并不是简单地将 BV码、标题拼接成一个字符串。脚本会将两项信息分别保存:

{ bv: "BV133411V714", title: "学了三年动画,动画做成这样能毕业么?《十点五十九》动画片段。", savedAt: ... }

这使后续可以根据不同页面环境决定粘贴 BV、粘贴标题、自动填两个输入框,而不需要再重新解析剪贴板文本。

07

普通 B站页面中的循环粘贴

在需要手动分别填写两个位置的情况下,BiliClip 支持交替粘贴。

第一次 Ctrl/Cmd+V 粘贴 BV133411V714,第二次粘贴标题,第三次重新回到 BV,第四次标题,形成BV → 标题 → BV → 标题 → BV → 标题……的循环。

这解决了早期版本中"第二次 Ctrl+V 仍然只能粘贴 BV"的问题。

08

为什么还要同步修改系统剪贴板

每一次特殊粘贴成功后,脚本都会同步调整系统剪贴板内容。

例如第一次粘贴 BV 后:下一次剪贴板内容 = 标题;第二次粘贴标题后:下一次剪贴板内容 = BV。

这样浏览器事件处理与真实系统剪贴板尽量保持一致,降低脚本内部状态和用户实际剪贴板之间发生不同步的概率。

09

B站创作中心自动填充

这是 BiliClip 喵贴 7.x 系列的重要能力。当脚本判断当前页面属于 B站创作中心,并且检测到关联视频 ID / 链接、描述文案两个输入区域时,只需要按一次 Ctrl/Cmd+V,脚本就会自动执行:

BV → 关联视频 ID / 链接 标题 → 描述文案

而不再执行普通的 BV → 标题 → BV → 标题 循环模式。因此创作中心实际上属于一个专门的"智能分流"场景。

10

创作中心输入框识别

为了避免依赖单一 CSS 类名,脚本没有把创作中心输入框完全写死。它会扫描可见的 inputtextareacontenteditablerole="textbox",然后根据输入框周围的 placeholder、aria-label、title、name、id、label、父级文字识别其用途。

BV 输入框关键词包括:关联视频、关联视频 ID / 链接、视频 ID / 链接、BV号、BV ID。标题/文案输入框关键词包括:描述文案、关联描述、视频描述。

脚本同时会根据标签与输入框的上下距离、横向位置、共同父节点、DOM 深度、文本长度进行候选评分。因此相比只写 document.querySelector('.xxx') 具有更好的页面结构兼容能力。

11

自动填充的输入事件兼容

很多现代网站使用 Vue、React 等框架。直接执行 input.value = 'xxx' 可能出现界面看起来有内容,但网站内部状态实际上没有更新的问题。

因此 BiliClip 会优先调用原生 value setter,并主动触发 InputEventinputchange。其中粘贴输入事件使用 insertFromPaste,尽量让网页认为这是一次真实输入行为。

12

ContentEditable 支持

除了普通 <input><textarea>,脚本还支持 contenteditable="true"role="textbox" 类型编辑器。

在普通 input/textarea 中,通过 selectionStart / selectionEnd 控制光标位置。而在 ContentEditable 中,则通过 Selection、Range、execCommand('insertText') 或者 TextNode 回退方式插入文字。因此不是只能处理最简单的 HTML 输入框。

13

新复制内容优先机制

这是 7.2.1 中进一步强化的重要规则。BiliClip 的 BV+标题模式不能永久占用用户的剪贴板。

例如用户复制 BV133411V714 + 视频标题,随后又在网页中手动复制"今天下午开会",此时用户真实意图显然已经变成粘贴"今天下午开会"。

因此 BiliClip 会监听页面 copy、cut、Ctrl/Cmd+C、Ctrl/Cmd+X,一旦确认这是用户主动产生的新复制行为,就执行 clearPair();,立即清空之前保存的 BV + 标题智能粘贴状态。之后 Ctrl/Cmd+V 完全按照新的系统剪贴板运行。

14

为什么脚本依旧匹配所有网站

脚本头部仍然使用 // @match http://*/*// @match https://*/*。这是有意设计,而不是意味着所有网站都会被 BiliClip 接管。

原因是 BiliClip 必须知道用户是否在其他网页重新执行了复制、剪切。否则用户在 B站复制 BV+标题,前往其他网站复制了一段新文字,BiliClip 因为没有运行无法知道,返回 B站以后仍可能保留旧 BV+标题状态。

因此全站运行的目的只是:检测新的复制行为

15

7.2.1 的粘贴作用域隔离

虽然脚本会在所有网页运行,但从 7.2.1 开始,特殊 Ctrl/Cmd+V 接管只允许在 Bilibili 域名执行。关键逻辑:

function onPaste(event) { if (!isBili) return; if (!pair) return; // ... }

域名判断:const isBili = /(^|\.)bilibili\.com$/i.test(location.hostname);

因此行为被明确拆成两层:全网页只负责检测用户是否产生新的复制内容;B站允许执行 BV+标题智能粘贴;其他网站完全不接管 Ctrl/Cmd+V。这样既可以识别用户新的复制行为,也不会干扰飞书、ChatGPT、知乎、搜索引擎、后台系统、文档页面等其他网站输入框的正常剪贴板操作。

16

脚本自身写入剪贴板的保护机制

由于 BiliClip 自己也会调用 GM_setClipboard(),如果所有 copy 行为都会触发 clearPair(),那么脚本刚保存 BV+标题,自己写了一次剪贴板,就可能又把自己的状态清掉。

因此脚本使用 writingClipboard 作为内部写入锁。写剪贴板前 writingClipboard = true;,完成后延迟恢复 writingClipboard = false;。监听页面 copy/cut 时,只在 !writingClipboard 的情况下清空 pair。从而区分用户主动复制和 BiliClip 自己更新剪贴板。

17

跨页面状态同步

BV+标题信息通过 Tampermonkey 的 GM_setValueGM_getValue 进行保存。状态 Key:biliclip_pair_v72

因此 B站首页复制 → 打开视频页 → 进入创作中心之间可以共享同一份 BV+标题状态。同时通过 GM_addValueChangeListener 监听页面间状态变化。当新 pair 写入时,更新 BV、更新标题、step 重置为 0,保证新的复制任务不会继续沿用之前的粘贴步骤。

18

新任务覆盖旧任务

如果第一次复制错了视频 A,然后马上又复制视频 B,BiliClip 不会继续 A标题、A BV,而是直接将 pair 替换成 B:B BV、B 标题,同时 step = 0; 重新从 BV 开始。因此新复制永远拥有最高优先级。

19

Shadow DOM UI

为了降低 Bilibili 页面 CSS 对脚本按钮的影响,后续版本的 BiliClip UI 使用 Shadow DOM。脚本创建独立的 biliclip-ui-host,并 attachShadow({ mode: 'open' }),按钮和样式都放入自己的 Shadow DOM。

这样可以减少 B站 CSS 改写按钮、脚本 CSS 污染 B站、页面 flex 布局被插入元素破坏、顶部导航异常、按钮跟随页面结构错位等问题。

20

悬浮层而不是强插 DOM

早期版本中,按钮如果直接插入 B站信息区域,可能改变原页面的 flex、width、overflow、定位、层级,甚至出现顶部导航不显示、原布局被挤压的问题。

后续改为 position: fixed、pointer-events、独立浮层。按钮视觉上出现在视频标题/日期信息附近,但实际上并没有插入 B站原始信息栏。这样可以同时达到看起来属于原页面、实际上不破坏原 DOM 的效果。

21

按钮定位机制

视频页操作栏会根据视频标题位置、视频信息行位置、发布日期位置动态计算。优先定位在发布日期附近。通过 getBoundingClientRect() 实时获取坐标,再计算 left、top。同时监听页面 scroll、resize 重新定位。因此页面滚动、窗口尺寸变化后,按钮仍然可以跟随目标区域。

22

卡片按钮定位机制

视频卡片上的"复制 BV + 标题"按钮同样使用浮层。当鼠标进入某个封面后,会根据封面的 top、right、width 动态计算按钮位置。按钮不会永久塞在每一张卡片中,只有当前鼠标所在视频才显示。这样减少 DOM 节点数量、页面污染、视觉干扰、与 B站卡片结构的耦合。

23

提示系统

BiliClip 自带独立 Toast 提示系统。主要状态包括:

  • ✓ BV 已复制 / ✓ 标题已复制
  • ✓ 已复制 · 创作中心一次粘贴
  • ✓ BV 已粘贴 · 下次标题 / ✓ 标题已粘贴 · 下次 BV
  • 已复制 BV + 标题|打开「关联视频」后按 Ctrl+V 自动填充
  • 失败:请先打开「关联视频」

提示采用独立 Shadow DOM,并尽量使用轻量动画,避免破坏 B站 UI。

24

产品设计原则

BiliClip 喵贴目前遵循几个核心原则:

  • 不破坏 B站:功能增强应尽量通过浮层实现,而不是强行修改 B站原 DOM
  • 新复制永远优先:用户一旦产生新的复制行为,旧 BV+标题状态立即失效
  • 非 B站不接管粘贴:即使脚本为了监听复制行为运行在全网页,也不能影响其他网站正常 Ctrl/Cmd+V
  • 能自动就自动:在创作中心能够确认字段身份时,一次粘贴自动填两个输入框
  • 不能确认时不乱填:如果无法识别创作中心字段,应提示用户,而不是盲目写入错误输入框
  • 页面结构变化要有容错:DOM 查询、标签识别、API 查询、位置计算均采用多路径处理,而不是依赖一个固定类名

总结

BiliClip 喵贴从最初的"复制 BV 号"小工具,经过多个版本的迭代,已经发展为一套完整的 Bilibili 内容运营工作流效率工具。它涵盖视频信息识别、多级标题提取、卡片悬浮复制、详情页操作栏、BV/标题双数据状态管理、循环粘贴机制、系统剪贴板同步、新复制覆盖、创作中心智能分流、双字段自动填充、跨页面状态同步、Shadow DOM 独立 UI、非 B站粘贴作用域隔离等核心能力。当前版本 7.2.1 已经形成稳定、清晰、可预期的行为模型。

BiliClip 喵贴 Version 7.2.1 · 作者:小丁猫同学 Garry Jung
FAQ

常见问题

遇到问题先看这里,大部分情况都能快速解决。

安装后页面上没有看到悬浮按钮怎么办?

请依次检查:1)脚本管理器(Tampermonkey 等)是否已启用;2)脚本本身是否在管理器中处于启用状态;3)刷新 B 站视频页面(Ctrl+F5 强制刷新);4)确认当前页面是 B 站视频播放页,而非首页或其他页面;5)关闭其他篡改 B 站页面的冲突脚本后重试。

点击复制后,创作中心填充不生效怎么办?

B 站页面大版本改版后可能导致脚本暂时失效。请先确认脚本已更新到最新版本(脚本管理器会自动检测更新)。如果已是最新版本仍无法填充,请到 GitHub Issues 或 GreasyFork 反馈区提交问题,说明浏览器版本、脚本版本和具体复现步骤,开发者会尽快适配。

复制失败,剪贴板报错怎么办?

浏览器会限制剪贴板权限。请确保:1)当前页面为 HTTPS;2)脚本管理器已授予剪贴板权限;3)部分浏览器隐私模式下 GM 剪贴板 API 可能受限,建议在普通窗口下使用。

这个脚本会收集我的浏览数据吗?

不会。BiliClip 喵贴是纯前端用户脚本,所有操作均在你的浏览器本地完成。脚本仅读取当前 B 站页面上已公开显示的 BV 号和标题文本,仅写入系统剪贴板,不连接任何后端服务器,不收集、不上传、不存储任何用户数据。你可以在脚本管理器中查看完整源码自行验证。

支持哪些浏览器和脚本管理器?

浏览器方面支持 Chrome、Edge、Firefox 以及其他基于 Chromium 内核的浏览器(如 Brave、Vivaldi 等)。脚本管理器方面支持 Tampermonkey 和 Violentmonkey。不支持 B 站客户端、移动端浏览器以及不支持用户脚本的浏览器环境。

开始用喵贴,告别手动复制

免费开源,一分钟安装,立刻提升 B 站创作效率。

立即安装脚本