Files
iddaai-be/src/scripts/render-social-card-v2.ts
T
fahricansecer 988ee2f50d Add backtest pipeline, betting_brain filters, score coherence + social v3
betting_brain.py:
- HARD_MIN_SAMPLES=50 floor for calibrator bypass
- ev_edge < 0 + >= 0.20 hard vetoes
- BTTS muted (grid search found no profitable config)
- Per-market optimal envelopes (MS, OU25)
- Score coherence filter: main_pick must agree with score prediction
- HTFT reversal cross-check for MS picks

feature_builder.py / data_loader.py:
- Real home/away_position from data (was hardcoded 10)
- Cup detection wired into UpsetEngine
- _estimate_league_position with 300-day season filter

New scripts:
- diagnostic_backtest.py: per-bet diagnostic backtest with loss patterns
- optimize_filters.py: grid search per-market optimal thresholds
- analyze_backtest_csv.py: root-cause hypothesis testing on CSV
- compare_backtests.py: side-by-side validation with verdict
- test_score_coherence.py: smoke test for coherence filter (20/20 pass)

Reports:
- diagnostic_backtest_20260525_024437 (50-match smoke)
- diagnostic_backtest_20260525_035649 (1000-match in-sample)
- filter_optimization_patch.json (grid search winners per market)

Social poster v3:
- satori + resvg HTML/CSS rendering pipeline
- Twemoji football/basketball + flag SVGs
- caption SEO: 12 curated hashtags per post
- image SEO: descriptive filenames + .json metadata sidecar
- /health, /preview-png, /run-now endpoints

Docs:
- mds/SESSION_HANDOFF.md: full session state for cross-machine continuity
- mds/SOCIAL_POSTER_SETUP.md: API keys + test commands

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 20:43:28 +03:00

