From bedebf44f6552e46ec60f50178e169b5702527db Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Wed, 23 Sep 2026 02:26:07 +0200 Subject: [PATCH] Round-robin Viaplay VOD accounts and slow Token Tray login. VOD play rotates accounts like live; tray types/clicks with human delays and pauses between accounts to reduce captcha triggers. --- apps/master-api/src/viaplay/play.ts | 5 ++- apps/token-tray/src/jobs.ts | 7 +++ apps/token-tray/src/login/f1tv.ts | 40 ++++++++++------- apps/token-tray/src/login/human.ts | 66 ++++++++++++++++++++++++++++ apps/token-tray/src/login/viaplay.ts | 66 ++++++++++++++++------------ 5 files changed, 140 insertions(+), 44 deletions(-) create mode 100644 apps/token-tray/src/login/human.ts diff --git a/apps/master-api/src/viaplay/play.ts b/apps/master-api/src/viaplay/play.ts index 35edf3a..709d611 100644 --- a/apps/master-api/src/viaplay/play.ts +++ b/apps/master-api/src/viaplay/play.ts @@ -281,7 +281,10 @@ export async function resolveViaplayGuidPlay( let cachedKeys = await keysForMediaGuid(sessionSecret, guid); let lastError: Error | null = null; - for (const account of accounts) { + // Zelfde round-robin als live: elke VOD-play start op een ander account. + const ordered = accountOrder(guid, accounts, []); + + for (const account of ordered) { try { const play = await playByGuid(account, guid, { deviceKey: tweaks.deviceKey, diff --git a/apps/token-tray/src/jobs.ts b/apps/token-tray/src/jobs.ts index b728930..34834ce 100644 --- a/apps/token-tray/src/jobs.ts +++ b/apps/token-tray/src/jobs.ts @@ -3,6 +3,7 @@ import type { TrayConfig } from "./config"; import { saveConfig } from "./config"; import { loginViaplayUi } from "./login/viaplay"; import { loginF1tvUi } from "./login/f1tv"; +import { betweenAccounts } from "./login/human"; export type AgentAccount = { provider: "viaplay" | "f1tv"; @@ -144,11 +145,17 @@ export async function refreshAccountsSilent( const notify = opts?.notify !== false; const summary: RefreshSummary = { ok: 0, failed: 0, skipped: 0, errors: [] }; + let attempted = 0; for (const acc of accounts) { if (!acc.password?.trim()) { summary.skipped += 1; continue; } + if (attempted > 0) { + // Captcha-vriendelijk: niet back-to-back inloggen op meerdere accounts + await betweenAccounts(); + } + attempted += 1; try { await refreshOne(cfg, acc, headless); summary.ok += 1; diff --git a/apps/token-tray/src/login/f1tv.ts b/apps/token-tray/src/login/f1tv.ts index 5d889dc..360e9f5 100644 --- a/apps/token-tray/src/login/f1tv.ts +++ b/apps/token-tray/src/login/f1tv.ts @@ -1,6 +1,14 @@ import { chromium, type BrowserContext, type Page } from "playwright"; import path from "node:path"; import { app } from "electron"; +import { + afterNavigate, + humanClick, + humanDelay, + humanType, + think, + waitForVisiblePassword, +} from "./human"; export type F1tvLoginResult = { ascendontoken: string; @@ -20,8 +28,8 @@ async function dismissCookies(page: Page) { for (const loc of candidates) { try { const btn = loc.first(); - if (await btn.isVisible({ timeout: 1500 })) { - await btn.click({ timeout: 2000 }); + if (await btn.isVisible({ timeout: 2500 })) { + await humanClick(btn, 3000); return; } } catch { @@ -76,6 +84,7 @@ async function launchProfile(userDataDir: string, headless: boolean) { headless, viewport: { width: 1280, height: 900 } as const, args: ["--disable-blink-features=AutomationControlled"], + slowMo: 40 + Math.floor(Math.random() * 50), }; for (const channel of ["chrome", "msedge"] as const) { try { @@ -97,10 +106,9 @@ async function launchProfile(userDataDir: string, headless: boolean) { /** Login-formulier: zichtbaar password → e-mail in zelfde form (niet forgot-password). */ async function fillLogin(page: Page, email: string, password: string) { await dismissCookies(page); + await think(); - const passBox = page.locator('input[type="password"]').filter({ visible: true }).first(); - await passBox.waitFor({ state: "visible", timeout: 60_000 }); - + const passBox = await waitForVisiblePassword(page, 60_000); const form = page.locator("form").filter({ has: passBox }).first(); // F1 gebruikt type=text name=email class=txtEmail — niet type=email const emailBox = form @@ -110,15 +118,16 @@ async function fillLogin(page: Page, email: string, password: string) { .filter({ visible: true }) .first(); await emailBox.waitFor({ state: "visible", timeout: 15_000 }); - await emailBox.click({ clickCount: 3 }); - await emailBox.fill(email); - await passBox.fill(password); + await humanType(emailBox, email); + await think(); + await humanType(passBox, password); + await humanDelay(700, 1800); const submit = form .getByRole("button", { name: /log\s*in|sign\s*in|continue|submit/i }) .or(form.locator('button[type="submit"], input[type="submit"], button.btnLogin, a.btnLogin')) .first(); - await submit.click(); + await humanClick(submit); } export async function loginF1tvUi(opts: { @@ -139,7 +148,7 @@ export async function loginF1tvUi(opts: { "https://f1tv.formula1.com/", ]) { await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60_000 }); - await new Promise((r) => setTimeout(r, 2000)); + await afterNavigate(); await dismissCookies(page); let tokens = await readTokens(context); @@ -150,9 +159,9 @@ export async function loginF1tvUi(opts: { const signIn = page.getByRole("link", { name: /sign in|log in/i }).or( page.getByRole("button", { name: /sign in|log in/i }) ); - if (await signIn.first().isVisible({ timeout: 2000 })) { - await signIn.first().click(); - await new Promise((r) => setTimeout(r, 2000)); + if (await signIn.first().isVisible({ timeout: 2500 })) { + await humanClick(signIn.first()); + await afterNavigate(); } } catch { /* ignore */ @@ -168,9 +177,9 @@ export async function loginF1tvUi(opts: { } } - const deadline = Date.now() + 120_000; + const deadline = Date.now() + 150_000; while (Date.now() < deadline) { - await new Promise((r) => setTimeout(r, 2000)); + await humanDelay(2200, 4000); if (!/f1tv\.formula1\.com/i.test(page.url())) { await page .goto("https://f1tv.formula1.com/", { @@ -178,6 +187,7 @@ export async function loginF1tvUi(opts: { timeout: 45_000, }) .catch(() => undefined); + await afterNavigate(); } const tokens = await readTokens(context); if (tokens) return tokens; diff --git a/apps/token-tray/src/login/human.ts b/apps/token-tray/src/login/human.ts new file mode 100644 index 0000000..fe60a5a --- /dev/null +++ b/apps/token-tray/src/login/human.ts @@ -0,0 +1,66 @@ +import type { Locator, Page } from "playwright"; + +/** Willekeurige pauze (ms). */ +export function humanDelay(minMs: number, maxMs: number): Promise { + const lo = Math.max(0, Math.min(minMs, maxMs)); + const hi = Math.max(minMs, maxMs); + const ms = lo + Math.floor(Math.random() * (hi - lo + 1)); + return new Promise((r) => setTimeout(r, ms)); +} + +/** Korte “denk”-pauze tussen UI-stappen. */ +export function think(): Promise { + return humanDelay(450, 1400); +} + +/** Langere pauze na navigatie / pagina-load. */ +export function afterNavigate(): Promise { + return humanDelay(1800, 4200); +} + +/** Pauze tussen accounts bij batch-refresh (captcha-vriendelijk). */ +export function betweenAccounts(): Promise { + return humanDelay(12_000, 28_000); +} + +/** Klik met kleine delay (geen instant robot-click). */ +export async function humanClick(locator: Locator, timeoutMs = 8000): Promise { + await think(); + await locator.click({ timeout: timeoutMs, delay: 80 + Math.floor(Math.random() * 160) }); + await humanDelay(300, 900); +} + +/** + * Typ als mens: focus → selecteer → per teken met wisselende vertraging. + * Vermijdt instant `.fill()` dat bot-detectie triggert. + */ +export async function humanType( + locator: Locator, + text: string, + opts?: { clearFirst?: boolean } +): Promise { + await locator.waitFor({ state: "visible", timeout: 30_000 }); + await think(); + await locator.click({ + clickCount: opts?.clearFirst === false ? 1 : 3, + delay: 60 + Math.floor(Math.random() * 100), + }); + await humanDelay(200, 550); + // Per-character delay; geen instant fill() + await locator.pressSequentially(text, { + delay: 55 + Math.floor(Math.random() * 90), + }); + await humanDelay(280, 800); +} + +/** Wacht tot password zichtbaar is, met menselijke poll i.p.v. agressief spammen. */ +export async function waitForVisiblePassword(page: Page, timeoutMs = 60_000): Promise { + const passBox = page.locator('input[type="password"]').filter({ visible: true }).first(); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await passBox.isVisible().catch(() => false)) return passBox; + await humanDelay(700, 1600); + } + await passBox.waitFor({ state: "visible", timeout: 5_000 }); + return passBox; +} diff --git a/apps/token-tray/src/login/viaplay.ts b/apps/token-tray/src/login/viaplay.ts index dd411bd..3d3761b 100644 --- a/apps/token-tray/src/login/viaplay.ts +++ b/apps/token-tray/src/login/viaplay.ts @@ -1,6 +1,14 @@ import { chromium, type BrowserContext, type Page } from "playwright"; import path from "node:path"; import { app } from "electron"; +import { + afterNavigate, + humanClick, + humanDelay, + humanType, + think, + waitForVisiblePassword, +} from "./human"; export type ViaplayLoginResult = { session: string; @@ -22,9 +30,8 @@ async function dismissCookies(page: Page) { for (const loc of candidates) { try { const btn = loc.first(); - if (await btn.isVisible({ timeout: 1500 })) { - await btn.click({ timeout: 2000 }); - await new Promise((r) => setTimeout(r, 500)); + if (await btn.isVisible({ timeout: 2500 })) { + await humanClick(btn, 3000); return; } } catch { @@ -36,10 +43,9 @@ async function dismissCookies(page: Page) { /** Vul login via zichtbaar wachtwoordveld → zelfde form (vermijdt verborgen forgot-password). */ async function fillLogin(page: Page, email: string, password: string) { await dismissCookies(page); + await think(); - const passBox = page.locator('input[type="password"]').filter({ visible: true }).first(); - await passBox.waitFor({ state: "visible", timeout: 45_000 }); - + const passBox = await waitForVisiblePassword(page, 45_000); const form = page.locator("form").filter({ has: passBox }).first(); const emailInForm = form .locator( @@ -49,26 +55,27 @@ async function fillLogin(page: Page, email: string, password: string) { .first(); if (await emailInForm.count()) { - await emailInForm.fill(email); + await humanType(emailInForm, email); } else { - // Fallback: zichtbaar e-mailveld op de pagina (niet forgotPassword) - const emailBox = page - .locator( - 'input[type="email"], input[autocomplete="username"], input[autocomplete="email"]' - ) - .or( - page.locator('input[type="text"], input[name*="email" i]').filter({ - visible: true, - hasNot: page.locator("[data-i18n*='forgot' i]"), - }) - ) - .filter({ visible: true }) - .first(); + const emailBox = page + .locator( + 'input[type="email"], input[autocomplete="username"], input[autocomplete="email"]' + ) + .or( + page.locator('input[type="text"], input[name*="email" i]').filter({ + visible: true, + hasNot: page.locator("[data-i18n*='forgot' i]"), + }) + ) + .filter({ visible: true }) + .first(); await emailBox.waitFor({ state: "visible", timeout: 15_000 }); - await emailBox.fill(email); + await humanType(emailBox, email); } - await passBox.fill(password); + await think(); + await humanType(passBox, password); + await humanDelay(600, 1600); const submit = form .getByRole("button", { name: /aanmelden|log\s*in|sign\s*in|inloggen|continue/i }) @@ -77,7 +84,7 @@ async function fillLogin(page: Page, email: string, password: string) { page.getByRole("button", { name: /aanmelden|log\s*in|sign\s*in|inloggen/i }) ) .first(); - await submit.click(); + await humanClick(submit); } async function readCookies(context: BrowserContext): Promise { @@ -98,6 +105,7 @@ async function launchProfile(userDataDir: string, headless: boolean) { headless, viewport: { width: 1280, height: 900 } as const, args: ["--disable-blink-features=AutomationControlled"], + slowMo: 40 + Math.floor(Math.random() * 50), }; for (const channel of ["chrome", "msedge"] as const) { try { @@ -118,6 +126,7 @@ async function launchProfile(userDataDir: string, headless: boolean) { /** * Stille UI-login in echte Chromium (geen HTTP login-API → geen cmd-CAPTCHA). + * Tempo is expres menselijk — te snelle fills/clicks triggeren CAPTCHA. */ export async function loginViaplayUi(opts: { accountId: string; @@ -135,6 +144,7 @@ export async function loginViaplayUi(opts: { waitUntil: "domcontentloaded", timeout: 60_000, }); + await afterNavigate(); await dismissCookies(page); let tokens = await readCookies(context); if (tokens) return tokens; @@ -147,7 +157,7 @@ export async function loginViaplayUi(opts: { ]) { try { await page.goto(url, { waitUntil: "domcontentloaded", timeout: 45_000 }); - await new Promise((r) => setTimeout(r, 1200)); + await afterNavigate(); await dismissCookies(page); const hasPass = await page .locator('input[type="password"]') @@ -161,9 +171,9 @@ export async function loginViaplayUi(opts: { await fillLogin(page, opts.email, opts.password); - const deadline = Date.now() + 90_000; + const deadline = Date.now() + 120_000; while (Date.now() < deadline) { - await new Promise((r) => setTimeout(r, 1500)); + await humanDelay(2000, 3500); tokens = await readCookies(context); if (tokens) return tokens; try { @@ -171,8 +181,8 @@ export async function loginViaplayUi(opts: { .locator('[data-testid*="profile" i], [class*="profile" i] button, button') .filter({ hasText: /./ }) .first(); - if (await profile.isVisible({ timeout: 400 })) { - await profile.click().catch(() => undefined); + if (await profile.isVisible({ timeout: 800 })) { + await humanClick(profile).catch(() => undefined); } } catch { /* ignore */