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.
This commit is contained in:
Jos Vooges | STH 2026-09-23 02:26:07 +02:00
parent 8b39ce06ee
commit bedebf44f6
5 changed files with 140 additions and 44 deletions

View file

@ -281,7 +281,10 @@ export async function resolveViaplayGuidPlay(
let cachedKeys = await keysForMediaGuid(sessionSecret, guid); let cachedKeys = await keysForMediaGuid(sessionSecret, guid);
let lastError: Error | null = null; 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 { try {
const play = await playByGuid(account, guid, { const play = await playByGuid(account, guid, {
deviceKey: tweaks.deviceKey, deviceKey: tweaks.deviceKey,

View file

@ -3,6 +3,7 @@ import type { TrayConfig } from "./config";
import { saveConfig } from "./config"; import { saveConfig } from "./config";
import { loginViaplayUi } from "./login/viaplay"; import { loginViaplayUi } from "./login/viaplay";
import { loginF1tvUi } from "./login/f1tv"; import { loginF1tvUi } from "./login/f1tv";
import { betweenAccounts } from "./login/human";
export type AgentAccount = { export type AgentAccount = {
provider: "viaplay" | "f1tv"; provider: "viaplay" | "f1tv";
@ -144,11 +145,17 @@ export async function refreshAccountsSilent(
const notify = opts?.notify !== false; const notify = opts?.notify !== false;
const summary: RefreshSummary = { ok: 0, failed: 0, skipped: 0, errors: [] }; const summary: RefreshSummary = { ok: 0, failed: 0, skipped: 0, errors: [] };
let attempted = 0;
for (const acc of accounts) { for (const acc of accounts) {
if (!acc.password?.trim()) { if (!acc.password?.trim()) {
summary.skipped += 1; summary.skipped += 1;
continue; continue;
} }
if (attempted > 0) {
// Captcha-vriendelijk: niet back-to-back inloggen op meerdere accounts
await betweenAccounts();
}
attempted += 1;
try { try {
await refreshOne(cfg, acc, headless); await refreshOne(cfg, acc, headless);
summary.ok += 1; summary.ok += 1;

View file

@ -1,6 +1,14 @@
import { chromium, type BrowserContext, type Page } from "playwright"; import { chromium, type BrowserContext, type Page } from "playwright";
import path from "node:path"; import path from "node:path";
import { app } from "electron"; import { app } from "electron";
import {
afterNavigate,
humanClick,
humanDelay,
humanType,
think,
waitForVisiblePassword,
} from "./human";
export type F1tvLoginResult = { export type F1tvLoginResult = {
ascendontoken: string; ascendontoken: string;
@ -20,8 +28,8 @@ async function dismissCookies(page: Page) {
for (const loc of candidates) { for (const loc of candidates) {
try { try {
const btn = loc.first(); const btn = loc.first();
if (await btn.isVisible({ timeout: 1500 })) { if (await btn.isVisible({ timeout: 2500 })) {
await btn.click({ timeout: 2000 }); await humanClick(btn, 3000);
return; return;
} }
} catch { } catch {
@ -76,6 +84,7 @@ async function launchProfile(userDataDir: string, headless: boolean) {
headless, headless,
viewport: { width: 1280, height: 900 } as const, viewport: { width: 1280, height: 900 } as const,
args: ["--disable-blink-features=AutomationControlled"], args: ["--disable-blink-features=AutomationControlled"],
slowMo: 40 + Math.floor(Math.random() * 50),
}; };
for (const channel of ["chrome", "msedge"] as const) { for (const channel of ["chrome", "msedge"] as const) {
try { try {
@ -97,10 +106,9 @@ async function launchProfile(userDataDir: string, headless: boolean) {
/** Login-formulier: zichtbaar password → e-mail in zelfde form (niet forgot-password). */ /** Login-formulier: zichtbaar password → e-mail in zelfde form (niet forgot-password). */
async function fillLogin(page: Page, email: string, password: string) { async function fillLogin(page: Page, email: string, password: string) {
await dismissCookies(page); await dismissCookies(page);
await think();
const passBox = page.locator('input[type="password"]').filter({ visible: true }).first(); const passBox = await waitForVisiblePassword(page, 60_000);
await passBox.waitFor({ state: "visible", timeout: 60_000 });
const form = page.locator("form").filter({ has: passBox }).first(); const form = page.locator("form").filter({ has: passBox }).first();
// F1 gebruikt type=text name=email class=txtEmail — niet type=email // F1 gebruikt type=text name=email class=txtEmail — niet type=email
const emailBox = form const emailBox = form
@ -110,15 +118,16 @@ async function fillLogin(page: Page, email: string, password: string) {
.filter({ visible: true }) .filter({ visible: true })
.first(); .first();
await emailBox.waitFor({ state: "visible", timeout: 15_000 }); await emailBox.waitFor({ state: "visible", timeout: 15_000 });
await emailBox.click({ clickCount: 3 }); await humanType(emailBox, email);
await emailBox.fill(email); await think();
await passBox.fill(password); await humanType(passBox, password);
await humanDelay(700, 1800);
const submit = form const submit = form
.getByRole("button", { name: /log\s*in|sign\s*in|continue|submit/i }) .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')) .or(form.locator('button[type="submit"], input[type="submit"], button.btnLogin, a.btnLogin'))
.first(); .first();
await submit.click(); await humanClick(submit);
} }
export async function loginF1tvUi(opts: { export async function loginF1tvUi(opts: {
@ -139,7 +148,7 @@ export async function loginF1tvUi(opts: {
"https://f1tv.formula1.com/", "https://f1tv.formula1.com/",
]) { ]) {
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60_000 }); await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60_000 });
await new Promise((r) => setTimeout(r, 2000)); await afterNavigate();
await dismissCookies(page); await dismissCookies(page);
let tokens = await readTokens(context); 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( const signIn = page.getByRole("link", { name: /sign in|log in/i }).or(
page.getByRole("button", { name: /sign in|log in/i }) page.getByRole("button", { name: /sign in|log in/i })
); );
if (await signIn.first().isVisible({ timeout: 2000 })) { if (await signIn.first().isVisible({ timeout: 2500 })) {
await signIn.first().click(); await humanClick(signIn.first());
await new Promise((r) => setTimeout(r, 2000)); await afterNavigate();
} }
} catch { } catch {
/* ignore */ /* 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) { while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 2000)); await humanDelay(2200, 4000);
if (!/f1tv\.formula1\.com/i.test(page.url())) { if (!/f1tv\.formula1\.com/i.test(page.url())) {
await page await page
.goto("https://f1tv.formula1.com/", { .goto("https://f1tv.formula1.com/", {
@ -178,6 +187,7 @@ export async function loginF1tvUi(opts: {
timeout: 45_000, timeout: 45_000,
}) })
.catch(() => undefined); .catch(() => undefined);
await afterNavigate();
} }
const tokens = await readTokens(context); const tokens = await readTokens(context);
if (tokens) return tokens; if (tokens) return tokens;

View file

@ -0,0 +1,66 @@
import type { Locator, Page } from "playwright";
/** Willekeurige pauze (ms). */
export function humanDelay(minMs: number, maxMs: number): Promise<void> {
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<void> {
return humanDelay(450, 1400);
}
/** Langere pauze na navigatie / pagina-load. */
export function afterNavigate(): Promise<void> {
return humanDelay(1800, 4200);
}
/** Pauze tussen accounts bij batch-refresh (captcha-vriendelijk). */
export function betweenAccounts(): Promise<void> {
return humanDelay(12_000, 28_000);
}
/** Klik met kleine delay (geen instant robot-click). */
export async function humanClick(locator: Locator, timeoutMs = 8000): Promise<void> {
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<void> {
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<Locator> {
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;
}

View file

@ -1,6 +1,14 @@
import { chromium, type BrowserContext, type Page } from "playwright"; import { chromium, type BrowserContext, type Page } from "playwright";
import path from "node:path"; import path from "node:path";
import { app } from "electron"; import { app } from "electron";
import {
afterNavigate,
humanClick,
humanDelay,
humanType,
think,
waitForVisiblePassword,
} from "./human";
export type ViaplayLoginResult = { export type ViaplayLoginResult = {
session: string; session: string;
@ -22,9 +30,8 @@ async function dismissCookies(page: Page) {
for (const loc of candidates) { for (const loc of candidates) {
try { try {
const btn = loc.first(); const btn = loc.first();
if (await btn.isVisible({ timeout: 1500 })) { if (await btn.isVisible({ timeout: 2500 })) {
await btn.click({ timeout: 2000 }); await humanClick(btn, 3000);
await new Promise((r) => setTimeout(r, 500));
return; return;
} }
} catch { } catch {
@ -36,10 +43,9 @@ async function dismissCookies(page: Page) {
/** Vul login via zichtbaar wachtwoordveld → zelfde form (vermijdt verborgen forgot-password). */ /** Vul login via zichtbaar wachtwoordveld → zelfde form (vermijdt verborgen forgot-password). */
async function fillLogin(page: Page, email: string, password: string) { async function fillLogin(page: Page, email: string, password: string) {
await dismissCookies(page); await dismissCookies(page);
await think();
const passBox = page.locator('input[type="password"]').filter({ visible: true }).first(); const passBox = await waitForVisiblePassword(page, 45_000);
await passBox.waitFor({ state: "visible", timeout: 45_000 });
const form = page.locator("form").filter({ has: passBox }).first(); const form = page.locator("form").filter({ has: passBox }).first();
const emailInForm = form const emailInForm = form
.locator( .locator(
@ -49,9 +55,8 @@ async function fillLogin(page: Page, email: string, password: string) {
.first(); .first();
if (await emailInForm.count()) { if (await emailInForm.count()) {
await emailInForm.fill(email); await humanType(emailInForm, email);
} else { } else {
// Fallback: zichtbaar e-mailveld op de pagina (niet forgotPassword)
const emailBox = page const emailBox = page
.locator( .locator(
'input[type="email"], input[autocomplete="username"], input[autocomplete="email"]' 'input[type="email"], input[autocomplete="username"], input[autocomplete="email"]'
@ -65,10 +70,12 @@ async function fillLogin(page: Page, email: string, password: string) {
.filter({ visible: true }) .filter({ visible: true })
.first(); .first();
await emailBox.waitFor({ state: "visible", timeout: 15_000 }); 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 const submit = form
.getByRole("button", { name: /aanmelden|log\s*in|sign\s*in|inloggen|continue/i }) .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 }) page.getByRole("button", { name: /aanmelden|log\s*in|sign\s*in|inloggen/i })
) )
.first(); .first();
await submit.click(); await humanClick(submit);
} }
async function readCookies(context: BrowserContext): Promise<ViaplayLoginResult | null> { async function readCookies(context: BrowserContext): Promise<ViaplayLoginResult | null> {
@ -98,6 +105,7 @@ async function launchProfile(userDataDir: string, headless: boolean) {
headless, headless,
viewport: { width: 1280, height: 900 } as const, viewport: { width: 1280, height: 900 } as const,
args: ["--disable-blink-features=AutomationControlled"], args: ["--disable-blink-features=AutomationControlled"],
slowMo: 40 + Math.floor(Math.random() * 50),
}; };
for (const channel of ["chrome", "msedge"] as const) { for (const channel of ["chrome", "msedge"] as const) {
try { 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). * 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: { export async function loginViaplayUi(opts: {
accountId: string; accountId: string;
@ -135,6 +144,7 @@ export async function loginViaplayUi(opts: {
waitUntil: "domcontentloaded", waitUntil: "domcontentloaded",
timeout: 60_000, timeout: 60_000,
}); });
await afterNavigate();
await dismissCookies(page); await dismissCookies(page);
let tokens = await readCookies(context); let tokens = await readCookies(context);
if (tokens) return tokens; if (tokens) return tokens;
@ -147,7 +157,7 @@ export async function loginViaplayUi(opts: {
]) { ]) {
try { try {
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 45_000 }); await page.goto(url, { waitUntil: "domcontentloaded", timeout: 45_000 });
await new Promise((r) => setTimeout(r, 1200)); await afterNavigate();
await dismissCookies(page); await dismissCookies(page);
const hasPass = await page const hasPass = await page
.locator('input[type="password"]') .locator('input[type="password"]')
@ -161,9 +171,9 @@ export async function loginViaplayUi(opts: {
await fillLogin(page, opts.email, opts.password); await fillLogin(page, opts.email, opts.password);
const deadline = Date.now() + 90_000; const deadline = Date.now() + 120_000;
while (Date.now() < deadline) { while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 1500)); await humanDelay(2000, 3500);
tokens = await readCookies(context); tokens = await readCookies(context);
if (tokens) return tokens; if (tokens) return tokens;
try { try {
@ -171,8 +181,8 @@ export async function loginViaplayUi(opts: {
.locator('[data-testid*="profile" i], [class*="profile" i] button, button') .locator('[data-testid*="profile" i], [class*="profile" i] button, button')
.filter({ hasText: /./ }) .filter({ hasText: /./ })
.first(); .first();
if (await profile.isVisible({ timeout: 400 })) { if (await profile.isVisible({ timeout: 800 })) {
await profile.click().catch(() => undefined); await humanClick(profile).catch(() => undefined);
} }
} catch { } catch {
/* ignore */ /* ignore */