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');