Last active
May 13, 2020 16:26
-
-
Save conundrumer/209304cb040bd1a28bf0219f9cd97a41 to your computer and use it in GitHub Desktop.
yt-ad-buster.js: Prevents a YouTube video from being used as an ad by toggling visibility when views from ad are detected.
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
/* yt-ad-buster.js: Prevents a YouTube video from being used as an ad by toggling visibility when views from ad are detected. | |
Usage: | |
Go to video "Details" page and run this script. | |
To stop, focus on main page and press Escape. | |
When running, the main page header should turn RED. | |
When stopped, the header should turn light blue. | |
If the main page is hidden in a tab, the automation may run slower. | |
If it runs too fast, YouTube will rate limit with 429 RESOURCE_EXHAUSTED errors. | |
*/ | |
class AdBusterRunner { | |
/** | |
* @param {AdBusterYoutubeApi} analytics | |
* @param {AdBusterDatabase} database | |
* @param {AdBusterDetector} detector | |
*/ | |
constructor (videoId, analytics, database, detector) { | |
this.videoId = videoId | |
this.api = analytics | |
this.database = database | |
this.detector = detector | |
this.errorTimeout = 1000 * 10 // something failed to load, wait 10 seconds | |
// wait 5 seconds to retry after these errors | |
this.offlineTimeout = 1000 * 5 | |
this.updateFailTimeout = 1000 * 5 // yellow eye warning | |
this.cancelled = false | |
// this.analyticsUploadDelay = hashString(videoId) % (1000 * 60) // one minute | |
this.onKeydown = (e) => { | |
if (e.key === 'Escape') { | |
this.stop() | |
} | |
} | |
} | |
setupMainInfo(win = window) { | |
const mainInfo = win.document.body.appendChild(win.document.createElement('div')) | |
mainInfo.id = "yt-auto-toggler-info" | |
mainInfo.style.position = "fixed" | |
mainInfo.style.backgroundColor = 'rgba(230,255,230,0.9)' | |
return mainInfo | |
} | |
/** @param win {Window} */ | |
setupPopupInfo(win = window) { | |
const popupInfo = win.document.body.appendChild(win.document.createElement('div')) | |
popupInfo.style.position = "fixed" | |
popupInfo.style.backgroundColor = 'rgba(255,230,255,0.9)' | |
return popupInfo | |
} | |
sleep(t = 0) { | |
return new Promise((resolve) => { | |
this.sleepTimer = setTimeout(() => { | |
if (!this.cancelled) { | |
resolve() | |
} | |
}, t) | |
}) | |
} | |
async waitUntilOnline() { | |
let notifiedOffline = false | |
while (!navigator.onLine) { | |
if (!notifiedOffline) { | |
console.warn(`[${new Date().toLocaleString()}] Offline!`) | |
notifiedOffline = true | |
} | |
await this.sleep(this.offlineTimeout) // poll until online again | |
} | |
} | |
async toggleVisibility(onSetPrivateOnce = () => {}) { | |
let didSetPrivate = false | |
// keep attempting to toggle until successful | |
console.info(`[${new Date().toLocaleString()}] Toggling visibility!`) | |
while (true) { | |
await this.waitUntilOnline() | |
try { | |
if (!didSetPrivate) { | |
const privateSuccess = await this.api.setVisibility("PRIVATE") | |
if (!privateSuccess) { | |
console.warn(`[${new Date().toLocaleString()}] Failed to make video private! Trying again in ${(this.updateFailTimeout/1000).toFixed()} seconds`) | |
await this.sleep(this.updateFailTimeout) | |
continue | |
} | |
didSetPrivate = true | |
onSetPrivateOnce() | |
} | |
const publicSuccess = await this.api.setVisibility('PUBLIC') | |
if (!publicSuccess) { | |
console.warn(`[${new Date().toLocaleString()}] Failed to make video public! Trying again in ${(this.updateFailTimeout/1000).toFixed()} seconds`) | |
await this.sleep(this.updateFailTimeout) | |
continue | |
} | |
let successMessage = `[${new Date().toLocaleString()}] Toggled!` | |
console.info(successMessage) | |
/* done toggling, exit */ | |
return | |
} catch (e) { | |
console.error(e) | |
const errorMessage = `[${new Date().toLocaleString()}] Failed to toggle visibility! Trying again at ${new Date(Date.now() + this.errorTimeout).toLocaleTimeString()}` | |
console.warn(errorMessage) | |
/* wait and try again */ | |
await this.sleep(this.errorTimeout) | |
} | |
} | |
} | |
async run() { | |
console.info(`[${new Date().toLocaleString()}] yt-ad-buster.js activated!`) | |
document.body.style.setProperty("--ytcp-background-color", "red") | |
let mainInfo = document.getElementById("yt-auto-toggler-info") | |
if (!mainInfo) { | |
mainInfo = this.setupMainInfo() | |
} | |
mainInfo.textContent = `[${new Date().toLocaleString()}] Just started!` | |
window.addEventListener('keydown', this.onKeydown, { once: true }) | |
this.database.updateParameters() | |
/* run every hour */ | |
runAtSecondInMinute(0).then(() => { | |
this.parameterUpdateInterval = setInterval(() => { | |
this.database.updateParameters() | |
this.api.refreshGlobals() | |
}, 1000 * 60 * 60) | |
}) | |
while (true) { | |
await this.waitUntilOnline() | |
/* run every minute at the 30th second*/ | |
await runAtSecondInMinute(30) | |
if (this.cancelled) { | |
return | |
} | |
let json, views | |
try { | |
json = await this.api.getAnalytics() | |
views = this.api.getAdViewsFromAnalytics(json) | |
} catch (e) { | |
console.error(e) | |
const errorMessage = `[${new Date().toLocaleString()}] Failed to get analytics!` | |
console.warn(errorMessage) | |
/* try again the next check */ | |
await sleep(1000) | |
continue | |
} | |
const timestamp = Date.now() | |
// decrease likelihood of upload rate limiting collisions | |
const uploadDelay = Math.random() * 1000 * 60 | |
setTimeout(() => { | |
this.database.upload(`views.${timestamp}.tsv`, [timestamp, ...views].join('\t')) | |
if (this.api.analyticsHasAnomaly(json)) { | |
this.database.upload(`anomaly.${timestamp}.json`, JSON.stringify(json)) | |
} | |
}, uploadDelay) | |
const prevState = this.detector.state | |
const shouldToggle = this.detector.handleViews(views, this.database.parameters, timestamp) | |
if (shouldToggle) { | |
console.info(`[${new Date().toLocaleString()}] Ad detected! ${views.join(' ')}`) | |
for (let echoLag of this.database.parameters.echoToggles) { | |
this.sleep((echoLag - 0.5) * 60 * 1000).then(() => { | |
console.info(`[${new Date().toLocaleString()}] Preemptive toggle! ${echoLag} minutes`) | |
this.toggleVisibility(() => { | |
setTimeout(() => { | |
this.database.upload(`toggle.${Date.now()}.txt`, 0) | |
}, uploadDelay) | |
}) | |
}) | |
} | |
await this.toggleVisibility(() => { | |
this.detector.setLastToggleTime(Date.now()) | |
const name = `toggle.${this.detector.lastToggleTime}.txt` | |
const count = this.detector.consecutiveToggleCount.toString() | |
setTimeout(() => { | |
this.database.upload(name, count) | |
}, uploadDelay) | |
}) | |
} | |
let message = `[${new Date().toLocaleString()}] ` | |
if (this.detector.state === prevState) { | |
message += this.detector.state | |
} else { | |
const transition = `${prevState} -> ${this.detector.state}` | |
console.info(`[${new Date().toLocaleString()}] ${transition}`) | |
message += transition | |
} | |
if (shouldToggle) { | |
message += ` - Toggled ${this.detector.consecutiveToggleCount}` | |
} | |
message += " - Views: " | |
message += views.join() | |
mainInfo.textContent = message | |
await sleep(1000) | |
} | |
} | |
stop() { | |
console.info(`[${new Date().toLocaleString()}] Stopping!`) | |
document.body.style.setProperty("--ytcp-background-color", "lightblue") | |
this.cancelled = true | |
clearTimeout(this.sleepTimer) | |
clearInterval(this.parameterUpdateInterval) | |
} | |
} | |
// function hashString(s) { | |
// var h = 0, l = s.length, i = 0; | |
// if ( l > 0 ) | |
// while (i < l) | |
// h = (h << 5) - h + s.charCodeAt(i++) | 0; | |
// return h; | |
// }; | |
function runAtSecondInMinute (second) { | |
const ms = second * 1000 | |
const currentPhase = Date.now() % (1000 * 60) // period: one minute | |
let delay = ms - currentPhase | |
if (delay < 0) { | |
delay += 1000 * 60 | |
} | |
return sleep(delay) | |
} | |
function sleep (t = 0) { | |
return new Promise((resolve) => setTimeout(resolve, t)) | |
} | |
class AdBusterDetector { | |
constructor (interval = 5) { | |
this.lastToggleTime = 0 | |
this.lastViewTime = 0 | |
this.consecutiveToggleCount = 0 | |
/** @type {"NO_ADS" | "AMBIENT" | "ACTIVE"} */ | |
this.state = "NO_ADS" | |
this.interval = interval | |
this.activePeakValues = new Array(interval).fill(0) | |
} | |
/** | |
* | |
* @param {number[]} views | |
* @param {typeof AdBusterDatabase.defaultParameters} parameters | |
*/ | |
handleViews (views, parameters, timestamp) { | |
const anyViews = views.some(count => count > 0) | |
if (anyViews) { | |
this.lastViewTime = Date.now() | |
} | |
const resetActivePeakValues = () => { | |
for (let lag = 0; lag < this.activePeakValues.length; lag++) { | |
this.activePeakValues[lag] = Math.max(0, views[lag] - parameters.ambientThresholds[lag]) | |
} | |
} | |
const handleAmbient = () => { | |
if (views.some((count, lag) => count > parameters.ambientThresholds[lag])) { | |
this.state = "ACTIVE" | |
this.consecutiveToggleCount = 1 | |
resetActivePeakValues() | |
return true | |
} else if ((Date.now() - this.lastViewTime) > parameters.ambientDuration * 60 * 1000) { | |
this.state = "NO_ADS" | |
this.consecutiveToggleCount = 0 | |
} | |
return false | |
} | |
switch (this.state) { | |
case "AMBIENT": | |
return handleAmbient() | |
case "NO_ADS": | |
if (anyViews) { | |
this.state = "ACTIVE" | |
this.consecutiveToggleCount = 1 | |
resetActivePeakValues() | |
return true | |
} | |
return false | |
case "ACTIVE": | |
const minutesSinceToggle = Math.floor((timestamp - this.lastToggleTime) / 1000 / 60) | |
let checkedThreshold = false | |
let sum = 0 | |
for (let lag = 0; lag < this.interval; lag++) { | |
let activeThreshold = parameters.activeThresholds[lag][minutesSinceToggle] | |
if (activeThreshold == null) { | |
activeThreshold = 0 | |
} else { | |
checkedThreshold = true | |
} | |
const value = Math.max(0, views[lag] - parameters.ambientThresholds[lag]) | |
if (activeThreshold === -1) { | |
this.activePeakValues[lag] = Math.max(this.activePeakValues[lag], value) | |
} else if (value > this.activePeakValues[lag] * activeThreshold) { | |
sum += parameters.activeFactors[lag] | |
} | |
} | |
if (sum >= 1) { | |
this.consecutiveToggleCount++ | |
resetActivePeakValues() | |
return true | |
} | |
if (!checkedThreshold) { | |
this.state = "AMBIENT" | |
this.consecutiveToggleCount = 0 | |
return handleAmbient() | |
} | |
return false | |
} | |
} | |
setLastToggleTime(timestamp) { | |
this.lastToggleTime = timestamp | |
} | |
} | |
class AdBusterYoutubeApi { | |
static globalKeys = [ | |
'INNERTUBE_API_KEY', | |
'DELEGATED_SESSION_ID', | |
'INNERTUBE_CONTEXT_CLIENT_NAME', | |
'INNERTUBE_CONTEXT_CLIENT_VERSION' | |
] | |
/** @param {string} videoId */ | |
constructor (videoId, interval = 5) { | |
this.videoId = videoId | |
this.analyticsInterval = interval | |
this.ytGlobals = {} | |
for (let key of AdBusterYoutubeApi.globalKeys) { | |
this.getYoutubeGlobal(key) | |
} | |
this.refreshGlobals() | |
// this.testData = [] | |
// this.testCounter = 0 | |
} | |
async refreshGlobals () { | |
const keys = [ | |
'INNERTUBE_API_KEY', | |
'DELEGATED_SESSION_ID', | |
'INNERTUBE_CONTEXT_CLIENT_NAME', | |
'INNERTUBE_CONTEXT_CLIENT_VERSION' | |
] | |
try { | |
const res = await fetch(`https://studio.youtube.com/video/${this.videoId}`) | |
if (res.ok) { | |
const text = await res.text() | |
for (let key of keys) { | |
const matches = text.match(key + '":([^,}]+)') | |
if (matches && matches[1]) { | |
const newValue = JSON.parse(matches[1]) | |
if (this.ytGlobals[key] !== newValue) { | |
this.ytGlobals[key] = newValue | |
console.info(`[${new Date().toLocaleString()}] AdBusterYoutubeApi: updated ${key}!`) | |
} | |
} | |
} | |
} | |
} catch (e) { | |
console.warn(`Could not refresh youtube globals: ${e}`) | |
} | |
} | |
/** @param {string} str */ | |
async sha1 (str) { | |
const buffer = new TextEncoder().encode(str); | |
const digest = await crypto.subtle.digest('SHA-1', buffer); | |
// Convert digest to hex string | |
const result = Array.from(new Uint8Array(digest)).map( x => x.toString(16).padStart(2,'0') ).join(''); | |
return result | |
} | |
/** @param {string} name */ | |
getCookie (name) { | |
const r = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)')) | |
return r && r[2] | |
} | |
async getAuth(d = new Date().getTime()) { | |
const hash = await this.sha1(`${d} ${this.getCookie('SAPISID')} ${location.origin}`) | |
return `SAPISIDHASH ${d}_${hash}` | |
} | |
getYoutubeGlobal(key) { | |
if (key in this.ytGlobals) { | |
return this.ytGlobals[key] | |
} | |
if (this.gKey == null || !(window['_g'][this.gKey] instanceof Object) || !(key in window['_g'][this.gKey])) { | |
// let's find the object with the globals we're looking for, this field name can and will change | |
// example: window['_g'].sc.INNERTUBE_API_KEY | |
for (let k in window['_g']) { | |
const v = window['_g'][k] | |
if (v instanceof Object && key in v) { | |
this.gKey = k | |
break | |
} | |
} | |
} | |
this.ytGlobals[key] = window['_g'][this.gKey][key] | |
return this.ytGlobals[key] | |
} | |
/** | |
* @param {"PRIVATE" | "PUBLIC"} privacy | |
*/ | |
async setVisibility(privacy) { | |
console.info(`[${new Date().toLocaleString()}] setVisibility(${privacy})`) | |
const url = "https://studio.youtube.com/youtubei/v1/video_manager/metadata_update?alt=json&key=" + this.getYoutubeGlobal('INNERTUBE_API_KEY') | |
const body = { | |
"encryptedVideoId": this.videoId, | |
"privacyState": { "newPrivacy": privacy }, | |
"context": { | |
"client": { | |
"clientName": this.getYoutubeGlobal("INNERTUBE_CONTEXT_CLIENT_NAME"), | |
"clientVersion": this.getYoutubeGlobal("INNERTUBE_CONTEXT_CLIENT_VERSION") | |
}, | |
"user": { | |
"onBehalfOfUser": this.getYoutubeGlobal('DELEGATED_SESSION_ID') | |
} | |
} | |
} | |
const json = await this.postApi(url, body) | |
if (json.overallResult.resultCode !== "UPDATE_SUCCESS") { // SOME_ERRORS | |
console.warn(`setVisibility(${privacy}): ${json.overallResult.resultCode}`) | |
return false | |
} | |
if (!json.privacy.success) { | |
console.warn(`setVisibility(${privacy}): already ${privacy}`) | |
} | |
// if (Math.random() < 0.5) { | |
// console.warn("Chaos monkey") | |
// return false | |
// } | |
// if (Math.random() < 0.5) { | |
// throw new Error("Chaos monkey") | |
// } | |
return true | |
} | |
async getAnalytics() { | |
const url = 'https://studio.youtube.com/youtubei/v1/analytics_data/join?alt=json&key=' + this.getYoutubeGlobal('INNERTUBE_API_KEY') | |
const span = 60 * this.analyticsInterval // in minutes | |
// round up because we are excluding the minute after now | |
const now = Math.ceil(Date.now() / 1000 / 60) * 60 | |
const inclusiveStart = (now - span).toString() | |
const exclusiveEnd = now.toString() | |
const body = { | |
"nodes": [ | |
{ | |
"key": "0__spark_chart_query_key_60_minutes", | |
"value": { | |
"query": { | |
"dimensions": [ { "type": "MINUTE" }, { "type": "TRAFFIC_SOURCE_TYPE" } ], | |
"metrics": [ { "type": "VIEWS" } ], | |
"restricts": [ { "dimension": { "type": "VIDEO" }, "inValues": [ this.videoId ] } ], | |
"orders": [ | |
{ "dimension": { "type": "TRAFFIC_SOURCE_TYPE" }, "direction": "ANALYTICS_ORDER_DIRECTION_ASC" }, | |
{ "dimension": { "type": "MINUTE" }, "direction": "ANALYTICS_ORDER_DIRECTION_ASC" } | |
], | |
"timeRange": { "unixTimeRange": { "inclusiveStart": inclusiveStart, "exclusiveEnd": exclusiveEnd } }, | |
"returnDataInNewFormat": true, | |
"limitedToBatchedData": false | |
} | |
} | |
} | |
], | |
"connectors": [], | |
"allowFailureResultNodes": true, | |
"context": { | |
"user": { | |
"onBehalfOfUser": this.getYoutubeGlobal('DELEGATED_SESSION_ID') | |
}, | |
} | |
} | |
return this.postApi(url, body) | |
} | |
async postApi(url, body) { | |
const auth = await this.getAuth() | |
const params = { | |
method:"POST", | |
body: JSON.stringify(body), | |
headers:{ | |
'Authorization': auth, | |
'Content-Type':'application/json' | |
} | |
} | |
const res = await fetch(url, params) | |
if (res.status === 502) { | |
this.refreshGlobals() | |
} | |
if (res.status !== 200) { | |
throw new Error("Failed to call api: " + res.status) | |
} | |
const json = await res.json() | |
return json | |
} | |
analyticsHasAnomaly (json) { | |
// return false | |
const result = json.results[0] | |
const table = result.value.resultTable | |
return !!table.anomalyContext | |
} | |
getAdViewsFromAnalytics(json) { | |
// return this.testData[this.testCounter++] | |
const result = json.results[0] | |
const table = result.value.resultTable | |
const minutes = table.dimensionColumns[0].timestamps.values | |
const trafficSourceTypes = table.dimensionColumns[1].enumValues.values | |
const views = table.metricColumns[0].counts.values | |
/** @type {number[]} */ | |
const adViewsRow = Array(this.analyticsInterval).fill(0) | |
const empty = !minutes || !trafficSourceTypes || !views | |
/** @type {number} */ | |
let timestamp | |
if (!empty) { | |
let lag = 0 // 0 1 2 3 4 | |
for (let i = trafficSourceTypes.length - 1; i >= 0; i--) { | |
if (trafficSourceTypes[i] === 'ADVERTISING') { | |
if (!timestamp) { | |
timestamp = minutes[i] | |
} | |
const adViews = views[i] | |
adViewsRow[lag] = adViews | |
lag++ | |
} | |
} | |
} | |
return adViewsRow | |
} | |
} | |
// used for privately-run analytics | |
class AdBusterDatabase { | |
static defaultParameters = { | |
version: "v2.0", | |
ambientThresholds: [1,1,2,3,3], | |
ambientDuration: 120, | |
activeThresholds: [ | |
[], | |
[-1,-1,-1,-1,-1,-1, 1.0, 1.0, 0.4, 0.05], | |
[-1,-1,-1,-1,-1,-1, 1.4, 1.3, 0.7, 0.2, 0.05], | |
[-1,-1,-1,-1,-1,-1,-1.0, 1.3, 1.1, 0.6, 0.15, 0.05], | |
[-1,-1,-1,-1,-1,-1,-1.0,-1.0, 1.3, 1.1, 0.6, 0.15, 0.05] | |
], | |
activeFactors: [0,1,1,1,1], | |
echoToggles: [] | |
} | |
/** @param {string} videoId */ | |
constructor (videoId) { | |
this.videoId = videoId | |
this.parameters = AdBusterDatabase.defaultParameters | |
} | |
async upload(fileName, textData) { | |
// stub | |
} | |
async updateParameters() { | |
// stub | |
} | |
} | |
function getCurrentVideoId () { | |
const r = location.pathname.match(/\/video\/(.*?)\//) | |
return r && r[1] | |
} | |
;(()=>{ | |
// @ts-ignore | |
if (window.buster && window.buster.stop instanceof Function) { | |
// @ts-ignore | |
window.buster.stop() | |
} | |
const videoId = getCurrentVideoId() | |
const buster = new AdBusterRunner( | |
videoId, | |
new AdBusterYoutubeApi(videoId), | |
new AdBusterDatabase(videoId), | |
new AdBusterDetector() | |
) | |
buster.run() | |
// @ts-ignore | |
window.buster = buster | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment