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:
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user