Skip to content

Instantly share code, notes, and snippets.

@conundrumer
Last active May 13, 2020 16:26
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save conundrumer/209304cb040bd1a28bf0219f9cd97a41 to your computer and use it in GitHub Desktop.
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.
/* 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