245 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* V2 social card renderer — satori (HTML/CSS → SVG) → resvg (SVG → PNG).
* Modern editorial design. Uses React.createElement (h) form for satori's
* strict layout requirements (every multi-child node needs explicit display).
*/
import satori from "satori";
import { Resvg } from "@resvg/resvg-js";
import * as fs from "fs";
import * as path from "path";
const OUTPUT_DIR = path.join(process.cwd(), "public", "predictions");
const ARIAL = fs.readFileSync("C:/Windows/Fonts/arial.ttf");
const ARIAL_BOLD = fs.readFileSync("C:/Windows/Fonts/arialbd.ttf");
const ARIAL_BLACK = fs.existsSync("C:/Windows/Fonts/ariblk.ttf")
? fs.readFileSync("C:/Windows/Fonts/ariblk.ttf")
: ARIAL_BOLD;
interface Pick { market: string; pick: string; confidence: number; odds: number; }
interface Card {
sport: "football" | "basketball";
leagueName: string;
countryName: string;
matchDate: string;
homeTeam: string;
awayTeam: string;
htScore: string;
ftScore: string;
scoreConfidence: number;
picks: Pick[];
}
// satori expects React-element shape: { type, props: { children, style, ... } }
type N = string | { type: string; props: { style?: any; children?: any } };
const h = (type: string, style: any, ...children: N[]): N => ({
type,
props: { style, children: children.length === 1 ? children[0] : children },
});
function buildCard(c: Card): N {
const isBasket = c.sport === "basketball";
const accent = isBasket ? "#F59E0B" : "#22C55E";
const accentDark = isBasket ? "#92400E" : "#14532D";
const away = "#3B82F6";
const awayDark = "#1E3A8A";
const sportLabel = isBasket ? "BASKETBOL" : "FUTBOL";
const halfLabel = isBasket ? "İLK DEVRE" : "İLK YARI";
const avatar = (name: string, c1: string, c2: string) =>
h("div", {
display: "flex", alignItems: "center", justifyContent: "center",
width: 200, height: 200, borderRadius: 200,
background: `linear-gradient(135deg, ${c1} 0%, ${c2} 100%)`,
fontSize: 120, fontWeight: 900, color: "white",
boxShadow: "0 25px 60px rgba(0,0,0,0.55), inset 0 -8px 0 rgba(0,0,0,0.22)",
}, (name.match(/\p{L}/u)?.[0] || "?").toUpperCase());
const teamColumn = (name: string, c1: string, c2: string) =>
h("div", {
display: "flex", flexDirection: "column", alignItems: "center", width: 300,
},
avatar(name, c1, c2),
h("div", {
display: "flex", marginTop: 22, fontSize: 32, fontWeight: 900,
color: "white", textAlign: "center", lineHeight: 1.05,
maxWidth: 300, justifyContent: "center",
}, name),
);
const scoreBlock = h("div", {
display: "flex", flexDirection: "column", alignItems: "center",
},
h("div", {
display: "flex", fontSize: 14, color: "rgba(255,255,255,0.4)",
letterSpacing: 4, fontWeight: 700, marginBottom: 8,
}, "MAÇ SONU"),
h("div", {
display: "flex", fontSize: 110, fontWeight: 900, color: "white",
lineHeight: 1, letterSpacing: -4,
}, c.ftScore),
h("div", {
display: "flex", marginTop: 16, fontSize: 18, color: accent,
fontWeight: 800, background: `${accent}22`, padding: "8px 16px",
borderRadius: 10, letterSpacing: 1,
}, `${halfLabel} ${c.htScore}`),
);
const pickRow = (p: Pick, idx: number) =>
h("div", {
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "20px 26px", marginBottom: 12,
background: "rgba(255,255,255,0.045)",
borderLeft: `5px solid ${accent}`,
borderRadius: 12,
},
h("div", { display: "flex", alignItems: "center", flexGrow: 1 },
h("div", {
display: "flex", justifyContent: "center", alignItems: "center",
width: 44, height: 44, marginRight: 18, borderRadius: 8,
background: `${accent}22`, color: accent, fontSize: 24, fontWeight: 900,
}, String(idx + 1)),
h("div", { display: "flex", flexDirection: "column" },
h("div", { display: "flex", fontSize: 28, fontWeight: 800, color: "white", lineHeight: 1.1 }, p.market),
h("div", { display: "flex", marginTop: 4, fontSize: 18, color: "rgba(255,255,255,0.5)" }, `Oran ${p.odds.toFixed(2)}`),
),
),
h("div", {
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 38, fontWeight: 900, color: accent,
background: `${accent}15`,
padding: "6px 18px", borderRadius: 10,
border: `1px solid ${accent}`,
}, `%${p.confidence}`),
);
return h("div", {
width: 1080, height: 1080,
display: "flex", flexDirection: "column",
padding: "50px 60px",
background: "linear-gradient(180deg, #0F172A 0%, #020617 100%)",
color: "white",
fontFamily: "Arial",
},
// HEADER
h("div", {
display: "flex", alignItems: "center", justifyContent: "space-between",
marginBottom: 36,
},
h("div", { display: "flex", alignItems: "center" },
h("div", {
display: "flex", width: 8, height: 44, background: accent,
marginRight: 16, borderRadius: 4,
}),
h("div", { display: "flex", flexDirection: "column" },
h("div", {
display: "flex", fontSize: 14, color: "rgba(255,255,255,0.5)",
letterSpacing: 3, fontWeight: 700,
}, `${sportLabel} · ${c.countryName.toUpperCase()}`),
h("div", {
display: "flex", marginTop: 2, fontSize: 26, fontWeight: 900, color: "white",
}, c.leagueName),
),
),
h("div", {
display: "flex", fontSize: 16, color: "rgba(255,255,255,0.6)",
background: "rgba(255,255,255,0.05)",
padding: "10px 16px", borderRadius: 10,
border: "1px solid rgba(255,255,255,0.1)", fontWeight: 600,
}, c.matchDate),
),
// TEAMS + SCORE
h("div", {
display: "flex", alignItems: "center", justifyContent: "space-between",
marginBottom: 30, paddingLeft: 10, paddingRight: 10,
},
teamColumn(c.homeTeam, accent, accentDark),
scoreBlock,
teamColumn(c.awayTeam, away, awayDark),
),
// PICKS TITLE
h("div", {
display: "flex", alignItems: "center", justifyContent: "space-between",
marginBottom: 16, paddingLeft: 6, paddingRight: 6,
},
h("div", { display: "flex", fontSize: 22, fontWeight: 900, color: "white", letterSpacing: 2 }, "EN İYİ 3 TAHMİN"),
h("div", { display: "flex", alignItems: "center" },
h("div", { display: "flex", fontSize: 14, color: "rgba(255,255,255,0.55)", fontWeight: 700, marginRight: 8 }, "SKOR GÜVENİ"),
h("div", { display: "flex", fontSize: 22, fontWeight: 900, color: accent }, `%${c.scoreConfidence}`),
),
),
// PICKS LIST
h("div", { display: "flex", flexDirection: "column" },
...c.picks.slice(0, 3).map((p, i) => pickRow(p, i)),
),
// FOOTER
h("div", {
display: "flex", marginTop: "auto", alignItems: "center", justifyContent: "space-between",
paddingTop: 22, borderTop: "1px solid rgba(255,255,255,0.08)",
},
h("div", { display: "flex", fontSize: 16, color: "rgba(255,255,255,0.45)", fontWeight: 600 }, "AI Destekli Maç Tahmini · iddaai"),
h("div", { display: "flex", fontSize: 20, color: "white", fontWeight: 900, letterSpacing: 2 }, "iddaai.com"),
),
);
}
async function renderCard(c: Card, name: string) {
const svg = await satori(buildCard(c) as any, {
width: 1080,
height: 1080,
fonts: [
{ name: "Arial", data: ARIAL, weight: 400, style: "normal" },
{ name: "Arial", data: ARIAL_BOLD, weight: 700, style: "normal" },
{ name: "Arial", data: ARIAL_BLACK, weight: 900, style: "normal" },
],
});
const png = new Resvg(svg).render().asPng();
const out = path.join(OUTPUT_DIR, `v2-${name}.png`);
fs.writeFileSync(out, png);
console.log(`${out}`);
}
async function main() {
if (!fs.existsSync(OUTPUT_DIR)) fs.mkdirSync(OUTPUT_DIR, { recursive: true });
await renderCard({
sport: "basketball",
leagueName: "Şampiyonlar Ligi",
countryName: "Avrupa",
matchDate: "25 May 2026, 21:00",
homeTeam: "Unicaja Malaga",
awayTeam: "AEK",
htScore: "40-35",
ftScore: "88-75",
scoreConfidence: 90,
picks: [
{ market: "Toplam Sayı Üst 160.5", pick: "Üst", confidence: 85, odds: 1.85 },
{ market: "Ev Sahibi Toplam Üst 82.5", pick: "Üst", confidence: 83, odds: 1.78 },
{ market: "Handikap Ev Sahibi -5.5", pick: "1", confidence: 78, odds: 1.92 },
],
}, "basket-unicaja-aek");
await renderCard({
sport: "football",
leagueName: "Süper Lig",
countryName: "Türkiye",
matchDate: "26 May 2026, 20:00",
homeTeam: "Galatasaray",
awayTeam: "Fenerbahçe",
htScore: "1-0",
ftScore: "2-1",
scoreConfidence: 72,
picks: [
{ market: "Maç Sonucu - Ev Sahibi", pick: "1", confidence: 68, odds: 2.15 },
{ market: "Üst 2.5 Gol", pick: "Üst", confidence: 61, odds: 1.92 },
{ market: "Karşılıklı Gol", pick: "Var", confidence: 58, odds: 1.75 },
],
}, "football-gs-fb");
}
main().catch((e) => { console.error(e); process.exit(1); });