diff --git a/pages/mitgliederbereich/qttr.vue b/pages/mitgliederbereich/qttr.vue
index e5d88a2..ae3b5d0 100755
--- a/pages/mitgliederbereich/qttr.vue
+++ b/pages/mitgliederbereich/qttr.vue
@@ -3,18 +3,11 @@
- 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',
@@ -126,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) {
@@ -180,6 +178,6 @@ function formatDate(value) {
}
useHead({
- title: 'QTTR-Werte - Harheimer TC'
+ title: 'TTR- und QTTR-Werte - Harheimer TC'
})
diff --git a/server/api/cms/mytischtennis.get.js b/server/api/cms/mytischtennis.get.js
new file mode 100644
index 0000000..511c4e9
--- /dev/null
+++ b/server/api/cms/mytischtennis.get.js
@@ -0,0 +1,10 @@
+import { getUserFromToken, hasAnyRole } from '../../utils/auth.js'
+import { publicConnectionStatus, readMyTischtennisConnection } from '../../utils/mytischtennis-connection.js'
+
+export default defineEventHandler(async (event) => {
+ const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
+ const user = token ? await getUserFromToken(token) : null
+ if (!user) throw createError({ statusCode: 401, statusMessage: 'Nicht authentifiziert' })
+ if (!hasAnyRole(user, 'admin', 'vorstand')) throw createError({ statusCode: 403, statusMessage: 'Keine Berechtigung' })
+ return publicConnectionStatus(await readMyTischtennisConnection())
+})
diff --git a/server/api/cms/mytischtennis.put.js b/server/api/cms/mytischtennis.put.js
new file mode 100644
index 0000000..6d6cb00
--- /dev/null
+++ b/server/api/cms/mytischtennis.put.js
@@ -0,0 +1,17 @@
+import { getUserFromToken, hasAnyRole } from '../../utils/auth.js'
+import { readMyTischtennisConnection, saveMyTischtennisConnection, publicConnectionStatus } from '../../utils/mytischtennis-connection.js'
+
+export default defineEventHandler(async (event) => {
+ const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
+ const user = token ? await getUserFromToken(token) : null
+ if (!user) throw createError({ statusCode: 401, statusMessage: 'Nicht authentifiziert' })
+ if (!hasAnyRole(user, 'admin', 'vorstand')) throw createError({ statusCode: 403, statusMessage: 'Keine Berechtigung' })
+ const body = await readBody(event)
+ if (!/^\S+@\S+\.\S+$/.test(String(body.email || '')) || String(body.password || '').length < 1) {
+ throw createError({ statusCode: 400, statusMessage: 'E-Mail und Passwort sind erforderlich.' })
+ }
+ const previous = await readMyTischtennisConnection()
+ const connection = { ...previous, email: body.email.trim(), password: body.password, association: String(body.association || 'HeTTV'), clubId: String(body.clubId || '43030') }
+ await saveMyTischtennisConnection(connection)
+ return publicConnectionStatus(connection)
+})
diff --git a/server/api/cms/mytischtennis/import.post.js b/server/api/cms/mytischtennis/import.post.js
new file mode 100644
index 0000000..3542ed3
--- /dev/null
+++ b/server/api/cms/mytischtennis/import.post.js
@@ -0,0 +1,24 @@
+import { getUserFromToken, hasAnyRole } from '../../../utils/auth.js'
+import { importQttrValues } from '../../../utils/qttr-import.js'
+import { readMyTischtennisConnection, saveMyTischtennisConnection } from '../../../utils/mytischtennis-connection.js'
+
+export default defineEventHandler(async (event) => {
+ const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
+ const user = token ? await getUserFromToken(token) : null
+ if (!user) throw createError({ statusCode: 401, statusMessage: 'Nicht authentifiziert' })
+ if (!hasAnyRole(user, 'admin', 'vorstand')) throw createError({ statusCode: 403, statusMessage: 'Keine Berechtigung' })
+ const connection = await readMyTischtennisConnection()
+ if (!connection) throw createError({ statusCode: 409, statusMessage: 'Keine myTischtennis-Verbindung eingerichtet.' })
+ try {
+ const result = await importQttrValues({ connection })
+ return { success: true, rowCount: result.rowCount, importedAt: result.importedAt }
+ } catch (error) {
+ 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, message: 'TTR-Abruf fehlgeschlagen. Details stehen im Serverlog.' })
+ }
+})
diff --git a/server/utils/mytischtennis-client.js b/server/utils/mytischtennis-client.js
new file mode 100644
index 0000000..efbf9c5
--- /dev/null
+++ b/server/utils/mytischtennis-client.js
@@ -0,0 +1,170 @@
+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
+}
diff --git a/server/utils/mytischtennis-connection.js b/server/utils/mytischtennis-connection.js
new file mode 100644
index 0000000..0be49c0
--- /dev/null
+++ b/server/utils/mytischtennis-connection.js
@@ -0,0 +1,44 @@
+import { promises as fs } from 'fs'
+import { encryptObject, decryptObject } from './encryption.js'
+import { getServerDataPath } from './paths.js'
+
+const FILE = getServerDataPath('mytischtennis-connection.json')
+
+function encryptionKey() {
+ const key = process.env.ENCRYPTION_KEY
+ if (!key) throw new Error('ENCRYPTION_KEY ist für die myTischtennis-Verbindung erforderlich.')
+ return key
+}
+
+export async function readMyTischtennisConnection() {
+ try {
+ const stored = JSON.parse(await fs.readFile(FILE, 'utf8'))
+ return decryptObject(stored.encrypted, encryptionKey())
+ } catch (error) {
+ if (error?.code === 'ENOENT') return null
+ throw error
+ }
+}
+
+export async function saveMyTischtennisConnection(connection) {
+ const payload = {
+ version: 1,
+ encrypted: encryptObject(connection, encryptionKey())
+ }
+ await fs.mkdir(getServerDataPath(), { recursive: true })
+ await fs.writeFile(FILE, `${JSON.stringify(payload)}\n`, { encoding: 'utf8', mode: 0o600 })
+}
+
+export function publicConnectionStatus(connection) {
+ if (!connection) return { configured: false }
+ const email = String(connection.email || '')
+ const [name, domain] = email.split('@')
+ return {
+ configured: true,
+ email: name ? `${name.slice(0, 2)}${'•'.repeat(Math.max(1, name.length - 2))}${domain ? `@${domain}` : ''}` : null,
+ association: connection.association || null,
+ clubId: connection.clubId || null,
+ lastSuccessfulImportAt: connection.lastSuccessfulImportAt || null,
+ lastImportError: connection.lastImportError || null
+ }
+}
diff --git a/server/utils/qttr-import.js b/server/utils/qttr-import.js
index 5c5428d..7cdaad2 100755
--- a/server/utils/qttr-import.js
+++ b/server/utils/qttr-import.js
@@ -1,5 +1,7 @@
import { promises as fs } from 'fs'
import { getServerDataPath } from './paths.js'
+import { fetchClubRankings } from './mytischtennis-client.js'
+import { readMyTischtennisConnection, saveMyTischtennisConnection } from './mytischtennis-connection.js'
const QTTR_URL = '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=no&ttr-range=100%3B3000&birth-range=1926%3B2021'
const OUTPUT_FILE = getServerDataPath('qttr-values.json')
@@ -59,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
}
@@ -160,6 +162,9 @@ function deriveQttrFields(headers, cells) {
}
export async function importQttrValues(options = {}) {
+ const connection = options.connection ?? await readMyTischtennisConnection()
+ if (connection) return importAuthenticatedTtrValues(connection)
+
const url = options.url || QTTR_URL
const response = await fetch(url, {
headers: {
@@ -207,4 +212,42 @@ export async function importQttrValues(options = {}) {
rowCount: parsedRows.length,
...payload
}
-}
\ No newline at end of file
+}
+
+async function importAuthenticatedTtrValues(connection) {
+ 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 ?? 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),
+ currentTtr: toNumberOrNull(entry.fedRank ?? entry.ttr ?? entry.current_ttr),
+ previousQttr: null,
+ valuesByHeader: entry,
+ rawCells: []
+ })
+ }).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()
+ const payload = {
+ format: 'harheimertc.qttr.v2', importedAt,
+ source: { type: 'mytischtennis-authenticated', association: connection.association, clubId: connection.clubId },
+ title: 'Aktuelle myTischtennis-Rangliste', headerCount: 0, rowCount: rows.length, headers: [], rows
+ }
+ await fs.mkdir(getServerDataPath(), { recursive: true })
+ await fs.writeFile(OUTPUT_FILE, `${JSON.stringify(payload, null, 2)}\n`, 'utf8')
+ await saveMyTischtennisConnection({ ...connection, lastSuccessfulImportAt: importedAt, lastImportError: null })
+ return { outputFile: OUTPUT_FILE, tableCount: 1, ...payload }
+}