+
+
Date: Fri, 4 Sep 2026 14:24:11 +0200
Subject: [PATCH 04/13] =?UTF-8?q?F=C3=BCge=20myTischtennis-Verbindung=20hi?=
=?UTF-8?q?nzu:=20Erg=C3=A4nze=20Navigationslinks,=20verbessere=20Fehlerbe?=
=?UTF-8?q?handlung=20und=20aktualisiere=20Import-Logik?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
components/Navigation.vue | 14 ++++++++++++++
pages/cms/mytischtennis.vue | 5 ++++-
server/api/cms/mytischtennis/import.post.js | 6 ++++--
3 files changed, 22 insertions(+), 3 deletions(-)
diff --git a/components/Navigation.vue b/components/Navigation.vue
index 6c23640..c5b3e4d 100755
--- a/components/Navigation.vue
+++ b/components/Navigation.vue
@@ -443,6 +443,13 @@
>
Einstellungen
+
+ myTischtennis
+
Einstellungen
+
+ myTischtennis
+
Vereinsnummer
Hinterlegt: {{ status.email }} · letzter erfolgreicher Abruf: {{ formattedLastImport }}
+
+ Letzter Abruf fehlgeschlagen: {{ status.lastImportError }}
+
{{ message }}
Zugang speichern
@@ -40,6 +43,6 @@ const busy = ref(false); const message = ref(''); const error = ref(false)
const formattedLastImport = computed(() => status.value.lastSuccessfulImportAt ? new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(status.value.lastSuccessfulImportAt)) : 'noch nie')
async function load() { status.value = await $fetch('/api/cms/mytischtennis') }
async function save() { busy.value = true; message.value = ''; error.value = false; try { status.value = await $fetch('/api/cms/mytischtennis', { method: 'PUT', body: form }); form.password = ''; message.value = 'Zugang verschlüsselt gespeichert.' } catch { error.value = true; message.value = 'Zugang konnte nicht gespeichert werden.' } finally { busy.value = false } }
-async function runImport() { busy.value = true; message.value = ''; error.value = false; try { const result = await $fetch('/api/cms/mytischtennis/import', { method: 'POST' }); message.value = `${result.rowCount} Werte wurden aktualisiert.`; await load() } catch { error.value = true; message.value = 'Abruf fehlgeschlagen. Bitte Zugang und CAPTCHA-Status prüfen.' } finally { busy.value = false } }
+async function runImport() { busy.value = true; message.value = ''; error.value = false; try { const result = await $fetch('/api/cms/mytischtennis/import', { method: 'POST' }); message.value = `${result.rowCount} Werte wurden aktualisiert.`; await load() } catch (caught) { error.value = true; message.value = caught?.data?.statusMessage || 'Abruf fehlgeschlagen.'; await load().catch(() => {}) } finally { busy.value = false } }
onMounted(load)
diff --git a/server/api/cms/mytischtennis/import.post.js b/server/api/cms/mytischtennis/import.post.js
index 51cad21..b9c2d93 100644
--- a/server/api/cms/mytischtennis/import.post.js
+++ b/server/api/cms/mytischtennis/import.post.js
@@ -13,7 +13,9 @@ export default defineEventHandler(async (event) => {
const result = await importQttrValues({ connection })
return { success: true, rowCount: result.rowCount, importedAt: result.importedAt }
} catch (error) {
- await saveMyTischtennisConnection({ ...connection, lastImportError: String(error.message || error).slice(0, 500) })
- throw createError({ statusCode: 502, statusMessage: 'TTR-Abruf bei myTischtennis fehlgeschlagen.' })
+ const detail = String(error.message || error).slice(0, 500)
+ console.error('[mytischtennis] TTR-Abruf fehlgeschlagen:', detail)
+ await saveMyTischtennisConnection({ ...connection, lastImportError: detail })
+ throw createError({ statusCode: 502, statusMessage: `TTR-Abruf fehlgeschlagen: ${detail}` })
}
})
--
2.34.1
From 4dca2af645bfcb32d44d520890cd223047f9a56b Mon Sep 17 00:00:00 2001
From: "Torsten Schulz (local)"
Date: Fri, 4 Sep 2026 14:46:14 +0200
Subject: [PATCH 05/13] =?UTF-8?q?F=C3=BCge=20Playwright-Installation=20hin?=
=?UTF-8?q?zu:=20=C3=9Cberpr=C3=BCfe=20und=20installiere=20den=20Chromium-?=
=?UTF-8?q?Browser=20in=20den=20Deployment-Skripten?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
deploy-production.sh | 13 +++++++++++++
deploy-test.sh | 13 +++++++++++++
2 files changed, 26 insertions(+)
diff --git a/deploy-production.sh b/deploy-production.sh
index 2324f48..34ef61e 100755
--- a/deploy-production.sh
+++ b/deploy-production.sh
@@ -102,6 +102,18 @@ install_dependencies_if_needed() {
printf '%s\n' "$current_lock_hash" > "$lock_hash_file"
}
+install_playwright_browser() {
+ if [ ! -d "node_modules/playwright" ]; then
+ echo " Playwright ist nicht installiert; Browser-Installation wird übersprungen"
+ return 0
+ fi
+
+ # Muss mit demselben Benutzer laufen, der später den PM2-Prozess startet;
+ # Playwright legt den Browser im benutzerspezifischen Cache ab.
+ echo " Installing/verifying Playwright Chromium browser..."
+ npx playwright install chromium
+}
+
use_project_node() {
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
if [ -s "$NVM_DIR/nvm.sh" ]; then
@@ -278,6 +290,7 @@ echo "3. Installing dependencies..."
use_project_node
ensure_node_version
install_dependencies_if_needed
+install_playwright_browser
# 4. Stop running apps before replacing build artifacts
echo ""
diff --git a/deploy-test.sh b/deploy-test.sh
index 307ec60..e6da5f5 100755
--- a/deploy-test.sh
+++ b/deploy-test.sh
@@ -115,6 +115,18 @@ install_dependencies_if_needed() {
printf '%s\n' "$current_lock_hash" > "$lock_hash_file"
}
+install_playwright_browser() {
+ if [ ! -d "node_modules/playwright" ]; then
+ echo " Playwright ist nicht installiert; Browser-Installation wird übersprungen"
+ return 0
+ fi
+
+ # Muss mit demselben Benutzer laufen, der später den PM2-Prozess startet;
+ # Playwright legt den Browser im benutzerspezifischen Cache ab.
+ echo " Installing/verifying Playwright Chromium browser..."
+ npx playwright install chromium
+}
+
use_project_node() {
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
if [ -s "$NVM_DIR/nvm.sh" ]; then
@@ -284,6 +296,7 @@ echo "3. Installing dependencies..."
use_project_node
ensure_node_version
install_dependencies_if_needed
+install_playwright_browser
# 4. Stop running app before replacing build artifacts
echo ""
--
2.34.1
From d31026a60666253b5c358fcce65ed7ee1ad22b05 Mon Sep 17 00:00:00 2001
From: "Torsten Schulz (local)"
Date: Fri, 4 Sep 2026 15:05:12 +0200
Subject: [PATCH 06/13] =?UTF-8?q?Verbessere=20Fehlerbehandlung=20beim=20Im?=
=?UTF-8?q?port=20von=20TTR-Werten:=20K=C3=BCrze=20Fehlermeldungen=20im=20?=
=?UTF-8?q?CMS=20und=20schreibe=20vollst=C3=A4ndige=20Details=20ins=20Serv?=
=?UTF-8?q?erlog.?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
pages/cms/mytischtennis.vue | 2 +-
server/api/cms/mytischtennis/import.post.js | 9 ++++++---
2 files changed, 7 insertions(+), 4 deletions(-)
diff --git a/pages/cms/mytischtennis.vue b/pages/cms/mytischtennis.vue
index 60a4a87..514ba7b 100644
--- a/pages/cms/mytischtennis.vue
+++ b/pages/cms/mytischtennis.vue
@@ -43,6 +43,6 @@ const busy = ref(false); const message = ref(''); const error = ref(false)
const formattedLastImport = computed(() => status.value.lastSuccessfulImportAt ? new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(status.value.lastSuccessfulImportAt)) : 'noch nie')
async function load() { status.value = await $fetch('/api/cms/mytischtennis') }
async function save() { busy.value = true; message.value = ''; error.value = false; try { status.value = await $fetch('/api/cms/mytischtennis', { method: 'PUT', body: form }); form.password = ''; message.value = 'Zugang verschlüsselt gespeichert.' } catch { error.value = true; message.value = 'Zugang konnte nicht gespeichert werden.' } finally { busy.value = false } }
-async function runImport() { busy.value = true; message.value = ''; error.value = false; try { const result = await $fetch('/api/cms/mytischtennis/import', { method: 'POST' }); message.value = `${result.rowCount} Werte wurden aktualisiert.`; await load() } catch (caught) { error.value = true; message.value = caught?.data?.statusMessage || 'Abruf fehlgeschlagen.'; await load().catch(() => {}) } finally { busy.value = false } }
+async function runImport() { busy.value = true; message.value = ''; error.value = false; try { const result = await $fetch('/api/cms/mytischtennis/import', { method: 'POST' }); message.value = `${result.rowCount} Werte wurden aktualisiert.`; await load() } catch (caught) { error.value = true; message.value = caught?.data?.message || 'Abruf fehlgeschlagen. Details stehen im Serverlog.'; await load().catch(() => {}) } finally { busy.value = false } }
onMounted(load)
diff --git a/server/api/cms/mytischtennis/import.post.js b/server/api/cms/mytischtennis/import.post.js
index b9c2d93..3542ed3 100644
--- a/server/api/cms/mytischtennis/import.post.js
+++ b/server/api/cms/mytischtennis/import.post.js
@@ -13,9 +13,12 @@ export default defineEventHandler(async (event) => {
const result = await importQttrValues({ connection })
return { success: true, rowCount: result.rowCount, importedAt: result.importedAt }
} catch (error) {
- const detail = String(error.message || error).slice(0, 500)
- console.error('[mytischtennis] TTR-Abruf fehlgeschlagen:', detail)
+ const fullDetail = String(error.stack || error.message || error)
+ const detail = fullDetail.slice(0, 500)
+ // Vollständig nur ins serverseitige PM2-Log schreiben; im CMS wird der Fehler
+ // bewusst gekürzt gespeichert, damit Browser-/Systempfade nicht öffentlich werden.
+ console.error('[mytischtennis] TTR-Abruf fehlgeschlagen:', fullDetail)
await saveMyTischtennisConnection({ ...connection, lastImportError: detail })
- throw createError({ statusCode: 502, statusMessage: `TTR-Abruf fehlgeschlagen: ${detail}` })
+ throw createError({ statusCode: 502, message: 'TTR-Abruf fehlgeschlagen. Details stehen im Serverlog.' })
}
})
--
2.34.1
From ee93e15af8cdd2f7c43408be1632dbbdd80d3c2b Mon Sep 17 00:00:00 2001
From: "Torsten Schulz (local)"
Date: Fri, 4 Sep 2026 15:11:15 +0200
Subject: [PATCH 07/13] Verbessere CAPTCHA-Handling im Login-Prozess:
Implementiere automatische Token-Generierung und verbessere Fehlerbehandlung.
---
server/utils/mytischtennis-client.js | 45 ++++++++++++++++++++++------
1 file changed, 36 insertions(+), 9 deletions(-)
diff --git a/server/utils/mytischtennis-client.js b/server/utils/mytischtennis-client.js
index 921c394..34626cd 100644
--- a/server/utils/mytischtennis-client.js
+++ b/server/utils/mytischtennis-client.js
@@ -37,17 +37,44 @@ async function loginWithBrowser(email, password) {
const browser = await chromium.launch({ headless: true })
try {
const page = await browser.newPage()
- await page.goto(`${BASE_URL}/login?next=%2F`, { waitUntil: 'networkidle' })
+ // Das myTT-CAPTCHA wird erst nach dem Laden der Seite erzeugt. Der Ablauf
+ // entspricht der erprobten Integration im Trainingstagebuch.
+ await page.goto(BASE_URL, { waitUntil: 'domcontentloaded', timeout: 30_000 }).catch(() => {})
+ await page.goto(`${BASE_URL}/login?next=%2F`, { waitUntil: 'domcontentloaded', timeout: 45_000 })
await page.locator('input[name="email"]').fill(email)
await page.locator('input[name="password"]').fill(password)
- // Das CAPTCHA wird vom Anbieter im Browser erzeugt. Ohne erfolgreiches Token wird nicht abgesendet.
- await page.waitForFunction(() => {
- const input = document.querySelector('input[name="captcha"]')
- return Boolean(input?.value)
- }, { timeout: 25_000 })
- await page.locator('form').evaluate(form => form.requestSubmit())
- await page.waitForTimeout(1_500)
- const cookie = (await page.context().cookies(BASE_URL)).find(item => item.name === 'sb-10-auth-token')
+
+ const captchaHost = page.locator('private-captcha').first()
+ const hasCaptcha = await captchaHost.count() > 0
+ if (hasCaptcha) {
+ await page.waitForTimeout(1_200)
+ 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 captchaReady = await page.evaluate(() => (document.querySelector('input[name="captcha"]')?.value?.trim().length || 0) > 80)
+ if (!captchaReady) throw new Error('CAPTCHA konnte nicht automatisch gelöst werden.')
+ await page.waitForTimeout(2_500)
+ }
+
+ const submit = page.locator('button[type="submit"][name="intent"][value="login"], button[type="submit"], input[type="submit"]').first()
+ if (await submit.count()) await submit.click({ noWaitAfter: true })
+ else await page.locator('form').evaluate(form => form.requestSubmit())
+ let cookie = null
+ for (let attempt = 0; attempt < 40; attempt += 1) {
+ cookie = (await page.context().cookies(BASE_URL)).find(item => /^sb-\d+-auth-token$/.test(item.name))
+ if (cookie) break
+ await page.waitForTimeout(500)
+ }
if (!cookie) throw new Error('CAPTCHA konnte nicht automatisch gelöst werden.')
return `${cookie.name}=${cookie.value}`
} finally { await browser.close() }
--
2.34.1
From 02c176521497c81d7711385c6c1b743eca430549 Mon Sep 17 00:00:00 2001
From: "Torsten Schulz (local)"
Date: Fri, 4 Sep 2026 15:43:11 +0200
Subject: [PATCH 08/13] =?UTF-8?q?F=C3=BCge=20Unterst=C3=BCtzung=20f=C3=BCr?=
=?UTF-8?q?=20das=20Akzeptieren=20von=20Cookie-Zustimmungen=20hinzu:=20Imp?=
=?UTF-8?q?lementiere=20eine=20Funktion,=20die=20verschiedene=20Consent-Di?=
=?UTF-8?q?aloge=20automatisch=20akzeptiert.?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
server/utils/mytischtennis-client.js | 18 +++++++++++++++++-
1 file changed, 17 insertions(+), 1 deletion(-)
diff --git a/server/utils/mytischtennis-client.js b/server/utils/mytischtennis-client.js
index 34626cd..861f337 100644
--- a/server/utils/mytischtennis-client.js
+++ b/server/utils/mytischtennis-client.js
@@ -34,13 +34,29 @@ async function loginDirect(email, password) {
}
async function loginWithBrowser(email, password) {
- const browser = await chromium.launch({ headless: true })
+ const browser = await chromium.launch({ headless: true, args: ['--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)
--
2.34.1
From 9a4eb77c565dd45e5f89ec70d31032e3a26fb4de Mon Sep 17 00:00:00 2001
From: "Torsten Schulz (local)"
Date: Fri, 4 Sep 2026 16:07:18 +0200
Subject: [PATCH 09/13] =?UTF-8?q?Verbessere=20CAPTCHA-Handling=20im=20Logi?=
=?UTF-8?q?n-Prozess:=20F=C3=BCge=20detaillierte=20Protokollierung=20des?=
=?UTF-8?q?=20CAPTCHA-Status=20hinzu=20und=20verbessere=20die=20Handhabung?=
=?UTF-8?q?=20des=20CAPTCHA-Widgets.?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
server/utils/mytischtennis-client.js | 30 ++++++++++++++++++++++++++--
1 file changed, 28 insertions(+), 2 deletions(-)
diff --git a/server/utils/mytischtennis-client.js b/server/utils/mytischtennis-client.js
index 861f337..7371621 100644
--- a/server/utils/mytischtennis-client.js
+++ b/server/utils/mytischtennis-client.js
@@ -34,7 +34,10 @@ async function loginDirect(email, password) {
}
async function loginWithBrowser(email, password) {
- const browser = await chromium.launch({ headless: true, args: ['--disable-dev-shm-usage'] })
+ 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
@@ -60,10 +63,19 @@ async function loginWithBrowser(email, password) {
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')
@@ -77,7 +89,21 @@ async function loginWithBrowser(email, password) {
const clicked = document.querySelector('input[name="captcha_clicked"]')?.value?.toLowerCase() || ''
return token.length > 80 && (clicked === 'true' || clicked === '1')
}, { timeout: 32_000 }).catch(() => {})
- const captchaReady = await page.evaluate(() => (document.querySelector('input[name="captcha"]')?.value?.trim().length || 0) > 80)
+ 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)
}
--
2.34.1
From 3fb60227dff71506ab99091f8fa747d9bcecf4be Mon Sep 17 00:00:00 2001
From: "Torsten Schulz (local)"
Date: Fri, 4 Sep 2026 16:25:08 +0200
Subject: [PATCH 10/13] =?UTF-8?q?Verbessere=20Login-Handling:=20F=C3=BCge?=
=?UTF-8?q?=20Unterst=C3=BCtzung=20f=C3=BCr=20das=20automatische=20Hinzuf?=
=?UTF-8?q?=C3=BCgen=20des=20Intent-Feldes=20im=20Login-Formular=20hinzu?=
=?UTF-8?q?=20und=20verbessere=20die=20Cookie-Verwaltung=20bei=20der=20Anm?=
=?UTF-8?q?eldung.?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
server/utils/mytischtennis-client.js | 32 +++++++++++++++++++++++++---
1 file changed, 29 insertions(+), 3 deletions(-)
diff --git a/server/utils/mytischtennis-client.js b/server/utils/mytischtennis-client.js
index 7371621..0074b2d 100644
--- a/server/utils/mytischtennis-client.js
+++ b/server/utils/mytischtennis-client.js
@@ -108,16 +108,42 @@ async function loginWithBrowser(email, password) {
await page.waitForTimeout(2_500)
}
- const submit = page.locator('button[type="submit"][name="intent"][value="login"], button[type="submit"], input[type="submit"]').first()
+ 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) {
- cookie = (await page.context().cookies(BASE_URL)).find(item => /^sb-\d+-auth-token$/.test(item.name))
+ 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) throw new Error('CAPTCHA konnte nicht automatisch gelöst werden.')
+ 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() }
}
--
2.34.1
From 395e2a84bd2a93eb63531f94bf6c072ee1f7e6e0 Mon Sep 17 00:00:00 2001
From: "Torsten Schulz (local)"
Date: Fri, 4 Sep 2026 16:39:34 +0200
Subject: [PATCH 11/13] =?UTF-8?q?Verbessere=20TTR-Wert-Import:=20F=C3=BCge?=
=?UTF-8?q?=20parallele=20Abrufe=20f=C3=BCr=20aktuelle=20und=20fr=C3=BCher?=
=?UTF-8?q?e=20Ranglisten=20hinzu=20und=20verbessere=20die=20Zuordnung=20v?=
=?UTF-8?q?on=20QTTR-Daten.?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
server/utils/mytischtennis-client.js | 10 ++++++++--
server/utils/qttr-import.js | 20 +++++++++++++++-----
2 files changed, 23 insertions(+), 7 deletions(-)
diff --git a/server/utils/mytischtennis-client.js b/server/utils/mytischtennis-client.js
index 0074b2d..efbf9c5 100644
--- a/server/utils/mytischtennis-client.js
+++ b/server/utils/mytischtennis-client.js
@@ -148,17 +148,23 @@ async function loginWithBrowser(email, password) {
} finally { await browser.close() }
}
-export async function fetchClubRankings(connection) {
+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': 'yes', 'results-per-page': '100', page: '0', _data: 'routes/$' })
+ 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
}
diff --git a/server/utils/qttr-import.js b/server/utils/qttr-import.js
index a565edd..7eb35e6 100755
--- a/server/utils/qttr-import.js
+++ b/server/utils/qttr-import.js
@@ -215,19 +215,29 @@ export async function importQttrValues(options = {}) {
}
async function importAuthenticatedTtrValues(connection) {
- const entries = await fetchClubRankings(connection)
- const rows = entries.map((entry, index) => ({
+ const [ttrEntries, qttrEntries] = await Promise.all([
+ fetchClubRankings(connection, 'yes'),
+ fetchClubRankings(connection, 'no')
+ ])
+ const qttrByPlayer = new Map(qttrEntries.map(entry => [
+ String(entry.personId || `${entry.firstname || ''}|${entry.lastname || ''}`).toLowerCase(), entry
+ ]))
+ const rows = ttrEntries.map((entry, index) => {
+ const key = String(entry.personId || `${entry.firstname || ''}|${entry.lastname || ''}`).toLowerCase()
+ const qttrEntry = qttrByPlayer.get(key)
+ return ({
rank: toNumberOrNull(entry.rank ?? entry.ranking ?? index + 1),
playerNumber: toNumberOrNull(entry.playernr ?? entry.player_number ?? entry.nuid),
gender: normalizeGender(entry.gender),
playerName: `${entry.firstname ?? entry.first_name ?? ''} ${entry.lastname ?? entry.last_name ?? ''}`.trim() || entry.name || null,
clubName: entry.clubname ?? entry.club_name ?? 'Harheimer TC',
- currentQttr: toNumberOrNull(entry.qttr ?? entry.q_ttr ?? entry.ttr),
- currentTtr: toNumberOrNull(entry.ttr ?? entry.current_ttr),
+ currentQttr: toNumberOrNull(qttrEntry?.fedRank ?? qttrEntry?.qttr ?? entry.qttr),
+ currentTtr: toNumberOrNull(entry.fedRank ?? entry.ttr ?? entry.current_ttr),
previousQttr: null,
valuesByHeader: entry,
rawCells: []
- })).filter(row => row.playerName && row.currentQttr != null)
+ })
+ }).filter(row => row.playerName && (row.currentTtr != null || row.currentQttr != null))
if (!rows.length) throw new Error('Keine verwertbaren TTR-Werte in der myTischtennis-Rangliste gefunden.')
const importedAt = new Date().toISOString()
--
2.34.1
From eb75f86d4508c8ee1daf1b70e1b74423083a256f Mon Sep 17 00:00:00 2001
From: "Torsten Schulz (local)"
Date: Fri, 4 Sep 2026 16:58:17 +0200
Subject: [PATCH 12/13] =?UTF-8?q?Verbessere=20QTTR-Handling:=20Aktualisier?=
=?UTF-8?q?e=20die=20Anzeige=20von=20QTTR-Werten=20zu=20TTR-=20und=20QTTR-?=
=?UTF-8?q?Werten=20und=20passe=20die=20zugeh=C3=B6rigen=20Texte=20an.?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
components/Navigation.vue | 4 ++--
pages/mitgliederbereich/qttr.vue | 24 +++++++++++-------------
2 files changed, 13 insertions(+), 15 deletions(-)
diff --git a/components/Navigation.vue b/components/Navigation.vue
index c5b3e4d..e8fbda4 100755
--- a/components/Navigation.vue
+++ b/components/Navigation.vue
@@ -311,7 +311,7 @@
class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
active-class="text-white bg-primary-600"
>
- QTTR
+ TTR / QTTR
- QTTR
+ TTR / QTTR
- QTTR-Werte
+ TTR- und QTTR-Werte
- Aus technischen Gründen sind nur die QTTR-Werte verfügbar. Für TTR bitte auf
- myTischtennis
- wechseln.
+ Aktuelle TTR- und QTTR-Werte des Harheimer TC. Der Abruf erfolgt über die geschützte Vereinsverbindung.
@@ -37,13 +30,13 @@
v-if="pending"
class="py-12 text-center text-gray-500"
>
- Lade QTTR-Werte...
+ Lade TTR- und QTTR-Werte...
- {{ error.statusMessage || error.message || 'QTTR-Werte konnten nicht geladen werden.' }}
+ {{ error.statusMessage || error.message || 'TTR- und QTTR-Werte konnten nicht geladen werden.' }}
Verein
+
+ TTR
+
QTTR
@@ -83,6 +79,9 @@
{{ row.clubName || 'Harheimer TC' }}
+
+ {{ row.currentTtr ?? '–' }}
+
{{ row.currentQttr ?? '–' }}
@@ -99,7 +98,6 @@
import { computed } from 'vue'
const authStore = useAuthStore()
-const externalUrl = 'https://www.mytischtennis.de/rankings/andro-rangliste?continent=all&country=Deutschland&all-players=on&as=DE.WE.R4.07&di=DE.WE.R4.07.04&area=DE.WE.R4.07.04.43&clubnr-search=Harheimer+TC&clubnr=43030&fednickname=HeTTV&gender=all¤t-ranking=yes&ttr-range=100%3B3000&birth-range=1926%3B2021'
definePageMeta({
middleware: 'auth',
@@ -180,6 +178,6 @@ function formatDate(value) {
}
useHead({
- title: 'QTTR-Werte - Harheimer TC'
+ title: 'TTR- und QTTR-Werte - Harheimer TC'
})
--
2.34.1
From 5ca4253b44880a70f2132a4b5fda6868377c776b Mon Sep 17 00:00:00 2001
From: "Torsten Schulz (local)"
Date: Fri, 4 Sep 2026 23:12:06 +0200
Subject: [PATCH 13/13] =?UTF-8?q?Verbessere=20Geschlechtsnormalisierung:?=
=?UTF-8?q?=20F=C3=BCge=20Unterst=C3=BCtzung=20f=C3=BCr=20englische=20Begr?=
=?UTF-8?q?iffe=20hinzu=20und=20verbessere=20die=20Zuordnung=20von=20Gesch?=
=?UTF-8?q?lechtsdaten=20im=20Importprozess.?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
pages/mitgliederbereich/qttr.vue | 2 +-
server/utils/qttr-import.js | 6 +++---
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/pages/mitgliederbereich/qttr.vue b/pages/mitgliederbereich/qttr.vue
index 202dcd1..ae3b5d0 100755
--- a/pages/mitgliederbereich/qttr.vue
+++ b/pages/mitgliederbereich/qttr.vue
@@ -124,7 +124,7 @@ function isMaleGender(value) {
function isFemaleGender(value) {
const gender = normalizeName(value)
- return gender.startsWith('w') || gender.includes('weib') || gender.includes('frau')
+ return gender.startsWith('w') || gender.includes('weib') || gender.includes('frau') || gender.includes('female')
}
function isOwnRow(playerName) {
diff --git a/server/utils/qttr-import.js b/server/utils/qttr-import.js
index 7eb35e6..7cdaad2 100755
--- a/server/utils/qttr-import.js
+++ b/server/utils/qttr-import.js
@@ -61,8 +61,8 @@ function toNumberOrNull(value) {
function normalizeGender(value) {
const normalized = String(value || '').trim().toLowerCase()
- if (normalized === 'm' || normalized === 'männlich') return 'männlich'
- if (normalized === 'w' || normalized === 'weiblich') return 'weiblich'
+ if (['m', 'männlich', 'maennlich', 'male', 'man'].includes(normalized)) return 'männlich'
+ if (['w', 'weiblich', 'female', 'woman', 'f'].includes(normalized)) return 'weiblich'
return normalized || null
}
@@ -228,7 +228,7 @@ async function importAuthenticatedTtrValues(connection) {
return ({
rank: toNumberOrNull(entry.rank ?? entry.ranking ?? index + 1),
playerNumber: toNumberOrNull(entry.playernr ?? entry.player_number ?? entry.nuid),
- gender: normalizeGender(entry.gender),
+ gender: normalizeGender(entry.gender ?? entry.sex ?? entry.genderCode ?? entry.gender_code),
playerName: `${entry.firstname ?? entry.first_name ?? ''} ${entry.lastname ?? entry.last_name ?? ''}`.trim() || entry.name || null,
clubName: entry.clubname ?? entry.club_name ?? 'Harheimer TC',
currentQttr: toNumberOrNull(qttrEntry?.fedRank ?? qttrEntry?.qttr ?? entry.qttr),
--
2.34.1