diff --git a/package.json b/package.json index 7be88d4..2513ca6 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "author": "Webrecorder Software", "license": "AGPL-3.0-or-later", "devDependencies": { + "@types/query-selector-shadow-dom": "^1.0.4", "@typescript-eslint/eslint-plugin": "^8.28.0", "@typescript-eslint/parser": "^8.28.0", "@webpack-cli/init": "^1.1.3", diff --git a/src/autoclick.ts b/src/autoclick.ts index b2bc23c..8498790 100644 --- a/src/autoclick.ts +++ b/src/autoclick.ts @@ -3,11 +3,11 @@ import { addToExternalSet, sleep } from "./lib/utils"; export class AutoClick extends BackgroundBehavior { _donePromise: Promise; - _markDone: () => void; + _markDone!: () => void; selector: string; seenElem = new WeakSet(); - static id = "Autoclick"; + static id = "Autoclick" as const; constructor(selector = "a") { super(); @@ -40,7 +40,7 @@ export class AutoClick extends BackgroundBehavior { return elem; } } catch (e) { - this.debug(e.toString()); + this.debug((e as Error).toString()); } return null; @@ -49,7 +49,7 @@ export class AutoClick extends BackgroundBehavior { async start() { const origHref = self.location.href; - const beforeUnload = (event) => { + const beforeUnload = (event: BeforeUnloadEvent) => { event.preventDefault(); return false; }; @@ -59,7 +59,7 @@ export class AutoClick extends BackgroundBehavior { // process external links on current origin - // eslint-disable-next-line no-constant-condition + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition, no-constant-condition while (true) { const elem = this.nextSameOriginLink(); @@ -109,11 +109,12 @@ export class AutoClick extends BackgroundBehavior { }); } } - catch(e) { - this.debug(e.toString()); + // TODO: this looks like maybe a typo, there's no associated `try` block for this `catch` block, so it ends up being a method + catch(e: unknown) { + this.debug((e as Error).toString()); } - done() { + async done() { return this._donePromise; } } diff --git a/src/autofetcher.ts b/src/autofetcher.ts index 080ca91..c7cd2da 100644 --- a/src/autofetcher.ts +++ b/src/autofetcher.ts @@ -25,21 +25,25 @@ const MAX_CONCURRENT = 6; // =========================================================================== export class AutoFetcher extends BackgroundBehavior { - urlSet: Set = new Set(); + urlSet = new Set(); pendingQueue: string[] = []; waitQueue: string[] = []; - mutationObserver: MutationObserver; - numPending: number = 0; - numDone: number = 0; - headers: object; + mutationObserver?: MutationObserver; + numPending = 0; + numDone = 0; + headers: Record; _donePromise: Promise; - _markDone: (value: any) => void; + _markDone!: (value: PromiseLike | null) => void; active: boolean; running = false; - static id = "Autofetcher"; + static id = "Autofetcher" as const; - constructor(active = false, headers = null, startEarly = false) { + constructor( + active = false, + headers: Record | null = null, + startEarly = false, + ) { super(); this.headers = headers || {}; @@ -63,16 +67,16 @@ export class AutoFetcher extends BackgroundBehavior { this.initObserver(); - this.run(); + void this.run(); - sleep(500).then(() => { + void sleep(500).then(() => { if (!this.pendingQueue.length && !this.numPending) { this._markDone(null); } }); } - done() { + async done() { return this._donePromise; } @@ -80,7 +84,7 @@ export class AutoFetcher extends BackgroundBehavior { this.running = true; for (const url of this.waitQueue) { - this.doFetch(url); + void this.doFetch(url); } this.waitQueue = []; @@ -93,10 +97,10 @@ export class AutoFetcher extends BackgroundBehavior { return url && (url.startsWith("http:") || url.startsWith("https:")); } - queueUrl(url: string, immediate: boolean = false) { + queueUrl(url: string, immediate = false) { try { url = new URL(url, document.baseURI).href; - } catch (e) { + } catch (_) { return false; } @@ -111,7 +115,7 @@ export class AutoFetcher extends BackgroundBehavior { this.urlSet.add(url); if (this.running || immediate) { - this.doFetch(url); + void this.doFetch(url); } else { this.waitQueue.push(url); } @@ -128,10 +132,13 @@ export class AutoFetcher extends BackgroundBehavior { }); this.debug(`Autofetch: started ${url}`); - const reader = resp.body.getReader(); + const reader = resp.body!.getReader(); + + let res: ReadableStreamReadResult | null = null; - let res = null; - while ((res = await reader.read()) && !res.done); + do { + res = await reader.read(); + } while (!res.done); this.debug(`Autofetch: finished ${url}`); @@ -156,7 +163,7 @@ export class AutoFetcher extends BackgroundBehavior { } as {}); abort.abort(); this.debug(`Autofetch: started non-cors stream for ${url}`); - } catch (e) { + } catch (_) { this.debug(`Autofetch: failed non-cors for ${url}`); } } @@ -165,11 +172,11 @@ export class AutoFetcher extends BackgroundBehavior { this.pendingQueue.push(url); if (this.numPending <= MAX_CONCURRENT) { while (this.pendingQueue.length > 0) { - const url = this.pendingQueue.shift(); + const url = this.pendingQueue.shift()!; this.numPending++; - let success = await doExternalFetch(url); + const success = await doExternalFetch(url); if (!success) { await this.doFetchNonCors(url); @@ -203,38 +210,41 @@ export class AutoFetcher extends BackgroundBehavior { }); } - processChangedNode(target) { + processChangedNode(target: Node) { switch (target.nodeType) { case Node.ATTRIBUTE_NODE: if (target.nodeName === "srcset") { - this.extractSrcSetAttr(target.nodeValue); + this.extractSrcSetAttr(target.nodeValue!); } if (target.nodeName === "loading" && target.nodeValue === "lazy") { - const elem = target.parentNode; - if (elem.tagName === "IMG") { + const elem = target.parentNode as Element | null; + if (elem?.tagName === "IMG") { elem.setAttribute("loading", "eager"); } } break; case Node.TEXT_NODE: - if (target.parentNode && target.parentNode.tagName === "STYLE") { - this.extractStyleText(target.nodeValue); + if ( + target.parentNode && + (target.parentNode as Element).tagName === "STYLE" + ) { + this.extractStyleText(target.nodeValue!); } break; case Node.ELEMENT_NODE: - if (target.sheet) { - this.extractStyleSheet(target.sheet); + if ("sheet" in target) { + this.extractStyleSheet((target as HTMLStyleElement).sheet!); } - this.extractSrcSrcSet(target); - setTimeout(() => this.extractSrcSrcSetAll(target), 1000); + this.extractSrcSrcSet(target as HTMLElement); + setTimeout(() => this.extractSrcSrcSetAll(target as HTMLElement), 1000); setTimeout(() => this.extractDataAttributes(target), 1000); break; } } - observeChange(changes) { + observeChange(changes: MutationRecord[]) { for (const change of changes) { this.processChangedNode(change.target); @@ -246,7 +256,7 @@ export class AutoFetcher extends BackgroundBehavior { } } - extractSrcSrcSetAll(root) { + extractSrcSrcSetAll(root: Document | HTMLElement) { const elems = querySelectorAllDeep(SRC_SET_SELECTOR, root); for (const elem of elems) { @@ -254,7 +264,7 @@ export class AutoFetcher extends BackgroundBehavior { } } - extractSrcSrcSet(elem) { + extractSrcSrcSet(elem: HTMLElement | null) { if (!elem || elem.nodeType !== Node.ELEMENT_NODE) { console.warn("No elem to extract from"); return; @@ -288,13 +298,13 @@ export class AutoFetcher extends BackgroundBehavior { if ( src && - (srcset || data_srcset || elem.parentElement.tagName === "NOSCRIPT") + (srcset || data_srcset || elem.parentElement?.tagName === "NOSCRIPT") ) { this.queueUrl(src); } } - extractSrcSetAttr(srcset) { + extractSrcSetAttr(srcset: string) { for (const v of srcset.split(SRCSET_REGEX)) { if (v) { const parts = v.trim().split(" "); @@ -303,7 +313,7 @@ export class AutoFetcher extends BackgroundBehavior { } } - extractStyleSheets(root?) { + extractStyleSheets(root?: Document | null) { root = root || document; for (const sheet of root.styleSheets) { @@ -311,12 +321,13 @@ export class AutoFetcher extends BackgroundBehavior { } } - extractStyleSheet(sheet) { + extractStyleSheet(sheet: CSSStyleSheet) { let rules; try { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition rules = sheet.cssRules || sheet.rules; - } catch (e) { + } catch (_) { this.debug("Can't access stylesheet"); return; } @@ -328,8 +339,8 @@ export class AutoFetcher extends BackgroundBehavior { } } - extractStyleText(text) { - const urlExtractor = (m, n1, n2, n3) => { + extractStyleText(text: string) { + const urlExtractor = (_m: unknown, n1: string, n2: string, n3: string) => { this.queueUrl(n2); return n1 + n2 + n3; }; @@ -337,13 +348,14 @@ export class AutoFetcher extends BackgroundBehavior { text.replace(STYLE_REGEX, urlExtractor).replace(IMPORT_REGEX, urlExtractor); } - extractDataAttributes(root) { + extractDataAttributes(root: Node | null) { const QUERY = "//@*[starts-with(name(), 'data-') and " + "(starts-with(., 'http') or starts-with(., '/') or starts-with(., './') or starts-with(., '../'))]"; for (const attr of xpathNodes(QUERY, root)) { - this.queueUrl(attr.value); + // @ts-expect-error TODO not sure what type `attr` should have here + this.queueUrl(attr.value as string); } } } diff --git a/src/autoplay.ts b/src/autoplay.ts index fd63351..54d89a8 100644 --- a/src/autoplay.ts +++ b/src/autoplay.ts @@ -9,12 +9,12 @@ export class Autoplay extends BackgroundBehavior { mediaSet: Set; autofetcher: AutoFetcher; numPlaying: number; - promises: Promise[]; + promises: Promise[]; _initDone: Function; running = false; polling = false; - static id = "Autoplay"; + static id = "Autoplay" as const; constructor(autofetcher: AutoFetcher, startEarly = false) { super(); @@ -25,7 +25,7 @@ export class Autoplay extends BackgroundBehavior { this._initDone = () => null; this.promises.push(new Promise((resolve) => (this._initDone = resolve))); if (startEarly) { - document.addEventListener("DOMContentLoaded", () => + document.addEventListener("DOMContentLoaded", async () => this.pollAudioVideo(), ); } @@ -35,7 +35,7 @@ export class Autoplay extends BackgroundBehavior { this.running = true; //this.initObserver(); - this.pollAudioVideo(); + void this.pollAudioVideo(); this._initDone(); } @@ -49,19 +49,22 @@ export class Autoplay extends BackgroundBehavior { this.polling = true; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition while (run) { for (const [, elem] of querySelectorAllDeep( "video, audio, picture", - ).entries()) { + ).entries() as ArrayIterator< + [number, HTMLVideoElement | HTMLAudioElement | HTMLPictureElement] + >) { if (!elem["__bx_autoplay_found"]) { if (!this.running) { - if (this.processFetchableUrl(elem)) { + if (this.processFetchableUrl(elem as HTMLMediaElement)) { elem["__bx_autoplay_found"] = true; } continue; } - await this.loadMedia(elem); + await this.loadMedia(elem as HTMLMediaElement); elem["__bx_autoplay_found"] = true; } } @@ -72,8 +75,8 @@ export class Autoplay extends BackgroundBehavior { this.polling = false; } - fetchSrcUrl(source) { - const url: string = source.src || source.currentSrc; + fetchSrcUrl(source: HTMLMediaElement | HTMLSourceElement) { + const url: string = source.src || (source as HTMLMediaElement).currentSrc; if (!url) { return false; @@ -94,7 +97,7 @@ export class Autoplay extends BackgroundBehavior { return true; } - processFetchableUrl(media) { + processFetchableUrl(media: HTMLMediaElement) { let found = this.fetchSrcUrl(media); const sources = media.querySelectorAll("source"); @@ -107,11 +110,12 @@ export class Autoplay extends BackgroundBehavior { return found; } - async loadMedia(media) { + async loadMedia(media: HTMLMediaElement) { this.debug("processing media element: " + media.outerHTML); const found = this.processFetchableUrl(media); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (!media.play) { this.debug("media not playable, skipping"); return; @@ -138,46 +142,45 @@ export class Autoplay extends BackgroundBehavior { ); } - this.attemptMediaPlay(media).then( - async (finished: Promise | null) => { - let check = true; + void this.attemptMediaPlay(media).then(async (finished) => { + let check = true; - if (finished) { - finished.then(() => (check = false)); - } + if (finished) { + // @ts-expect-error TODO: not sure what this is supposed to be, I believe `finished` is a boolean here? + void finished.then(() => (check = false)); + } - while (check) { - if (this.processFetchableUrl(media)) { - check = false; - } - this.debug( - "Waiting for fixed URL or media to finish: " + media.currentSrc, - ); - await sleep(1000); + while (check) { + if (this.processFetchableUrl(media)) { + check = false; } - }, - ); + this.debug( + "Waiting for fixed URL or media to finish: " + media.currentSrc, + ); + await sleep(1000); + } + }); } else if (media.currentSrc) { this.debug("media playing from non-URL source: " + media.currentSrc); } } - async attemptMediaPlay(media) { + async attemptMediaPlay(media: HTMLMediaElement) { // finished promise - let resolveFinished; + let resolveFinished: (value?: boolean) => void; - const finished = new Promise((res) => { + const finished = new Promise((res) => { resolveFinished = res; }); // started promise - let resolveStarted; + let resolveStarted!: (value?: boolean) => void; - const started = new Promise((res) => { + const started = new Promise((res) => { resolveStarted = res; }); - started.then(() => this.promises.push(finished)); + void started.then(() => this.promises.push(finished)); // already started if (!media.paused && media.currentTime > 0) { @@ -240,7 +243,7 @@ export class Autoplay extends BackgroundBehavior { } } - media.play(); + void media.play(); if (await Promise.race([started, sleep(1000)])) { this.debug("play started after media.play()"); @@ -249,7 +252,7 @@ export class Autoplay extends BackgroundBehavior { return finished; } - done() { + async done() { return Promise.allSettled(this.promises); } } diff --git a/src/autoscroll.ts b/src/autoscroll.ts index 09f5de6..cde4ce1 100644 --- a/src/autoscroll.ts +++ b/src/autoscroll.ts @@ -1,4 +1,9 @@ -import { Behavior } from "./lib/behavior"; +import { + type AbstractBehavior, + BackgroundBehavior, + type Context, + type EmptyObject, +} from "./lib/behavior"; import { sleep, waitUnit, @@ -7,11 +12,15 @@ import { waitUntil, behaviorLog, addLink, + getState, } from "./lib/utils"; import { type AutoFetcher } from "./autofetcher"; // =========================================================================== -export class AutoScroll extends Behavior { +export class AutoScroll + extends BackgroundBehavior + implements AbstractBehavior +{ autoFetcher: AutoFetcher; showMoreQuery: string; state: { segments: number } = { segments: 1 }; @@ -35,7 +44,9 @@ export class AutoScroll extends Behavior { this.origPath = document.location.pathname; } - static id = "Autoscroll"; + static get id() { + return "Autoscroll"; + } currScrollPos() { return Math.round(self.scrollY + self.innerHeight); @@ -57,9 +68,9 @@ export class AutoScroll extends Behavior { this.lastMsg = msg; } - hasScrollEL(obj) { + hasScrollEL(obj: HTMLElement | Document | Window) { try { - return !!self["getEventListeners"](obj).scroll; + return !!self["getEventListeners"]!(obj).scroll; } catch (_) { // unknown, assume has listeners this.debug("getEventListeners() not available"); @@ -81,12 +92,12 @@ export class AutoScroll extends Behavior { return true; } - const lastScrollHeight = self.document.scrollingElement.scrollHeight; + const lastScrollHeight = self.document.scrollingElement!.scrollHeight; const numFetching = this.autoFetcher.numFetching; // scroll to almost end of page const scrollEnd = - document.scrollingElement.scrollHeight * 0.98 - self.innerHeight; + document.scrollingElement!.scrollHeight * 0.98 - self.innerHeight; window.scrollTo({ top: scrollEnd, left: 0, behavior: "smooth" }); @@ -95,7 +106,7 @@ export class AutoScroll extends Behavior { // scroll height changed, should scroll if ( - lastScrollHeight !== self.document.scrollingElement.scrollHeight || + lastScrollHeight !== self.document.scrollingElement!.scrollHeight || numFetching < this.autoFetcher.numFetching ) { window.scrollTo({ top: 0, left: 0, behavior: "auto" }); @@ -112,7 +123,7 @@ export class AutoScroll extends Behavior { if ( (self.window.scrollY + self["scrollHeight"]) / - self.document.scrollingElement.scrollHeight < + self.document.scrollingElement!.scrollHeight < 0.9 ) { return false; @@ -121,48 +132,49 @@ export class AutoScroll extends Behavior { return true; } - async *[Symbol.asyncIterator]() { + async *run(ctx: Context) { if (this.shouldScrollUp()) { - yield* this.scrollUp(); + yield* this.scrollUp(ctx); return; } if (await this.shouldScroll()) { - yield* this.scrollDown(); + yield* this.scrollDown(ctx); return; } - yield this.getState( + yield getState( + ctx, "Skipping autoscroll, page seems to not be responsive to scrolling events", ); } - async *scrollDown() { + async *scrollDown(ctx: Context) { const scrollInc = Math.min( - self.document.scrollingElement.clientHeight * 0.1, + self.document.scrollingElement!.clientHeight * 0.1, 30, ); const interval = 75; let elapsedWait = 0; - let showMoreElem = null; + let showMoreElem: HTMLElement | null = null; let ignoreShowMoreElem = false; const scrollOpts = { top: scrollInc, left: 0, behavior: "auto" }; - let lastScrollHeight = self.document.scrollingElement.scrollHeight; + let lastScrollHeight = self.document.scrollingElement!.scrollHeight; while (this.canScrollMore()) { if (document.location.pathname !== this.origPath) { - behaviorLog( + void behaviorLog( "Location Changed, stopping scroll: " + `${document.location.pathname} != ${this.origPath}`, "info", ); - addLink(document.location.href); + void addLink(document.location.href); return; } - const scrollHeight = self.document.scrollingElement.scrollHeight; + const scrollHeight = self.document.scrollingElement!.scrollHeight; if (scrollHeight > lastScrollHeight) { this.state.segments++; @@ -170,38 +182,38 @@ export class AutoScroll extends Behavior { } if (!showMoreElem && !ignoreShowMoreElem) { - showMoreElem = xpathNode(this.showMoreQuery); + showMoreElem = xpathNode(this.showMoreQuery) as HTMLElement | null; } if (showMoreElem && isInViewport(showMoreElem)) { - yield this.getState("Clicking 'Show More', awaiting more content"); + yield getState(ctx, "Clicking 'Show More', awaiting more content"); showMoreElem["click"](); await sleep(waitUnit); await Promise.race([ waitUntil( - () => self.document.scrollingElement.scrollHeight > scrollHeight, + () => self.document.scrollingElement!.scrollHeight > scrollHeight, 500, ), sleep(30000), ]); - if (self.document.scrollingElement.scrollHeight === scrollHeight) { + if (self.document.scrollingElement!.scrollHeight === scrollHeight) { ignoreShowMoreElem = true; } showMoreElem = null; } - // eslint-disable-next-line self.scrollBy(scrollOpts as ScrollToOptions); await sleep(interval); if (this.state.segments === 1) { // only print this the first time - yield this.getState( + yield getState( + ctx, `Scrolling down by ${scrollOpts.top} pixels every ${interval / 1000.0} seconds`, ); elapsedWait = 2.0; @@ -237,33 +249,33 @@ export class AutoScroll extends Behavior { } } - async *scrollUp() { + async *scrollUp(ctx: Context) { const scrollInc = Math.min( - self.document.scrollingElement.clientHeight * 0.1, + self.document.scrollingElement!.clientHeight * 0.1, 30, ); const interval = 75; const scrollOpts = { top: -scrollInc, left: 0, behavior: "auto" }; - let lastScrollHeight = self.document.scrollingElement.scrollHeight; + let lastScrollHeight = self.document.scrollingElement!.scrollHeight; while (self.scrollY > 0) { - const scrollHeight = self.document.scrollingElement.scrollHeight; + const scrollHeight = self.document.scrollingElement!.scrollHeight; if (scrollHeight > lastScrollHeight) { this.state.segments++; lastScrollHeight = scrollHeight; } - // eslint-disable-next-line self.scrollBy(scrollOpts as ScrollToOptions); await sleep(interval); if (this.state.segments === 1) { // only print this the first time - yield this.getState( + yield getState( + ctx, `Scrolling up by ${scrollOpts.top} pixels every ${interval / 1000.0} seconds`, ); } else { diff --git a/src/index.ts b/src/index.ts index 8fa2f3a..9d095ae 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,7 +12,7 @@ import { addLink, checkToJsonOverride, } from "./lib/utils"; -import { type Behavior, BehaviorRunner } from "./lib/behavior"; +import { type AbstractBehavior, BehaviorRunner } from "./lib/behavior"; import * as Lib from "./lib/utils"; import siteBehaviors from "./site"; @@ -27,10 +27,10 @@ interface BehaviorManagerOpts { autoplay?: boolean; autoscroll?: boolean; autoclick?: boolean; - log?: ((...message: string[]) => void) | string | false; - siteSpecific?: boolean | object; + log?: ((...message: string[]) => void) | keyof typeof self | false; + siteSpecific?: boolean | Record; timeout?: number; - fetchHeaders?: object | null; + fetchHeaders?: Record | null; startEarly?: boolean | null; clickSelector?: string; } @@ -54,12 +54,24 @@ const DEFAULT_CLICK_SELECTOR = "a"; const DEFAULT_LINK_SELECTOR = "a[href]"; const DEFAULT_LINK_EXTRACT = "href"; +type BehaviorClass = + | (typeof siteBehaviors)[number] + | typeof AutoClick + | typeof AutoScroll + | typeof Autoplay + | typeof AutoFetcher + // eslint-disable-next-line @typescript-eslint/no-explicit-any + | typeof BehaviorRunner; + +type BehaviorInstance = InstanceType; + export class BehaviorManager { - autofetch: AutoFetcher; - behaviors: any[]; - loadedBehaviors: any; - mainBehavior: Behavior | BehaviorRunner | null; - mainBehaviorClass: any; + autofetch?: AutoFetcher; + behaviors: BehaviorInstance[]; + loadedBehaviors: Record; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mainBehavior: BehaviorInstance | BehaviorRunner | null; + mainBehaviorClass!: BehaviorClass; inited: boolean; started: boolean; timeout?: number; @@ -68,10 +80,13 @@ export class BehaviorManager { constructor() { this.behaviors = []; - this.loadedBehaviors = siteBehaviors.reduce((behaviors, next) => { - behaviors[next.id] = next; - return behaviors; - }, {}); + this.loadedBehaviors = siteBehaviors.reduce>( + (behaviors, next) => { + behaviors[next.id] = next; + return behaviors; + }, + {}, + ); this.mainBehavior = null; this.inited = false; this.started = false; @@ -79,13 +94,13 @@ export class BehaviorManager { selector: DEFAULT_LINK_SELECTOR, extractName: DEFAULT_LINK_EXTRACT, }; - behaviorLog("Loaded behaviors for: " + self.location.href); + void behaviorLog("Loaded behaviors for: " + self.location.href); } init( opts: BehaviorManagerOpts = DEFAULT_OPTS, restart = false, - customBehaviors: any[] = null, + customBehaviors: BehaviorClass[] | null = null, ) { if (this.inited && !restart) { return; @@ -94,6 +109,7 @@ export class BehaviorManager { this.inited = true; this.opts = opts; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (!self.window) { return; } @@ -119,21 +135,21 @@ export class BehaviorManager { this.autofetch = new AutoFetcher( !!opts.autofetch, opts.fetchHeaders, - opts.startEarly, + !!opts.startEarly, ); if (opts.autofetch) { - behaviorLog("Using AutoFetcher"); + void behaviorLog("Using AutoFetcher"); this.behaviors.push(this.autofetch); } if (opts.autoplay) { - behaviorLog("Using Autoplay"); - this.behaviors.push(new Autoplay(this.autofetch, opts.startEarly)); + void behaviorLog("Using Autoplay"); + this.behaviors.push(new Autoplay(this.autofetch, !!opts.startEarly)); } if (opts.autoclick) { - behaviorLog("Using AutoClick"); + void behaviorLog("Using AutoClick"); this.behaviors.push( new AutoClick(opts.clickSelector || DEFAULT_CLICK_SELECTOR), ); @@ -144,7 +160,10 @@ export class BehaviorManager { try { this.load(behaviorClass); } catch (e) { - behaviorLog(`Failed to load custom behavior: ${e} ${behaviorClass}`); + void behaviorLog( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `Failed to load custom behavior: ${e} ${behaviorClass}`, + ); } } } @@ -157,11 +176,11 @@ export class BehaviorManager { const opts = this.opts; let siteMatch = false; - if (opts.siteSpecific) { + if (opts?.siteSpecific) { for (const name in this.loadedBehaviors) { const siteBehaviorClass = this.loadedBehaviors[name]; - if (siteBehaviorClass.isMatch()) { - behaviorLog("Using Site-Specific Behavior: " + name); + if ("isMatch" in siteBehaviorClass && siteBehaviorClass.isMatch()) { + void behaviorLog("Using Site-Specific Behavior: " + name); this.mainBehaviorClass = siteBehaviorClass; const siteSpecificOpts = typeof opts.siteSpecific === "object" @@ -169,11 +188,15 @@ export class BehaviorManager { : {}; try { this.mainBehavior = new BehaviorRunner( + // @ts-expect-error TODO figure out types here siteBehaviorClass, siteSpecificOpts, ); } catch (e) { - behaviorLog({ msg: e.toString(), siteSpecific: true }, "error"); + void behaviorLog( + { msg: (e as Error).toString(), siteSpecific: true }, + "error", + ); } siteMatch = true; break; @@ -181,10 +204,10 @@ export class BehaviorManager { } } - if (!siteMatch && opts.autoscroll) { - behaviorLog("Using Autoscroll"); + if (!siteMatch && opts?.autoscroll) { + void behaviorLog("Using Autoscroll"); this.mainBehaviorClass = AutoScroll; - this.mainBehavior = new AutoScroll(this.autofetch); + this.mainBehavior = new AutoScroll(this.autofetch!); } if (this.mainBehavior) { @@ -198,9 +221,17 @@ export class BehaviorManager { return ""; } - load(behaviorClass) { - if (typeof behaviorClass.id !== "string") { - behaviorLog( + load(behaviorClass: unknown) { + if (typeof behaviorClass !== "function") { + void behaviorLog( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `Must pass a class object, got ${behaviorClass}`, + "error", + ); + return; + } + if (!("id" in behaviorClass) || typeof behaviorClass.id !== "string") { + void behaviorLog( 'Behavior class must have a string string "id" property', "error", ); @@ -209,16 +240,13 @@ export class BehaviorManager { const name = behaviorClass.id; - if (typeof behaviorClass !== "function") { - behaviorLog(`Must pass a class object, got ${behaviorClass}`, "error"); - return; - } - if ( + !("isMatch" in behaviorClass) || typeof behaviorClass.isMatch !== "function" || + !("init" in behaviorClass) || typeof behaviorClass.init !== "function" ) { - behaviorLog( + void behaviorLog( "Behavior class must have an is `isMatch()` and `init()` static methods", "error", ); @@ -226,8 +254,8 @@ export class BehaviorManager { } if (!this.isInTopFrame()) { - if (!behaviorClass.runInIframe) { - behaviorLog( + if (!("runInIframe" in behaviorClass) || !behaviorClass.runInIframe) { + void behaviorLog( `Behavior class ${name}: not running in iframes (.runInIframe not set)`, "debug", ); @@ -235,11 +263,11 @@ export class BehaviorManager { } } - behaviorLog(`Behavior class ${name}: loaded`, "debug"); - this.loadedBehaviors[name] = behaviorClass; + void behaviorLog(`Behavior class ${name}: loaded`, "debug"); + this.loadedBehaviors[name] = behaviorClass as BehaviorClass; } - async resolve(target) { + async resolve(target: string) { const imported = await import(`${target}`); // avoid Webpack warning if (Array.isArray(imported)) { for (const behavior of imported) { @@ -252,11 +280,16 @@ export class BehaviorManager { async awaitPageLoad() { this.selectMainBehavior(); - if (this.mainBehavior?.awaitPageLoad) { - behaviorLog("Waiting for custom page load via behavior"); + if ( + this.mainBehavior && + "awaitPageLoad" in this.mainBehavior && + (this.mainBehavior as AbstractBehavior).awaitPageLoad + ) { + void behaviorLog("Waiting for custom page load via behavior"); + // @ts-expect-error TODO why isn't `log` passed in here? It seems like functions expect it to be await this.mainBehavior.awaitPageLoad({ Lib }); } else { - behaviorLog("No custom wait behavior"); + void behaviorLog("No custom wait behavior"); } } @@ -276,9 +309,12 @@ export class BehaviorManager { await awaitLoad(); this.behaviors.forEach((x) => { - const id = x.id || x.constructor.id || "(Unnamed)"; - behaviorLog("Starting behavior: " + id, "debug"); - x.start(); + const id = + (x as unknown as typeof AbstractBehavior).id || + (x.constructor as unknown as typeof AbstractBehavior).id || + "(Unnamed)"; + void behaviorLog("Starting behavior: " + id, "debug"); + "start" in x && void x.start(); }); this.started = true; @@ -286,35 +322,39 @@ export class BehaviorManager { await sleep(500); const allBehaviors = Promise.allSettled( - this.behaviors.map((x) => x.done()), + this.behaviors.map(async (x) => "done" in x && x.done()), ); if (this.timeout) { - behaviorLog( + void behaviorLog( `Waiting for behaviors to finish or ${this.timeout}ms timeout`, "debug", ); await Promise.race([allBehaviors, sleep(this.timeout)]); } else { - behaviorLog("Waiting for behaviors to finish", "debug"); + void behaviorLog("Waiting for behaviors to finish", "debug"); await allBehaviors; } - behaviorLog("All Behaviors Done for " + self.location.href, "debug"); + void behaviorLog("All Behaviors Done for " + self.location.href, "debug"); - if (this.mainBehavior && this.mainBehaviorClass.cleanup) { + if (this.mainBehavior && "cleanup" in this.mainBehavior) { this.mainBehavior.cleanup(); } } - async runOne(name, behaviorOpts = {}) { + async runOne(name: string, behaviorOpts = {}) { const siteBehaviorClass = siteBehaviors.find((b) => b.name === name); if (typeof siteBehaviorClass === "undefined") { console.error(`No behavior of name ${name} found`); return; } //const behavior = new siteBehaviorClass(behaviorOpts); - const behavior = new BehaviorRunner(siteBehaviorClass, behaviorOpts); + const behavior = new BehaviorRunner( + // @ts-expect-error TODO figure out types here + siteBehaviorClass, + behaviorOpts, + ); behavior.start(); console.log(`Running behavior: ${name}`); await behavior.done(); @@ -322,18 +362,18 @@ export class BehaviorManager { } pause() { - behaviorLog("Pausing Main Behavior" + this.mainBehaviorClass.name); - this.behaviors.forEach((x) => x.pause()); + void behaviorLog("Pausing Main Behavior" + this.mainBehaviorClass.name); + this.behaviors.forEach((x) => "pause" in x && x.pause()); } unpause() { // behaviorLog("Unpausing Main Behavior: " + this.mainBehaviorClass.name); - this.behaviors.forEach((x) => x.unpause()); + this.behaviors.forEach((x) => "pause" in x && x.unpause()); } - doAsyncFetch(url) { - behaviorLog("Queueing Async Fetch Url: " + url); - return this.autofetch.queueUrl(url, true); + doAsyncFetch(url: string) { + void behaviorLog("Queueing Async Fetch Url: " + url); + return this.autofetch!.queueUrl(url, true); } isInTopFrame() { @@ -362,9 +402,10 @@ export class BehaviorManager { const urls = new Set(); - document.querySelectorAll(selector).forEach((elem: any) => { + document.querySelectorAll(selector).forEach((elem) => { // first, try property, unless attrOnly is set - let value = !attrOnly ? elem[extractName] : null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let value = !attrOnly ? (elem as any)[extractName] : null; if (!value) { value = elem.getAttribute(extractName); } @@ -374,7 +415,7 @@ export class BehaviorManager { } }); - const promises = []; + const promises: Promise[] = []; for (const url of urls) { promises.push(addLink(url)); diff --git a/src/lib/behavior.ts b/src/lib/behavior.ts index 41b1dba..ff2d6ef 100644 --- a/src/lib/behavior.ts +++ b/src/lib/behavior.ts @@ -3,144 +3,82 @@ import * as Lib from "./utils"; // =========================================================================== export class BackgroundBehavior { - debug(msg) { - behaviorLog(msg, "debug"); + debug(msg: unknown) { + void behaviorLog(msg, "debug"); } - error(msg) { - behaviorLog(msg, "error"); + error(msg: unknown) { + void behaviorLog(msg, "error"); } - log(msg, type = "info") { - behaviorLog(msg, type); + log(msg: unknown, type = "info") { + void behaviorLog(msg, type); } } -// =========================================================================== -export class Behavior extends BackgroundBehavior { - _running: any; - paused: any; - _unpause: any; - state: any; - scrollOpts: { - behavior: string; - block: string; - inline: string; - }; - - constructor() { - super(); - this._running = null; - this.paused = null; - this._unpause = null; - this.state = {}; - - this.scrollOpts = { behavior: "smooth", block: "center", inline: "center" }; - } - - start() { - this._running = this.run(); - } - - done() { - return this._running ? this._running : Promise.resolve(); - } - - async run() { - try { - for await (const step of this) { - this.debug(step); - if (this.paused) { - await this.paused; - } - } - this.debug(this.getState("done!")); - } catch (e) { - this.error(e.toString()); - } - } - - pause() { - if (this.paused) { - return; - } - this.paused = new Promise((resolve) => { - this._unpause = resolve; - }); - } - - unpause() { - if (this._unpause) { - this._unpause(); - this.paused = null; - this._unpause = null; - } - } +// WIP: BehaviorRunner class allows for arbitrary behaviors outside of the +// library to be run through the BehaviorManager - getState(msg: string, incrValue?) { - if (incrValue) { - if (this.state[incrValue] === undefined) { - this.state[incrValue] = 1; - } else { - this.state[incrValue]++; - } - } +export type EmptyObject = Record; - return { state: this.state, msg }; - } +export type Context = { + Lib: typeof Lib; + state: State; + opts: Opts; + log: (data: unknown, type?: string) => Promise; +}; - cleanup() {} +export abstract class AbstractBehavior { + static readonly id: string; + static isMatch: () => boolean; + static init: () => { + // TODO: type these + // eslint-disable-next-line @typescript-eslint/no-explicit-any + state?: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + opts?: any; + }; - async awaitPageLoad(_: any) { - // wait for initial page load here - } + abstract run: ( + ctx: Context, + ) => AsyncIterable<{ state?: State }> | Promise; - static load() { - if (self["__bx_behaviors"]) { - self["__bx_behaviors"].load(this); - } else { - console.warn( - `Could not load ${this.name} behavior: window.__bx_behaviors is not initialized`, - ); - } - } + abstract [Symbol.asyncIterator]?(): AsyncIterable; - async *[Symbol.asyncIterator]() { - yield; - } + abstract awaitPageLoad?: (ctx: Context) => Promise; } -// WIP: BehaviorRunner class allows for arbitrary behaviors outside of the -// library to be run through the BehaviorManager - -abstract class AbstractBehaviorInst { - abstract run: (ctx: any) => AsyncIterable; - - abstract awaitPageLoad?: (ctx: any) => Promise; -} +type StaticProps = { + [K in keyof T]: T[K]; +}; -interface StaticAbstractBehavior { - id: String; - isMatch: () => boolean; - init: () => any; -} +type StaticBehaviorProps = StaticProps; -type AbstractBehavior = (new () => AbstractBehaviorInst) & - StaticAbstractBehavior; +// Non-abstract constructor type +type ConcreteBehaviorConstructor = StaticBehaviorProps & { + new (): AbstractBehavior; +}; -export class BehaviorRunner extends BackgroundBehavior { - inst: AbstractBehaviorInst; - behaviorProps: StaticAbstractBehavior; - ctx: any; - _running: any; - paused: any; - _unpause: any; +export class BehaviorRunner + extends BackgroundBehavior + implements AbstractBehavior +{ + inst: AbstractBehavior; + behaviorProps: ConcreteBehaviorConstructor; + ctx: Context; + _running: Promise | null; + paused: Promise | (() => Promise) | null; + _unpause: ((value: void | PromiseLike) => void) | null; get id() { - return (this.inst?.constructor as any).id; + return (this.inst.constructor as ConcreteBehaviorConstructor) + .id; } - constructor(behavior: AbstractBehavior, mainOpts = {}) { + constructor( + behavior: ConcreteBehaviorConstructor, + mainOpts = {}, + ) { super(); this.behaviorProps = behavior; this.inst = new behavior(); @@ -156,7 +94,7 @@ export class BehaviorRunner extends BackgroundBehavior { state = state || {}; opts = opts ? { ...opts, ...mainOpts } : mainOpts; // eslint-disable-next-line @typescript-eslint/no-explicit-any - const log = async (data: any, type: string) => this.wrappedLog(data, type); + const log = async (data: any, type?: string) => this.wrappedLog(data, type); this.ctx = { Lib, state, opts, log }; @@ -183,12 +121,13 @@ export class BehaviorRunner extends BackgroundBehavior { this._running = this.run(); } - done() { + async done() { return this._running ? this._running : Promise.resolve(); } async run() { try { + // @ts-expect-error TODO how does this work for behaviors where `run` isn't an iterator, e.g. Autoplay and Autoscroll? for await (const step of this.inst.run(this.ctx)) { if (step) { this.wrappedLog(step); @@ -199,7 +138,10 @@ export class BehaviorRunner extends BackgroundBehavior { } this.debug({ msg: "done!", behavior: this.behaviorProps.id }); } catch (e) { - this.error({ msg: e.toString(), behavior: this.behaviorProps.id }); + this.error({ + msg: (e as Error).toString(), + behavior: this.behaviorProps.id, + }); } } diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 977d1a1..74af8df 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,9 +1,20 @@ -let _logFunc = console.log; -let _behaviorMgrClass = null; - -const scrollOpts = { behavior: "smooth", block: "center", inline: "center" }; - -export async function scrollAndClick(node, interval = 500, opts = scrollOpts) { +import { type BehaviorManager } from ".."; +import { type Context } from "./behavior"; + +let _logFunc: ((...data: unknown[]) => void) | null = console.log; +let _behaviorMgrClass: typeof BehaviorManager | null = null; + +const scrollOpts: ScrollIntoViewOptions = { + behavior: "smooth", + block: "center", + inline: "center", +}; + +export async function scrollAndClick( + node: HTMLElement, + interval = 500, + opts: ScrollIntoViewOptions = scrollOpts, +) { node.scrollIntoView(opts); await sleep(interval); node.click(); @@ -11,24 +22,24 @@ export async function scrollAndClick(node, interval = 500, opts = scrollOpts) { export const waitUnit = 200; -export async function sleep(timeout) { +export async function sleep(timeout: number) { return new Promise((resolve) => setTimeout(resolve, timeout)); } -export async function waitUntil(pred, interval = waitUnit) { +export async function waitUntil(pred: () => boolean, interval = waitUnit) { while (!pred()) { await sleep(interval); } } export async function waitUntilNode( - path, - root = document, - old = null, + path: string, + root: Node = document, + old: Node | null = null, timeout = 1000, interval = waitUnit, -) { - let node = null; +): Promise { + let node: Node | null = null; let stop = false; const waitP = waitUntil(() => { node = xpathNode(path, root); @@ -48,14 +59,15 @@ export async function awaitLoad(iframe?: HTMLIFrameElement) { const doc = iframe ? iframe.contentDocument : document; const win = iframe ? iframe.contentWindow : window; return new Promise((resolve) => { - if (doc.readyState === "complete") { + if (doc?.readyState === "complete") { resolve(null); } else { - win.addEventListener("load", resolve); + win?.addEventListener("load", resolve); } }); } +// eslint-disable-next-line @typescript-eslint/no-explicit-any function unsetToJson(obj: any) { if (obj.toJSON) { try { @@ -67,6 +79,7 @@ function unsetToJson(obj: any) { } } +// eslint-disable-next-line @typescript-eslint/no-explicit-any function restoreToJson(obj: any) { if (obj.__bx__toJSON) { try { @@ -79,37 +92,44 @@ function restoreToJson(obj: any) { } function unsetAllJson() { - unsetToJson(Object as any); - unsetToJson(Object.prototype as any); - unsetToJson(Array as any); - unsetToJson(Array.prototype as any); + unsetToJson(Object); + unsetToJson(Object.prototype); + unsetToJson(Array); + unsetToJson(Array.prototype); } function restoreAllJson() { - restoreToJson(Object as any); - restoreToJson(Object.prototype as any); - restoreToJson(Array as any); - restoreToJson(Array.prototype as any); + restoreToJson(Object); + restoreToJson(Object.prototype); + restoreToJson(Array); + restoreToJson(Array.prototype); } let needUnsetToJson = false; +type WithToJSON = T & { + toJSON?: () => unknown; +}; + export function checkToJsonOverride() { needUnsetToJson = - !!(Object as any).toJSON || - !!(Object.prototype as any).toJSON || - !!(Array as any).toJSON || - !!(Array.prototype as any).toJSON; + !!(Object as WithToJSON).toJSON || + !!(Object.prototype as WithToJSON).toJSON || + !!(Array as WithToJSON).toJSON || + !!(Array.prototype as WithToJSON).toJSON; } -export async function callBinding(binding, obj): Promise { +export async function callBinding( + binding: (obj: P) => R, + obj: P, +): Promise { try { if (needUnsetToJson) { unsetAllJson(); } return binding(obj); } catch (_) { - return binding(JSON.stringify(obj)); + return binding(JSON.stringify(obj) as P); } finally { if (needUnsetToJson) { restoreAllJson(); @@ -117,19 +137,19 @@ export async function callBinding(binding, obj): Promise { } } -export async function behaviorLog(data, type = "debug") { +export async function behaviorLog(data: unknown, type = "debug") { if (_logFunc) { await callBinding(_logFunc, { data, type }); } } -export async function addLink(url): Promise { +export async function addLink(url: string): Promise { if (typeof self["__bx_addLink"] === "function") { return await callBinding(self["__bx_addLink"], url); } } -export async function doExternalFetch(url): Promise { +export async function doExternalFetch(url: string): Promise { if (typeof self["__bx_fetch"] === "function") { return await callBinding(self["__bx_fetch"], url); } @@ -137,7 +157,7 @@ export async function doExternalFetch(url): Promise { return false; } -export async function addToExternalSet(url): Promise { +export async function addToExternalSet(url: string): Promise { if (typeof self["__bx_addSet"] === "function") { return await callBinding(self["__bx_addSet"], url); } @@ -152,7 +172,8 @@ export async function waitForNetworkIdle(idleTime = 500, concurrency = 0) { } } -export async function initFlow(params): Promise { +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export async function initFlow(params: any): Promise { if (typeof self["__bx_initFlow"] === "function") { return await callBinding(self["__bx_initFlow"], params); } @@ -160,6 +181,7 @@ export async function initFlow(params): Promise { return -1; } +// eslint-disable-next-line @typescript-eslint/no-explicit-any export async function nextFlowStep(id: number): Promise { if (typeof self["__bx_nextFlowStep"] === "function") { return await callBinding(self["__bx_nextFlowStep"], id); @@ -174,21 +196,23 @@ export function assertContentValid( ) { if (typeof self["__bx_contentCheckFailed"] === "function") { if (!assertFunc()) { - behaviorLog("Behavior content check failed: " + reason, "error"); - callBinding(self["__bx_contentCheckFailed"], reason); + void behaviorLog("Behavior content check failed: " + reason, "error"); + void callBinding(self["__bx_contentCheckFailed"], reason); } } } -export async function openWindow(url) { +export async function openWindow( + url: string | URL, +): Promise { if (self["__bx_open"]) { const p = new Promise((resolve) => (self["__bx_openResolve"] = resolve)); await callBinding(self["__bx_open"], { url }); - let win = null; + let win: WindowProxy | null = null; try { - win = await p; + win = (await p) as WindowProxy | null; if (win) { return win; } @@ -202,27 +226,29 @@ export async function openWindow(url) { return window.open(url); } -export function _setLogFunc(func) { - _logFunc = func; +export function _setLogFunc( + func: ((message: string, level: string) => void) | null, +) { + _logFunc = func as (...data: unknown[]) => void; } -export function _setBehaviorManager(cls) { +export function _setBehaviorManager(cls: typeof BehaviorManager) { _behaviorMgrClass = cls; } -export function installBehaviors(obj) { - obj.__bx_behaviors = new _behaviorMgrClass(); +export function installBehaviors(obj: Window | WorkerGlobalScope) { + obj.__bx_behaviors = new _behaviorMgrClass!(); } // =========================================================================== export class RestoreState { matchValue: string; - constructor(childMatchSelect, child) { + constructor(childMatchSelect: string, child: Node) { this.matchValue = xpathString(childMatchSelect, child); } - async restore(rootPath, childMatch) { - let root = null; + async restore(rootPath: string, childMatch: string) { + let root: Node | null = null; while (((root = xpathNode(rootPath)), !root)) { await sleep(100); @@ -235,7 +261,7 @@ export class RestoreState { // =========================================================================== export class HistoryState { loc: string; - constructor(op) { + constructor(op: () => void) { this.loc = window.location.href; op(); } @@ -244,7 +270,7 @@ export class HistoryState { return window.location.href !== this.loc; } - async goBack(backButtonQuery) { + async goBack(backButtonQuery: string) { if (!this.changed) { return Promise.resolve(true); } @@ -261,7 +287,7 @@ export class HistoryState { ); if (backButton) { - backButton["click"](); + (backButton as HTMLElement)["click"](); } else { window.history.back(); } @@ -270,7 +296,7 @@ export class HistoryState { } // =========================================================================== -export function xpathNode(path, root?) { +export function xpathNode(path: string, root?: Node | null) { root = root || document; return document.evaluate( path, @@ -280,7 +306,7 @@ export function xpathNode(path, root?) { ).singleNodeValue; } -export function* xpathNodes(path, root) { +export function* xpathNodes(path: string, root?: Node | null) { root = root || document; const iter = document.evaluate( path, @@ -288,19 +314,23 @@ export function* xpathNodes(path, root) { null, XPathResult.ORDERED_NODE_ITERATOR_TYPE, ); - let result = null; + let result: Node | null = null; while ((result = iter.iterateNext()) !== null) { yield result; } } -export function xpathString(path, root) { +export function xpathString(path: string, root?: Node | null) { root = root || document; return document.evaluate(path, root, null, XPathResult.STRING_TYPE) .stringValue; } -export async function* iterChildElem(root, timeout, totalTimeout) { +export async function* iterChildElem( + root: Element, + timeout: number, + totalTimeout: number, +) { let child = root.firstElementChild; while (child) { @@ -308,7 +338,7 @@ export async function* iterChildElem(root, timeout, totalTimeout) { if (!child.nextElementSibling) { await Promise.race([ - waitUntil(() => !!child.nextElementSibling, timeout), + waitUntil(() => !!child?.nextElementSibling, timeout), sleep(totalTimeout), ]); } @@ -318,13 +348,14 @@ export async function* iterChildElem(root, timeout, totalTimeout) { } export async function* iterChildMatches( - path, - root, + path: string, + root: Node | null, interval = waitUnit, timeout = 5000, ) { let node = xpathNode(`.//${path}`, root); - const getMatch = (node) => xpathNode(`./following-sibling::${path}`, node); + const getMatch = (node: Node | null) => + xpathNode(`./following-sibling::${path}`, node); while (node) { yield node; let next = getMatch(node); @@ -335,7 +366,7 @@ export async function* iterChildMatches( await Promise.race([ waitUntil(() => { next = getMatch(node); - return next; + return !!next; }, interval), sleep(timeout), ]); @@ -344,7 +375,7 @@ export async function* iterChildMatches( } // =========================================================================== -export function isInViewport(elem) { +export function isInViewport(elem: Element) { const bounding = elem.getBoundingClientRect(); return ( bounding.top >= 0 && @@ -356,15 +387,15 @@ export function isInViewport(elem) { ); } -export function scrollToOffset(element, offset = 0) { +export function scrollToOffset(element: Element, offset = 0) { const elPosition = element.getBoundingClientRect().top; const topPosition = elPosition + window.pageYOffset - offset; window.scrollTo({ top: topPosition, behavior: "smooth" }); } export function scrollIntoView( - element, - opts = { + element: Element, + opts: ScrollIntoViewOptions = { behavior: "smooth", block: "center", inline: "center", @@ -373,15 +404,23 @@ export function scrollIntoView( element.scrollIntoView(opts); } -export function getState(ctx: any, msg: string, incrValue?: string) { +export type NumberKeys = { + [K in keyof T]: NonNullable extends number ? K : never; +}[keyof T]; + +export function getState< + State extends NonNullable, + Opts, + IncrKey extends NumberKeys, +>(ctx: Context, msg: string, incrValue?: IncrKey) { if (typeof ctx.state === "undefined") { - ctx.state = {}; + (ctx.state as Partial) = {}; } if (incrValue) { if (ctx.state[incrValue] === undefined) { - ctx.state[incrValue] = 1; + (ctx.state[incrValue] as number) = 1; } else { - ctx.state[incrValue]++; + (ctx.state[incrValue] as number)++; } } diff --git a/src/site/facebook.ts b/src/site/facebook.ts index bdce82c..d1a0290 100644 --- a/src/site/facebook.ts +++ b/src/site/facebook.ts @@ -1,3 +1,5 @@ +import { type AbstractBehavior, type Context } from "../lib/behavior"; + const Q = { feed: "//div[@role='feed']", article: ".//div[@role='article']", @@ -35,11 +37,20 @@ const Q = { pageLoadWaitUntil: "//div[@role='main']", }; -export class FacebookTimelineBehavior { - extraWindow: any; +type FacebookState = Partial<{ + photos: number; + videos: number; + comments: number; + posts: number; +}>; + +export class FacebookTimelineBehavior + implements AbstractBehavior +{ + extraWindow: WindowProxy | null; allowNewWindow: boolean; - static id = "Facebook"; + static id = "Facebook" as const; static isMatch() { // match just for posts for now @@ -60,32 +71,40 @@ export class FacebookTimelineBehavior { this.allowNewWindow = false; } - async *iterPostFeeds(ctx) { + async *iterPostFeeds(ctx: Context) { const { iterChildElem, waitUnit, waitUntil, xpathNode, xpathNodes } = ctx.Lib; - const feeds = Array.from(xpathNodes(Q.feed)); - if (feeds && feeds.length) { + const feeds = Array.from(xpathNodes(Q.feed)) as Element[]; + if (feeds.length) { for (const feed of feeds) { for await (const post of iterChildElem( feed, waitUnit, + // @ts-expect-error TODO: `waitUntil` is a function, why are we trying to multiply it by 10? waitUntil * 10, )) { - yield* this.viewPost(ctx, xpathNode(Q.article, post)); + yield* this.viewPost( + ctx, + xpathNode(Q.article, post) as Element | null, + ); } } } else { - const feed = - xpathNode(Q.pageletPostList) || + const feed = (xpathNode(Q.pageletPostList) || xpathNode(Q.pageletProfilePostList) || - xpathNode(Q.articleToPostList); + xpathNode(Q.articleToPostList)) as Element | null; if (!feed) { return; } - for await (const post of iterChildElem(feed, waitUnit, waitUntil * 10)) { - yield* this.viewPost(ctx, xpathNode(Q.article, post)); + for await (const post of iterChildElem( + feed, + waitUnit, + // @ts-expect-error TODO: again, `waitUntil` is a function, not a number + waitUntil * 10, + )) { + yield* this.viewPost(ctx, xpathNode(Q.article, post) as Element); } } @@ -94,15 +113,19 @@ export class FacebookTimelineBehavior { } } - async *viewPost(ctx, post, maxExpands = 2) { + async *viewPost( + ctx: Context, + post: Element | null, + maxExpands = 2, + ) { const { getState, scrollIntoView, sleep, waitUnit, xpathNode } = ctx.Lib; if (!post) { return; } - const postLink = xpathNode(Q.postQuery, post); + const postLink = xpathNode(Q.postQuery, post) as HTMLAnchorElement | null; - let url = null; + let url: URL | null = null; if (postLink) { url = new URL(postLink.href, window.location.href); @@ -122,23 +145,31 @@ export class FacebookTimelineBehavior { //yield* this.viewPhotosOrVideos(ctx, post); - let commentRootUL = xpathNode(Q.commentList, post); + let commentRootUL = xpathNode( + Q.commentList, + post, + ) as HTMLUListElement | null; if (!commentRootUL) { - const viewCommentsButton = xpathNode(Q.viewComments, post); + const viewCommentsButton = xpathNode( + Q.viewComments, + post, + ) as HTMLElement | null; if (viewCommentsButton) { viewCommentsButton.click(); await sleep(waitUnit * 2); } - commentRootUL = xpathNode(Q.commentList, post); + commentRootUL = xpathNode(Q.commentList, post) as HTMLUListElement | null; } yield* this.iterComments(ctx, commentRootUL, maxExpands); await sleep(waitUnit * 5); } - async *viewPhotosOrVideos(ctx, post) { + async *viewPhotosOrVideos(ctx: Context, post: Element | null) { const { getState, sleep, waitUnit, xpathNode, xpathNodes } = ctx.Lib; - const objects: any[] = Array.from(xpathNodes(Q.photosOrVideos, post)); + const objects = Array.from( + xpathNodes(Q.photosOrVideos, post), + ) as HTMLAnchorElement[]; const objHrefs = new Set(); let count = 0; @@ -178,7 +209,7 @@ export class FacebookTimelineBehavior { yield* this.viewExtraObjects(ctx, obj, type, this.allowNewWindow); } - const close = xpathNode(Q.nextSlide); + const close = xpathNode(Q.nextSlide) as HTMLElement | null; if (close) { close.click(); @@ -187,9 +218,14 @@ export class FacebookTimelineBehavior { } } - async *viewExtraObjects(ctx, obj, type, openNew) { + async *viewExtraObjects( + ctx: Context, + obj: Node | null, + type: string, + openNew: boolean, + ) { const { getState, sleep, waitUnit, waitUntil, xpathNode } = ctx.Lib; - const extraLabel = xpathNode(Q.extraLabel, obj); + const extraLabel = xpathNode(Q.extraLabel, obj) as HTMLElement | null; if (!extraLabel) { return; @@ -200,10 +236,10 @@ export class FacebookTimelineBehavior { return; } - let lastHref; + let lastHref: string | undefined; for (let i = 0; i < num; i++) { - const nextSlideButton = xpathNode(Q.nextSlideQuery); + const nextSlideButton = xpathNode(Q.nextSlideQuery) as HTMLElement | null; if (!nextSlideButton) { continue; @@ -224,7 +260,7 @@ export class FacebookTimelineBehavior { } } - async openNewWindow(ctx, url) { + async openNewWindow(ctx: Context, url: string) { if (!this.extraWindow) { this.extraWindow = await ctx.Lib.openWindow(url); } else { @@ -232,14 +268,18 @@ export class FacebookTimelineBehavior { } } - async *iterComments(ctx, commentRootUL, maxExpands = 2) { + async *iterComments( + ctx: Context, + commentRootUL: HTMLUListElement | null, + maxExpands = 2, + ) { const { getState, scrollIntoView, sleep, waitUnit, xpathNode } = ctx.Lib; if (!commentRootUL) { await sleep(waitUnit * 5); return; } let commentBlock = commentRootUL.firstElementChild; - let lastBlock = null; + let lastBlock: Element | null = null; let count = 0; @@ -249,7 +289,10 @@ export class FacebookTimelineBehavior { scrollIntoView(commentBlock); await sleep(waitUnit * 2); - const moreReplies = xpathNode(Q.commentMoreReplies, commentBlock); + const moreReplies = xpathNode( + Q.commentMoreReplies, + commentBlock, + ) as HTMLElement | null; if (moreReplies) { moreReplies.click(); await sleep(waitUnit * 5); @@ -264,7 +307,10 @@ export class FacebookTimelineBehavior { break; } - const moreButton = xpathNode(Q.commentMoreComments, commentRootUL); + const moreButton = xpathNode( + Q.commentMoreComments, + commentRootUL, + ) as HTMLElement | null; if (moreButton) { scrollIntoView(moreButton); moreButton.click(); @@ -279,10 +325,10 @@ export class FacebookTimelineBehavior { await sleep(waitUnit * 2); } - async *iterPhotoSlideShow(ctx) { + async *iterPhotoSlideShow(ctx: Context) { const { getState, scrollIntoView, sleep, waitUnit, waitUntil, xpathNode } = ctx.Lib; - const firstPhoto = xpathNode(Q.firstPhotoThumbnail); + const firstPhoto = xpathNode(Q.firstPhotoThumbnail) as HTMLElement | null; if (!firstPhoto) { return; @@ -296,9 +342,11 @@ export class FacebookTimelineBehavior { await sleep(waitUnit * 5); await waitUntil(() => window.location.href !== lastHref, waitUnit * 2); - let nextSlideButton = null; + let nextSlideButton: HTMLElement | null = null; - while ((nextSlideButton = xpathNode(Q.nextSlideQuery))) { + while ( + (nextSlideButton = xpathNode(Q.nextSlideQuery) as HTMLElement | null) + ) { lastHref = window.location.href; await sleep(waitUnit); @@ -316,14 +364,14 @@ export class FacebookTimelineBehavior { yield getState(ctx, `Viewing photo ${window.location.href}`, "photos"); - const root = xpathNode(Q.photoCommentList); + const root = xpathNode(Q.photoCommentList) as HTMLUListElement | null; yield* this.iterComments(ctx, root, 2); await sleep(waitUnit * 5); } } - async *iterAllVideos(ctx) { + async *iterAllVideos(ctx: Context) { const { getState, scrollIntoView, @@ -333,14 +381,14 @@ export class FacebookTimelineBehavior { xpathNode, xpathNodes, } = ctx.Lib; - const firstInlineVideo = xpathNode("//video"); + const firstInlineVideo = xpathNode("//video") as HTMLElement | null; if (firstInlineVideo) { scrollIntoView(firstInlineVideo); await sleep(waitUnit * 5); } - let videoLink = - xpathNode(Q.firstVideoThumbnail) || xpathNode(Q.firstVideoSimple); + let videoLink = (xpathNode(Q.firstVideoThumbnail) || + xpathNode(Q.firstVideoSimple)) as HTMLElement | null; if (!videoLink) { return; @@ -360,7 +408,9 @@ export class FacebookTimelineBehavior { // wait for video to play, or 20s await Promise.race([ waitUntil(() => { - for (const video of xpathNodes("//video")) { + for (const video of xpathNodes( + "//video", + ) as Generator) { if (video.readyState >= 3) { return true; } @@ -372,7 +422,7 @@ export class FacebookTimelineBehavior { await sleep(waitUnit * 10); - const close = xpathNode(Q.nextSlide); + const close = xpathNode(Q.nextSlide) as HTMLElement | null; if (!close) { break; @@ -382,11 +432,11 @@ export class FacebookTimelineBehavior { close.click(); await waitUntil(() => window.location.href !== lastHref, waitUnit * 2); - videoLink = xpathNode(Q.nextVideo, videoLink); + videoLink = xpathNode(Q.nextVideo, videoLink) as HTMLElement | null; } } - async *run(ctx) { + async *run(ctx: Context) { const { getState, sleep, xpathNode } = ctx.Lib; yield getState(ctx, "Starting..."); @@ -406,7 +456,7 @@ export class FacebookTimelineBehavior { if (Q.isPhotoVideoPage.exec(window.location.href)) { ctx.state = { comments: 0 }; - const root = xpathNode(Q.photoCommentList); + const root = xpathNode(Q.photoCommentList) as HTMLUListElement | null; yield* this.iterComments(ctx, root, 1000); return; } @@ -415,11 +465,11 @@ export class FacebookTimelineBehavior { yield* this.iterPostFeeds(ctx); } - async awaitPageLoad(ctx: any) { + async awaitPageLoad(ctx: Context) { const { Lib, log } = ctx; const { assertContentValid, waitUntilNode } = Lib; - log("Waiting for Facebook to fully load", "info"); + void log("Waiting for Facebook to fully load", "info"); await waitUntilNode(Q.pageLoadWaitUntil, document, null, 10000); diff --git a/src/site/index.ts b/src/site/index.ts index 275864b..2629ea9 100644 --- a/src/site/index.ts +++ b/src/site/index.ts @@ -11,6 +11,6 @@ const siteBehaviors = [ TelegramBehavior, TikTokVideoBehavior, TikTokProfileBehavior, -]; +] as const; export default siteBehaviors; diff --git a/src/site/instagram.ts b/src/site/instagram.ts index de6912c..e422be6 100644 --- a/src/site/instagram.ts +++ b/src/site/instagram.ts @@ -1,3 +1,5 @@ +import { type AbstractBehavior, type Context } from "../lib/behavior"; + const subpostNextOnlyChevron = "//article[@role='presentation']//div[@role='presentation']/following-sibling::button"; @@ -18,11 +20,20 @@ const Q = { pageLoadWaitUntil: "//main", }; -export class InstagramPostsBehavior { +type InstagramState = { + comments: number; + slides: number; + posts: number; + rows: number; +}; + +export class InstagramPostsBehavior + implements AbstractBehavior +{ maxCommentsTime: number; - postOnlyWindow: any; + postOnlyWindow: WindowProxy | null; - static id = "Instagram"; + static id = "Instagram" as const; static isMatch() { return !!window.location.href.match(/https:\/\/(www\.)?instagram\.com\//); @@ -52,7 +63,7 @@ export class InstagramPostsBehavior { } } - async waitForNext(ctx, child) { + async waitForNext(ctx: Context, child: Element | null) { if (!child) { return null; } @@ -66,9 +77,9 @@ export class InstagramPostsBehavior { return child.nextElementSibling; } - async *iterRow(ctx) { + async *iterRow(ctx: Context) { const { RestoreState, sleep, waitUnit, xpathNode } = ctx.Lib; - const root = xpathNode(Q.rootPath); + const root = xpathNode(Q.rootPath) as Element | null; if (!root) { return; @@ -88,17 +99,20 @@ export class InstagramPostsBehavior { if (restorer.matchValue) { yield child; - child = await restorer.restore(Q.rootPath, Q.childMatch); + child = (await restorer.restore( + Q.rootPath, + Q.childMatch, + )) as Element | null; } child = await this.waitForNext(ctx, child); } } - async *viewStandalonePost(ctx, origLoc) { + async *viewStandalonePost(ctx: Context, origLoc: string) { const { getState, sleep, waitUnit, waitUntil, xpathNode, xpathString } = ctx.Lib; - const root = xpathNode(Q.rootPath); + const root = xpathNode(Q.rootPath) as HTMLElement | null; if (!root?.firstElementChild) { return; @@ -117,13 +131,13 @@ export class InstagramPostsBehavior { window.history.replaceState({}, "", firstPostHref); window.dispatchEvent(new PopStateEvent("popstate", { state: {} })); - let root2 = null; - let root3 = null; + let root2: Node | null = null; + let root3: Node | null = null; await sleep(waitUnit * 5); await waitUntil( - () => (root2 = xpathNode(Q.rootPath)) !== root && root2, + () => !!((root2 = xpathNode(Q.rootPath)) !== root && root2), waitUnit * 5, ); @@ -133,15 +147,15 @@ export class InstagramPostsBehavior { window.dispatchEvent(new PopStateEvent("popstate", { state: {} })); await waitUntil( - () => (root3 = xpathNode(Q.rootPath)) !== root2 && root3, + () => !!((root3 = xpathNode(Q.rootPath)) !== root2 && root3), waitUnit * 5, ); //} } - async *iterSubposts(ctx) { + async *iterSubposts(ctx: Context) { const { getState, sleep, waitUnit, xpathNode } = ctx.Lib; - let next = xpathNode(Q.subpostNextOnlyChevron); + let next = xpathNode(Q.subpostNextOnlyChevron) as HTMLElement | null; let count = 1; @@ -155,15 +169,15 @@ export class InstagramPostsBehavior { "slides", ); - next = xpathNode(Q.subpostPrevNextChevron); + next = xpathNode(Q.subpostPrevNextChevron) as HTMLElement | null; } await sleep(waitUnit * 5); } - async iterComments(ctx) { + async iterComments(ctx: Context) { const { scrollIntoView, sleep, waitUnit, waitUntil, xpathNode } = ctx.Lib; - const root = xpathNode(Q.commentRoot); + const root = xpathNode(Q.commentRoot) as HTMLElement | null; if (!root) { return; @@ -173,8 +187,8 @@ export class InstagramPostsBehavior { let commentsLoaded = false; - const getViewRepliesButton = (child) => { - return xpathNode(Q.viewReplies, child); + const getViewRepliesButton = (child: Element) => { + return xpathNode(Q.viewReplies, child) as HTMLElement | null; }; while (child) { @@ -190,7 +204,7 @@ export class InstagramPostsBehavior { ctx.state.comments++; await sleep(waitUnit * 2.5); - await waitUntil(() => orig !== viewReplies.textContent, waitUnit); + await waitUntil(() => orig !== viewReplies?.textContent, waitUnit); viewReplies = getViewRepliesButton(child); } @@ -200,7 +214,10 @@ export class InstagramPostsBehavior { child.nextElementSibling.tagName === "LI" && !child.nextElementSibling.nextElementSibling ) { - const loadMore = xpathNode(Q.loadMore, child.nextElementSibling); + const loadMore = xpathNode( + Q.loadMore, + child.nextElementSibling, + ) as HTMLElement | null; if (loadMore) { loadMore.click(); ctx.state.comments++; @@ -215,7 +232,7 @@ export class InstagramPostsBehavior { return commentsLoaded; } - async *iterPosts(ctx, next) { + async *iterPosts(ctx: Context, next: HTMLElement | null) { const { getState, sleep, waitUnit, xpathNode } = ctx.Lib; //let count = 0; @@ -229,7 +246,7 @@ export class InstagramPostsBehavior { yield* this.handleSinglePost(ctx); - next = xpathNode(Q.nextPost); + next = xpathNode(Q.nextPost) as HTMLElement | null; while (!next && xpathNode(Q.postLoading)) { await sleep(waitUnit * 2.5); @@ -239,7 +256,7 @@ export class InstagramPostsBehavior { await sleep(waitUnit * 5); } - async *handleSinglePost(ctx) { + async *handleSinglePost(ctx: Context) { const { getState, sleep } = ctx.Lib; yield* this.iterSubposts(ctx); @@ -249,7 +266,7 @@ export class InstagramPostsBehavior { await Promise.race([this.iterComments(ctx), sleep(this.maxCommentsTime)]); } - async *run(ctx) { + async *run(ctx: Context) { if (window.location.pathname.startsWith("/p/")) { yield* this.handleSinglePost(ctx); return; @@ -267,11 +284,11 @@ export class InstagramPostsBehavior { yield getState(ctx, "Loading Row", "rows"); - const first = xpathNode(Q.firstPostInRow, row); + const first = xpathNode(Q.firstPostInRow, row) as HTMLElement | null; yield* this.iterPosts(ctx, first); - const close = xpathNode(Q.postCloseButton); + const close = xpathNode(Q.postCloseButton) as HTMLElement | null; if (close) { close.click(); } @@ -280,11 +297,11 @@ export class InstagramPostsBehavior { } } - async awaitPageLoad(ctx: any) { + async awaitPageLoad(ctx: Context) { const { Lib, log } = ctx; const { assertContentValid, waitUntilNode } = Lib; - log("Waiting for Instagram to fully load"); + void log("Waiting for Instagram to fully load"); await waitUntilNode(Q.pageLoadWaitUntil, document, null, 10000); diff --git a/src/site/telegram.ts b/src/site/telegram.ts index 608b8d7..510850b 100644 --- a/src/site/telegram.ts +++ b/src/site/telegram.ts @@ -1,3 +1,5 @@ +import { type AbstractBehavior, type Context } from "../lib/behavior"; + const Q = { telegramContainer: "//main//section[@class='tgme_channel_history js-message_history']", @@ -6,8 +8,12 @@ const Q = { "string(.//a[@class='tgme_widget_message_link_preview' and @href]/@href)", }; -export class TelegramBehavior { - static id = "Telegram"; +type TelegramState = { + messages?: number; +}; + +export class TelegramBehavior implements AbstractBehavior { + static id = "Telegram" as const; static isMatch() { return !!window.location.href.match(/https:\/\/t.me\/s\/\w[\w]+/); @@ -19,7 +25,7 @@ export class TelegramBehavior { }; } - async waitForPrev(ctx, child) { + async waitForPrev(ctx: Context, child: Element | null) { if (!child) { return null; } @@ -33,7 +39,7 @@ export class TelegramBehavior { return child.previousElementSibling; } - async *run(ctx) { + async *run(ctx: Context) { const { getState, scrollIntoView, @@ -42,7 +48,7 @@ export class TelegramBehavior { xpathNode, xpathString, } = ctx.Lib; - const root = xpathNode(Q.telegramContainer); + const root = xpathNode(Q.telegramContainer) as Element | null; if (!root) { return; @@ -57,7 +63,7 @@ export class TelegramBehavior { const linkUrl = xpathString(Q.linkExternal, child); - if (linkUrl?.endsWith(".jpg") || linkUrl.endsWith(".png")) { + if (linkUrl.endsWith(".jpg") || linkUrl.endsWith(".png")) { yield getState(ctx, "Loading External Image: " + linkUrl); const image = new Image(); image.src = linkUrl; diff --git a/src/site/tiktok.ts b/src/site/tiktok.ts index 45e8046..b01a9c6 100644 --- a/src/site/tiktok.ts +++ b/src/site/tiktok.ts @@ -1,3 +1,5 @@ +import { type AbstractBehavior, type Context } from "../lib/behavior"; + const Q = { commentList: "//div[contains(@class, 'CommentListContainer')]", commentItem: "div[contains(@class, 'CommentItemContainer')]", @@ -12,8 +14,16 @@ const Q = { export const BREADTH_ALL = Symbol("BREADTH_ALL"); +type TikTokState = { + comments: number; + videos: number; +}; +type TikTokOpts = { + breadth: number | typeof BREADTH_ALL; +}; + export class TikTokSharedBehavior { - async awaitPageLoad(ctx: any) { + async awaitPageLoad(ctx: Context) { const { assertContentValid, waitUntilNode } = ctx.Lib; await waitUntilNode(Q.pageLoadWaitUntil, document, null, 10000); @@ -24,8 +34,11 @@ export class TikTokSharedBehavior { } } -export class TikTokVideoBehavior extends TikTokSharedBehavior { - static id = "TikTokVideo"; +export class TikTokVideoBehavior + extends TikTokSharedBehavior + implements AbstractBehavior +{ + static id = "TikTokVideo" as const; static init() { return { @@ -39,34 +52,49 @@ export class TikTokVideoBehavior extends TikTokSharedBehavior { return !!window.location.href.match(pathRegex); } - breadthComplete({ opts: { breadth } }, iter) { + breadthComplete( + { opts: { breadth } }: { opts: { breadth: number | typeof BREADTH_ALL } }, + iter: number, + ) { return breadth !== BREADTH_ALL && breadth <= iter; } - async *crawlThread(ctx, parentNode, prev = null, iter = 0) { + async *crawlThread( + ctx: Context, + parentNode: Node | null = null, + prev: Node | null = null, + iter = 0, + ): AsyncGenerator<{ state: TikTokState; msg: string }> { const { waitUntilNode, scrollAndClick, getState } = ctx.Lib; - const next = await waitUntilNode(Q.viewMoreThread, parentNode, prev); + const next = (await waitUntilNode( + Q.viewMoreThread, + parentNode ?? undefined, + prev, + )) as HTMLElement | null; if (!next || this.breadthComplete(ctx, iter)) return; await scrollAndClick(next, 500); yield getState(ctx, "View more replies", "comments"); yield* this.crawlThread(ctx, parentNode, next, iter + 1); } - async *expandThread(ctx, item) { + async *expandThread( + ctx: Context, + item: Node | null, + ) { const { xpathNode, scrollAndClick, getState } = ctx.Lib; - const viewMore = xpathNode(Q.viewMoreReplies, item); + const viewMore = xpathNode(Q.viewMoreReplies, item) as HTMLElement | null; if (!viewMore) return; await scrollAndClick(viewMore, 500); yield getState(ctx, "View comment", "comments"); yield* this.crawlThread(ctx, item, null, 1); } - async *run(ctx) { + async *run(ctx: Context) { const { xpathNode, iterChildMatches, scrollIntoView, getState } = ctx.Lib; const commentList = xpathNode(Q.commentList); const commentItems = iterChildMatches(Q.commentItem, commentList); for await (const item of commentItems) { - scrollIntoView(item); + scrollIntoView(item as Element); yield getState(ctx, "View comment", "comments"); if (this.breadthComplete(ctx, 0)) continue; yield* this.expandThread(ctx, item); @@ -75,8 +103,11 @@ export class TikTokVideoBehavior extends TikTokSharedBehavior { } } -export class TikTokProfileBehavior extends TikTokSharedBehavior { - static id = "TikTokProfile"; +export class TikTokProfileBehavior + extends TikTokSharedBehavior + implements AbstractBehavior +{ + static id = "TikTokProfile" as const; static isMatch() { const pathRegex = @@ -91,9 +122,9 @@ export class TikTokProfileBehavior extends TikTokSharedBehavior { }; } - async *openVideo(ctx, item) { + async *openVideo(ctx: Context, item: Node | null) { const { HistoryState, xpathNode, sleep } = ctx.Lib; - const link = xpathNode(".//a", item); + const link = xpathNode(".//a", item) as HTMLElement | null; if (!link) return; const viewState = new HistoryState(() => link.click()); await sleep(500); @@ -105,7 +136,7 @@ export class TikTokProfileBehavior extends TikTokSharedBehavior { } } - async *run(ctx) { + async *run(ctx: Context) { const { xpathNode, iterChildMatches, scrollIntoView, getState, sleep } = ctx.Lib; const profileVideoList = xpathNode(Q.profileVideoList); @@ -114,7 +145,7 @@ export class TikTokProfileBehavior extends TikTokSharedBehavior { profileVideoList, ); for await (const item of profileVideos) { - scrollIntoView(item); + scrollIntoView(item as HTMLElement); yield getState(ctx, "View video", "videos"); yield* this.openVideo(ctx, item); await sleep(500); diff --git a/src/site/twitter.ts b/src/site/twitter.ts index 1a04ef6..e5e59d5 100644 --- a/src/site/twitter.ts +++ b/src/site/twitter.ts @@ -1,3 +1,5 @@ +import { type AbstractBehavior, type Context } from "../lib/behavior"; + const Q = { rootPath: "//h1[@role='heading' and @aria-level='1']/following-sibling::div[@aria-label]//div[@style]", @@ -22,11 +24,24 @@ const Q = { promoted: ".//div[data-testid='placementTracking']", }; -export class TwitterTimelineBehavior { - seenTweets: Set; - seenMediaTweets: Set; +type TwitterState = { + tweets?: number; + images?: number; + videos?: number; + threads?: number; +}; + +type TwitterOpts = { + maxDepth: number; +}; + +export class TwitterTimelineBehavior + implements AbstractBehavior +{ + seenTweets: Set; + seenMediaTweets: Set; - static id = "Twitter"; + static id = "Twitter" as const; static isMatch() { return !!window.location.href.match(/https:\/\/(www\.)?(x|twitter)\.com\//); @@ -50,9 +65,12 @@ export class TwitterTimelineBehavior { this.seenMediaTweets = new Set(); } - showingProgressBar(ctx, root) { + showingProgressBar( + ctx: Context, + root: Node | null, + ) { const { xpathNode } = ctx.Lib; - const node = xpathNode(Q.progress, root); + const node = xpathNode(Q.progress, root) as Element | null; if (!node) { return false; } @@ -60,7 +78,10 @@ export class TwitterTimelineBehavior { return node.clientHeight > 10; } - async waitForNext(ctx, child) { + async waitForNext( + ctx: Context, + child: Element | null, + ) { const { sleep, waitUnit } = ctx.Lib; if (!child) { return null; @@ -79,44 +100,47 @@ export class TwitterTimelineBehavior { return child.nextElementSibling; } - async expandMore(ctx, child) { + async expandMore( + ctx: Context, + child: Element | null, + ) { const { sleep, waitUnit, xpathNode } = ctx.Lib; - const expandElem = xpathNode(Q.expand, child); + const expandElem = xpathNode(Q.expand, child) as HTMLElement | null; if (!expandElem) { return child; } - const prev = child.previousElementSibling; + const prev = child?.previousElementSibling; expandElem.click(); await sleep(waitUnit); - while (this.showingProgressBar(ctx, prev.nextElementSibling)) { + while (this.showingProgressBar(ctx, prev?.nextElementSibling ?? null)) { await sleep(waitUnit); } - child = prev.nextElementSibling; + child = prev?.nextElementSibling ?? null; return child; } - async *infScroll(ctx) { + async *infScroll(ctx: Context) { const { scrollIntoView, RestoreState, sleep, waitUnit, xpathNode } = ctx.Lib; - const root = xpathNode(Q.rootPath); + const root = xpathNode(Q.rootPath) as Element | null; if (!root) { return; } - let child = root.firstElementChild; + let child = root.firstElementChild as HTMLElement | null; if (!child) { return; } while (child) { - let anchorElem = xpathNode(Q.anchor, child); + let anchorElem = xpathNode(Q.anchor, child) as HTMLElement | null; if (!anchorElem && Q.expand) { - child = await this.expandMore(ctx, child); - anchorElem = xpathNode(Q.anchor, child); + child = (await this.expandMore(ctx, child)) as HTMLElement | null; + anchorElem = xpathNode(Q.anchor, child) as HTMLElement | null; } if (child?.innerText) { @@ -131,22 +155,31 @@ export class TwitterTimelineBehavior { yield anchorElem; if (restorer.matchValue) { - child = await restorer.restore(Q.rootPath, Q.childMatch); + child = (await restorer.restore( + Q.rootPath, + Q.childMatch, + )) as HTMLElement | null; } } - child = await this.waitForNext(ctx, child); + child = (await this.waitForNext(ctx, child)) as HTMLElement | null; } } - async *mediaPlaying(ctx, tweet) { + async *mediaPlaying( + ctx: Context, + tweet: HTMLElement, + ) { const { getState, sleep, xpathNode, xpathString } = ctx.Lib; - const media = xpathNode("(.//video | .//audio)", tweet); + const media = xpathNode( + "(.//video | .//audio)", + tweet, + ) as HTMLMediaElement | null; if (!media || media.paused) { return; } - let mediaTweetUrl = null; + let mediaTweetUrl: string | null = null; try { mediaTweetUrl = new URL( @@ -192,9 +225,12 @@ export class TwitterTimelineBehavior { await Promise.race([p, sleep(60000)]); } - async *clickImages(ctx, tweet) { + async *clickImages( + ctx: Context, + tweet: HTMLElement, + ) { const { getState, HistoryState, sleep, waitUnit, xpathNode } = ctx.Lib; - const imagePopup = xpathNode(Q.image, tweet); + const imagePopup = xpathNode(Q.image, tweet) as HTMLElement | null; if (imagePopup) { const imageState = new HistoryState(() => imagePopup.click()); @@ -203,7 +239,7 @@ export class TwitterTimelineBehavior { await sleep(waitUnit * 5); - let nextImage = xpathNode(Q.imageFirstNext); + let nextImage = xpathNode(Q.imageFirstNext) as HTMLElement | null; let prevLocation = window.location.href; while (nextImage) { @@ -219,14 +255,18 @@ export class TwitterTimelineBehavior { yield getState(ctx, "Loading Image: " + window.location.href, "images"); await sleep(waitUnit * 5); - nextImage = xpathNode(Q.imageNext); + nextImage = xpathNode(Q.imageNext) as HTMLElement | null; } await imageState.goBack(Q.imageClose); } } - async *clickTweet(ctx, tweet, depth) { + async *clickTweet( + ctx: Context, + tweet: HTMLElement, + depth: number, + ): AsyncGenerator<{ state: TwitterState; msg: string }> { const { getState, HistoryState, sleep, waitUnit } = ctx.Lib; const tweetState = new HistoryState(() => tweet.click()); @@ -250,7 +290,7 @@ export class TwitterTimelineBehavior { } } - async *iterTimeline(ctx, depth = 0) { + async *iterTimeline(ctx: Context, depth = 0) { const { getState, sleep, waitUnit, xpathNode } = ctx.Lib; if (this.seenTweets.has(window.location.href)) { return; @@ -267,7 +307,10 @@ export class TwitterTimelineBehavior { await sleep(waitUnit * 2.5); - const viewButton = xpathNode(Q.viewSensitive, tweet); + const viewButton = xpathNode( + Q.viewSensitive, + tweet, + ) as HTMLElement | null; if (viewButton) { viewButton.click(); await sleep(waitUnit * 2.5); @@ -277,7 +320,7 @@ export class TwitterTimelineBehavior { yield* this.clickImages(ctx, tweet); // process quoted tweet - const quoteTweet = xpathNode(Q.quote, tweet); + const quoteTweet = xpathNode(Q.quote, tweet) as HTMLElement | null; if (quoteTweet) { yield* this.clickTweet(ctx, quoteTweet, 1000); @@ -294,11 +337,11 @@ export class TwitterTimelineBehavior { } } - async *run(ctx) { + async *run(ctx: Context) { yield* this.iterTimeline(ctx, 0); } - async awaitPageLoad(ctx: any) { + async awaitPageLoad(ctx: Context) { const { sleep, assertContentValid } = ctx.Lib; await sleep(5); assertContentValid( diff --git a/src/site/youtube.ts b/src/site/youtube.ts index d0322e4..0fcffa3 100644 --- a/src/site/youtube.ts +++ b/src/site/youtube.ts @@ -1,12 +1,14 @@ import { AutoScroll } from "../autoscroll"; +import { type Context } from "../lib/behavior"; export class YoutubeBehavior extends AutoScroll { - override async awaitPageLoad(ctx: any) { + static override id = "Youtube" as const; + async awaitPageLoad(ctx: Context<{}, {}>) { const { sleep, assertContentValid } = ctx.Lib; await sleep(10); assertContentValid(() => { const video = document.querySelector("video"); - const paused = video && video.paused; + const paused = video?.paused; if (paused) { return false; } diff --git a/tsconfig.json b/tsconfig.json index f1b3d79..d917b3e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,11 +6,12 @@ "preserveConstEnums": true, "allowJs": true, "checkJs": true, + "strict": true, "target": "es2022", "lib": ["es2022", "dom", "dom.iterable"], "outDir": "./dist/" }, "files": ["index.ts"], - "include": ["src/**/*"], + "include": ["src/**/*", "types/**/*"], "exclude": ["node_modules", "**/*.spec.ts"] } diff --git a/types/global.d.ts b/types/global.d.ts new file mode 100644 index 0000000..60fdfff --- /dev/null +++ b/types/global.d.ts @@ -0,0 +1,55 @@ +import { type BehaviorManager } from "../src"; + +interface BehaviorGlobals { + __bx_addLink?: (url: string) => Promise; + __bx_fetch?: (url: string) => Promise; + __bx_addSet?: (url: string) => Promise; + __bx_netIdle?: (params: { + idleTime: number; + concurrency: number; + }) => Promise; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + __bx_initFlow?: (params: any) => Promise; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + __bx_nextFlowStep?: (id: number) => Promise; + __bx_contentCheckFailed?: (reason: string) => void; + __bx_open?: (params: { url: string | URL }) => Promise; + __bx_openResolve?: (window: WindowProxy | null) => void; + __bx_behaviors?: BehaviorManager; +} + +interface AutoplayProperties { + __bx_autoplay_found?: boolean; +} + +declare global { + interface WorkerGlobalScope extends BehaviorGlobals { + scrollHeight?: number; + getEventListeners?: ( + obj: Obj, + ) => Record< + Obj extends Window ? keyof WindowEventMap : string, + EventListenerOrEventListenerObject[] + >; + [key: string]: any; + } + interface Window extends BehaviorGlobals { + __WB_replay_top?: Window; + + /** + * Chrome DevTools's `getEventListeners` API + * @see https://developer.chrome.com/docs/devtools/console/utilities/#getEventListeners-function + */ + getEventListeners?: ( + obj: Obj, + ) => Record< + Obj extends Window ? keyof WindowEventMap : string, + EventListenerOrEventListenerObject[] + >; + [key: string]: any; + } + + interface HTMLVideoElement extends AutoplayProperties {} + interface HTMLAudioElement extends AutoplayProperties {} + interface HTMLPictureElement extends AutoplayProperties {} +} diff --git a/yarn.lock b/yarn.lock index 5a367ec..f248779 100644 --- a/yarn.lock +++ b/yarn.lock @@ -535,6 +535,11 @@ resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz#e486d0d97396d79beedd0a6e33f4534ff6b4973e" integrity sha512-f5j5b/Gf71L+dbqxIpQ4Z2WlmI/mPJ0fOkGGmFgtb6sAu97EPczzbS3/tJKxmcYDj55OX6ssqwDAWOHIYDRDGA== +"@types/query-selector-shadow-dom@^1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@types/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.4.tgz#89712de2b066866865f6e01698b4cbbe6fedab82" + integrity sha512-8jfGPD0wCMCdyBvrvOrWVn8bHL1UEjkPVJKsqNZpEXp+a7mIIvvGpJMd6n6dlNl7IkG2ryxIVqFI516RPE0uhQ== + "@types/responselike@*", "@types/responselike@^1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@types/responselike/-/responselike-1.0.0.tgz#251f4fe7d154d2bad125abe1b429b23afd262e29"