import { chromium } from 'playwright' const BASE_URL = 'https://www.mytischtennis.de' const HEADERS = { 'accept-language': 'de-DE,de;q=0.9', 'user-agent': 'Harheimer-TC-Vereinsverwaltung/1.0' } function findAuthCookie(headers) { const raw = headers.getSetCookie?.() || (headers.get('set-cookie') ? [headers.get('set-cookie')] : []) return raw.find(value => value.startsWith('sb-10-auth-token='))?.split(';')[0] || null } function findEntries(value) { if (!value || typeof value !== 'object') return null if (Array.isArray(value.entries)) return value.entries for (const child of Object.values(value)) { const result = findEntries(child) if (result) return result } return null } async function loginDirect(email, password) { const page = await fetch(`${BASE_URL}/login?next=%2F`, { headers: HEADERS }) const html = await page.text() if (/captcha/i.test(html)) throw new Error('CAPTCHA_REQUIRED') const xsrf = html.match(/name=["']xsrf["'][^>]*value=["']([^"']+)/i)?.[1] const form = new URLSearchParams({ email, password, intent: 'login' }) if (xsrf) form.set('xsrf', xsrf) const result = await fetch(`${BASE_URL}/login?next=%2F&_data=routes%2F_auth%2B%2Flogin`, { method: 'POST', headers: { ...HEADERS, 'content-type': 'application/x-www-form-urlencoded' }, body: form, redirect: 'manual' }) const cookie = findAuthCookie(result.headers) if (!cookie) throw new Error('Direkter myTischtennis-Login fehlgeschlagen.') return cookie } async function loginWithBrowser(email, password) { const browser = await chromium.launch({ headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'] }) try { const page = await browser.newPage() // Das myTT-CAPTCHA wird erst nach dem Laden der Seite erzeugt. Der Ablauf // entspricht der erprobten Integration im Trainingstagebuch. const acceptConsentDialog = async (waitMs = 0) => { if (waitMs) await page.waitForTimeout(waitMs) for (const selector of [ '#onetrust-accept-btn-handler', 'button:has-text("Alle akzeptieren")', 'button:has-text("Akzeptieren")', 'button:has-text("Einverstanden")', 'button:has-text("Zustimmen")', '[data-testid="accept-button"]', '.cmp-accept-all', '.accept-all-btn' ]) { try { const button = page.locator(selector).first() if (await button.count()) { await button.click({ timeout: 2_500 }); await page.waitForTimeout(800); return true } } catch { /* try next CMP selector */ } } return false } await page.goto(BASE_URL, { waitUntil: 'domcontentloaded', timeout: 30_000 }).catch(() => {}) if (!await acceptConsentDialog()) await acceptConsentDialog(2_500) await page.goto(`${BASE_URL}/login?next=%2F`, { waitUntil: 'domcontentloaded', timeout: 45_000 }) if (!await acceptConsentDialog()) await acceptConsentDialog(1_500) await page.locator('input[name="email"]').fill(email) await page.locator('input[name="password"]').fill(password) // Exakt wie in trainingstagebuch: Das Widget wird nach dem DOM-Load // nachgeladen; ein sofortiges count() würde es häufig übersehen. await page.waitForSelector('private-captcha', { timeout: 8_000 }).catch(() => {}) const captchaHost = page.locator('private-captcha').first() const hasCaptcha = await captchaHost.count() > 0 console.info('[mytischtennis] CAPTCHA widget detected:', hasCaptcha) if (hasCaptcha) { await page.waitForTimeout(1_200) // Ein echter Playwright-Klick erzeugt im Gegensatz zu element.click() ein // vertrauenswürdiges Pointer-Ereignis für das Widget. await captchaHost.click({ timeout: 5_000, force: true }).catch(error => { console.warn('[mytischtennis] CAPTCHA pointer click failed:', error.message) }) await page.evaluate(() => { const host = document.querySelector('private-captcha') const checkbox = host?.shadowRoot?.querySelector('#pc-checkbox') if (!checkbox) return checkbox.click() checkbox.dispatchEvent(new Event('input', { bubbles: true })) checkbox.dispatchEvent(new Event('change', { bubbles: true })) }) await page.waitForFunction(() => { const token = document.querySelector('input[name="captcha"]')?.value?.trim() || '' const clicked = document.querySelector('input[name="captcha_clicked"]')?.value?.toLowerCase() || '' return token.length > 80 && (clicked === 'true' || clicked === '1') }, { timeout: 32_000 }).catch(() => {}) const captchaState = await page.evaluate(() => { const host = document.querySelector('private-captcha') const checkbox = host?.shadowRoot?.querySelector('#pc-checkbox') const token = document.querySelector('input[name="captcha"]')?.value?.trim() || '' return { tokenLength: token.length, clicked: document.querySelector('input[name="captcha_clicked"]')?.value || null, shadowRoot: Boolean(host?.shadowRoot), checkboxFound: Boolean(checkbox), checkboxChecked: Boolean(checkbox?.checked), widgetState: host?.getAttribute('data-state') || null } }) console.info('[mytischtennis] CAPTCHA state after wait:', captchaState) const captchaReady = captchaState.tokenLength > 80 if (!captchaReady) throw new Error('CAPTCHA konnte nicht automatisch gelöst werden.') await page.waitForTimeout(2_500) } await page.evaluate(() => { const form = document.querySelector('form[action*="/login"]') if (!form) return let intent = form.querySelector('input[name="intent"]') if (!intent) { intent = document.createElement('input') intent.setAttribute('type', 'hidden') intent.setAttribute('name', 'intent') form.appendChild(intent) } intent.setAttribute('value', 'login') }) const submit = page.locator('button[type="submit"][name="intent"][value="login"]').first() const genericSubmit = page.locator('button[type="submit"], input[type="submit"]').first() if (await submit.count()) await submit.click({ noWaitAfter: true }) else if (await genericSubmit.count()) await genericSubmit.click({ noWaitAfter: true }) else await page.locator('form').evaluate(form => form.requestSubmit()) let cookie = null let cookieNames = [] let loginPageText = '' for (let attempt = 0; attempt < 40; attempt += 1) { const cookies = await page.context().cookies() cookieNames = cookies.map(item => item.name) cookie = cookies.find(item => item.name === 'sb-10-auth-token' || /^sb-\d+-auth-token$/.test(item.name) || item.name.includes('auth-token')) if (cookie) break if (attempt % 4 === 0) { loginPageText = await page.locator('body').innerText().catch(() => '') } await page.waitForTimeout(500) } if (!cookie) { console.warn('[mytischtennis] Login after solved CAPTCHA did not create a session', { url: page.url(), cookieNames, pageText: loginPageText.slice(0, 1_000) }) throw new Error('myTischtennis-Login nach gelöstem CAPTCHA fehlgeschlagen.') } return `${cookie.name}=${cookie.value}` } finally { await browser.close() } } export async function fetchClubRankings(connection, currentRanking = 'yes') { let cookie try { cookie = await loginDirect(connection.email, connection.password) } catch (error) { if (error.message !== 'CAPTCHA_REQUIRED') throw error cookie = await loginWithBrowser(connection.email, connection.password) } const url = new URL('/rankings/andro-rangliste', BASE_URL) url.search = new URLSearchParams({ 'all-players': 'on', clubnr: connection.clubId, fednickname: connection.association, 'current-ranking': currentRanking, 'results-per-page': '100', page: '0', _data: 'routes/$' }) console.info('[mytischtennis] Ranking request URL:', url.toString()) const response = await fetch(url, { headers: { ...HEADERS, cookie, accept: 'application/json', referer: `${BASE_URL}/` } }) if (!response.ok) throw new Error(`Ranglisten-Abruf fehlgeschlagen (HTTP ${response.status}).`) const entries = findEntries(await response.json()) if (!entries) throw new Error('myTischtennis hat keine Ranglisten-Daten geliefert.') console.info('[mytischtennis] Ranking response:', { currentRanking, entryCount: entries.length, entryKeys: Object.keys(entries[0] || {}) }) return entries }