Skip to content

Instantly share code, notes, and snippets.

@getjump
Created November 2, 2021 09:18
Show Gist options
  • Save getjump/2c35893e4de3885e1082f53a9cf5de80 to your computer and use it in GitHub Desktop.
Save getjump/2c35893e4de3885e1082f53a9cf5de80 to your computer and use it in GitHub Desktop.
Allows to manipulate predictions via chat commands (was done when there were no api interface, mostly broken)
import puppeteer from 'puppeteer';
const fs = require('fs').promises;
const {promisify} = require('util')
const readline = require('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
readline.Interface.prototype.question[promisify.custom] = function(prompt: any) {
return new Promise(resolve =>
readline.Interface.prototype.question.call(this, prompt, resolve),
);
};
readline.Interface.prototype.questionAsync = promisify(
readline.Interface.prototype.question,
);
const puppeteerOptions = {
headless: false,
args: ['--lang=en-US,en']
};
const PREDICTION_PERIOD_TO_TIME = {
'1m': '60',
'2m': '120',
'5m': '300',
'10m': '600',
'15m': '900',
'20m': '1200',
'30m': '1800'
};
const PREDICTION_COMMAND = '/prediction';
const RE_CHAT_COMMAND: RegExp = /(!\S+)\ ?([\S ]*)/
const START_PREDICTION_COMMAND = '!startp';
const FINISH_PREDICTION_COMMAND = '!finishp';
const DELETE_PREDICTION_COMMAND = '!deletep';
const MAX_LEN_PREDICTION_NAME = 45
const MAX_LEN_PREDICTION_INPUT = 25
const KEY_CODE_ENTER = String.fromCharCode(13);
const SELECTOR_PREDICTION_BUTTON = '[data-a-target="/prediction"]';
const SELECTOR_CHAT_INPUT = '[autocomplete=twitch-chat]';
const SELECTOR_CLOSE_MODAL = '[aria-label="Close"]';
const SELECTOR_OUTCOME = '[data-test-selector="outcome-selection-modal__outcome"] ~ label';
const SELECTOR_PREDICTION_NAME = '#prediction-details-step__prediction-name'
const SELECTOR_PREDICTION_INPUTS = '.prediction-outcome-input__badge ~ div input[type=text]'
const SELECTOR_SUBMISSION_PERIOD_SELECT = '#prediction-details-step__prediction-window'
const SELECTOR_XPATH_START_PREDICTION = '//button[contains(., \'Начать прогноз\')]'
const SELECTOR_XPATH_CONFIRM_PREDICTION = '//button[contains(., \'Все понятно\')]'
const SELECTOR_START_PREDICTION_BUTTON = '[data-test-selector="prediction-summary-step__create-button"]'
const SELECTOR_CONFIRM_OUTCOME_BUTTON = '[data-test-selector="outcome-selection-modal__confirm-button"]';
const SELECTOR_CONFIRMATION_OUTCOME_BUTTON = '[data-test-selector="outcome-confirmation-modal__confirm-button"]';
const SELECTOR_DELETE_PREDICTION_BUTTON = '[data-test-selector="prediction-summary-step__delete-button"]';
const SELECTOR_CHOOSE_OUTCOME_BUTTON = '[data-test-selector="prediction-summary-step__choose-outcome-button"]';
const SELECTOR_CHAT_CONTAINER = '[data-test-selector="chat-scrollable-area__message-container"]';
const SELECTOR_CHAT_LINE_MESSAGE = '.chat-line__message';
async function processChat(page: puppeteer.Page) {
console.log('Setting chat processor');
await page.waitForSelector(SELECTOR_CHAT_CONTAINER);
await page.exposeFunction('listenChatUpdates', () => {
(async () => {
const el = await page.waitForSelector(`${SELECTOR_CHAT_CONTAINER} .chat-line__message:last-child`)
const usernameContainer = await el?.$('.chat-line__username-container')
const badgesImages = await usernameContainer?.$$('span:first-child img.chat-badge')
const _username = await page.evaluate(element => element.textContent, usernameContainer!)
const messageContainer = await el?.$('[data-a-target="chat-message-text"]')
const message = await page.evaluate(element => element.textContent, messageContainer!)
let isModerator = false;
for (const badgeImage of badgesImages!) {
const altValue = await page.evaluate(element => element.getAttribute('alt'), badgeImage)
if (altValue === 'Moderator' || altValue === 'Broadcaster' || _username === 'getjump') {
isModerator = true;
}
}
if (!isModerator) {
return;
}
console.log(`New message: ${message} from ${_username}`)
const match = RE_CHAT_COMMAND.exec(<string> message);
if (match === null) {
return;
}
if (match[1] === START_PREDICTION_COMMAND) {
const args = match[2].split(',')
if (args.length < 3) {
// Exception
}
console.log(`Publishing prediction ${args[0]} with arguments ${args.slice(1)}`)
await publishPrediction(page, args[0], args.slice(1));
} else if (match[1] == FINISH_PREDICTION_COMMAND) {
console.log(`Outcome for prediction ${parseInt(match[2], 10)}`)
await chooseOutcome(page, parseInt(match[2], 10));
} else if (match[1] == DELETE_PREDICTION_COMMAND) {
console.log(`Deleting prediction`);
await deletePrediction(page);
}
})();
})
await page.exposeFunction('deepClone', deepClone)
await page.evaluate((SELECTOR_CHAT_CONTAINER) => {
const target = document.querySelector(SELECTOR_CHAT_CONTAINER)
const observer = new MutationObserver(mutations => {
for (const mutation of mutations) {
if (mutation.type === 'childList') {
if (mutation.addedNodes.length > 0) {
if ((<Element> mutation.addedNodes[0]).classList.contains('chat-line__message') && !(<Element> mutation.addedNodes[0]).classList.contains('tw-align-items-center')) {
// @ts-ignore
listenChatUpdates();
}
}
}
}
})
observer.observe(target!, { childList: true })
}, SELECTOR_CHAT_CONTAINER);
}
async function isAuthenticatedTwitch(page: puppeteer.Page) {
if (await page.$('[data-a-target="login-button"]') !== null) {
return false;
}
return true;
}
async function isPredictionInProgress(page: puppeteer.Page) {
if (await page.$(SELECTOR_DELETE_PREDICTION_BUTTON) !== null) {
return true;
}
return false;
}
async function navigateToPredictionModal (page: puppeteer.Page) {
await page.waitForSelector(SELECTOR_CHAT_INPUT)
await page.click(SELECTOR_CHAT_INPUT)
await page.type(SELECTOR_CHAT_INPUT, PREDICTION_COMMAND)
// await page.type(SELECTOR_CHAT_INPUT, KEY_CODE_ENTER);
await page.click('[data-a-target="chat-send-button"]')
}
async function closePredictionModal (page: puppeteer.Page) {
await page.waitForSelector(SELECTOR_CLOSE_MODAL);
await page.click(SELECTOR_CLOSE_MODAL);
}
async function processInProgress (page: puppeteer.Page) {
await navigateToPredictionModal(page);
if (!isPredictionInProgress(page)) {
return;
}
await closePredictionModal(page);
}
async function deletePrediction (page: puppeteer.Page) {
await navigateToPredictionModal(page);
const handle = await page.waitForSelector(SELECTOR_DELETE_PREDICTION_BUTTON);
await handle?.click()
const confirm = await page.waitForSelector('[data-test-selector="prediction-delete-confirmation__confirm"]')
await confirm?.click()
}
async function chooseOutcome (page: puppeteer.Page, outcomeNumber: number) {
await navigateToPredictionModal(page);
const chooseOutcome = await page.waitForSelector(SELECTOR_CHOOSE_OUTCOME_BUTTON)
await chooseOutcome?.click()
const outcomesButtons = await page.$$(SELECTOR_OUTCOME)
if (outcomesButtons.length < outcomeNumber) {
// SHINIMA HUYNYA
}
const outcomeButton = outcomesButtons[outcomeNumber]
await outcomeButton.click()
const confirmButton = await page.waitForSelector(SELECTOR_CONFIRM_OUTCOME_BUTTON)
await confirmButton?.click()
let confirmationButton = null;
try {
confirmationButton = await page.waitForSelector(SELECTOR_CONFIRMATION_OUTCOME_BUTTON, { timeout: 1000 })
} catch {}
await confirmationButton?.click()
}
async function publishPrediction (page: puppeteer.Page, predictionName: string, predictions: string[],
predictionPeriod: '1m' | '2m' | '5m' | '10m' | '15m' | '20m' | '30m' = '1m') {
await navigateToPredictionModal(page);
if (!(predictionPeriod in PREDICTION_PERIOD_TO_TIME)) {
// Exception
return;
}
if (predictionName.length > MAX_LEN_PREDICTION_NAME) {
//Exception
}
const predictionTime = PREDICTION_PERIOD_TO_TIME[predictionPeriod]
if (predictions.length > 2) {
// Not supported (just yet?)
}
console.log('Check if prediction is in progress');
if (await isPredictionInProgress(page)) {
console.log('Prediction is in progress');
return;
// Do nothing? Exception?
}
let startButton = null;
try {
startButton = await page.waitForSelector(SELECTOR_START_PREDICTION_BUTTON, {
timeout: 1000
})
} catch {}
if (startButton) {
await startButton!.click()
}
const predictionNameEl = await page.waitForSelector(SELECTOR_PREDICTION_NAME)
await predictionNameEl?.type(predictionName)
await page.waitForSelector(SELECTOR_PREDICTION_INPUTS)
const predictionsFields = await page.$$(SELECTOR_PREDICTION_INPUTS)
if (predictionsFields.length !== predictions.length) {
// Exception?
}
for (let idx = 0; idx < predictions.length; idx ++) {
if (predictions[idx].length > MAX_LEN_PREDICTION_INPUT) {
// Exception
}
await predictionsFields[idx].type(predictions[idx].trim())
}
const select = await page.waitForSelector(SELECTOR_SUBMISSION_PERIOD_SELECT)
select?.select(predictionTime)
const [button] = await page.$x(SELECTOR_XPATH_START_PREDICTION)
if (button) {
await button.click()
}
try {
await page.waitForXPath(SELECTOR_XPATH_CONFIRM_PREDICTION, {
timeout: 500
})
} catch {}
const [confirmButton] = await page.$x(SELECTOR_XPATH_CONFIRM_PREDICTION)
if (confirmButton) {
await confirmButton.click()
}
}
async function twitchAuthenticate(page: puppeteer.Page, username: string, password: string) {
await page.goto('https://www.twitch.tv/')
const loginModalButton = await page.waitForSelector('[data-a-target="login-button"]')
await loginModalButton?.click()
const loginButton = await page.waitForSelector('[data-a-target="passport-login-button"]')
await page.type('#login-username', username, { delay: 105 })
await page.type('#password-input', password, { delay: 115 })
await loginButton?.click()
await page.waitForSelector('[data-test-selector="auth-shell-header-header"]')
const code = await rl.questionAsync('Enter 2fa code: ')
console.log(code)
await page.focus('[pattern="[0-9]*"]')
await page.type('[pattern="[0-9]*"]', code, { delay: 125 })
}
(async () => {
const browser = await puppeteer.launch(puppeteerOptions)
const page = await browser.newPage()
await page.setViewport({ width: 1647, height: 1042 })
await page.setExtraHTTPHeaders({'Accept-Language': 'en'})
const cookiesString = await fs.readFile('./cookies.json');
let cookies = JSON.parse(cookiesString);
await page.setCookie(...cookies);
await page.goto('https://www.twitch.tv', {
waitUntil: 'networkidle0',
})
console.log('Checking for authentication');
const isAuthenticated = await isAuthenticatedTwitch(page)
console.log(`Authenticated: ${isAuthenticated}`)
if (!isAuthenticated) {
console.log('Authenticating');
await twitchAuthenticate(page, LOGIN, PASSWORD)
await page.waitForNavigation();
}
cookies = await page.cookies()
await fs.writeFile('./cookies.json', JSON.stringify(cookies, null, 2));
await page.goto('https://www.twitch.tv/getjump', {
waitUntil: 'networkidle0',
})
try {
const chatTab = await page.waitForSelector('[data-a-target="channel-home-tab-Chat"]', {
timeout: 2500,
})
await chatTab?.click()
} catch {}
await processChat(page)
})()
function deepClone(obj: any, hash = new WeakMap()): any {
console.log('test');
// Do not try to clone primitives or functions
if (Object(obj) !== obj || obj instanceof Function) return obj;
if (hash.has(obj)) return hash.get(obj); // Cyclic reference
try { // Try to run constructor (without arguments, as we don't know them)
var result = new obj.constructor();
} catch(e) { // Constructor failed, create object without running the constructor
result = Object.create(Object.getPrototypeOf(obj));
}
// Optional: support for some standard constructors (extend as desired)
if (obj instanceof Map)
Array.from(obj, ([key, val]) => result.set(deepClone(key, hash),
deepClone(val, hash)) );
else if (obj instanceof Set)
Array.from(obj, (key) => result.add(deepClone(key, hash)) );
// Register in hash
hash.set(obj, result);
// Clone and assign enumerable own properties recursively
return Object.assign(result, ...Object.keys(obj).map (
key => ({ [key]: deepClone(obj[key], hash) }) ));
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment