feat(myTischtennis): integrate Playwright for CAPTCHA handling and enhance login form functionality

- Added Playwright as a dependency to handle CAPTCHA challenges during login attempts.
- Implemented a new endpoint to retrieve the login form from myTischtennis, parsing necessary fields for user input.
- Enhanced the login process to utilize Playwright for browser automation when CAPTCHA is required.
- Updated the MyTischtennisDialog component to support local login form submission instead of using an iframe.
- Refactored the MyTischtennisController to include proxy functionality for serving resources and handling login submissions.
- Improved error handling and user feedback during login attempts, ensuring a smoother user experience.
This commit is contained in:
Torsten Schulz (local)
2026-02-27 17:15:20 +01:00
parent b2017b7365
commit 4e81a1c4a7
14 changed files with 917 additions and 258 deletions

View File

@@ -1,4 +1,5 @@
import axios from 'axios';
import { chromium } from 'playwright';
const BASE_URL = 'https://www.mytischtennis.de';
@@ -24,36 +25,136 @@ class MyTischtennisClient {
async getLoginPage() {
try {
const response = await this.client.get('/login?next=%2F');
const html = response.data;
const html = typeof response.data === 'string' ? response.data : String(response.data || '');
const extractFirst = (patterns) => {
for (const pattern of patterns) {
const match = html.match(pattern);
if (match && (match[1] || match[2] || match[3])) {
return match[1] || match[2] || match[3];
}
}
return null;
};
// Parse form action and input fields for frontend login-form endpoint
const formMatch = html.match(/<form[^>]*action=(?:"([^"]+)"|'([^']+)')[^>]*>([\s\S]*?)<\/form>/i);
const loginAction = formMatch ? (formMatch[1] || formMatch[2] || '/login') : '/login';
const formHtml = formMatch ? formMatch[3] : html;
const fields = [];
const inputRegex = /<input\b([\s\S]*?)>/gi;
let inputMatch = null;
while ((inputMatch = inputRegex.exec(formHtml)) !== null) {
const rawAttributes = inputMatch[1] || '';
const attributes = {};
// Parses key="value", key='value', key=value and boolean attributes.
const attributeRegex = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)(?:=(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
let attributeMatch = null;
while ((attributeMatch = attributeRegex.exec(rawAttributes)) !== null) {
const key = attributeMatch[1];
const value = attributeMatch[2] ?? attributeMatch[3] ?? attributeMatch[4] ?? true;
attributes[key] = value;
}
fields.push({
name: typeof attributes.name === 'string' ? attributes.name : null,
id: typeof attributes.id === 'string' ? attributes.id : null,
type: typeof attributes.type === 'string' ? attributes.type : 'text',
placeholder: typeof attributes.placeholder === 'string' ? attributes.placeholder : null,
autocomplete: typeof attributes.autocomplete === 'string' ? attributes.autocomplete : null,
minlength: typeof attributes.minlength === 'string' ? attributes.minlength : null,
required: attributes.required === true || attributes.required === 'required',
value: typeof attributes.value === 'string' ? attributes.value : null
});
}
// Fallback: if page is JS-rendered and no input tags are server-rendered, provide usable defaults.
const hasEmailField = fields.some((f) => f?.name === 'email' || f?.type === 'email');
const hasPasswordField = fields.some((f) => f?.name === 'password' || f?.type === 'password');
if (!hasEmailField) {
fields.push({
name: 'email',
id: null,
type: 'email',
placeholder: null,
autocomplete: 'email',
minlength: null,
required: true,
value: null
});
}
if (!hasPasswordField) {
fields.push({
name: 'password',
id: null,
type: 'password',
placeholder: null,
autocomplete: 'current-password',
minlength: null,
required: true,
value: null
});
}
// Extract XSRF token from hidden input
const xsrfMatch = html.match(/<input[^>]*name="xsrf"[^>]*value="([^"]+)"/);
const xsrfToken = xsrfMatch ? xsrfMatch[1] : null;
const xsrfToken = extractFirst([
/<input[^>]*name=(?:"xsrf"|'xsrf')[^>]*value=(?:"([^"]+)"|'([^']+)')/i,
/(?:^|[,{])\s*"xsrf"\s*:\s*"([^"]+)"/i
]);
// Extract CAPTCHA token from hidden input (if present)
const captchaMatch = html.match(/<input[^>]*name="captcha"[^>]*value="([^"]+)"/);
const captchaToken = captchaMatch ? captchaMatch[1] : null;
const captchaToken = extractFirst([
/<input[^>]*name=(?:"captcha"|'captcha')[^>]*value=(?:"([^"]+)"|'([^']+)')/i,
/(?:^|[,{])\s*"captcha"\s*:\s*"([^"]+)"/i
]);
// Check if captcha_clicked is true or false
const captchaClickedMatch = html.match(/<input[^>]*name="captcha_clicked"[^>]*value="([^"]+)"/);
const captchaClicked = captchaClickedMatch ? captchaClickedMatch[1] === 'true' : false;
const captchaClickedRaw = extractFirst([
/<input[^>]*name=(?:"captcha_clicked"|'captcha_clicked')[^>]*value=(?:"([^"]+)"|'([^']+)')/i,
/(?:^|[,{])\s*"captcha_clicked"\s*:\s*"([^"]+)"/i
]);
const captchaClicked = String(captchaClickedRaw || '').toLowerCase() === 'true';
// Check if CAPTCHA is required (look for private-captcha element or captcha input)
const requiresCaptcha = html.includes('private-captcha') || html.includes('name="captcha"');
const requiresCaptcha = html.includes('private-captcha')
|| html.includes('name="captcha"')
|| html.includes("name='captcha'")
|| /captcha/i.test(html);
// Extract CAPTCHA metadata used by frontend
const captchaSiteKey = extractFirst([
/data-sitekey=(?:"([^"]+)"|'([^']+)'|([^\s>]+))/i,
/(?:^|[,{])\s*"sitekey"\s*:\s*"([^"]+)"/i,
/(?:^|[,{])\s*"captchaSiteKey"\s*:\s*"([^"]+)"/i
]);
const captchaPuzzleEndpoint = extractFirst([
/data-puzzle-endpoint=(?:"([^"]+)"|'([^']+)'|([^\s>]+))/i,
/(?:^|[,{])\s*"puzzle_endpoint"\s*:\s*"([^"]+)"/i,
/(?:^|[,{])\s*"captchaPuzzleEndpoint"\s*:\s*"([^"]+)"/i
]);
console.log('[myTischtennisClient.getLoginPage]', {
hasXsrfToken: !!xsrfToken,
hasCaptchaToken: !!captchaToken,
captchaClicked,
requiresCaptcha
requiresCaptcha,
fieldsCount: fields.length,
hasCaptchaSiteKey: !!captchaSiteKey,
hasCaptchaPuzzleEndpoint: !!captchaPuzzleEndpoint
});
return {
success: true,
loginAction,
fields,
xsrfToken,
captchaToken,
captchaClicked,
requiresCaptcha
requiresCaptcha,
captchaSiteKey,
captchaPuzzleEndpoint
};
} catch (error) {
console.error('Error fetching login page:', error.message);
@@ -247,6 +348,202 @@ class MyTischtennisClient {
}
}
/**
* Browser-based fallback login for CAPTCHA flows.
* @param {string} email
* @param {string} password
* @returns {Promise<Object>} Login response with token and session data
*/
async loginWithBrowserAutomation(email, password) {
let browser = null;
let context = null;
try {
console.log('[myTischtennisClient.playwright] Start browser login flow');
browser = await chromium.launch({
headless: true,
args: ['--no-sandbox', '--disable-dev-shm-usage']
});
context = await browser.newContext();
const page = await context.newPage();
await page.goto(`${this.baseURL}/login?next=%2F`, { waitUntil: 'domcontentloaded', timeout: 45000 });
console.log('[myTischtennisClient.playwright] Page loaded');
// Best-effort: Consent/overlay dialogs that can block form interaction.
const consentSelectors = [
'#onetrust-accept-btn-handler',
'button:has-text("Alle akzeptieren")',
'button:has-text("Akzeptieren")',
'button:has-text("Einverstanden")'
];
for (const selector of consentSelectors) {
try {
const button = page.locator(selector).first();
if (await button.count()) {
await button.click({ timeout: 1500 });
console.log('[myTischtennisClient.playwright] Consent dialog accepted');
break;
}
} catch (_e) {
// ignore and try next selector
}
}
// Fill credentials
await page.locator('input[name="email"]').first().fill(email, { timeout: 10000 });
await page.locator('input[name="password"]').first().fill(password, { timeout: 10000 });
console.log('[myTischtennisClient.playwright] Credentials filled');
// Try to interact with private-captcha if present.
const captchaHost = page.locator('private-captcha').first();
if (await captchaHost.count()) {
try {
await page.waitForTimeout(1200);
const interaction = await page.evaluate(() => {
const host = document.querySelector('private-captcha');
const checkbox = host?.shadowRoot?.querySelector('#pc-checkbox');
if (!checkbox) {
return { clicked: false, reason: 'checkbox-missing' };
}
checkbox.click();
checkbox.dispatchEvent(new Event('input', { bubbles: true }));
checkbox.dispatchEvent(new Event('change', { bubbles: true }));
return {
clicked: true,
viaShadowRoot: true,
className: checkbox.className || null,
checked: !!checkbox.checked
};
});
console.log('[myTischtennisClient.playwright] evaluate interaction result:', interaction);
// Wait until hidden captcha fields are populated by site scripts.
try {
await page.waitForFunction(() => {
const captchaField = document.querySelector('input[name="captcha"]');
const clickedField = document.querySelector('input[name="captcha_clicked"]');
const captchaValue = (captchaField && captchaField.value ? captchaField.value.trim() : '');
const clickedValue = (clickedField && clickedField.value ? clickedField.value.toLowerCase() : '');
return captchaValue.length > 80 && (clickedValue === 'true' || clickedValue === '1');
}, { timeout: 15000 });
const captchaState = await page.evaluate(() => {
const captchaField = document.querySelector('input[name="captcha"]');
const clickedField = document.querySelector('input[name="captcha_clicked"]');
return {
captchaLen: captchaField?.value?.length || 0,
captchaClicked: clickedField?.value || null
};
});
console.log('[myTischtennisClient.playwright] Captcha value ready:', captchaState);
} catch (_waitErr) {
// Keep going; some flows still succeed without explicit hidden field update.
console.warn('[myTischtennisClient.playwright] Captcha value not ready in time');
}
} catch (captchaError) {
console.warn('[myTischtennisClient.playwright] Captcha interaction warning:', captchaError?.message || captchaError);
}
}
// Ensure captcha_clicked field is set if available.
await page.evaluate(() => {
const clickedField = document.querySelector('input[name="captcha_clicked"]');
if (clickedField && !clickedField.value) {
clickedField.value = 'true';
}
});
// Submit form
const submitButton = page.locator('button[type="submit"], input[type="submit"]').first();
if (await submitButton.count()) {
await submitButton.click({ timeout: 15000, noWaitAfter: true });
} else {
await page.keyboard.press('Enter');
}
console.log('[myTischtennisClient.playwright] Submit clicked');
// Wait for auth cookie after submit (polling avoids timing races).
let authCookieObj = null;
const maxAttempts = 20;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const cookies = await context.cookies();
authCookieObj = cookies.find((c) => c.name === 'sb-10-auth-token');
if (authCookieObj?.value) {
break;
}
await page.waitForTimeout(500);
}
if (!authCookieObj || !authCookieObj.value) {
let errorText = null;
try {
const textContent = await page.locator('body').innerText({ timeout: 1000 });
if (textContent?.includes('Captcha-Bestätigung fehlgeschlagen')) {
errorText = 'Captcha-Bestätigung fehlgeschlagen';
}
} catch (_e) {
// ignore text read errors
}
return {
success: false,
error: errorText
? `Playwright-Login fehlgeschlagen: ${errorText}`
: 'Playwright-Login fehlgeschlagen: Kein sb-10-auth-token Cookie gefunden'
};
}
// Cookie value is expected as "base64-<tokenData>"
const tokenMatch = String(authCookieObj.value).match(/^base64-(.+)$/);
if (!tokenMatch) {
return {
success: false,
error: 'Playwright-Login fehlgeschlagen: Token-Format ungültig'
};
}
let tokenData;
try {
tokenData = JSON.parse(Buffer.from(tokenMatch[1], 'base64').toString('utf-8'));
} catch (decodeError) {
return {
success: false,
error: `Playwright-Login fehlgeschlagen: Token konnte nicht dekodiert werden (${decodeError.message})`
};
}
const cookie = `sb-10-auth-token=${authCookieObj.value}`;
console.log('[myTischtennisClient.playwright] Browser login successful');
return {
success: true,
accessToken: tokenData.access_token,
refreshToken: tokenData.refresh_token,
expiresAt: tokenData.expires_at,
expiresIn: tokenData.expires_in,
user: tokenData.user,
cookie
};
} catch (error) {
console.error('[myTischtennisClient.playwright] Browser login failed:', error?.message || error);
return {
success: false,
error: error?.message || 'Playwright-Login fehlgeschlagen'
};
} finally {
if (context) {
try {
await context.close();
} catch (contextCloseError) {
console.warn('[myTischtennisClient.playwright] Context close warning:', contextCloseError?.message || contextCloseError);
}
}
if (browser) {
try {
await browser.close();
} catch (browserCloseError) {
console.warn('[myTischtennisClient.playwright] Browser close warning:', browserCloseError?.message || browserCloseError);
}
console.log('[myTischtennisClient.playwright] Browser closed');
}
}
}
/**
* Verify login credentials
* @param {string} email - myTischtennis email
@@ -411,4 +708,3 @@ class MyTischtennisClient {
}
export default new MyTischtennisClient();