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:
parent
8b39ce06ee
commit
bedebf44f6
5 changed files with 140 additions and 44 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
66
apps/token-tray/src/login/human.ts
Normal file
66
apps/token-tray/src/login/human.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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<ViaplayLoginResult | null> {
|
||||
|
|
@ -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 */
|
||||
|
|
|
|||
Loading…
Reference in a new issue