Last active
June 18, 2024 12:30
-
-
Save jim60105/43b2c53bb59fb588e351982c1a14e273 to your computer and use it in GitHub Desktop.
Youtube: 自動拍手機器
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name Youtube: Automatic clapping machine | |
// @name:zh Youtube: 自動拍手機器 | |
// @version 1.4.5 | |
// @description 在其它人拍手時自動跟著一起拍 | |
// @author 琳(jim60105) | |
// @match https://www.youtube.com/* | |
// @icon https://www.google.com/s2/favicons?sz=64&domain=youtube.com | |
// @license GPL3 | |
// ==/UserScript== | |
(function () { | |
'use strict'; | |
/** | |
* 注意: 這個腳本只能在 Youtube 的直播聊天室使用 | |
* | |
* 若聊天室不是在前景,Youtube 可能會停止更新聊天室,導致功能停止 | |
* | |
* 聊天室由背景來到前景時,或是捲動停住後回到最下方時,有可能因為訊息一口氣噴出來而直接觸發 | |
* 請調整下方的 throttle 數值,以避免這種情況 | |
* 訊息過多時預設的1.5秒可能會不夠,但設定太高會影響偵測判定 | |
* 訊息過多時建議直接F5重整,不要讓它一直跑 | |
* | |
* 要使用或偵測 Youtube 貼圖/會員貼圖,可填入像是這種格式 :_右サイリウム::_おんぷちゃん::_ハート: | |
* 若你有使用貼圖的權限,它就能自動轉換成貼圖,請小心使用 | |
*/ | |
// --- 設定區塊 --- | |
/** | |
* 要偵測的觸發字串 | |
* 這是一個文字陣列,這些字串偵測到時就會記數觸發 | |
* 可以輸入多個不同頻道的會員拍手貼圖做為偵測字串 | |
*/ | |
const stringToDetect = [ | |
':clapping_hands::clapping_hands::clapping_hands:', // 這是三個拍手表符(👏👏👏) | |
':_拍手8::_拍手8::_拍手8:', // 以下是其它頻道的三拍手會員貼圖 | |
':_clap::_clap::_clap:', | |
':_yuriClap::_yuriClap::_yuriClap::_yuriClap:', | |
':_pachipachi::_pachipachi::_pachipachi:', | |
]; | |
/** | |
* 要發出去的字串 | |
*/ | |
const stringToReply = '👏✨👏✨👏✨👏✨👏'; | |
// 範例條件說明: | |
// 偵測到「4」次字串才觸發 | |
// (同一則訊息內重覆比對時只會計算一次) | |
// 在「1.5」秒內重覆被偵測到也只計算一次 | |
// 偵測間隔不得超過「10」秒,超過的話就重新計算 | |
// 自動發話後至少等待「120」秒後才會再次自動發話 | |
/** | |
* 要偵測的次數 | |
*/ | |
const triggerCount = 4; | |
/** | |
* 每次間隔不得超過的秒數 | |
*/ | |
const triggerBetweenSeconds = 10; | |
/** | |
* 自動發話後至少等待的秒數 | |
*/ | |
const minTimeout = 120; | |
/** | |
* 在這個秒數內重覆偵測到觸發字串,至多只會計算一次 | |
* (這是用來避免當視窗由背景來到前景時,聊天記錄一口氣噴出來造成誤觸發) | |
*/ | |
const throttle = 1.5; | |
// --- 設定區塊結束 --- | |
let lastDetectTime = new Date(null); | |
let currentDetectCount = 0; | |
let lastTriggerTime = new Date(null); | |
if (window.location.pathname.startsWith('/embed')) return; | |
if ( | |
typeof ytInitialData !== 'undefined' && | |
ytInitialData.continuationContents?.liveChatContinuation?.isReplay | |
) { | |
console.debug('Replay mode, exit.'); | |
return; | |
} | |
onAppend( | |
document | |
.getElementsByTagName('yt-live-chat-item-list-renderer')[0] | |
?.querySelector('#items'), | |
function (added) { | |
added.forEach((node) => { | |
console.debug('Messages node: ', node); | |
const text = GetMessage(node); | |
if (!text) return; | |
if (!DetectMatch(text)) return; | |
if (!CheckTriggerCount()) return; | |
if (!CheckTimeout()) return; | |
SendMessage(stringToReply); | |
}); | |
} | |
); | |
function onAppend(elem, f) { | |
if (!elem) return; | |
var observer = new MutationObserver(function (mutations) { | |
mutations.forEach(function (m) { | |
if (m.addedNodes.length) { | |
f(m.addedNodes); | |
} | |
}); | |
}); | |
observer.observe(elem, { childList: true }); | |
} | |
function GetMessage(node) { | |
const messageNode = node.querySelector('#message'); | |
if (!messageNode) return ''; | |
let text = messageNode.innerText; | |
const emojis = messageNode.getElementsByTagName('img'); | |
for (const emojiNode of emojis) { | |
text += emojiNode.getAttribute('shared-tooltip-text'); | |
} | |
console.debug('Message: ', text); | |
return text; | |
} | |
function DetectMatch(text) { | |
let match = false; | |
stringToDetect.forEach((p) => { | |
match |= text.includes(p); | |
}); | |
if (!match) return false; | |
console.debug(`Matched!`); | |
if (lastDetectTime.valueOf() + throttle * 1000 >= Date.now()) { | |
console.debug('Throttle detected'); | |
return false; | |
} | |
if (lastDetectTime.valueOf() + triggerBetweenSeconds * 1000 < Date.now()) { | |
currentDetectCount = 1; | |
console.debug('Over max trigger seconds. Reset detect count to 1.'); | |
} else { | |
currentDetectCount++; | |
} | |
lastDetectTime = Date.now(); | |
console.debug(`Count: ${currentDetectCount}`); | |
return true; | |
} | |
function CheckTriggerCount() { | |
const shouldTrigger = currentDetectCount >= triggerCount; | |
if (shouldTrigger) console.debug('Triggered!'); | |
return shouldTrigger; | |
} | |
function CheckTimeout() { | |
const isInTimeout = lastTriggerTime.valueOf() + minTimeout * 1000 > Date.now(); | |
if (isInTimeout) console.debug('Still waiting for minTimeout'); | |
return !isInTimeout; | |
} | |
function SendMessage(message) { | |
try { | |
const input = document | |
.getElementsByTagName('yt-live-chat-text-input-field-renderer')[0] | |
?.querySelector('#input'); | |
if (!input) { | |
console.warn('Cannot find input element'); | |
console.warn('可能是訂閱者專屬模式?'); | |
return; | |
} | |
const data = new DataTransfer(); | |
data.setData('text/plain', message); | |
input.dispatchEvent( | |
new ClipboardEvent('paste', { bubbles: true, clipboardData: data }) | |
); | |
setTimeout(() => { | |
// Youtube is 💩 that they're reusing the same ID | |
const buttons = document.querySelectorAll('#send-button'); | |
// Click any buttons under #send-button | |
buttons.forEach((b) => { | |
const _buttons = b.getElementsByTagName('button'); | |
// HTMLCollection not array | |
Array.from(_buttons).forEach((_b) => { | |
_b.click(); | |
}); | |
}); | |
console.log(`[${new Date().toISOString()}]自動發話觸發: ${message}`); | |
}, 500); | |
} finally { | |
lastTriggerTime = Date.now(); | |
currentDetectCount = 0; | |
} | |
} | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment