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 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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
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 { 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 */
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue