// ==UserScript==
// @name Bilibili 一键跳转 Youtube 同名视频
// @namespace A user script about something
// @version 1.0
// @description 在 Bilibili/哔哩哔哩 网站上提供一个按钮,点击即可跳转 YouTube 同名视频。
// @icon https://www.google.com/s2/favicons?domain=www.bilibili.com
// @author WhiteBr1ck
// @license MIT
// @match *://www.bilibili.com/video/*
// @grant GM_xmlhttpRequest
// @grant GM_registerMenuCommand
// @grant GM_getValue
// @grant GM_setValue
// @connect www.youtube.com
// ==/UserScript==
(function() {
'use strict';
const BUTTON_ID = 'b2yt-finder-button';
let lastUrl = '';
let mainLogicInterval;
function openSettingsMenu() {
const currentThreshold = GM_getValue('similarity_threshold', 0.6);
const userInput = prompt('请输入新的匹配阈值 (范围 0.0 - 1.0):', currentThreshold);
if (userInput === null) return;
const newThreshold = parseFloat(userInput);
if (isNaN(newThreshold) || newThreshold < 0.0 || newThreshold > 1.0) {
alert('输入无效!请输入一个介于 0.0 和 1.0 之间的数字。');
return;
}
GM_setValue('similarity_threshold', newThreshold);
alert(`匹配阈值已成功保存为: ${newThreshold * 100}%`);
}
/**
* 注册菜单命令: 在菜单中添加一个可点击的命令
*/
GM_registerMenuCommand('设置匹配阈值', openSettingsMenu);
// --- 主逻辑 (URL侦测器) ---
setInterval(() => {
const currentUrl = window.location.href;
if (currentUrl !== lastUrl) {
console.log("B2YT: 检测到URL变化,准备刷新按钮...");
lastUrl = currentUrl;
runMainLogic();
}
}, 1000);
function runMainLogic() {
clearInterval(mainLogicInterval);
document.getElementById(BUTTON_ID)?.remove();
mainLogicInterval = setInterval(() => {
const toolbar = document.querySelector('.video-toolbar-right');
if (toolbar) {
clearInterval(mainLogicInterval);
createAndSearch(toolbar);
}
}, 300);
}
function createAndSearch(toolbar) {
if (document.getElementById(BUTTON_ID)) return;
console.log("B2YT: 找到工具栏,正在注入新按钮...");
const ytButton = document.createElement('a');
ytButton.id = BUTTON_ID;
ytButton.innerText = 'YT查找中...';
ytButton.style.cssText = `
margin-left: 10px; padding: 4px 8px; border-radius: 4px;
background-color: #00a1d6; color: white; text-decoration: none;
font-size: 14px; cursor: not-allowed; opacity: 0.7;
`;
toolbar.appendChild(ytButton);
const titleElement = document.querySelector('h1.video-title');
if (!titleElement || !titleElement.title) {
updateButtonState(ytButton, 'error', '未能获取B站标题');
return;
}
const biliTitle = titleElement.title;
searchAndVerifyOnYouTube(biliTitle, ytButton);
}
function searchAndVerifyOnYouTube(bTitle, button) {
// 动态读取已保存的阈值,如果不存在则使用 0.6 作为默认值
const SIMILARITY_THRESHOLD = GM_getValue('similarity_threshold', 0.6);
const searchUrl = `https://www.youtube.com/results?search_query=${encodeURIComponent(bTitle)}`;
console.log("--- B2YT 开始查找 ---");
console.log(`B站原始标题: ${bTitle}`);
console.log(`当前匹配阈值设置为: ${SIMILARITY_THRESHOLD * 100}%`);
GM_xmlhttpRequest({
method: 'GET', url: searchUrl, onload: function(response) {
if (response.status >= 200 && response.status < 300) {
const regex = /\/watch\?v=([a-zA-Z0-9_-]{11})/;
const match = response.responseText.match(regex);
if (match && match[0]) {
const firstVideoUrl = `https://www.youtube.com${match[0]}`;
console.log(`[步骤1成功] 找到首个候选视频URL: ${firstVideoUrl}`);
GM_xmlhttpRequest({
method: 'GET', url: firstVideoUrl, onload: function(videoPageResponse) {
try {
const parser = new DOMParser();
const doc = parser.parseFromString(videoPageResponse.responseText, "text/html");
const rawYtTitle = doc.title;
if (rawYtTitle && rawYtTitle !== 'YouTube') {
const ytTitle = rawYtTitle.replace(/ - YouTube$/, '').trim();
console.log(`[步骤2成功] 获取到候选视频的真实标题: ${rawYtTitle}`);
console.log(`[步骤2.1] 移除后缀后的YT标题: ${ytTitle}`);
const similarity = calculateStringSimilarity(bTitle, ytTitle);
console.log(`[最终对比] 相似度: ${(similarity * 100).toFixed(2)}%`);
if (similarity >= SIMILARITY_THRESHOLD) {
updateButtonState(button, 'success', firstVideoUrl);
} else {
updateButtonState(button, 'no_match', searchUrl);
}
} else {
console.error("[步骤2失败] 未能从视频页提取到有效标题。");
updateButtonState(button, 'no_match', searchUrl);
}
} catch (e) {
console.error("[步骤2失败] 解析视频页面时发生严重错误:", e);
updateButtonState(button, 'error', "解析YT页面失败");
}
}, onerror: function() { console.error("[步骤2失败] 请求候选视频页面时发生网络错误。"); updateButtonState(button, 'error', "验证视频失败"); }
});
} else { console.error("[步骤1失败] 未能在搜索结果页找到任何视频链接。"); updateButtonState(button, 'no_match', searchUrl); }
} else { updateButtonState(button, 'error', '请求失败: ' + response.status); }
}, onerror: function() { updateButtonState(button, 'error', '网络请求失败'); }
});
}
/**
* 工具函数:净化标题,移除所有非字母、非数字的字符
*/
function normalizeTitle(str) {
if (!str) return '';
return str.toLowerCase().replace(/[^\p{L}\p{N}]/gu, '');
}
/**
* 工具函数:计算两个字符串的相似度
*/
function calculateStringSimilarity(str1, str2) {
const s1 = normalizeTitle(str1);
const s2 = normalizeTitle(str2);
if (s1 === s2) return 1.0;
if (s1.length < 2 || s2.length < 2) return 0.0;
const firstBigrams = new Map();
for (let i = 0; i < s1.length - 1; i++) {
const bigram = s1.substring(i, i + 2);
const count = firstBigrams.has(bigram) ? firstBigrams.get(bigram) + 1 : 1;
firstBigrams.set(bigram, count);
}
let intersectionSize = 0;
for (let i = 0; i < s2.length - 1; i++) {
const bigram = s2.substring(i, i + 2);
if (firstBigrams.has(bigram) && firstBigrams.get(bigram) > 0) {
intersectionSize++;
firstBigrams.set(bigram, firstBigrams.get(bigram) - 1);
}
}
return (2.0 * intersectionSize) / (s1.length + s2.length - 2);
}
/**
* 工具函数:更新按钮的状态
*/
function updateButtonState(button, state, info) {
if (!button) return;
button.style.cursor = 'pointer';
button.style.opacity = '1';
button.target = '_blank';
switch (state) {
case 'success':
button.innerText = '✅ 打开 Youtube 视频';
button.href = info;
button.style.backgroundColor = '#4CAF50';
break;
case 'no_match':
button.innerText = '🟡 匹配度低';
button.href = info;
button.style.backgroundColor = '#FFC107';
break;
case 'error':
button.innerText = `⚠️ ${info}`;
button.href = 'javascript:void(0);';
button.target = '';
button.style.cursor = 'not-allowed';
button.style.backgroundColor = '#f44336';
break;
}
}
})();