Last active
November 13, 2025 21:30
-
-
Save shinmai/340d8d851f3b2a09cca921c244747bef to your computer and use it in GitHub Desktop.
LurkEngaygement - twitch engaygement enhancer
This file contains hidden or 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 LurkEngaygement | |
| // @namespace https://twitch.lurk.shi.wtf | |
| // @author @shi - shi.wtf | |
| // @version 0.3 | |
| // @description twitch engaygement enhancer (fuck jeff bezos) | |
| // @match https://www.twitch.tv/* | |
| // @run-at document-end | |
| // @updateURL https://gist.github.com/shinmai/340d8d851f3b2a09cca921c244747bef/raw/LurkEngaygement.user.js | |
| // @downloadURL https://gist.github.com/shinmai/340d8d851f3b2a09cca921c244747bef/raw/LurkEngaygement.user.js | |
| // @grant none | |
| // ==/UserScript== | |
| /** | |
| * Changelog: | |
| * 0.1 (251113) - initial version | |
| * 0.2 (251113) - prefix for clarity | |
| * 0.3 (251113) - bugfix, add indicator | |
| */ | |
| (function () { | |
| "use strict"; | |
| // --------------------------------------------------------------------- | |
| // config | |
| // --------------------------------------------------------------------- | |
| const MIN_DELAY_MIN = 20, | |
| MAX_DELAY_MIN = 28, | |
| MARKOV_MAX_WORDS = 7, | |
| corpusText = ` | |
| this is some example text for the markov chain | |
| replace this block with your own phrases or chat logs or whatever | |
| the more text you give it the more interesting the output will be | |
| ` | |
| // --------------------------------------------------------------------- | |
| // markov chain generator | |
| // --------------------------------------------------------------------- | |
| function buildMarkovChain(text) { | |
| const words = text.replace(/\s+/g, " ").trim().split(" ").filter(Boolean), | |
| chain = Object.create(null) | |
| for(let i = 0; i < words.length - 1; i++) { | |
| const w1 = words[i], w2 = words[i + 1]; | |
| if(!chain[w1]) chain[w1] = [] | |
| chain[w1].push(w2) | |
| } | |
| return chain | |
| } | |
| function generateMarkov(chain, maxWords = MARKOV_MAX_WORDS) { | |
| const keys = Object.keys(chain) | |
| if(!keys.length) return "" | |
| let word = keys[Math.floor(Math.random() * keys.length)] | |
| const out = [word] | |
| for(let i = 1; i < maxWords; i++) { | |
| const options = chain[word] | |
| if(!options || !options.length) break | |
| word = options[Math.floor(Math.random() * options.length)] | |
| out.push(word) | |
| } | |
| return out.join(" ") | |
| } | |
| const markovChain = buildMarkovChain(corpusText) | |
| // --------------------------------------------------------------------- | |
| // twitch chat helpers | |
| // --------------------------------------------------------------------- | |
| let chatInput = null | |
| const CHAT_INPUT = 'textarea[data-a-target="chat-input"], div[data-a-target="chat-input"]'; | |
| function getReactInstance(element) { | |
| for(const key in element) | |
| if(key.startsWith('__reactInternalInstance$') || key.startsWith('__reactFiber$')) return element[key] | |
| return null | |
| } | |
| function searchReactParents(node, predicate, maxDepth = 15, depth = 0) { | |
| try { if (predicate(node)) return node } catch (_) {} | |
| if(!node || depth > maxDepth) return null | |
| const {return: parent} = node | |
| if(parent) return searchReactParents(parent, predicate, maxDepth, depth + 1) | |
| return null | |
| } | |
| function getChatInput(element = null) { | |
| let chatInput | |
| try { chatInput = searchReactParents(getReactInstance(element || document.querySelector(CHAT_INPUT)), (n) => n.memoizedProps && n.memoizedProps.componentType != null && n.memoizedProps.value != null) } catch (_) {} | |
| return chatInput | |
| } | |
| function getChatInputEditor(element = null) { | |
| let chatInputEditor | |
| try { chatInputEditor = searchReactParents(getReactInstance(element || document.querySelector(CHAT_INPUT)), (n) => n.memoizedProps?.value?.editor != null || n.stateNode?.state?.slateEditor != null) } catch (_) {} | |
| return chatInputEditor?.memoizedProps?.value?.editor ?? chatInputEditor?.stateNode?.state?.slateEditor | |
| } | |
| function getChatInputValue() { | |
| const element = document.querySelector(CHAT_INPUT), | |
| {value: currentValue} = element | |
| if(currentValue != null) return currentValue | |
| const chatInput = getChatInput(element) | |
| if(chatInput == null) return null | |
| return chatInput.memoizedProps.value | |
| } | |
| function setChatInputValue(text) { | |
| const element = document.querySelector(CHAT_INPUT) | |
| const {value: currentValue, selectionStart} = element | |
| if(currentValue != null) { | |
| element.value = text | |
| element.dispatchEvent(new Event('input', {bubbles: true})) | |
| const instance = getReactInstance(element) | |
| if(instance) { | |
| const props = instance.memoizedProps | |
| if(props && props.onChange) props.onChange({target: element}) | |
| } | |
| const selectionEnd = selectionStart + text.length | |
| element.setSelectionRange(selectionEnd, selectionEnd) | |
| return | |
| } | |
| const chatInput = getChatInput(element) | |
| if(chatInput == null) return | |
| chatInput.memoizedProps.value = text | |
| chatInput.memoizedProps.setInputValue(text) | |
| chatInput.memoizedProps.onValueUpdate(text) | |
| } | |
| function onChatKeyDown(ev) { | |
| if(!chatInput) return | |
| if(ev.key !== "Enter" || ev.shiftKey) return | |
| const value = getChatInputValue(), | |
| trimmed = value.trim().toLowerCase() | |
| if(trimmed.startsWith("!lurk")) startLurk() | |
| else if(trimmed.startsWith("!unlurk")) stopLurk() | |
| } | |
| function installChatListener() { | |
| if(chatInput) return | |
| const input = document.querySelector('[role="textbox"]') | |
| if(!input) return | |
| chatInput = input | |
| chatInput.addEventListener("keydown", onChatKeyDown, true) | |
| console.log("[LurkEngaygement] chat input hooked") | |
| } | |
| function removeChatListener() { | |
| if(!chatInput) return | |
| chatInput.removeEventListener("keydown", onChatKeyDown, true) | |
| chatInput = null | |
| } | |
| function sendChatMessage(text) { | |
| setChatInputValue(text) | |
| requestAnimationFrame(()=>document.querySelector('[data-a-target="chat-send-button"]').click()) | |
| } | |
| // --------------------------------------------------------------------- | |
| // lurk timer logic (chained timeouts) | |
| // --------------------------------------------------------------------- | |
| let lurking = false, | |
| lurkTimeout = null | |
| function getRandomDelayMs() { | |
| const min = MIN_DELAY_MIN * 60 * 1000 | |
| const extraRange = (MAX_DELAY_MIN - MIN_DELAY_MIN) * 60 * 1000 | |
| return min + Math.random() * extraRange | |
| } | |
| function scheduleNextTick() { | |
| if(!lurking) return | |
| const delay = getRandomDelayMs() | |
| console.log(`[LurkEngaygement] next message in ~${ Math.round(delay / 60000) } minutes`); | |
| lurkTimeout = setTimeout(onLurkTick, delay) | |
| } | |
| function onLurkTick() { | |
| lurkTimeout = null | |
| if(!lurking) return | |
| if(isStreamOffline()) { | |
| console.log("[LurkEngaygement] stream offline detected, stopping lurk") | |
| stopLurk() | |
| return | |
| } | |
| const msg = generateMarkov(markovChain) | |
| if(msg.trim()) sendChatMessage(`nonsense for engaygement: ${msg}`) | |
| scheduleNextTick() | |
| } | |
| function showLurkBadge() { | |
| if(document.getElementById("lurking-indicator")) return | |
| const el = document.createElement("div") | |
| el.id = "lurking-indicator" | |
| el.textContent = "lurking..." | |
| Object.assign(el.style, { | |
| position: "fixed", | |
| bottom: "10px", | |
| right: "100px", | |
| padding: "6px 10px", | |
| background: "rgba(128, 0, 0, 0.6)", | |
| color: "#fff", | |
| font: "12px sans-serif", | |
| borderRadius: "4px", | |
| zIndex: "2147483647", | |
| opacity: "0.85", | |
| pointerEvents: "none", | |
| }) | |
| document.body.appendChild(el) | |
| } | |
| function hideLurkBadge() { document.getElementById("lurking-indicator")?.remove() } | |
| function startLurk() { | |
| if(lurking) return | |
| if(isStreamOffline()) return | |
| lurking = true | |
| showLurkBadge() | |
| console.log("[LurkEngaygement] lurk started") | |
| scheduleNextTick() | |
| } | |
| function stopLurk() { | |
| if(!lurking) return | |
| lurking = false | |
| hideLurkBadge() | |
| if (lurkTimeout) { | |
| clearTimeout(lurkTimeout) | |
| lurkTimeout = null | |
| } | |
| console.log("[LurkEngaygement] lurk stopped") | |
| } | |
| // --------------------------------------------------------------------- | |
| // stream offline / navigation detection | |
| // --------------------------------------------------------------------- | |
| function isStreamOffline() { return document.querySelector('.tw-channel-status-text-indicator')?.textContent.toLowerCase()!="live" } | |
| function onLocationChange() { | |
| stopLurk() | |
| removeChatListener() | |
| installChatListener() | |
| } | |
| const origPushState = history.pushState | |
| history.pushState = function () { | |
| origPushState.apply(this, arguments) | |
| onLocationChange() | |
| } | |
| const origReplaceState = history.replaceState | |
| history.replaceState = function () { | |
| origReplaceState.apply(this, arguments); | |
| onLocationChange() | |
| } | |
| window.addEventListener("popstate", onLocationChange) | |
| window.addEventListener("beforeunload", () => stopLurk()) | |
| // --------------------------------------------------------------------- | |
| // DOM watcher to wait for chat box | |
| // --------------------------------------------------------------------- | |
| const observer = new MutationObserver(() => { if(!chatInput) installChatListener() }) | |
| if (document.body) { | |
| observer.observe(document.body, { childList: true, subtree: true }) | |
| installChatListener() | |
| } | |
| })() |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
If you have a User Script extension/addon for your browser installed (GreseMonkey, TamperMonkey, ViolentMonkey, a lot of monkeys), visit this link to install.
Edit the
MIN_DELAY_MIN,MAX_DELAY_MIN,MARKOV_MAX_WORDS, andcorpusTextconstants at the top of the script to configure (the delays are fine, but do replace the text corpus it generates messages from at the very least).