MZ - Exportar Transferências (CSV)

Exporta jogadores no mercado (todas as páginas) em CSV com atributos e metadados. Robusto a temas/idiomas, ignora Forma.

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         MZ - Exportar Transferências (CSV)
// @namespace    MZTools
// @version      0.1.0
// @description  Exporta jogadores no mercado (todas as páginas) em CSV com atributos e metadados. Robusto a temas/idiomas, ignora Forma.
// @author       MZ Tools
// @match        *://*.managerzone.com/*
// @icon         https://www.managerzone.com/favicon.ico
// @run-at       document-idle
// @grant        none
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  /** ==========================
   *  CONFIG / LABELS / HELPERS
   *  ==========================
   */

  // Mapeamento de labels dos atributos → chave no CSV
  // Aceita variações em PT/EN/ES e ignora maiúsc/minúsc.
  const ATTR_LABELS = {
    // Físicos / mentais (ajuste se seu idioma estiver diferente)
    'velocidade': 'Velocidade',
    'speed': 'Velocidade',
    'resist': 'Resistência',
    'stamina': 'Resistência',
    'intelig': 'Inteligência',
    'intelligence': 'Inteligência',
    'passe': 'Passe',
    'passing': 'Passe',
    'chute': 'Chute',
    'shooting': 'Chute',
    'cabece': 'Cabeceio',
    'heading': 'Cabeceio',
    'controle': 'Controle de Bola',
    'ball control': 'Controle de Bola',
    'marcação': 'Marcação',
    'marking': 'Marcação',
    'posicion': 'Posicionamento',
    'positioning': 'Posicionamento',
    'tackle': 'Desarme',
    'desarme': 'Desarme',
    'corrida com a bola': 'Corrida com Bola',
    'dribbling': 'Corrida com Bola',
    'finalização': 'Finalização',
    'finishing': 'Finalização',
    'cruzamento': 'Cruzamento',
    'crossing': 'Cruzamento',
    'experi': 'Experiência',
    'experience': 'Experiência',
    // goleiro (se aparecer nos cards)
    'goleiro': 'Goleiro',
    'keeper': 'Goleiro'
  };

  // Labels para estrelas (alto1, baixo2) e velocidade de treino
  const STAR_LABELS = {
    // “alto1” e “baixo2” são nomes de categorias no seu fluxo; aqui salvamos como colunas
    alto1: ['alto', 'high', 'alto1'],
    baixo2: ['baixo', 'low', 'baixo2'],
    treino: ['velocidade de treino', 'training speed', 'velocidad de entrenamiento']
  };

  // Funções auxiliares
  const $ = (sel, root = document) => root.querySelector(sel);
  const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
  const normText = (t) => (t || '').replace(/\s+/g, ' ').trim();
  const onlyDigits = (t) => (t || '').replace(/[^\d]/g, '');
  const toNumber = (t) => {
    if (t == null) return null;
    const n = String(t).replace(/[^\d.,-]/g, '').replace(/\./g, '').replace(',', '.');
    const v = parseFloat(n);
    return Number.isFinite(v) ? v : null;
  };

  // CSV safe
  const csvCell = (v) => {
    if (v == null) return '';
    const s = String(v);
    return /[;"\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
  };

  // Tenta ler número de "bolas" (total de pontos/níveis) exibidos no card
  function parseTotalBolas(container) {
    // Heurística 1: badges/bolinhas como <span class="ball ..."> repetidas
    const balls = $$('.ball', container);
    if (balls.length) return balls.length;

    // Heurística 2: texto tipo "Total de bolas: 123"
    const byText = $$('.player-attr, .small, .tiny, .text-muted', container)
      .map(el => normText(el.textContent))
      .find(t => /total.*bolas|balls total|total.*pontos/i.test(t));
    if (byText) {
      const n = toNumber(onlyDigits(byText));
      if (n) return n;
    }
    return '';
  }

  // Conta estrelas num bloco (checa <i class="fa-star">, <img alt="*">, etc.)
  function countStarsIn(node) {
    if (!node) return '';
    const starLike = $$('i[class*="star"], svg[class*="star"], img[alt*="star"], img[src*="star"]', node);
    if (starLike.length) {
      // Se houver half-stars, ajuste aqui (ex.: class*="half"); por ora, contamos inteiras.
      return starLike.length;
    }
    // Fallback: procura “★”
    const txt = normText(node.textContent);
    const stars = (txt.match(/★/g) || []).length;
    return stars || '';
    }

  // Encontra um bloco próximo cujo label contem determinado termo
  function findBlockByLabel(root, labelCandidates) {
    const nodes = $$('*', root).filter(el => {
      const t = normText(el.textContent).toLowerCase();
      return labelCandidates.some(lab => t.includes(lab));
    });
    return nodes[0] || null;
  }

  // Lê “alto/baixo” determinante a partir de rótulos contendo (1) ou (2)
  function parseAltoBaixoDeterminate(attrList) {
    // attrList: array de {label, value, rawLabel}
    // Regra: se o label contém "1", determinante = 'alto1'; se contém "2", 'baixo2'.
    // Salva também o NOME do atributo que carregou o 1 ou 2.
    let det = '';
    let atributo = '';
    for (const row of attrList) {
      const raw = (row.rawLabel || '').toLowerCase();
      if (/\b1\b|\(1\)/.test(raw)) { det = '1'; atributo = row.label; break; }
      if (/\b2\b|\(2\)/.test(raw)) { det = '2'; atributo = row.label; break; }
    }
    return { alto_baixo: det, atributo_determinante: atributo };
  }

  // Extrai ID do jogador a partir de links
  function extractPlayerId(container) {
    const a = $('a[href*="player_id="]', container) || $('a[href*="players&"]', container);
    if (!a) return '';
    const m = a.href.match(/player_id=(\d+)/);
    return m ? m[1] : '';
  }

  // Extrai nome (a partir do link do jogador ou título do card)
  function extractName(container) {
    const a = $('a[href*="player_id="]', container);
    if (a && a.textContent) return normText(a.textContent);
    // fallback: o primeiro H-tag com texto
    const h = $('h3, h4, .player-name, .name', container);
    return h ? normText(h.textContent) : '';
  }

  // Extrai idade e temporada de nascimento a partir de textos frequentes no card
  function extractAgeAndSeason(container) {
    const textNodes = $$('.small, .tiny, .text-muted, .player-meta, .details', container)
      .map(el => normText(el.textContent));
    let idade = '';
    let temporada = '';
    for (const t of textNodes) {
      // Exemplos: "Idade: 23 (Nasc.: temp 95)", "Age: 23 (Born: season 95)"
      const mAge = t.match(/idade[:\s]*?(\d{1,2})|age[:\s]*?(\d{1,2})/i);
      if (mAge) idade = mAge[1] || mAge[2] || idade;

      const mSeason = t.match(/temp(?:orad[ao])?[:\s]*?(\d{2,3})|season[:\s]*?(\d{2,3})/i);
      if (mSeason) temporada = mSeason[1] || mSeason[2] || temporada;
    }
    return { idade, temporada_nascimento: temporada };
  }

  // Extrai prazo final (deadline) e valor solicitado
  function extractDeadlineAndPrice(container) {
    const txts = $$('*', container).map(el => normText(el.textContent));
    // Prazo final
    const tPrazo = txts.find(t => /prazo|deadline|fecha.*em|ends/i.test(t)) || '';
    // ex.: "Prazo final: 2025-09-30 18:00", "Deadline: in 3h 14m", etc.
    let prazo_final = '';
    const mDate = tPrazo.match(/\b(\d{4}-\d{2}-\d{2}.*?)$/) || tPrazo.match(/(\d{1,2}[:h]\d{2}.*)/);
    if (mDate) prazo_final = mDate[1];

    // Valor solicitado
    const tPreco = txts.find(t => /valor|preço|asking|ask/i.test(t)) || '';
    // números com milhar/ponto e decimal/vírgula
    const mMoney = tPreco.match(/([\d\.\,]+)/);
    const valor_solicitado = mMoney ? mMoney[1] : '';

    return { prazo_final, valor_solicitado };
  }

  // Extrai tabela/lista de atributos do card
  function extractAttributes(container) {
    const rows = [];
    // Pega linhas que parecem "Label: valor" ou estão lado a lado
    const possibleRows = $$('li, .row, .attr, .attribute, tr', container);

    for (const r of possibleRows) {
      const t = normText(r.textContent).toLowerCase();
      // encontre um label conhecido
      const key = Object.keys(ATTR_LABELS).find(k => t.includes(k));
      if (!key) continue;

      // label “cru” para detecção de (1)/(2)
      const rawLabelNode = $('*', r) || r;
      const rawLabel = normText(rawLabelNode.textContent);

      // tenta achar número do atributo no mesmo bloco
      let val = '';
      // 1) números explícitos
      const m = rawLabel.match(/(\d{1,2})\s*$/) || normText(r.textContent).match(/(\d{1,2})\s*$/);
      if (m) val = m[1];

      // 2) bolinhas (nível visual)
      if (!val) {
        const dots = $$('i[class*="dot"], span[class*="dot"], .ball', r).length;
        if (dots) val = String(dots);
      }

      rows.push({
        label: ATTR_LABELS[key],
        value: val,
        rawLabel: rawLabel
      });
    }

    // Consolida num objeto {Atributo: valor}
    const out = {};
    for (const row of rows) {
      out[row.label] = row.value;
    }

    // Determina alto/baixo e atributo determinante
    const det = parseAltoBaixoDeterminate(rows);

    return { attrs: out, ...det };
  }

  // Extrai estrelas para alto1, baixo2, velocidade de treino
  function extractStars(container) {
    const findAndCount = (labels) => {
      const node = findBlockByLabel(container, labels);
      return countStarsIn(node);
    };
    return {
      estrelas_alto1: findAndCount(STAR_LABELS.alto1.map(s => s.toLowerCase())),
      estrelas_baixo2: findAndCount(STAR_LABELS.baixo2.map(s => s.toLowerCase())),
      estrelas_vel_treino: findAndCount(STAR_LABELS.treino.map(s => s.toLowerCase()))
    };
  }

  // Seleciona “cards/linhas” de jogadores nos resultados
  function selectPlayerCards() {
    // Heurísticas comuns:
    // - tabela: linhas com link ?player_id=...
    // - cards: divs com link do jogador
    const byTable = $$('tr').filter(tr => $('a[href*="player_id="]', tr));
    if (byTable.length) return byTable;

    const byCard = $$('div, li').filter(div => $('a[href*="player_id="]', div));
    return byCard;
  }

  // Extrai 1 jogador a partir de um card/linha
  function scrapeOne(container) {
    const id = extractPlayerId(container);
    const nome = extractName(container);
    const { idade, temporada_nascimento } = extractAgeAndSeason(container);
    const total_bolas = parseTotalBolas(container);
    const { prazo_final, valor_solicitado } = extractDeadlineAndPrice(container);
    const { attrs, alto_baixo, atributo_determinante } = extractAttributes(container);
    const { estrelas_alto1, estrelas_baixo2, estrelas_vel_treino } = extractStars(container);

    return {
      ID: id,
      Nome: nome,
      Idade: idade,
      'Temporada Nascimento': temporada_nascimento,
      'Total de Bolas': total_bolas,
      'Prazo Final': prazo_final,
      'Valor Solicitado': valor_solicitado,
      // Atributos (ignorando Forma)
      Velocidade: attrs['Velocidade'] || '',
      'Resistência': attrs['Resistência'] || '',
      Inteligência: attrs['Inteligência'] || '',
      Passe: attrs['Passe'] || '',
      Chute: attrs['Chute'] || '',
      Cabeceio: attrs['Cabeceio'] || '',
      'Controle de Bola': attrs['Controle de Bola'] || '',
      Marcação: attrs['Marcação'] || '',
      Posicionamento: attrs['Posicionamento'] || '',
      Desarme: attrs['Desarme'] || '',
      'Corrida com Bola': attrs['Corrida com Bola'] || '',
      Finalização: attrs['Finalização'] || '',
      Cruzamento: attrs['Cruzamento'] || '',
      Experiência: attrs['Experiência'] || '',
      // Estrelas
      'Estrelas Alto1': estrelas_alto1,
      'Estrelas Baixo2': estrelas_baixo2,
      'Velocidade de Treinamento (★)': estrelas_vel_treino,
      // Determinante alto/baixo
      'Alto/Baixo (1/2)': alto_baixo,
      'Atributo Determinante': atributo_determinante
    };
  }

  // Constrói CSV com separador ';'
  function toCSV(rows) {
    const headers = [
      'ID','Nome','Idade','Temporada Nascimento','Total de Bolas','Prazo Final','Valor Solicitado',
      'Velocidade','Resistência','Inteligência','Passe','Chute','Cabeceio','Controle de Bola','Marcação',
      'Posicionamento','Desarme','Corrida com Bola','Finalização','Cruzamento','Experiência',
      'Estrelas Alto1','Estrelas Baixo2','Velocidade de Treinamento (★)',
      'Alto/Baixo (1/2)','Atributo Determinante'
    ];

    const lines = [
      headers.map(csvCell).join(';'),
      ...rows.map(r => headers.map(h => csvCell(r[h])).join(';'))
    ];
    return lines.join('\n');
  }

  // Dispara download
  function download(name, text) {
    const blob = new Blob([text], { type: 'text/csv;charset=utf-8' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = name;
    document.body.appendChild(a);
    a.click();
    setTimeout(() => {
      URL.revokeObjectURL(url);
      a.remove();
    }, 250);
  }

  /** ==========================
   *  SCRAPER (PÁGINA ATUAL)
   *  ==========================
   */
  function scrapeCurrentPage() {
    const cards = selectPlayerCards();
    const rows = [];
    for (const c of cards) {
      try {
        const row = scrapeOne(c);
        if (row.ID || row.Nome) rows.push(row);
      } catch (e) {
        // silencioso, segue o baile
      }
    }
    return rows;
  }

  /** ==========================
   *  PAGINAÇÃO (TODAS AS PÁGINAS)
   *  ==========================
   *
   *  Estratégia: usamos ajaxSearchOffset se existir; caso não exista no escopo,
   *  simulamos cliques nos links de paginação que tenham offset.
   */
  async function scrapeAllPages() {
    const aggregate = [];
    const seen = new Set();

    // Se a função global existir, iteramos offsets razoáveis (até 1000 registros)
    const ajaxFn = window.ajaxSearchOffset;
    if (typeof ajaxFn === 'function') {
      // Detecta quantos offsets existem na paginação atual (lê links)
      const getOffsetsFromDOM = () =>
        $$('a[href*="offset="], a.page-link, .pagination a')
          .map(a => {
            const m = (a.getAttribute('onclick') || '').match(/ajaxSearchOffset\((\d+)\)/) ||
                      (a.href || '').match(/offset=(\d+)/);
            return m ? parseInt(m[1], 10) : null;
          })
          .filter(v => Number.isInteger(v));

      const offsets = Array.from(new Set([0, ...getOffsetsFromDOM()])).sort((a,b)=>a-b);
      for (const off of offsets) {
        await goOffset(off);
        await waitForResultsMutation();
        const pageRows = scrapeCurrentPage();
        for (const r of pageRows) {
          const key = r.ID || r.Nome + '|' + r['Prazo Final'] + '|' + r['Valor Solicitado'];
          if (!seen.has(key)) {
            seen.add(key);
            aggregate.push(r);
          }
        }
      }
      return aggregate;
    }

    // Fallback: sem ajaxSearchOffset — tentamos clicar nos links de paginação
    const pagerLinks = $$('a[href*="offset="], a.page-link, .pagination a');
    if (!pagerLinks.length) {
      // Sem paginador → só atual
      return scrapeCurrentPage();
    }

    // Primeira página
    aggregate.push(...scrapeCurrentPage());
    // Demais páginas (abrindo em AJAX se for SPA, ou navegando)
    for (const a of pagerLinks) {
      if (a.click) a.click();
      await waitForResultsMutation();
      aggregate.push(...scrapeCurrentPage());
    }
    return dedupe(aggregate);

    function dedupe(arr) {
      const out = [];
      const s = new Set();
      for (const r of arr) {
        const key = r.ID || r.Nome + '|' + r['Prazo Final'] + '|' + r['Valor Solicitado'];
        if (!s.has(key)) {
          s.add(key);
          out.push(r);
        }
      }
      return out;
    }

    function waitForResultsMutation() {
      return new Promise(resolve => {
        const container = document.body;
        const obs = new MutationObserver((muts) => {
          // heurística: quando houver muitos links de players, deve ter carregado
          const count = $$('a[href*="player_id="]').length;
          if (count > 0) {
            obs.disconnect();
            setTimeout(resolve, 100); // pequeno buffer
          }
        });
        obs.observe(container, { childList: true, subtree: true });
        // timeout de segurança
        setTimeout(() => { obs.disconnect(); resolve(); }, 4000);
      });
    }

    async function goOffset(n) {
      try {
        const ret = ajaxFn(n);
        // Se ajaxFn não retornar Promise, aguarda um pequeno tempo
        if (ret && typeof ret.then === 'function') await ret;
        await new Promise(r => setTimeout(r, 200));
      } catch (e) {
        await new Promise(r => setTimeout(r, 300));
      }
    }
  }

  /** ==========================
   *  UI (PAINEL FLUTUANTE)
   *  ==========================
   */
  function injectPanel() {
    if ($('#mz-export-panel')) return;
    const panel = document.createElement('div');
    panel.id = 'mz-export-panel';
    panel.innerHTML = `
      <div style="
        position: fixed; right: 14px; bottom: 14px; z-index: 99999;
        background: rgba(15, 17, 23, 0.9); color: #fff; padding: 12px 14px;
        border-radius: 12px; box-shadow: 0 6px 18px rgba(0,0,0,.25); font: 13px/1.3 system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, sans-serif;">
        <div style="font-weight:600;margin-bottom:8px;">MZ • Exportar Transferências</div>
        <div style="display:flex; gap:6px; flex-wrap:wrap; margin-bottom:8px;">
          <button id="mz-btn-page" style="padding:6px 10px;border-radius:8px;border:none;cursor:pointer;">Coletar (página)</button>
          <button id="mz-btn-all" style="padding:6px 10px;border-radius:8px;border:none;cursor:pointer;">Coletar (todas páginas)</button>
          <button id="mz-btn-dl" style="padding:6px 10px;border-radius:8px;border:none;cursor:pointer;" disabled>Baixar CSV</button>
        </div>
        <div id="mz-status" style="opacity:.85">Aguardando…</div>
      </div>
    `;
    document.body.appendChild(panel);

    let buffer = [];

    $('#mz-btn-page').addEventListener('click', () => {
      try {
        const rows = scrapeCurrentPage();
        buffer = rows;
        $('#mz-status').textContent = `Coletados (página): ${rows.length}`;
        $('#mz-btn-dl').disabled = rows.length === 0;
      } catch (e) {
        $('#mz-status').textContent = `Erro: ${e.message || e}`;
      }
    });

    $('#mz-btn-all').addEventListener('click', async () => {
      $('#mz-status').textContent = 'Coletando todas as páginas…';
      try {
        buffer = await scrapeAllPages();
        $('#mz-status').textContent = `Coletados (todas páginas): ${buffer.length}`;
        $('#mz-btn-dl').disabled = buffer.length === 0;
      } catch (e) {
        $('#mz-status').textContent = `Erro: ${e.message || e}`;
      }
    });

    $('#mz-btn-dl').addEventListener('click', () => {
      if (!buffer.length) return;
      const csv = toCSV(buffer);
      const stamp = new Date().toISOString().slice(0,19).replace(/[:T]/g,'-');
      download(`mz_transferencias_${stamp}.csv`, csv);
    });
  }

  /** ==========================
   *  BOOT
   *  ==========================
   */
  function isTransferPage() {
    const href = location.href;
    return /[?&]p=transfer(&|$)/i.test(href) || /\/transfer/i.test(href);
  }

  function boot() {
    if (!isTransferPage()) return;
    injectPanel();
  }

  // Espera DOM
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', boot);
  } else {
    boot();
  }
})();