Fix tray login selectors to use visible password form fields.

Avoid hidden forgot-password inputs on F1/Viaplay and prefer account.viaplay.com login URL.
This commit is contained in:
Jos Vooges | STH 2026-09-23 00:04:33 +02:00
parent 06ba757094
commit 4b4f0abcff
2 changed files with 171 additions and 58 deletions

View file

@ -1,4 +1,4 @@
import { chromium, type BrowserContext } from "playwright";
import { chromium, type BrowserContext, type Page } from "playwright";
import path from "node:path";
import { app } from "electron";
@ -12,6 +12,24 @@ function profileDir(accountId: string): string {
return path.join(app.getPath("userData"), "browser-profiles", "f1tv", accountId);
}
async function dismissCookies(page: Page) {
const candidates = [
page.getByRole("button", { name: /accept all|agree|allow all|akkoord/i }),
page.locator("#onetrust-accept-btn-handler"),
];
for (const loc of candidates) {
try {
const btn = loc.first();
if (await btn.isVisible({ timeout: 1500 })) {
await btn.click({ timeout: 2000 });
return;
}
} catch {
/* next */
}
}
}
async function readTokens(context: BrowserContext): Promise<F1tvLoginResult | null> {
const cookies = await context.cookies();
const storage = await context.storageState();
@ -26,7 +44,6 @@ async function readTokens(context: BrowserContext): Promise<F1tvLoginResult | nu
byName.get("entitlementtoken") || byName.get("entitlement_token") || "";
let sessionId = byName.get("sessionid") || byName.get("session-id") || "";
// localStorage / sessionStorage vaak JWT's
for (const origin of storage.origins || []) {
for (const item of origin.localStorage || []) {
const k = item.name.toLowerCase();
@ -57,7 +74,7 @@ async function readTokens(context: BrowserContext): Promise<F1tvLoginResult | nu
async function launchProfile(userDataDir: string, headless: boolean) {
const common = {
headless,
viewport: { width: 1280, height: 800 } as const,
viewport: { width: 1280, height: 900 } as const,
args: ["--disable-blink-features=AutomationControlled"],
};
for (const channel of ["chrome", "msedge"] as const) {
@ -77,6 +94,33 @@ 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);
const passBox = page.locator('input[type="password"]').filter({ visible: true }).first();
await passBox.waitFor({ state: "visible", timeout: 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
.locator(
'input.txtEmail, input[name="email"], input[type="email"], input[type="text"], input[autocomplete="username"]'
)
.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);
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();
}
export async function loginF1tvUi(opts: {
accountId: string;
email: string;
@ -88,36 +132,46 @@ export async function loginF1tvUi(opts: {
try {
const page = context.pages()[0] || (await context.newPage());
await page.goto("https://account.formula1.com/#/en/login", {
waitUntil: "domcontentloaded",
timeout: 60_000,
});
await new Promise((r) => setTimeout(r, 2000));
let tokens = await readTokens(context);
if (tokens) return tokens;
for (const url of [
"https://account.formula1.com/#/en/login?redirect=https%3A%2F%2Ff1tv.formula1.com%2F",
"https://account.formula1.com/#/en/login",
"https://f1tv.formula1.com/",
]) {
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60_000 });
await new Promise((r) => setTimeout(r, 2000));
await dismissCookies(page);
const emailBox = page
.locator('input[type="email"]')
.or(page.getByLabel(/e-?mail/i))
.or(page.locator("#LoginEmailAddress, #Email, input[name*=email i]"))
.first();
await emailBox.waitFor({ state: "visible", timeout: 45_000 });
await emailBox.fill(opts.email);
let tokens = await readTokens(context);
if (tokens) return tokens;
const passBox = page.locator('input[type="password"]').first();
await passBox.fill(opts.password);
// Op f1tv.com eerst op Sign in klikken
try {
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));
}
} catch {
/* ignore */
}
const submit = page
.getByRole("button", { name: /log\s*in|sign\s*in|continue/i })
.or(page.locator('button[type="submit"]'))
.first();
await submit.click();
const hasPass = await page
.locator('input[type="password"]')
.filter({ visible: true })
.count();
if (hasPass > 0) {
await fillLogin(page, opts.email, opts.password);
break;
}
}
const deadline = Date.now() + 120_000;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 2000));
if (!page.url().includes("f1tv") && Date.now() > deadline - 60_000) {
if (!/f1tv\.formula1\.com/i.test(page.url())) {
await page
.goto("https://f1tv.formula1.com/", {
waitUntil: "domcontentloaded",
@ -125,7 +179,7 @@ export async function loginF1tvUi(opts: {
})
.catch(() => undefined);
}
tokens = await readTokens(context);
const tokens = await readTokens(context);
if (tokens) return tokens;
}
throw new Error(

View file

@ -13,23 +13,69 @@ function profileDir(accountId: string): string {
return path.join(app.getPath("userData"), "browser-profiles", "viaplay", accountId);
}
async function fillLogin(page: Page, email: string, password: string) {
// Semantic selectors — werkt op account.viaplay.com / viaplay.com login
const emailBox = page
.locator('input[type="email"]')
.or(page.getByLabel(/e-?mail/i))
.or(page.getByPlaceholder(/e-?mail/i))
.first();
await emailBox.waitFor({ state: "visible", timeout: 30_000 });
await emailBox.fill(email);
async function dismissCookies(page: Page) {
const candidates = [
page.getByRole("button", { name: /alles accepteren|accept all|agree|akkoord|toestaan/i }),
page.locator("#onetrust-accept-btn-handler"),
page.locator('button[id*="accept" i]'),
];
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));
return;
}
} catch {
/* next */
}
}
}
/** Vul login via zichtbaar wachtwoordveld → zelfde form (vermijdt verborgen forgot-password). */
async function fillLogin(page: Page, email: string, password: string) {
await dismissCookies(page);
const passBox = page.locator('input[type="password"]').filter({ visible: true }).first();
await passBox.waitFor({ state: "visible", timeout: 45_000 });
const form = page.locator("form").filter({ has: passBox }).first();
const emailInForm = form
.locator(
'input[type="email"], input[type="text"], input[name*="email" i], input[autocomplete="username"], input[autocomplete="email"]'
)
.filter({ visible: true })
.first();
if (await emailInForm.count()) {
await emailInForm.fill(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();
await emailBox.waitFor({ state: "visible", timeout: 15_000 });
await emailBox.fill(email);
}
const passBox = page.locator('input[type="password"]').first();
await passBox.waitFor({ state: "visible", timeout: 15_000 });
await passBox.fill(password);
const submit = page
.getByRole("button", { name: /aanmelden|log\s*in|sign\s*in|inloggen/i })
.or(page.locator('button[type="submit"]'))
const submit = form
.getByRole("button", { name: /aanmelden|log\s*in|sign\s*in|inloggen|continue/i })
.or(form.locator('button[type="submit"], input[type="submit"]'))
.or(
page.getByRole("button", { name: /aanmelden|log\s*in|sign\s*in|inloggen/i })
)
.first();
await submit.click();
}
@ -50,10 +96,9 @@ async function readCookies(context: BrowserContext): Promise<ViaplayLoginResult
async function launchProfile(userDataDir: string, headless: boolean) {
const common = {
headless,
viewport: { width: 1280, height: 800 } as const,
viewport: { width: 1280, height: 900 } as const,
args: ["--disable-blink-features=AutomationControlled"],
};
// Voorkeur: geïnstalleerde Chrome/Edge (geen Playwright-download nodig)
for (const channel of ["chrome", "msedge"] as const) {
try {
return await chromium.launchPersistentContext(userDataDir, {
@ -73,7 +118,6 @@ async function launchProfile(userDataDir: string, headless: boolean) {
/**
* Stille UI-login in echte Chromium (geen HTTP login-API → geen cmd-CAPTCHA).
* Hergebruikt persistent profiel per account.
*/
export async function loginViaplayUi(opts: {
accountId: string;
@ -87,41 +131,56 @@ export async function loginViaplayUi(opts: {
try {
const page = context.pages()[0] || (await context.newPage());
// Snel pad: al cookies?
await page.goto("https://viaplay.com/nl-nl", {
waitUntil: "domcontentloaded",
timeout: 60_000,
});
await dismissCookies(page);
let tokens = await readCookies(context);
if (tokens) return tokens;
await page.goto("https://viaplay.com/nl-nl/login", {
waitUntil: "domcontentloaded",
timeout: 60_000,
});
// Soms redirect naar account.viaplay.com
await new Promise((r) => setTimeout(r, 1500));
// Direct account-login (stabieler dan /nl-nl/login 404/redirect)
for (const url of [
"https://account.viaplay.com/login",
"https://viaplay.com/login",
"https://viaplay.com/nl-nl/login",
]) {
try {
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 45_000 });
await new Promise((r) => setTimeout(r, 1200));
await dismissCookies(page);
const hasPass = await page
.locator('input[type="password"]')
.filter({ visible: true })
.count();
if (hasPass > 0) break;
} catch {
/* try next url */
}
}
await fillLogin(page, opts.email, opts.password);
// Wacht tot session cookie verschijnt
const deadline = Date.now() + 90_000;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 1500));
tokens = await readCookies(context);
if (tokens) return tokens;
const profileBtn = page
.locator('[data-testid*="profile"], button')
.filter({ hasText: /./ })
.first();
try {
if (await profileBtn.isVisible({ timeout: 500 })) {
await profileBtn.click().catch(() => undefined);
const profile = page
.locator('[data-testid*="profile" i], [class*="profile" i] button, button')
.filter({ hasText: /./ })
.first();
if (await profile.isVisible({ timeout: 400 })) {
await profile.click().catch(() => undefined);
}
} catch {
/* ignore */
}
}
throw new Error("Geen session/accessToken na UI-login — check wachtwoord of pagina-wijziging");
throw new Error(
"Geen session/accessToken na UI-login — check wachtwoord of pagina-wijziging"
);
} finally {
await context.close();
}