做二创、搬视频、整理素材时,BV 码和标题要分别复制、分别粘贴,来回切换页面烦不胜烦。喵贴把这两步合成一次点击。
在 B 站视频播放页,一键同时抓取 BV 码和视频标题,写入系统剪贴板。
跳转创作中心后,单次触发自动识别两个输入框,分别填入 BV 码和标题。
所有操作在浏览器本地完成,不连接任何服务器,不上传你的浏览数据。
跟进 B 站页面 DOM 变动,大版本改版后快速更新,保持脚本稳定可用。
从安装脚本管理器到开始使用,只需不到一分钟。
在浏览器扩展商店安装 Tampermonkey 或 Violentmonkey(暴力猴)
复制右侧 v7.2.1 脚本源码,替换编辑器全部内容,Ctrl+S 保存
打开 B 站视频页,喵贴悬浮按钮出现即表示脚本已生效
// ==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('');
}
});
}
);
}
})();
BiliClip 喵贴并不是一次完成的脚本。它最初只是解决"B站 BV 号不好复制"的一个小工具,随后随着实际运营流程不断使用,逐渐加入标题提取、封面悬浮复制、交替粘贴、页面定位、创作中心识别和智能填充等功能。下面记录主要版本演进。
最早期目标非常简单:不想每次进入视频页面以后再去地址栏手动复制 BV 号。
因此脚本首先实现:
这一阶段主要解决:BV 获取步骤多、手动选择容易出错、复制效率低的问题。
随着使用场景从"只要 BV"变成:BV + 标题,脚本加入视频标题识别。
开始支持分别执行:复制 BV、复制标题,后续又加入复制 BV+标题,为后面的智能粘贴机制提供基础。
这一阶段开始从"视频详情页工具"扩展到"B站浏览工具"。
加入:鼠标悬停视频封面 → 出现复制按钮 → 直接复制 BV+标题,无需进入视频详情页。
同时视频播放页保留:复制标题、复制 BV、复制 BV+标题三个按钮。这一阶段开始形成 BiliClip 后来的基本交互结构。
实际使用中发现一个重要问题:如果已经复制过视频 A,随后发现复制错了,再复制视频 B,旧粘贴队列不能继续存在。
因此加入:新复制自动覆盖旧 pair、step 自动归零、下一次重新从 BV 开始,从而形成"最后一次复制永远优先"的原则。
早期按钮和提示的位置影响正常浏览。随后进行交互调整:
按钮改为悬浮显示,不长期占据页面。提示从按钮旁边移动到按钮上方,避免提示跑得太靠右、远离用户当前视线、遮挡其他页面内容。
最终形成更加紧凑的"按钮 ↑ 短提示"交互方式。
曾出现加入按钮以后,B站顶部导航或原页面布局异常。主要原因是脚本元素直接参与 B站原始页面布局。
这一阶段开始减少直接插入 B站 DOM 的方式,逐步转向:独立浮层、fixed 定位、页面坐标跟随,让按钮视觉上属于视频信息区域,但实际上不进入 B站原始布局。
为了进一步解决样式污染问题,UI 改为 Shadow DOM。加入独立的 BiliClip UI Host、Shadow Root、独立 CSS。
主要解决:B站 CSS 覆盖按钮、按钮样式影响 B站、页面布局冲突、不同页面样式不一致的问题。
这一阶段开始强化产品感。按钮视觉逐步采用:B站蓝、B站粉、圆角按钮、轻阴影、悬浮反馈、点击缩放、短动画。
尽量使脚本看起来不像浏览器外挂的小灰按钮,而更接近 B站页面本身的视觉语言。
这一阶段重点解决复制以后如何快速粘贴。加入完整的BV → 标题 → BV → 标题交替粘贴机制。
例如:
并持续循环。这一修改解决早期存在的"第一次能够粘贴 BV,但第二次还是 BV"的问题。
仅在 JavaScript 内部维护 step = 0 / step = 1 并不足够稳定。因此每次智能粘贴完成后,也会同步更新真正的系统剪贴板。
第一次粘贴 BV 后:剪贴板 → 标题;粘贴标题后:剪贴板 → BV。让脚本内部状态与系统剪贴板保持更加一致。
实际运营时还存在:第一次复制错视频 → 马上复制另外一个视频的场景。
因此逻辑进一步调整为:只要有新的 BiliClip 复制,旧 BV、旧标题、旧 step 全部作废。新的 pair 写入以后 step = 0,始终重新从 BV 开始。
7.x 开始后,BiliClip 的目标发生明显变化。以前:帮用户更方便地复制。后来变成:帮用户完成 B站运营工作流。
重点开始针对B站创作中心进行优化。
这一阶段引入"页面场景判断"。脚本不再认为所有 Ctrl/Cmd+V 都应该执行 BV → 标题 循环。
如果当前页面是 B站创作中心,并检测到关联视频 ID / 链接、描述文案,则切换到"一次粘贴 → 自动填写两个字段"模式。
正式形成"智能分流"。
为了避免 B站页面更新后 CSS 类名变化导致功能失效,不再只绑定单一 class。增加基于 label、placeholder、aria-label、title、name、id、父级文字、位置关系进行字段判断。
支持识别:关联视频、关联视频 ID / 链接、视频 ID / 链接、BV号、BV ID、描述文案、关联描述、视频描述,并通过字段相对位置进一步确认。
早期如果用户直接进入创作中心按 Ctrl+V,但"关联视频"区域还没有打开,脚本无法找到目标输入框。
后来加入明确提示:"请先打开「关联视频」,等两个输入框出现后再粘贴",避免用户误以为脚本已经失效。
当 BV、标题已经成功分别写入关联视频 ID / 链接、描述文案后,本次 pair 就已经完成任务。
因此自动执行 clearPair();,避免下一次普通粘贴时继续出现刚才的视频信息。
7.2.0 将前期多个逻辑正式整合,核心流程统一为:
页面提示调整为:"已复制 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 最主要需要解决的问题。
特殊粘贴仅限 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 不参与,系统剪贴板正常工作;在任何网站重新复制内容立即取消智能模式,之后完全以最新复制内容为准。
作者:小丁猫同学 Garry Jung
从最初的复制 BV,到现在已经形成:
视频识别 + 标题提取 + 卡片悬浮 + 详情页操作栏 + BV/标题双数据状态 + 循环粘贴 + 新复制覆盖 + 创作中心识别 + 双字段自动填充 + 跨页面状态 + Shadow DOM UI + 非 B站粘贴隔离
BiliClip 喵贴已经从一个简单的 BV 复制脚本,逐步变成针对 Bilibili 内容运营工作流设计的效率工具。
BiliClip 喵贴是一款针对 Bilibili 内容运营场景设计的 Tampermonkey 油猴脚本,核心目标是减少运营人员在"B站视频页面 → B站创作中心"之间重复复制 BV 号、标题、关联视频信息时产生的机械操作。它并不是单纯的"复制按钮增强",而是围绕 B 站视频信息提取、剪贴板状态管理、智能粘贴分流、创作中心自动填充等场景构建的一套轻量化工作流。
BiliClip 喵贴主要解决这样一个高频操作流程:
传统操作需要多次进入页面、选择文字、复制、切换页面、粘贴。BiliClip 喵贴将其简化为:找到视频 → 复制 BV+标题 → 创作中心 Ctrl/Cmd+V → 自动填充。
脚本会识别标准 Bilibili BV 视频地址,例如:
并从 URL 中提取 BV133411V714。
BV 识别规则基于:
因此脚本并不是通过页面中文字猜测 BV 号,而是优先从标准视频 URL 中提取。这使视频卡片、视频详情页以及不同入口中的识别逻辑保持统一。
视频标题并不是单一来源读取。BiliClip 喵贴会按照页面环境采用多级提取策略。
1. 视频详情页:优先寻找 h1.video-title、h1[data-title]、.video-info-title h1、.video-title,然后依次读取 title 属性、data-title 属性、textContent,并执行空格清洗与文本标准化。
2. 视频卡片:鼠标悬停视频封面时,脚本会尝试从当前视频卡片附近获取标题。识别范围包括 .bili-video-card、.video-card、.feed-card、.small-item、article、li,并进一步寻找标题类链接。因此不需要进入视频详情页,也可以直接获取 BV码 + 视频标题。
3. API 回退机制:如果页面本身无法稳定读取标题,BiliClip 喵贴会使用 Bilibili 官方 Web API x/web-interface/view?bvid= 通过 BV 号重新查询视频信息。标题获取采用页面读取优先 → API 查询兜底的方式。
BiliClip 喵贴支持在 Bilibili 视频列表中直接操作。当鼠标移动到可识别的视频封面区域时,脚本会检测鼠标位置下方的元素。
通过 document.elementsFromPoint() 获取鼠标所在区域的 DOM,并进一步寻找 a[href],然后判断链接中是否存在合法 BV 号。
如果该链接同时包含 img、picture、video、cover、pic 等封面相关元素,就会被识别为视频封面。识别成功后会显示"复制 BV + 标题"悬浮按钮。这样不需要进入视频详情页即可直接获取视频信息。
在标准 /video/BVxxxxxxxxxx 页面中,脚本会识别视频标题区域及发布时间/信息行,随后显示三个操作按钮:
{ bv, title, savedAt },并将 BV 首先写入系统剪贴板,同时将粘贴步骤重置为 step = 0BiliClip 并不是简单地将 BV码、标题拼接成一个字符串。脚本会将两项信息分别保存:
这使后续可以根据不同页面环境决定粘贴 BV、粘贴标题、自动填两个输入框,而不需要再重新解析剪贴板文本。
在需要手动分别填写两个位置的情况下,BiliClip 支持交替粘贴。
第一次 Ctrl/Cmd+V 粘贴 BV133411V714,第二次粘贴标题,第三次重新回到 BV,第四次标题,形成BV → 标题 → BV → 标题 → BV → 标题……的循环。
这解决了早期版本中"第二次 Ctrl+V 仍然只能粘贴 BV"的问题。
每一次特殊粘贴成功后,脚本都会同步调整系统剪贴板内容。
例如第一次粘贴 BV 后:下一次剪贴板内容 = 标题;第二次粘贴标题后:下一次剪贴板内容 = BV。
这样浏览器事件处理与真实系统剪贴板尽量保持一致,降低脚本内部状态和用户实际剪贴板之间发生不同步的概率。
这是 BiliClip 喵贴 7.x 系列的重要能力。当脚本判断当前页面属于 B站创作中心,并且检测到关联视频 ID / 链接、描述文案两个输入区域时,只需要按一次 Ctrl/Cmd+V,脚本就会自动执行:
而不再执行普通的 BV → 标题 → BV → 标题 循环模式。因此创作中心实际上属于一个专门的"智能分流"场景。
为了避免依赖单一 CSS 类名,脚本没有把创作中心输入框完全写死。它会扫描可见的 input、textarea、contenteditable、role="textbox",然后根据输入框周围的 placeholder、aria-label、title、name、id、label、父级文字识别其用途。
BV 输入框关键词包括:关联视频、关联视频 ID / 链接、视频 ID / 链接、BV号、BV ID。标题/文案输入框关键词包括:描述文案、关联描述、视频描述。
脚本同时会根据标签与输入框的上下距离、横向位置、共同父节点、DOM 深度、文本长度进行候选评分。因此相比只写 document.querySelector('.xxx') 具有更好的页面结构兼容能力。
很多现代网站使用 Vue、React 等框架。直接执行 input.value = 'xxx' 可能出现界面看起来有内容,但网站内部状态实际上没有更新的问题。
因此 BiliClip 会优先调用原生 value setter,并主动触发 InputEvent、input、change。其中粘贴输入事件使用 insertFromPaste,尽量让网页认为这是一次真实输入行为。
除了普通 <input>、<textarea>,脚本还支持 contenteditable="true" 和 role="textbox" 类型编辑器。
在普通 input/textarea 中,通过 selectionStart / selectionEnd 控制光标位置。而在 ContentEditable 中,则通过 Selection、Range、execCommand('insertText') 或者 TextNode 回退方式插入文字。因此不是只能处理最简单的 HTML 输入框。
这是 7.2.1 中进一步强化的重要规则。BiliClip 的 BV+标题模式不能永久占用用户的剪贴板。
例如用户复制 BV133411V714 + 视频标题,随后又在网页中手动复制"今天下午开会",此时用户真实意图显然已经变成粘贴"今天下午开会"。
因此 BiliClip 会监听页面 copy、cut、Ctrl/Cmd+C、Ctrl/Cmd+X,一旦确认这是用户主动产生的新复制行为,就执行 clearPair();,立即清空之前保存的 BV + 标题智能粘贴状态。之后 Ctrl/Cmd+V 完全按照新的系统剪贴板运行。
脚本头部仍然使用 // @match http://*/* 和 // @match https://*/*。这是有意设计,而不是意味着所有网站都会被 BiliClip 接管。
原因是 BiliClip 必须知道用户是否在其他网页重新执行了复制、剪切。否则用户在 B站复制 BV+标题,前往其他网站复制了一段新文字,BiliClip 因为没有运行无法知道,返回 B站以后仍可能保留旧 BV+标题状态。
因此全站运行的目的只是:检测新的复制行为。
虽然脚本会在所有网页运行,但从 7.2.1 开始,特殊 Ctrl/Cmd+V 接管只允许在 Bilibili 域名执行。关键逻辑:
域名判断:const isBili = /(^|\.)bilibili\.com$/i.test(location.hostname);
因此行为被明确拆成两层:全网页只负责检测用户是否产生新的复制内容;B站允许执行 BV+标题智能粘贴;其他网站完全不接管 Ctrl/Cmd+V。这样既可以识别用户新的复制行为,也不会干扰飞书、ChatGPT、知乎、搜索引擎、后台系统、文档页面等其他网站输入框的正常剪贴板操作。
由于 BiliClip 自己也会调用 GM_setClipboard(),如果所有 copy 行为都会触发 clearPair(),那么脚本刚保存 BV+标题,自己写了一次剪贴板,就可能又把自己的状态清掉。
因此脚本使用 writingClipboard 作为内部写入锁。写剪贴板前 writingClipboard = true;,完成后延迟恢复 writingClipboard = false;。监听页面 copy/cut 时,只在 !writingClipboard 的情况下清空 pair。从而区分用户主动复制和 BiliClip 自己更新剪贴板。
BV+标题信息通过 Tampermonkey 的 GM_setValue、GM_getValue 进行保存。状态 Key:biliclip_pair_v72。
因此 B站首页复制 → 打开视频页 → 进入创作中心之间可以共享同一份 BV+标题状态。同时通过 GM_addValueChangeListener 监听页面间状态变化。当新 pair 写入时,更新 BV、更新标题、step 重置为 0,保证新的复制任务不会继续沿用之前的粘贴步骤。
如果第一次复制错了视频 A,然后马上又复制视频 B,BiliClip 不会继续 A标题、A BV,而是直接将 pair 替换成 B:B BV、B 标题,同时 step = 0; 重新从 BV 开始。因此新复制永远拥有最高优先级。
为了降低 Bilibili 页面 CSS 对脚本按钮的影响,后续版本的 BiliClip UI 使用 Shadow DOM。脚本创建独立的 biliclip-ui-host,并 attachShadow({ mode: 'open' }),按钮和样式都放入自己的 Shadow DOM。
这样可以减少 B站 CSS 改写按钮、脚本 CSS 污染 B站、页面 flex 布局被插入元素破坏、顶部导航异常、按钮跟随页面结构错位等问题。
早期版本中,按钮如果直接插入 B站信息区域,可能改变原页面的 flex、width、overflow、定位、层级,甚至出现顶部导航不显示、原布局被挤压的问题。
后续改为 position: fixed、pointer-events、独立浮层。按钮视觉上出现在视频标题/日期信息附近,但实际上并没有插入 B站原始信息栏。这样可以同时达到看起来属于原页面、实际上不破坏原 DOM 的效果。
视频页操作栏会根据视频标题位置、视频信息行位置、发布日期位置动态计算。优先定位在发布日期附近。通过 getBoundingClientRect() 实时获取坐标,再计算 left、top。同时监听页面 scroll、resize 重新定位。因此页面滚动、窗口尺寸变化后,按钮仍然可以跟随目标区域。
视频卡片上的"复制 BV + 标题"按钮同样使用浮层。当鼠标进入某个封面后,会根据封面的 top、right、width 动态计算按钮位置。按钮不会永久塞在每一张卡片中,只有当前鼠标所在视频才显示。这样减少 DOM 节点数量、页面污染、视觉干扰、与 B站卡片结构的耦合。
BiliClip 自带独立 Toast 提示系统。主要状态包括:
提示采用独立 Shadow DOM,并尽量使用轻量动画,避免破坏 B站 UI。
BiliClip 喵贴目前遵循几个核心原则:
BiliClip 喵贴从最初的"复制 BV 号"小工具,经过多个版本的迭代,已经发展为一套完整的 Bilibili 内容运营工作流效率工具。它涵盖视频信息识别、多级标题提取、卡片悬浮复制、详情页操作栏、BV/标题双数据状态管理、循环粘贴机制、系统剪贴板同步、新复制覆盖、创作中心智能分流、双字段自动填充、跨页面状态同步、Shadow DOM 独立 UI、非 B站粘贴作用域隔离等核心能力。当前版本 7.2.1 已经形成稳定、清晰、可预期的行为模型。
遇到问题先看这里,大部分情况都能快速解决。
请依次检查: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 站客户端、移动端浏览器以及不支持用户脚本的浏览器环境。