Trade Republic Kreditkarten-Umsätze → CSV/XLSX (v4.6 + minimize)

Start per Button; Monat/Jahr-Filter (+ Startjahr); Kategorie-Filter; Einnahmen/Ausgaben; CSV (UTF-16LE) & XLSX; automatischer Jahres-Rollover; Zusatzspalten Jahr & Monat; Minimize/Restore.

// ==UserScript==
// @name         Trade Republic Kreditkarten-Umsätze → CSV/XLSX (v4.6 + minimize)
// @namespace    de.user.traderepublic.csv.export
// @version      4.6
// @description  Start per Button; Monat/Jahr-Filter (+ Startjahr); Kategorie-Filter; Einnahmen/Ausgaben; CSV (UTF-16LE) & XLSX; automatischer Jahres-Rollover; Zusatzspalten Jahr & Monat; Minimize/Restore.
// @match        https://app.traderepublic.com/profile/transactions*
// @grant        GM_addStyle
// @grant        GM_download
// @run-at       document-idle
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/xlsx.full.min.js
// @license      MIT
// @copyright    (c) 2025 CGIELER
// @note         Uses SheetJS/xlsx via @require (Apache-2.0). No code is bundled
// ==/UserScript==

(function () {
  'use strict';

  // ---------- XLSX robust laden (Fallback, falls @require blockiert wird) ----------
  async function ensureXLSX(){
    if (window.XLSX) return true;
    return await new Promise(res=>{
      const s=document.createElement('script');
      s.src='https://cdn.jsdelivr.net/npm/[email protected]/dist/xlsx.full.min.js';
      s.onload=()=>res(true);
      s.onerror=()=>res(false);
      document.head.appendChild(s);
    });
  }

  // ---------- UI ----------
  const css = `
  #trBox{position:fixed;top:12px;right:12px;z-index:999999;background:#111;color:#fff;border:1px solid #2a2a2a;border-radius:12px;box-shadow:0 10px 30px rgba(0,0,0,.35);width:590px;max-height:84vh;display:flex;flex-direction:column;overflow:hidden;font:12px/1.4 system-ui,-apple-system,Segoe UI,Roboto,Arial}
  #trBox header{padding:10px 12px;background:#1b1b1b;font-weight:700;display:flex;align-items:center;justify-content:space-between}
  #trBox .hdrRight{display:flex;align-items:center;gap:8px}
  #trBox .pill{background:#2a2a2a;border:1px solid #3a3a3a;border-radius:999px;padding:2px 8px}
  #trBox .body{padding:10px 12px;overflow:auto}
  #trBox button{background:#2a2a2a;color:#fff;border:1px solid #3a3a3a;border-radius:8px;padding:6px 10px;cursor:pointer}
  #trBox button:disabled{opacity:.5;cursor:not-allowed}
  #trMin{width:28px;min-width:28px;padding:4px 0;text-align:center;border-radius:8px}
  #trBox textarea{width:100%;min-height:170px;background:#0f0f0f;color:#cde3ff;border:1px solid #262626;border-radius:8px;padding:8px;white-space:pre}
  .row{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:6px 0}
  .row label{display:flex;align-items:center;gap:6px}
  input.mmYY{width:90px;padding:4px 6px;border-radius:6px;border:1px solid #333;background:#0f0f0f;color:#fff}
  input.year{width:80px;padding:4px 6px;border-radius:6px;border:1px solid #333;background:#0f0f0f;color:#fff}

  /* Collapsed / minimized */
  #trBox.collapsed{top:auto;bottom:12px;right:12px;width:240px}
  #trBox.collapsed .body{display:none}
  #trBox.collapsed header{cursor:pointer}
  `;
  try { GM_addStyle?.(css); } catch { const s=document.createElement('style'); s.textContent=css; document.head.appendChild(s); }

  const ui = document.createElement('div');
  ui.id = 'trBox';
  ui.innerHTML = `
    <header id="trHeader">
      <div>TR Kreditkarten-Export</div>
      <div class="hdrRight">
        <div class="pill" id="trStatus">Bereit</div>
        <button id="trMin" title="Minimieren">—</button>
      </div>
    </header>
    <div class="body">
      <div class="row">
        <button id="trStart">Scan starten</button>
        <button id="trStop" disabled>Stop</button>
        <button id="trExportCsv" disabled>CSV speichern</button>
        <button id="trExportXlsx" disabled>XLSX speichern</button>
        <span id="trDbg" style="opacity:.85;font-size:11px;margin-left:auto"></span>
      </div>

      <div class="row">
        <label>Von (MM.YYYY)<input id="trFrom" class="mmYY" placeholder="MM.YYYY"></label>
        <label>Bis (MM.YYYY)<input id="trTo" class="mmYY" placeholder="MM.YYYY"></label>
        <label>Startjahr<input id="trBaseYear" class="year" placeholder="auto"></label>
        <button id="btnPrevMonth" title="Filter auf letzten Kalendermonat setzen">Letzter Monat</button>
      </div>

      <div class="row">
        <label><input type="checkbox" id="cbCard" checked> Kartenzahlungen</label>
        <label><input type="checkbox" id="cbIn"   checked> Einzahlungen</label>
        <label><input type="checkbox" id="cbOut"  checked> Auszahlungen</label>
        <label><input type="checkbox" id="cbPlan" checked> Sparpläne/Saveback/Round up</label>
      </div>

      <div>Zeilen (gefiltert/gesamt): <b id="trCount">0/0</b> · Scrolls: <b id="trScrolls">0</b> · DOM-Einträge: <b id="trNodes">0</b></div>
      <textarea id="trPreview" placeholder="Vorschau …"></textarea>
    </div>
  `;
  document.documentElement.appendChild(ui);
  const $ = (s)=>ui.querySelector(s);
  const elStatus=$('#trStatus'), elCount=$('#trCount'), elScrolls=$('#trScrolls'),
        elNodes=$('#trNodes'), elPreview=$('#trPreview'), elDbg=$('#trDbg'),
        btnStart=$('#trStart'), btnStop=$('#trStop'),
        btnExportCsv=$('#trExportCsv'), btnExportXlsx=$('#trExportXlsx'),
        inFrom=$('#trFrom'), inTo=$('#trTo'), inBaseYear=$('#trBaseYear'),
        cbCard=$('#cbCard'), cbIn=$('#cbIn'), cbOut=$('#cbOut'), cbPlan=$('#cbPlan'),
        btnPrevMonth=$('#btnPrevMonth'), btnMin=$('#trMin'), header=$('#trHeader');

  const sleep = (ms)=>new Promise(r=>setTimeout(r,ms));

  // ---------- Minimize / Restore ----------
  const STORE_KEY='trBoxCollapsed';
  function setCollapsed(c){
    ui.classList.toggle('collapsed', !!c);
    btnMin.textContent = c ? '▣' : '—';
    btnMin.title = c ? 'Wieder öffnen' : 'Minimieren';
    try{ localStorage.setItem(STORE_KEY, c?'1':'0'); }catch{}
  }
  setCollapsed(localStorage.getItem(STORE_KEY)==='1');
  btnMin.addEventListener('click', (e)=>{ e.stopPropagation(); setCollapsed(!ui.classList.contains('collapsed')); });
  header.addEventListener('dblclick', ()=> setCollapsed(!ui.classList.contains('collapsed')));

  // ---------- Utils ----------
  const NBSP = /\u00A0/g;
  function clean(s){ return (s||'').replace(NBSP,' ').replace(/\s+\n/g,'\n').trim(); }
  function txt(el){ return clean(el?.innerText || ''); }

  const monthMap = {Jan:'01', Feb:'02', Mär:'03', Mrz:'03', Apr:'04', Mai:'05', Jun:'06', Jul:'07', Aug:'08', Sep:'09', Okt:'10', Nov:'11', Dez:'12'};
  const reDateNum = /(^|\s)(\d{1,2})\.(\d{2})\.(?=\s|$|[^0-9])/;
  const reDateMon = /(^|\s)(\d{1,2})\.\s*(Jan|Feb|Mär|Mrz|Apr|Mai|Jun|Jul|Aug|Sep|Okt|Nov|Dez)\.(?=\s|$)/;

  function dateParts(s){
    s = clean(s);
    let m = s.match(reDateNum);
    if (m) return { d: parseInt(m[2],10), m: parseInt(m[3],10) };
    m = s.match(reDateMon);
    if (m) return { d: parseInt(m[2],10), m: parseInt(monthMap[m[3]],10) };
    return null;
  }
  function parseDateDE(ddmmyyyy){ const [d,m,y] = ddmmyyyy.split('.').map(x=>parseInt(x,10)); return new Date(y, m-1, d); }
  function parseMonthInput(s){ const m=/^\s*(\d{1,2})\.(\d{4})\s*$/.exec(s||''); if(!m) return null; return [parseInt(m[2],10), Math.min(Math.max(parseInt(m[1],10),1),12)]; }
  function lastMonthRange(){ const d=new Date(); d.setDate(1); d.setMonth(d.getMonth()-1); const y=d.getFullYear(), m=d.getMonth()+1, mm=String(m).padStart(2,'0'); return {from:`${mm}.${y}`, to:`${mm}.${y}`}; }
  function inRangeByMonth(ddmmyyyy, fromStr, toStr){
    if (!fromStr && !toStr) return true;
    const d = parseDateDE(ddmmyyyy);
    let ok = true;
    if (fromStr){ const [fy,fm]=parseMonthInput(fromStr)||[]; if (fy){ ok = ok && (d >= new Date(fy, fm-1, 1)); } }
    if (toStr){   const [ty,tm]=parseMonthInput(toStr)||[];   if (ty){ ok = ok && (d <= new Date(ty, tm, 0)); } }
    return ok;
  }
  function normNumDE(text){
    let s=clean(text).replace(/[^\d,.\-+]/g,'').trim();
    if(!s) return '';
    const i=Math.max(s.lastIndexOf(','), s.lastIndexOf('.'));
    let sign=''; if (/^[+-]/.test(s)){ sign = s[0]==='-'?'-':''; s=s.slice(1); }
    if (i>=0){
      const intp=s.slice(0,i).replace(/[^\d]/g,'');
      const decp=s.slice(i+1).replace(/[^\d]/g,'').slice(0,2).padEnd(2,'0');
      return sign + (intp||'0') + ',' + decp;
    }
    return sign + s.replace(/[^\d]/g,'');
  }
  const numFromDE = s => s ? parseFloat(s.replace(/\./g,'').replace(',','.')) : null;

  function currencyCodeFrom(text){
    const t = clean(text);
    if (/GBP|£/.test(t)) return 'GBP';
    if (/USD|\$/.test(t)) return 'USD';
    if (/EUR|€/.test(t)) return 'EUR';
    if (/CHF|Fr\b/.test(t)) return 'CHF';
    if (/PLN|zł/.test(t)) return 'PLN';
    if (/CZK|Kč/.test(t)) return 'CZK';
    if (/TRY|₺/.test(t)) return 'TRY';
    if (/JPY|¥/.test(t)) return 'JPY';
    if (/\bSEK\b|\bDKK\b|\bNOK\b|\bkr\b/.test(t)) return 'SEK';
    if (/HUF|Ft/.test(t)) return 'HUF';
    if (/RON|lei/.test(t)) return 'RON';
    return '';
  }

  // ---------- Scroll ----------
  function isScrollable(el){ if (!el) return false; const cs=getComputedStyle(el); const oy=cs.overflowY||cs.overflow; return /(auto|scroll)/.test(oy) && (el.scrollHeight - el.clientHeight > 100); }
  function findScrollEl(){
    try {
      const prefs = [
        document.querySelector('main#layout__main'),
        document.querySelector('section.layout__extended'), // korrekt
        document.querySelector('.layout__extended'),
        document.querySelector('.timeline'),
        document.scrollingElement,
        document.documentElement,
        document.body
      ];
      for (const el of prefs){
        if (el && isScrollable(el)) return el;
      }
    } catch (e) {
      console.warn('findScrollEl error', e);
    }
    return document.scrollingElement || document.documentElement || document.body;
  }
  function getTop(el){ return (el===document.documentElement||el===document.body||el===document.scrollingElement) ? (window.pageYOffset||document.documentElement.scrollTop||0) : el.scrollTop; }
  function doScroll(el,top){ const isDoc=(el===document.documentElement||el===document.body||el===document.scrollingElement); if (isDoc) window.scrollTo({top,behavior:'smooth'}); else el.scrollTo({top,behavior:'smooth'}); }

  // ---------- DOM ----------
  function getAllEntryNodes(scope){
    const root = scope || document;
    const nodes = Array.from(root.querySelectorAll('ol.timeline__entries li.timeline__entry > div.timelineEventAction[role="button"]'));
    return nodes.filter(n => n.offsetParent !== null && !n.closest('[aria-hidden="true"]'));
  }
  function qAny(container, selectors){ for (const s of selectors){ const n=container.querySelector(s); if (n) return n; } return null; }

  // ---------- Parsing & Klassifizierung ----------
  function classify(name, subtitle, eurTxt){
    const lowerName=(name||'').toLowerCase(), lowerSub=(subtitle||'').toLowerCase();
    const isPlus=/^\s*\+/.test(clean(eurTxt)), isMinus=/^\s*-/.test(clean(eurTxt));
    if (lowerName.includes('einzahlung')) return {category:'Einzahlung',flow:'Einnahme'};
    if (lowerName.includes('auszahlung')) return {category:'Auszahlung',flow:'Ausgabe'};
    if (lowerSub.includes('sparplan')||lowerSub.includes('saveback')||lowerSub.includes('round up')||lowerSub.includes('roundup')) return {category:'Sparplan',flow:'Ausgabe'};
    if (isPlus)  return {category:'Einzahlung',flow:'Einnahme'};
    if (isMinus) return {category:'Kartenzahlung',flow:'Ausgabe'};
    return {category:'Kartenzahlung',flow:'Ausgabe'};
  }

  function parseNode(node){
    const c = node.querySelector('.timelineV2Event') || node;
    const nameEl=qAny(c,['h2[class*="__title"]','h2[title]','h2']);
    const dateEl=qAny(c,['p[class*="__subtitle"]','p']);
    const priceBox=qAny(c,['div[class*="__price"]','div']);
    const majorEl=priceBox?qAny(priceBox,['p:not([class*="subPrice"])','p']):null;
    const fxEl   =priceBox?qAny(priceBox,['p[class*="subPrice"]']):null;

    const name=(nameEl?.getAttribute('title')||txt(nameEl)||'').trim();
    const subtitle=txt(dateEl);
    const dp = dateParts(subtitle) || dateParts(txt(c));
    if (!dp) return null;

    let eurTxt=txt(majorEl); if (!eurTxt && priceBox) eurTxt = txt(priceBox);
    const amountEur = normNumDE(eurTxt);
    if (!name || !amountEur) return null;

    const fxRaw=txt(fxEl)||''; const fxAmount = fxRaw ? normNumDE(fxRaw) : ''; const fxCode = fxRaw ? currencyCodeFrom(fxRaw) : '';

    const {category, flow} = classify(name, subtitle, eurTxt);
    const einnahmen = (flow==='Einnahme') ? amountEur : '';
    const ausgaben  = (flow==='Ausgabe')  ? amountEur : '';

    // Jahr wird später zugewiesen
    return { day:dp.d, month:dp.m, year:null, date:'', name, category, flow, amountEur, einnahmen, ausgaben, fxAmount, fxCode, fxRaw, raw: clean(c.innerText) };
  }

  function dedupe(items){
    const seen=new Set(), out=[];
    for (const it of items){ if (!it) continue;
      const key = `${it.day}.${it.month}|${it.name}|${it.amountEur}|${it.fxAmount}|${it.fxCode}`;
      if(!seen.has(key)){ seen.add(key); out.push(it); }
    }
    return out;
  }

  // ---------- Jahr zuweisen ----------
  function getBaseYear(){
    const user = parseInt(inBaseYear.value,10);
    if (!Number.isNaN(user) && user>1900 && user<3000) return user;
    const from = parseMonthInput(inFrom.value); if (from) return from[0];
    return (new Date()).getFullYear();
  }
  function assignYears(items){
    if (!items.length) return;
    let year = getBaseYear();
    let prevMonth = items[0].month;
    items[0].year = year;
    items[0].date = `${String(items[0].day).padStart(2,'0')}.${String(items[0].month).padStart(2,'0')}.${year}`;
    for (let i=1;i<items.length;i++){
      const m = items[i].month;
      if (m > prevMonth) year -= 1; // neu→alt: Monat springt hoch → Vorjahr
      items[i].year = year;
      items[i].date = `${String(items[i].day).padStart(2,'0')}.${String(m).padStart(2,'0')}.${year}`;
      prevMonth = m;
    }
    const minYear = items.reduce((a,b)=>Math.min(a, b.year||a), year);
    elDbg.textContent = `Startjahr=${getBaseYear()} → minJahr=${minYear}`;
  }

  // ---------- Filter ----------
  function passesCategory(it){
    if (it.category==='Kartenzahlung' && !cbCard.checked) return false;
    if (it.category==='Einzahlung'    && !cbIn.checked)   return false;
    if (it.category==='Auszahlung'    && !cbOut.checked)  return false;
    if (it.category==='Sparplan'      && !cbPlan.checked) return false;
    return true;
  }
  function filterItems(items){
    return items.filter(it => passesCategory(it) && inRangeByMonth(it.date, inFrom.value, inTo.value));
  }

  // ---------- CSV (UTF-16LE) ----------
  function toCSV(items){
    const esc=t=>{ t=t==null?'':String(t); return /[\";\n]/.test(t)?`"${t.replace(/"/g,'""')}"`:t; };
    const header=[
      'Datum (DD.MM.YYYY)','Jahr','Monat',
      'Kategorie','An wen','Betrag EUR','Einnahmen EUR','Ausgaben EUR','FX Betrag','FX Währung','Raw Text'
    ];
    const rows=items.map(it=>[
      esc(it.date), it.year, it.month,
      esc(it.category), esc(it.name), esc(it.amountEur), esc(it.einnahmen), esc(it.ausgaben),
      esc(it.fxAmount), esc(it.fxCode), esc(it.raw)
    ].join(';'));
    return 'sep=;\n'+header.join(';')+'\n'+rows.join('\n');
  }
  function utf16leBlob(str){
    const buf = new Uint8Array(2 + str.length*2); buf[0]=0xFF; buf[1]=0xFE;
    for (let i=0;i<str.length;i++){ const c=str.charCodeAt(i); buf[2+i*2]=c & 0xFF; buf[3+i*2]=c>>8; }
    return new Blob([buf], {type:'text/csv;charset=utf-16le'});
  }

  // ---------- XLSX ----------
  function toXLSXBlob(items){
    const header=[
      'Datum (DD.MM.YYYY)','Jahr','Monat',
      'Kategorie','An wen','Betrag EUR','Einnahmen EUR','Ausgaben EUR','FX Betrag','FX Währung','Raw Text'
    ];
    const rows=items.map(it=>[
      it.date, it.year, it.month,
      it.category, it.name,
      numFromDE(it.amountEur), numFromDE(it.einnahmen), numFromDE(it.ausgaben),
      numFromDE(it.fxAmount), it.fxCode || '', it.raw
    ]);
    const aoa=[header,...rows];
    const wb=XLSX.utils.book_new();
    const ws=XLSX.utils.aoa_to_sheet(aoa);
    ['F','G','H','I'].forEach(col=>{
      for(let r=2;r<=aoa.length;r++){
        const addr=col+r, cell=ws[addr];
        if(cell && typeof cell.v==='number') cell.z='0.00';
      }
    });
    XLSX.utils.book_append_sheet(wb, ws, 'Umsaetze');
    const out=XLSX.write(wb,{bookType:'xlsx',type:'array'});
    return new Blob([out],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
  }

  // ---------- Runner ----------
  let running=false, scrolls=0, scrollEl=null, allItems=[];

  function refreshPreview(){
    assignYears(allItems);
    const filtered = filterItems(allItems);
    elPreview.value = filtered.slice(0,150).map(i =>
      `${i.date} | ${i.year}-${String(i.month).padStart(2,'0')} | ${i.category} | ${i.name} | ${i.amountEur} €${i.fxAmount?` | ${i.fxAmount} ${i.fxCode}`:''}`
    ).join('\n');
    elCount.textContent = `${filtered.length}/${allItems.length}`;
    const disabled = (filtered.length === 0);
    btnExportCsv.disabled = disabled;
    btnExportXlsx.disabled = disabled;
  }

  async function autoScan(maxIdle=14, maxScrolls=2000){
    running=true; btnStart.disabled=true; btnStop.disabled=false; elStatus.textContent='Scanne …';
    scrollEl = findScrollEl();
    let idle=0, lastCount=0, lastTop=-1;

    while (running && scrolls < maxScrolls && idle < maxIdle){
      scrolls++; elScrolls.textContent = String(scrolls);
      const topNow=getTop(scrollEl), maxH=scrollEl.scrollHeight||document.body.scrollHeight, vh=scrollEl.clientHeight||window.innerHeight;
      const nextTop=Math.min(topNow+Math.round(vh*0.9),maxH);
      doScroll(scrollEl,nextTop);
      await sleep(900);
      if (getTop(scrollEl)===topNow){ window.scrollTo({top:nextTop,behavior:'smooth'}); await sleep(500); }

      const nodes = getAllEntryNodes(scrollEl);
      elNodes.textContent = String(nodes.length);

      allItems = dedupe(nodes.map(parseNode).filter(Boolean));
      assignYears(allItems);
      refreshPreview();

      const reachedEnd = getTop(scrollEl) >= ((scrollEl.scrollHeight - (scrollEl.clientHeight || window.innerHeight) - 2));
      if (allItems.length > lastCount) { lastCount = allItems.length; idle = 0; }
      else if (!reachedEnd && (getTop(scrollEl) !== lastTop)) { idle = 0; }
      else { idle++; }
      lastTop = getTop(scrollEl);
      if (reachedEnd) idle++;
    }

    elStatus.textContent='Fertig (keine neuen Daten)';
    btnStart.disabled=false; btnStop.disabled=true;
    refreshPreview();
    running=false;
  }

  function stopScan(){ running=false; elStatus.textContent='Pausiert'; btnStart.disabled=false; btnStop.disabled=true; }

  function downloadCSV(){
    assignYears(allItems);
    const items = filterItems(allItems);
    if (!items.length) return;
    const blob = utf16leBlob(toCSV(items));
    const url = URL.createObjectURL(blob);
    const name = `trade_republic_kreditkarten_umsatz_${new Date().toISOString().slice(0,10)}.csv`;
    try { GM_download?.({url, name, saveAs:true}); } catch { const a=document.createElement('a'); a.href=url; a.download=name; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); }
  }

  async function downloadXLSX(){
    assignYears(allItems);
    const items = filterItems(allItems);
    if (!items.length) return;
    const ok = await ensureXLSX();
    if (!ok || !window.XLSX){ alert('XLSX-Bibliothek konnte nicht geladen werden. Bitte CSV nutzen.'); return; }
    const blob = toXLSXBlob(items);
    const url = URL.createObjectURL(blob);
    const name = `trade_republic_kreditkarten_umsatz_${new Date().toISOString().slice(0,10)}.xlsx`;
    try { GM_download?.({url, name, saveAs:true}); } catch { const a=document.createElement('a'); a.href=url; a.download=name; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); }
  }

  // Events
  btnStart.addEventListener('click', ()=>{ scrolls=0; allItems=[]; elPreview.value=''; elCount.textContent='0/0'; autoScan(); });
  btnStop.addEventListener('click',  stopScan);
  btnExportCsv.addEventListener('click', downloadCSV);
  btnExportXlsx.addEventListener('click', downloadXLSX);
  [inFrom,inTo,inBaseYear,cbCard,cbIn,cbOut,cbPlan].forEach(el => el.addEventListener('input', refreshPreview));
  btnPrevMonth.addEventListener('click', ()=>{ const r=lastMonthRange(); inFrom.value=r.from; inTo.value=r.to; refreshPreview(); });

  // kein Autostart
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址