diff --git a/README.md b/README.md index e0dc83a..246db18 100644 --- a/README.md +++ b/README.md @@ -14,12 +14,29 @@ This repository is intentionally lightweight: no build step, no backend, and no - Verification script for data shape and HTML/script consistency - Architecture notes in [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) -## What This Does Not Claim - -- Not affiliated with EA Sports -- Not an official FC26 database -- Prices, promo cards, and meta notes must be checked against the live game/client -- Data should be treated as a working catalogue, not a canonical market source +## Data, sources & scope + +- **What this is:** an **independent, personal technical demonstration** — a static, build-less + single-page app exploring squad-building, tactics, and a football card catalogue. Its purpose + is to show front-end engineering (no framework, `localStorage` state, data validation, XSS-safe + rendering), not to reproduce or compete with any commercial product. +- **Player attribute data** (names, ratings, clubs, leagues) bundled in `data/*.json` originates + from a **community-compiled public dataset** (`dataSource: "EAFC26-DataHub"`). It is included + here only as a **static working snapshot** for the demo and is **not** presented as an official, + canonical, or up-to-date source. Verify anything against the live game before relying on it. +- **Player portraits are NOT included or fetched.** The app does **not** store, download, or + hotlink any third-party images. Faces are rendered locally as a generated **initials SVG + placeholder**. No request is made to any external image host. +- **No scraping is performed** by this repository. It contains only the static app and its data + snapshot — no data-collection or image-fetching scripts. + +## Independence & trademarks + +- **Not affiliated with, endorsed by, or associated with EA Sports, Electronic Arts, SoFIFA, or + any football league, club, or player.** "EA Sports FC" and related names are trademarks of their + respective owners; any reference is nominative and descriptive only. +- Not an official FC26 database. Prices, promo cards, and meta notes must be checked against the + live game/client. --- diff --git a/assets/fc26-command-center.js b/assets/fc26-command-center.js index 56fccdb..5e8b1a8 100644 --- a/assets/fc26-command-center.js +++ b/assets/fc26-command-center.js @@ -50,14 +50,9 @@ function fc26NormalizeCatalogCard(raw){ club: String(r.club || '').trim(), nation: String(r.nation || '').trim(), league: String(r.league || '').trim(), - face: (() => { - const f = r.face ? String(r.face).trim() : ''; - if (f && /^https?:\/\//i.test(f)) return f; - const id = String(r.id != null ? r.id : '').trim(); - if (!/^\d+$/.test(id)) return ''; - const p6 = id.padStart(6, '0'); - return 'https://cdn.sofifa.net/players/' + p6.slice(0, 3) + '/' + p6.slice(3) + '/26_120.png'; - })(), + // Public-compliance: no third-party portrait URL is derived or stored. + // Faces render as a local initials SVG placeholder (see fc26CatalogFaceImgHtml). + face: '', pace: Math.round(Number(r.pace) || 0), sho: Math.round(Number(r.sho) || 0), pas: Math.round(Number(r.pas) || 0), @@ -113,59 +108,9 @@ function ensureCatalogLoaded(){ return _fc26CatalogPromise; } -/** SofIFA : années 26 / 25 / 24 + tailles 120 / 180 / 60 (certaines têtes n’existent qu’en FC25). */ -function fc26SofifaFaceChainFromUrl(faceUrl){ - const s = String(faceUrl || '').trim(); - const m = s.match(/^(https:\/\/cdn\.sofifa\.net\/players\/\d+\/\d+\/)(\d+)_(\d+)\.png$/i); - if (!m) return s ? [s] : []; - const base = m[1]; - const out = []; - const seen = new Set(); - for (const yr of ['26', '25', '24']) { - for (const sz of ['120', '180', '60']) { - const u = `${base}${yr}_${sz}.png`; - if (!seen.has(u)) { - seen.add(u); - out.push(u); - } - } - } - return out; -} - -/** URL portrait SofIFA standard à partir de l’id EA (6 chiffres → dossiers aaa/bbb). */ -function fc26SofifaDefaultFaceUrlFromPlayerId(pid){ - const id = String(pid != null ? pid : '').trim(); - if (!/^\d+$/.test(id)) return ''; - const p6 = id.padStart(6, '0'); - return 'https://cdn.sofifa.net/players/' + p6.slice(0, 3) + '/' + p6.slice(3) + '/26_120.png'; -} - -function fc26FaceOnError(ev){ - const el = ev && ev.target; - if (!el || el.tagName !== 'IMG') return; - let chain = []; - try { - chain = JSON.parse(decodeURIComponent(el.getAttribute('data-fc-face-chain') || '%5B%5D')); - } catch (_) { - chain = []; - } - let i = parseInt(el.getAttribute('data-fc-face-i') || '0', 10); - i += 1; - if (i < chain.length) { - el.setAttribute('data-fc-face-i', String(i)); - el.src = chain[i]; - return; - } - if (el.dataset.fc26fb === '1') return; - el.dataset.fc26fb = '1'; - const enc = el.getAttribute('data-fcfallback'); - if (!enc) return; - try { - el.removeAttribute('onerror'); - el.src = decodeURIComponent(enc); - } catch (_) {} -} +// Public-compliance: the third-party (SoFIFA) portrait-URL builders and the remote +// image error-chain handler were removed. Portraits are rendered as a local initials +// SVG placeholder (see fc26CatalogFaceImgHtml) — no external image requests are made. function fc26CatalogFaceSvgDataUrl(displayName, w, h){ const W = Math.max(16, Math.round(Number(w) || 48)); @@ -183,42 +128,15 @@ function fc26CatalogFaceSvgDataUrl(displayName, w, h){ } function fc26CatalogFaceImgHtml(faceUrl, w, h, className, fallbackName, playerId){ + // Public-compliance: player portraits are NOT fetched from any third-party CDN. + // A locally-generated initials SVG placeholder is rendered instead — no hotlinking, + // no Referer handling, no external image request. faceUrl/playerId are intentionally ignored. const W = Math.round(Number(w) || 48); const H = Math.round(Number(h) || 48); const ph = fc26CatalogFaceSvgDataUrl(fallbackName, W, H); - const dataFb = encodeURIComponent(ph); - const cls = String(className || '') - .replace(/[<>"']/g, '') - .trim(); - let fu = faceUrl && /^https?:\/\//i.test(String(faceUrl)) ? String(faceUrl).replace(/"/g, '%22').replace(/'/g, '%27') : ''; - if (!fu) { - const derived = fc26SofifaDefaultFaceUrlFromPlayerId(playerId); - if (derived) fu = derived.replace(/"/g, '%22').replace(/'/g, '%27'); - } + const cls = String(className || '').replace(/[<>"']/g, '').trim(); const clsAttr = cls ? ` class="${cls}"` : ''; - if (!fu) { - return ``; - } - let chain = fc26SofifaFaceChainFromUrl(fu); - const sofifaIdUrl = fc26SofifaDefaultFaceUrlFromPlayerId(playerId); - if ( - sofifaIdUrl && - !/^https:\/\/cdn\.sofifa\.net\/players\/\d+\/\d+\/\d+_\d+\.png$/i.test(String(fu).trim()) - ) { - const extra = fc26SofifaFaceChainFromUrl(sofifaIdUrl); - const seen = new Set(chain); - for (const u of extra) { - if (u && !seen.has(u)) { - seen.add(u); - chain.push(u); - } - } - } - const primary = chain[0] || fu; - const chainEnc = encodeURIComponent(JSON.stringify(chain)); - const primaryEsc = primary.replace(/"/g, '%22').replace(/'/g, '%27'); - /** SofIFA CDN : 403 si Referer tiers — ne pas envoyer de référent pour afficher les portraits hors sofifa.com. */ - return ``; + return ``; } function initSquadStateOnce(){ @@ -636,7 +554,7 @@ function initSquadPage(){ statusEl.textContent = 'Catalogue indisponible : ' + (e && e.message) + - ' — exécutez « node scripts/build-fc26-cards-catalog.mjs » dans le dépôt, puis rechargez.'; + ' — vérifiez que data/fc26-cards-catalog.json est bien présent, puis rechargez.'; } if (countEl) countEl.textContent = '0'; renderSquadSlots(); diff --git a/scripts/backfill-sofifa-faces.mjs b/scripts/backfill-sofifa-faces.mjs deleted file mode 100644 index b8771d9..0000000 --- a/scripts/backfill-sofifa-faces.mjs +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env node -/** - * Remplit data/fc26-cards-catalog.json : pour chaque carte sans URL portrait - * valide, définit l’URL canonique cdn.sofifa.net dérivée de l’id joueur (6 chiffres → aaa/bbb). - * Les assets peuvent rester absents côté SofIFA (regens) : l’app enchaîne années / tailles puis initiales. - */ -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const root = path.join(__dirname, '..'); -const catalogPath = path.join(root, 'data', 'fc26-cards-catalog.json'); - -function sofifaFaceUrlFromPlayerId(idRaw) { - const id = String(idRaw != null ? idRaw : '').trim(); - if (!/^\d+$/.test(id)) return ''; - const p6 = id.padStart(6, '0'); - return `https://cdn.sofifa.net/players/${p6.slice(0, 3)}/${p6.slice(3)}/26_120.png`; -} - -const raw = fs.readFileSync(catalogPath, 'utf8'); -const data = JSON.parse(raw); -const cards = data.cards; -if (!Array.isArray(cards)) throw new Error('fc26-cards-catalog.json : tableau cards attendu'); - -let filled = 0; -for (const c of cards) { - const f = c.face != null ? String(c.face).trim() : ''; - if (f && /^https?:\/\//i.test(f)) continue; - const u = sofifaFaceUrlFromPlayerId(c.id); - if (!u) continue; - c.face = u; - filled += 1; -} - -if (filled) { - if (data.meta && typeof data.meta === 'object') { - data.meta.faceNoteFr = - 'Portrait : URL DataHub si présente, sinon URL SofIFA dérivée de l’id (aaa/bbb/26_120.png). Si le fichier n’existe pas (regens, etc.), l’app essaie FC25/FC24 et d’autres tailles puis affiche des initiales.'; - } - fs.writeFileSync(catalogPath, JSON.stringify(data)); - console.error('SofIFA faces :', filled, 'carte(s) complétée(s) ·', catalogPath); -} else { - console.error('SofIFA faces : rien à compléter (déjà renseigné).'); -} diff --git a/scripts/build-fc26-cards-catalog.mjs b/scripts/build-fc26-cards-catalog.mjs deleted file mode 100644 index 5a6d2a8..0000000 --- a/scripts/build-fc26-cards-catalog.mjs +++ /dev/null @@ -1,204 +0,0 @@ -#!/usr/bin/env node -/** - * Télécharge le jeu de données public EAFC26-DataHub (Kaggle / SoFIFA align FC26), - * le convertit au format « cartes » compact pour le créateur d’équipe local. - * - * Source : https://github.com/ismailoksuz/EAFC26-DataHub (fichier data/players.json.gz) - * Licence : vérifier le dépôt upstream ; données dérivées des notes / attributs FC26. - * - * Usage : - * node scripts/build-fc26-cards-catalog.mjs - * node scripts/build-fc26-cards-catalog.mjs --verify-faces - * - * --verify-faces : requête HEAD sur chaque URL SofIFA ; si non 200, champ face vidé - * (affiche les initiales dans l’app au lieu d’une image cassée). ~18k requêtes, ~2–4 min. - * - * Sortie : data/fc26-cards-catalog.json - * - * Compléments manuels (retraités, etc.) : éditer data/fc26-catalog-extra.json — fusionné - * automatiquement par l’app au chargement (pas par ce script). - */ -import fs from 'node:fs'; -import https from 'node:https'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import zlib from 'node:zlib'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const root = path.join(__dirname, '..'); -const outPath = path.join(root, 'data', 'fc26-cards-catalog.json'); -const url = - 'https://raw.githubusercontent.com/ismailoksuz/EAFC26-DataHub/main/data/players.json.gz'; - -const VERIFY_FACES = process.argv.includes('--verify-faces'); -const FACE_CHECK_CONCURRENCY = 24; -const UA = 'Mozilla/5.0 (compatible; FC26-Meta-catalog/1.0; +https://github.com/StrainUS/FC26-Meta)'; - -function fetchBuf(u) { - return new Promise((resolve, reject) => { - https - .get( - u, - { - headers: { 'User-Agent': UA }, - }, - (res) => { - if (res.statusCode && res.statusCode >= 400) { - reject(new Error(`HTTP ${res.statusCode}`)); - return; - } - const chunks = []; - res.on('data', (c) => chunks.push(c)); - res.on('end', () => resolve(Buffer.concat(chunks))); - }, - ) - .on('error', reject); - }); -} - -/** Le dépôt upstream peut contenir `NaN` / `Infinity` (non conformes JSON strict). */ -function parseJsonLenient(buf) { - const s = buf.toString('utf8'); - const fixed = s - .replace(/:\s*NaN\b/g, ': null') - .replace(/:\s*-Infinity\b/g, ': null') - .replace(/:\s*Infinity\b/g, ': null'); - return JSON.parse(fixed); -} - -function headOk(imageUrl) { - return new Promise((resolve) => { - let u; - try { - u = new URL(imageUrl); - } catch { - resolve(false); - return; - } - const opts = { - protocol: u.protocol, - hostname: u.hostname, - port: u.port || undefined, - path: u.pathname + u.search, - method: 'HEAD', - headers: { 'User-Agent': UA, Accept: '*/*' }, - }; - const req = https.request(opts, (res) => { - res.resume(); - resolve(res.statusCode === 200); - }); - req.on('error', () => resolve(false)); - req.setTimeout(15000, () => { - req.destroy(); - resolve(false); - }); - req.end(); - }); -} - -async function verifyFacesOnCards(cards) { - let next = 0; - let removed = 0; - let checked = 0; - const total = cards.filter((c) => c.face && /^https?:\/\//i.test(c.face)).length; - console.error('Vérification des portraits SofIFA…', total, 'URL'); - - async function worker() { - for (;;) { - const i = next++; - if (i >= cards.length) return; - const c = cards[i]; - if (!c.face || !/^https?:\/\//i.test(c.face)) continue; - checked += 1; - const ok = await headOk(c.face); - if (!ok) { - c.face = ''; - removed += 1; - } - if (checked % 800 === 0) { - console.error(' …', checked, '/', total, '· retirés', removed); - } - } - } - - await Promise.all(Array.from({ length: FACE_CHECK_CONCURRENCY }, () => worker())); - console.error('Portraits :', removed, 'URL invalides vidées sur', total, 'testées.'); - return removed; -} - -function sofifaFaceUrlFromPlayerId(idRaw) { - const id = String(idRaw != null ? idRaw : '').trim(); - if (!/^\d+$/.test(id)) return ''; - const p6 = id.padStart(6, '0'); - return `https://cdn.sofifa.net/players/${p6.slice(0, 3)}/${p6.slice(3)}/26_120.png`; -} - -function mapPlayer(p) { - const positions = String(p.player_positions || '') - .split(',') - .map((s) => s.trim()) - .filter(Boolean); - const hubFace = p.player_face_url ? String(p.player_face_url).trim() : ''; - const face = /^https?:\/\//i.test(hubFace) ? hubFace : sofifaFaceUrlFromPlayerId(p.player_id); - return { - id: String(p.player_id), - name: String(p.long_name || p.short_name || '').trim(), - shortName: String(p.short_name || '').trim(), - ovr: Math.round(Number(p.overall) || 0), - pot: Math.round(Number(p.potential) || 0), - age: Math.round(Number(p.age) || 0), - pos: positions[0] || 'CM', - positions, - club: String(p.club_name || '').trim(), - nation: String(p.nationality_name || '').trim(), - league: String(p.league_name || '').trim(), - face, - pace: Math.round(Number(p.pace) || 0), - sho: Math.round(Number(p.shooting) || 0), - pas: Math.round(Number(p.passing) || 0), - dri: Math.round(Number(p.dribbling) || 0), - def: Math.round(Number(p.defending) || 0), - phy: Math.round(Number(p.physic) || 0), - sm: Math.round(Number(p.skill_moves) || 0), - wf: Math.round(Number(p.weak_foot) || 0), - /** Type carte : base roster FC26 (pas TOTY / événements FUT — ce fichier = pool notes FC26). */ - cardType: 'Base FC26', - dataSource: 'EAFC26-DataHub', - }; -} - -async function main() { - fs.mkdirSync(path.dirname(outPath), { recursive: true }); - console.error('Téléchargement…', url); - const gz = await fetchBuf(url); - console.error('Décompression…', gz.length, 'octets gzip'); - const jsonBuf = zlib.gunzipSync(gz); - const arr = parseJsonLenient(jsonBuf); - if (!Array.isArray(arr)) throw new Error('JSON attendu : tableau racine'); - const cards = arr.map(mapPlayer).filter((c) => c.id && c.name); - - let facesRemoved = 0; - if (VERIFY_FACES) { - facesRemoved = await verifyFacesOnCards(cards); - } - - const meta = { - generatedAt: new Date().toISOString(), - count: cards.length, - upstream: url, - noteFr: - 'Pool « notes FC26 » (carrière / base de données joueurs). Les cartes spéciales FUT (TOTY, événements) ne sont pas dans ce fichier — uniquement les joueurs et GES de base.', - faceNoteFr: - 'Les portraits : URL DataHub (player_face_url) si valide, sinon URL SofIFA dérivée de l’id. Sans fichier sur le CDN (regens, etc.), l’app enchaîne FC26/25/24 et plusieurs tailles puis initiales. Rebuild avec --verify-faces pour vider les URL mortes renvoyées par le DataHub.', - ...(VERIFY_FACES ? { facesVerifiedAt: new Date().toISOString(), facesRemovedCount: facesRemoved } : {}), - }; - const payload = { meta, cards }; - fs.writeFileSync(outPath, JSON.stringify(payload), 'utf8'); - const st = fs.statSync(outPath); - console.error('Écrit', outPath, '·', cards.length, 'cartes ·', Math.round(st.size / 1024 / 1024), 'Mo'); -} - -main().catch((e) => { - console.error(e); - process.exit(1); -}); diff --git a/scripts/verify-fc26.mjs b/scripts/verify-fc26.mjs index fefa4c1..06a675f 100644 --- a/scripts/verify-fc26.mjs +++ b/scripts/verify-fc26.mjs @@ -283,7 +283,7 @@ for (const [label, re] of assetRefs) { const catalogPath = path.join(root, 'data', 'fc26-cards-catalog.json'); if (!fs.existsSync(catalogPath)) { - console.warn('VERIFY WARN: data/fc26-cards-catalog.json absent — prototype équipe : node scripts/build-fc26-cards-catalog.mjs'); + console.warn('VERIFY WARN: data/fc26-cards-catalog.json absent — the app expects this static catalogue to be present in the repository.'); } else { try { const raw = fs.readFileSync(catalogPath, 'utf8');