988ee2f50d
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>
245 lines
9.0 KiB
TypeScript
245 lines
9.0 KiB
TypeScript
/**
|
||
* 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); });
|