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>
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><circle fill="#F4900C" cx="18" cy="18" r="18"/><path fill="#231F20" d="M36 17h-8.981c.188-5.506 1.943-9.295 4.784-10.546-.445-.531-.926-1.027-1.428-1.504-2.83 1.578-5.145 5.273-5.354 12.049H19V0h-2v17h-6.021c-.208-6.776-2.523-10.471-5.353-12.049-.502.476-.984.972-1.428 1.503C7.039 7.705 8.793 11.494 8.981 17H0v2h8.981c-.188 5.506-1.942 9.295-4.783 10.546.445.531.926 1.027 1.428 1.504 2.831-1.578 5.145-5.273 5.353-12.05H17v17h2V19h6.021c.209 6.776 2.523 10.471 5.354 12.05.502-.476.984-.973 1.428-1.504-2.841-1.251-4.595-5.04-4.784-10.546H36v-2z"/></svg>
|
||||
|
After Width: | Height: | Size: 617 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#FFCD05" d="M0 27c0 2.209 1.791 4 4 4h28c2.209 0 4-1.791 4-4v-4H0v4z"/><path fill="#ED1F24" d="M0 14h36v9H0z"/><path fill="#141414" d="M32 5H4C1.791 5 0 6.791 0 9v5h36V9c0-2.209-1.791-4-4-4z"/></svg>
|
||||
|
After Width: | Height: | Size: 271 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#C60A1D" d="M36 27c0 2.209-1.791 4-4 4H4c-2.209 0-4-1.791-4-4V9c0-2.209 1.791-4 4-4h28c2.209 0 4 1.791 4 4v18z"/><path fill="#FFC400" d="M0 12h36v12H0z"/><path fill="#EA596E" d="M9 17v3c0 1.657 1.343 3 3 3s3-1.343 3-3v-3H9z"/><path fill="#F4A2B2" d="M12 16h3v3h-3z"/><path fill="#DD2E44" d="M9 16h3v3H9z"/><ellipse fill="#EA596E" cx="12" cy="14.5" rx="3" ry="1.5"/><ellipse fill="#FFAC33" cx="12" cy="13.75" rx="3" ry=".75"/><path fill="#99AAB5" d="M7 16h1v7H7zm9 0h1v7h-1z"/><path fill="#66757F" d="M6 22h3v1H6zm9 0h3v1h-3zm-8-7h1v1H7zm9 0h1v1h-1z"/></svg>
|
||||
|
After Width: | Height: | Size: 629 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#039" d="M32 5H4C1.791 5 0 6.791 0 9v18c0 2.209 1.791 4 4 4h28c2.209 0 4-1.791 4-4V9c0-2.209-1.791-4-4-4z"/><path d="M18.539 9.705l.849-.617h-1.049l-.325-.998-.324.998h-1.049l.849.617-.325.998.849-.617.849.617zm0 17.333l.849-.617h-1.049l-.325-.998-.324.998h-1.049l.849.617-.325.998.849-.617.849.617zm-8.666-8.667l.849-.617h-1.05l-.324-.998-.325.998H7.974l.849.617-.324.998.849-.617.849.617zm1.107-4.285l.849-.617h-1.05l-.324-.998-.324.998h-1.05l.849.617-.324.998.849-.617.849.617zm0 8.619l.849-.617h-1.05l-.324-.998-.324.998h-1.05l.849.617-.324.998.849-.617.849.617zm3.226-11.839l.849-.617h-1.05l-.324-.998-.324.998h-1.05l.849.617-.324.998.849-.617.849.617zm0 15.067l.849-.617h-1.05l-.324-.998-.324.998h-1.05l.849.617-.324.998.849-.616.849.616zm11.921-7.562l-.849-.617h1.05l.324-.998.325.998h1.049l-.849.617.324.998-.849-.617-.849.617zm-1.107-4.285l-.849-.617h1.05l.324-.998.324.998h1.05l-.849.617.324.998-.849-.617-.849.617zm0 8.619l-.849-.617h1.05l.324-.998.324.998h1.05l-.849.617.324.998-.849-.617-.849.617zm-3.226-11.839l-.849-.617h1.05l.324-.998.324.998h1.05l-.849.617.324.998-.849-.617-.849.617zm0 15.067l-.849-.617h1.05l.324-.998.324.998h1.05l-.849.617.324.998-.849-.616-.849.616z" fill="#FC0"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#ED2939" d="M36 27c0 2.209-1.791 4-4 4h-8V5h8c2.209 0 4 1.791 4 4v18z"/><path fill="#002495" d="M4 5C1.791 5 0 6.791 0 9v18c0 2.209 1.791 4 4 4h8V5H4z"/><path fill="#EEE" d="M12 5h12v26H12z"/></svg>
|
||||
|
After Width: | Height: | Size: 270 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#00247D" d="M0 9.059V13h5.628zM4.664 31H13v-5.837zM23 25.164V31h8.335zM0 23v3.941L5.63 23zM31.337 5H23v5.837zM36 26.942V23h-5.631zM36 13V9.059L30.371 13zM13 5H4.664L13 10.837z"/><path fill="#CF1B2B" d="M25.14 23l9.712 6.801c.471-.479.808-1.082.99-1.749L28.627 23H25.14zM13 23h-2.141l-9.711 6.8c.521.53 1.189.909 1.938 1.085L13 23.943V23zm10-10h2.141l9.711-6.8c-.521-.53-1.188-.909-1.937-1.085L23 12.057V13zm-12.141 0L1.148 6.2C.677 6.68.34 7.282.157 7.949L7.372 13h3.487z"/><path fill="#EEE" d="M36 21H21v10h2v-5.836L31.335 31H32c1.117 0 2.126-.461 2.852-1.199L25.14 23h3.487l7.215 5.052c.093-.337.158-.686.158-1.052v-.058L30.369 23H36v-2zM0 21v2h5.63L0 26.941V27c0 1.091.439 2.078 1.148 2.8l9.711-6.8H13v.943l-9.914 6.941c.294.07.598.116.914.116h.664L13 25.163V31h2V21H0zM36 9c0-1.091-.439-2.078-1.148-2.8L25.141 13H23v-.943l9.915-6.942C32.62 5.046 32.316 5 32 5h-.663L23 10.837V5h-2v10h15v-2h-5.629L36 9.059V9zM13 5v5.837L4.664 5H4c-1.118 0-2.126.461-2.852 1.2l9.711 6.8H7.372L.157 7.949C.065 8.286 0 8.634 0 9v.059L5.628 13H0v2h15V5h-2z"/><path fill="#CF1B2B" d="M21 15V5h-6v10H0v6h15v10h6V21h15v-6z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#CE2B37" d="M36 27c0 2.209-1.791 4-4 4h-8V5h8c2.209 0 4 1.791 4 4v18z"/><path fill="#009246" d="M4 5C1.791 5 0 6.791 0 9v18c0 2.209 1.791 4 4 4h8V5H4z"/><path fill="#EEE" d="M12 5h12v26H12z"/></svg>
|
||||
|
After Width: | Height: | Size: 270 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#E30917" d="M36 27c0 2.209-1.791 4-4 4H4c-2.209 0-4-1.791-4-4V9c0-2.209 1.791-4 4-4h28c2.209 0 4 1.791 4 4v18z"/><path fill="#EEE" d="M16 24c-3.314 0-6-2.685-6-6 0-3.314 2.686-6 6-6 1.31 0 2.52.425 3.507 1.138-1.348-1.524-3.312-2.491-5.507-2.491-4.061 0-7.353 3.292-7.353 7.353 0 4.062 3.292 7.354 7.353 7.354 2.195 0 4.16-.967 5.507-2.492C18.521 23.575 17.312 24 16 24zm3.913-5.77l2.44.562.22 2.493 1.288-2.146 2.44.561-1.644-1.888 1.287-2.147-2.303.98-1.644-1.889.22 2.494z"/></svg>
|
||||
|
After Width: | Height: | Size: 556 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#B22334" d="M35.445 7C34.752 5.809 33.477 5 32 5H18v2h17.445zM0 25h36v2H0zm18-8h18v2H18zm0-4h18v2H18zM0 21h36v2H0zm4 10h28c1.477 0 2.752-.809 3.445-2H.555c.693 1.191 1.968 2 3.445 2zM18 9h18v2H18z"/><path fill="#EEE" d="M.068 27.679c.017.093.036.186.059.277.026.101.058.198.092.296.089.259.197.509.333.743L.555 29h34.89l.002-.004c.135-.233.243-.483.332-.741.034-.099.067-.198.093-.301.023-.09.042-.182.059-.275.041-.22.069-.446.069-.679H0c0 .233.028.458.068.679zM0 23h36v2H0zm0-4v2h36v-2H18zm18-4h18v2H18zm0-4h18v2H18zM0 9c0-.233.03-.457.068-.679C.028 8.542 0 8.767 0 9zm.555-2l-.003.005L.555 7zM.128 8.044c.025-.102.06-.199.092-.297-.034.098-.066.196-.092.297zM18 9h18c0-.233-.028-.459-.069-.68-.017-.092-.035-.184-.059-.274-.027-.103-.059-.203-.094-.302-.089-.258-.197-.507-.332-.74.001-.001 0-.003-.001-.004H18v2z"/><path fill="#3C3B6E" d="M18 5H4C1.791 5 0 6.791 0 9v10h18V5z"/><path fill="#FFF" d="M2.001 7.726l.618.449-.236.725L3 8.452l.618.448-.236-.725L4 7.726h-.764L3 7l-.235.726zm2 2l.618.449-.236.725.617-.448.618.448-.236-.725L6 9.726h-.764L5 9l-.235.726zm4 0l.618.449-.236.725.617-.448.618.448-.236-.725.618-.449h-.764L9 9l-.235.726zm4 0l.618.449-.236.725.617-.448.618.448-.236-.725.618-.449h-.764L13 9l-.235.726zm-8 4l.618.449-.236.725.617-.448.618.448-.236-.725.618-.449h-.764L5 13l-.235.726zm4 0l.618.449-.236.725.617-.448.618.448-.236-.725.618-.449h-.764L9 13l-.235.726zm4 0l.618.449-.236.725.617-.448.618.448-.236-.725.618-.449h-.764L13 13l-.235.726zm-6-6l.618.449-.236.725L7 8.452l.618.448-.236-.725L8 7.726h-.764L7 7l-.235.726zm4 0l.618.449-.236.725.617-.448.618.448-.236-.725.618-.449h-.764L11 7l-.235.726zm4 0l.618.449-.236.725.617-.448.618.448-.236-.725.618-.449h-.764L15 7l-.235.726zm-12 4l.618.449-.236.725.617-.448.618.448-.236-.725.618-.449h-.764L3 11l-.235.726zM6.383 12.9L7 12.452l.618.448-.236-.725.618-.449h-.764L7 11l-.235.726h-.764l.618.449zm3.618-1.174l.618.449-.236.725.617-.448.618.448-.236-.725.618-.449h-.764L11 11l-.235.726zm4 0l.618.449-.236.725.617-.448.618.448-.236-.725.618-.449h-.764L15 11l-.235.726zm-12 4l.618.449-.236.725.617-.448.618.448-.236-.725.618-.449h-.764L3 15l-.235.726zM6.383 16.9L7 16.452l.618.448-.236-.725.618-.449h-.764L7 15l-.235.726h-.764l.618.449zm3.618-1.174l.618.449-.236.725.617-.448.618.448-.236-.725.618-.449h-.764L11 15l-.235.726zm4 0l.618.449-.236.725.617-.448.618.448-.236-.725.618-.449h-.764L15 15l-.235.726z"/></svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><circle fill="#F5F8FA" cx="18" cy="18" r="18"/><path d="M18 11c-.552 0-1-.448-1-1V3c0-.552.448-1 1-1s1 .448 1 1v7c0 .552-.448 1-1 1zm-6.583 4.5c-.1 0-.202-.015-.302-.047l-8.041-2.542c-.527-.167-.819-.728-.652-1.255.166-.527.73-.818 1.255-.652l8.042 2.542c.527.167.819.729.652 1.255-.136.426-.53.699-.954.699zm13.625-.291c-.434 0-.833-.285-.96-.722-.154-.531.151-1.085.682-1.239l6.75-1.958c.531-.153 1.085.153 1.238.682.154.531-.151 1.085-.682 1.239l-6.75 1.958c-.092.027-.186.04-.278.04zm2.001 14.958c-.306 0-.606-.14-.803-.403l-5.459-7.333c-.33-.442-.238-1.069.205-1.399.442-.331 1.069-.238 1.399.205l5.459 7.333c.33.442.238 1.069-.205 1.399-.179.134-.389.198-.596.198zm-18.294-.083c-.197 0-.395-.058-.57-.179-.454-.316-.565-.938-.25-1.392l5.125-7.375c.315-.454.938-.566 1.392-.251.454.315.565.939.25 1.392l-5.125 7.375c-.194.281-.506.43-.822.43zM3.5 27.062c-.44 0-.844-.293-.965-.738L.347 18.262c-.145-.533.17-1.082.704-1.227.535-.141 1.083.171 1.227.704l2.188 8.062c.145.533-.17 1.082-.704 1.226-.088.025-.176.035-.262.035zM22 34h-9c-.552 0-1-.447-1-1s.448-1 1-1h9c.553 0 1 .447 1 1s-.447 1-1 1zm10.126-6.875c-.079 0-.16-.009-.24-.029-.536-.132-.864-.674-.731-1.21l2.125-8.625c.133-.536.679-.862 1.21-.732.536.132.864.674.731 1.211l-2.125 8.625c-.113.455-.521.76-.97.76zM30.312 7.688c-.17 0-.342-.043-.5-.134L22.25 3.179c-.478-.277-.642-.888-.364-1.367.275-.478.886-.643 1.366-.365l7.562 4.375c.478.277.642.888.364 1.367-.185.32-.521.499-.866.499zm-24.811 0c-.312 0-.618-.145-.813-.417-.322-.45-.22-1.074.229-1.396l6.188-4.438c.449-.322 1.074-.219 1.396.229.322.449.219 1.074-.229 1.396L6.083 7.5c-.177.126-.38.188-.582.188z" fill="#CCD6DD"/><path d="M25.493 13.516l-7.208-5.083c-.348-.245-.814-.243-1.161.006l-7.167 5.167c-.343.248-.494.684-.375 1.091l2.5 8.583c.124.426.515.72.96.72H22c.43 0 .81-.274.948-.681l2.917-8.667c.141-.419-.011-.881-.372-1.136zM1.292 19.542c.058 0 .117-.005.175-.016.294-.052.55-.233.697-.494l3.375-6c.051-.091.087-.188.108-.291L6.98 6.2c.06-.294-.016-.6-.206-.832C6.584 5.135 6.3 5 6 5h-.428C2.145 8.277 0 12.884 0 18c0 .266.028.525.04.788l.602.514c.182.156.413.24.65.24zm9.325-16.547c.106.219.313.373.553.412l6.375 1.042c.04.006.081.01.121.01.04 0 .081-.003.122-.01l6.084-1c.2-.033.38-.146.495-.314.116-.168.158-.375.118-.575l-.292-1.443C22.26.407 20.18 0 18 0c-2.425 0-4.734.486-6.845 1.356l-.521.95c-.117.213-.123.47-.017.689zm20.517 2.724l-1.504-.095c-.228-.013-.455.076-.609.249-.152.173-.218.402-.175.63l1.167 6.198c.017.086.048.148.093.224 1.492 2.504 3.152 5.301 3.381 5.782.024.084.062.079.114.151.14.195.372.142.612.142h.007c.198 0 .323.094 1.768-.753.001-.083.012-.164.012-.247 0-4.753-1.856-9.064-4.866-12.281zM14.541 33.376c.011-.199-.058-.395-.191-.544l-4.5-5c-.06-.066-.131-.122-.211-.163-5.885-3.069-5.994-3.105-6.066-3.13-.078-.025-.161-.039-.242-.039-.537 0-.695.065-1.185 2.024 2.236 4.149 6.053 7.316 10.644 8.703l1.5-1.333c.149-.132.239-.319.251-.518zm17.833-8.567c-.189-.08-.405-.078-.592.005l-6.083 2.667c-.106.046-.2.116-.274.205l-4.25 5.083c-.129.154-.19.352-.172.552.02.2.117.384.272.51.683.559 1.261 1.03 1.767 1.44 4.437-1.294 8.154-4.248 10.454-8.146l-.712-1.889c-.072-.193-.221-.347-.41-.427z" fill="#31373D"/></svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -134,36 +134,106 @@ Sadece post metnini yaz, başka hiçbir şey ekleme.`;
|
||||
}
|
||||
|
||||
private ensureHashtags(text: string, card: PredictionCardDto): string {
|
||||
// If no hashtags in text, add them
|
||||
if (!text.includes("#")) {
|
||||
const leagueTag = card.leagueName
|
||||
.replace(/\s+/g, "")
|
||||
.replace(/[^a-zA-Z0-9üöçşğıİÜÖÇŞĞ]/g, "");
|
||||
const homeTag = card.homeTeam.replace(/\s+/g, "");
|
||||
const awayTag = card.awayTeam.replace(/\s+/g, "");
|
||||
const sportTag = card.sport === "basketball" ? "Basketbol" : "Futbol";
|
||||
text += `\n\n#${leagueTag} #${homeTag} #${awayTag} #${sportTag}`;
|
||||
// Always strip any LLM-emitted hashtags then append our curated SEO set,
|
||||
// so we control the canonical tags the post is indexed under.
|
||||
const stripped = text.replace(/(^|\s)#[^\s#]+/g, "").trim();
|
||||
const tags = this.buildSeoHashtags(card);
|
||||
return `${stripped}\n\n${tags}`.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the canonical SEO hashtag set for a prediction card.
|
||||
* Combines: league, country, teams, sport, betting keywords, day-of-week,
|
||||
* top-pick market, and evergreen brand tags. Capped at ~12 tags
|
||||
* (over-tagging gets penalised on X / IG).
|
||||
*/
|
||||
private buildSeoHashtags(card: PredictionCardDto): string {
|
||||
const slug = (s: string) =>
|
||||
(s || "")
|
||||
.replace(/[^\p{L}\p{N}]/gu, "")
|
||||
.replace(/\s+/g, "");
|
||||
|
||||
const tags = new Set<string>();
|
||||
const push = (t: string) => {
|
||||
if (t && t.length > 1 && t.length <= 30) tags.add(`#${t}`);
|
||||
};
|
||||
|
||||
// Brand / vertical
|
||||
push("MaçTahmini");
|
||||
push("İddaa");
|
||||
push("BugünMaç");
|
||||
push(card.sport === "basketball" ? "Basketbol" : "Futbol");
|
||||
push(card.sport === "basketball" ? "BasketTahmin" : "FutbolTahmin");
|
||||
|
||||
// Region / league
|
||||
if (card.countryName) push(slug(card.countryName));
|
||||
if (card.leagueName) {
|
||||
push(slug(card.leagueName));
|
||||
// Common popular shorthands
|
||||
const ln = card.leagueName.toLowerCase();
|
||||
if (ln.includes("süper")) push("SüperLig");
|
||||
if (ln.includes("premier")) push("PremierLeague");
|
||||
if (ln.includes("şampiyonlar")) push("ŞampiyonlarLigi");
|
||||
if (ln.includes("euroleague")) push("EuroLeague");
|
||||
if (ln.includes("nba")) push("NBA");
|
||||
}
|
||||
return text.trim();
|
||||
|
||||
// Teams
|
||||
push(slug(card.homeTeam));
|
||||
push(slug(card.awayTeam));
|
||||
if (card.homeTeam && card.awayTeam) {
|
||||
push(slug(`${card.homeTeam}${card.awayTeam}`));
|
||||
}
|
||||
|
||||
// Day of week (Turkish)
|
||||
const dayTags = ["Pazar", "Pazartesi", "Salı", "Çarşamba",
|
||||
"Perşembe", "Cuma", "Cumartesi"];
|
||||
push(`${dayTags[new Date().getDay()]}Tahmini`);
|
||||
|
||||
// Market-driven tags
|
||||
const top = card.topPicks?.[0];
|
||||
if (top) {
|
||||
const m = (top.marketEn || "").toLowerCase();
|
||||
if (m.includes("over") || m.includes("under")) push("AltÜst");
|
||||
if (m.includes("both teams")) push("KGVar");
|
||||
if (m.includes("double chance")) push("ÇifteŞans");
|
||||
if (m.includes("match result")) push("MaçSonucu");
|
||||
if (m.includes("handicap") || m.includes("spread")) push("Handikap");
|
||||
}
|
||||
|
||||
return Array.from(tags).slice(0, 12).join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback caption when Gemini is not available.
|
||||
* Designed to be informative + scannable + indexable; expanded vs the
|
||||
* old one-liner so the post still ranks even without LLM enrichment.
|
||||
*/
|
||||
private generateFallbackCaption(card: PredictionCardDto): string {
|
||||
const topPick = card.topPicks[0];
|
||||
const leagueTag = card.leagueName
|
||||
.replace(/\s+/g, "")
|
||||
.replace(/[^a-zA-Z0-9üöçşğıİÜÖÇŞĞ]/g, "");
|
||||
|
||||
const sportLabel = card.sport === "basketball" ? "Basketbol" : "Futbol";
|
||||
const halfLabel = card.sport === "basketball" ? "İD" : "İY";
|
||||
const halfLabel = card.sport === "basketball" ? "İlk Devre" : "İlk Yarı";
|
||||
const top = card.topPicks || [];
|
||||
|
||||
return `⚡ ${card.leagueName}${card.countryName ? ` (${card.countryName})` : ""}: ${card.homeTeam} vs ${card.awayTeam}
|
||||
🎯 ${sportLabel} tahminimiz: ${card.ftScore} (${halfLabel}: ${card.htScore})
|
||||
📊 Güven: %${card.scoreConfidence}
|
||||
${topPick ? `🔥 ${topPick.market}: ${topPick.pick} (%${topPick.confidence})` : ""}
|
||||
const lines: string[] = [];
|
||||
const flag = card.countryName ? `🌍 ${card.countryName}` : "🌍";
|
||||
lines.push(`${flag} | ${card.leagueName}`);
|
||||
lines.push(`⚽ ${card.homeTeam} 🆚 ${card.awayTeam}`);
|
||||
lines.push(`🗓️ ${card.matchDate}`);
|
||||
lines.push("");
|
||||
lines.push(`🎯 ${sportLabel} Skor Tahmini`);
|
||||
lines.push(` • ${halfLabel}: ${card.htScore}`);
|
||||
lines.push(` • Maç Sonu: ${card.ftScore}`);
|
||||
lines.push(` • Skor Güveni: %${card.scoreConfidence}`);
|
||||
|
||||
#${leagueTag} #${sportLabel} #MaçTahmini #iddaai`.trim();
|
||||
if (top.length) {
|
||||
lines.push("");
|
||||
lines.push("🔥 En İyi Tahminler:");
|
||||
top.slice(0, 3).forEach((p, i) => {
|
||||
const stars = "⭐".repeat(Math.min(3, Math.max(1, Math.round(p.confidence / 30))));
|
||||
lines.push(` ${i + 1}. ${p.market} (%${p.confidence}) ${stars}`);
|
||||
});
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,16 +45,85 @@ export class ImageRendererService implements OnModuleInit {
|
||||
throw new Error("canvas native module is not available");
|
||||
}
|
||||
|
||||
const fileName = `prediction_${card.sport}_${card.matchId}_${Date.now()}.jpg`;
|
||||
// SEO-friendly filename: "<league>-<home>-vs-<away>-<yyyymmdd>.jpg"
|
||||
// Search engines index image filenames; matchId is opaque, this isn't.
|
||||
const fileName = this.buildSeoFilename(card);
|
||||
const filePath = path.join(this.outputDir, fileName);
|
||||
|
||||
this.logger.log(
|
||||
`Rendering ${card.sport} social card for ${card.homeTeam} vs ${card.awayTeam}`,
|
||||
);
|
||||
await this.drawCanvas(card, filePath);
|
||||
|
||||
// Sidecar metadata for OpenGraph / SEO consumers (same basename + .json).
|
||||
try {
|
||||
this.writeMetadataSidecar(card, filePath);
|
||||
} catch (e) {
|
||||
this.logger.warn(`metadata sidecar failed: ${(e as Error).message}`);
|
||||
}
|
||||
|
||||
return filePath;
|
||||
}
|
||||
|
||||
private buildSeoFilename(card: PredictionCardDto): string {
|
||||
const slug = (s: string) =>
|
||||
(s || "")
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.replace(/[ıİ]/g, "i")
|
||||
.replace(/[şŞ]/g, "s")
|
||||
.replace(/[çÇ]/g, "c")
|
||||
.replace(/[ğĞ]/g, "g")
|
||||
.replace(/[öÖ]/g, "o")
|
||||
.replace(/[üÜ]/g, "u")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 40);
|
||||
const league = slug(card.leagueName) || "match";
|
||||
const home = slug(card.homeTeam) || "home";
|
||||
const away = slug(card.awayTeam) || "away";
|
||||
const d = new Date();
|
||||
const stamp = `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}${String(d.getDate()).padStart(2, "0")}`;
|
||||
return `${league}-${home}-vs-${away}-${stamp}.jpg`;
|
||||
}
|
||||
|
||||
private writeMetadataSidecar(card: PredictionCardDto, imagePath: string): void {
|
||||
const meta = {
|
||||
title: `${card.homeTeam} - ${card.awayTeam} Maç Tahmini | ${card.leagueName}`,
|
||||
description:
|
||||
`${card.leagueName} maçında ${card.homeTeam} - ${card.awayTeam} ` +
|
||||
`karşılaşması için AI destekli maç tahmini. Tahmini skor ${card.ftScore}, ` +
|
||||
`ilk yarı ${card.htScore}. En iyi 3 bahis önerisi.`,
|
||||
og: {
|
||||
type: "article",
|
||||
title: `${card.homeTeam} vs ${card.awayTeam} — ${card.leagueName}`,
|
||||
description: `Skor tahmini: ${card.ftScore} (İlk yarı: ${card.htScore})`,
|
||||
image_alt: `${card.homeTeam} - ${card.awayTeam} maç tahmin kartı`,
|
||||
},
|
||||
structured_data: {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SportsEvent",
|
||||
name: `${card.homeTeam} vs ${card.awayTeam}`,
|
||||
sport: card.sport === "basketball" ? "Basketball" : "Football",
|
||||
homeTeam: { "@type": "SportsTeam", name: card.homeTeam },
|
||||
awayTeam: { "@type": "SportsTeam", name: card.awayTeam },
|
||||
location: card.countryName || undefined,
|
||||
organizer: card.leagueName,
|
||||
startDate: card.matchDate,
|
||||
},
|
||||
picks: card.topPicks.map((p) => ({
|
||||
market: p.market,
|
||||
pick: p.pick,
|
||||
confidence: p.confidence,
|
||||
odds: p.odds,
|
||||
})),
|
||||
generated_at: new Date().toISOString(),
|
||||
};
|
||||
const sidecar = imagePath.replace(/\.jpg$/i, ".json");
|
||||
fs.writeFileSync(sidecar, JSON.stringify(meta, null, 2), "utf-8");
|
||||
}
|
||||
|
||||
private async drawCanvas(
|
||||
data: PredictionCardDto,
|
||||
outPath: string,
|
||||
|
||||
@@ -1,25 +1,81 @@
|
||||
import { Controller, Post, Param, Get, UseGuards } from "@nestjs/common";
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Param,
|
||||
Get,
|
||||
UseGuards,
|
||||
Res,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||
import type { Response } from "express";
|
||||
import * as fs from "fs";
|
||||
|
||||
import { SocialPosterService } from "./social-poster.service";
|
||||
import { Roles } from "../../common/decorators";
|
||||
import { RolesGuard } from "../auth/guards/auth.guards";
|
||||
|
||||
@ApiTags("Social Poster")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles("superadmin")
|
||||
@Controller("social-poster")
|
||||
export class SocialPosterController {
|
||||
constructor(private readonly socialPosterService: SocialPosterService) {}
|
||||
|
||||
/** Public health endpoint — config + last-run snapshot. Safe to expose. */
|
||||
@Get("health")
|
||||
health() {
|
||||
return this.socialPosterService.getHealthStatus();
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles("superadmin")
|
||||
@Get("preview/:matchId")
|
||||
async previewCard(@Param("matchId") matchId: string) {
|
||||
return this.socialPosterService.renderPreview(matchId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the prediction card for a match and stream the raw JPEG back —
|
||||
* the fastest way to QA the visual without opening the JSON or a viewer.
|
||||
*/
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles("superadmin")
|
||||
@Get("preview-png/:matchId")
|
||||
async previewPng(
|
||||
@Param("matchId") matchId: string,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const { imagePath } = await this.socialPosterService.renderPreview(matchId);
|
||||
if (!fs.existsSync(imagePath)) {
|
||||
throw new NotFoundException("Image not rendered");
|
||||
}
|
||||
res.setHeader("Content-Type", "image/jpeg");
|
||||
res.setHeader(
|
||||
"Cache-Control",
|
||||
"public, max-age=300, stale-while-revalidate=60",
|
||||
);
|
||||
fs.createReadStream(imagePath).pipe(res);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles("superadmin")
|
||||
@Post("post/:matchId")
|
||||
async postMatch(@Param("matchId") matchId: string) {
|
||||
return this.socialPosterService.manualPost(matchId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Force a cron-style sweep right now — useful right after pushing config
|
||||
* changes, instead of waiting up to 10 minutes for the next scheduled run.
|
||||
*/
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles("superadmin")
|
||||
@Post("run-now")
|
||||
async runNow() {
|
||||
await this.socialPosterService.checkAndPostUpcomingMatches();
|
||||
return this.socialPosterService.getHealthStatus();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,8 +34,15 @@ export class SocialPosterService {
|
||||
private readonly sports: string[];
|
||||
private readonly windowMinMinutes: number;
|
||||
private readonly windowMaxMinutes: number;
|
||||
private readonly maxPostsPerRun: number;
|
||||
private readonly postedMatchIds = new Set<string>();
|
||||
private topLeagueIds: Set<string> = new Set();
|
||||
private lastRunAt: Date | null = null;
|
||||
private lastRunResult: { posted: number; skipped: number; errors: number } = {
|
||||
posted: 0,
|
||||
skipped: 0,
|
||||
errors: 0,
|
||||
};
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@@ -59,11 +66,18 @@ export class SocialPosterService {
|
||||
.split(",")
|
||||
.map((sport) => sport.trim())
|
||||
.filter(Boolean);
|
||||
// Default expanded to 10-60 min so each 10-minute cron run has a fresh
|
||||
// batch of upcoming matches without overlap (each match falls in one
|
||||
// ~10-min slice of the window). Override via env if you want tighter
|
||||
// "just before kickoff" timing.
|
||||
this.windowMinMinutes = Number(
|
||||
this.configService.get<string>("SOCIAL_POSTER_WINDOW_MIN") || 25,
|
||||
this.configService.get<string>("SOCIAL_POSTER_WINDOW_MIN") || 10,
|
||||
);
|
||||
this.windowMaxMinutes = Number(
|
||||
this.configService.get<string>("SOCIAL_POSTER_WINDOW_MAX") || 45,
|
||||
this.configService.get<string>("SOCIAL_POSTER_WINDOW_MAX") || 60,
|
||||
);
|
||||
this.maxPostsPerRun = Number(
|
||||
this.configService.get<string>("SOCIAL_POSTER_MAX_PER_RUN") || 5,
|
||||
);
|
||||
|
||||
this.loadTopLeagues();
|
||||
@@ -108,12 +122,15 @@ export class SocialPosterService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Cron: Every 15 minutes, check for upcoming matches.
|
||||
* Posts predictions 30 minutes before kickoff.
|
||||
* Cron: Every 10 minutes, check for upcoming matches and post prediction
|
||||
* cards. Window is 10-60 min before kickoff; max posts/run is capped so
|
||||
* one cron pass cannot flood Twitter / IG rate limits.
|
||||
*/
|
||||
@Cron("*/15 * * * *")
|
||||
@Cron("*/10 * * * *")
|
||||
async checkAndPostUpcomingMatches() {
|
||||
if (!this.isEnabled) return;
|
||||
this.lastRunAt = new Date();
|
||||
this.lastRunResult = { posted: 0, skipped: 0, errors: 0 };
|
||||
|
||||
try {
|
||||
const matches = await this.getUpcomingMatches(
|
||||
@@ -121,11 +138,22 @@ export class SocialPosterService {
|
||||
this.windowMaxMinutes,
|
||||
);
|
||||
this.logger.log(
|
||||
`📅 Found ${matches.length} upcoming matches in the window`,
|
||||
`📅 Found ${matches.length} upcoming matches in window ` +
|
||||
`[${this.windowMinMinutes}-${this.windowMaxMinutes} min]`,
|
||||
);
|
||||
|
||||
let postedThisRun = 0;
|
||||
for (const match of matches) {
|
||||
if (this.postedMatchIds.has(match.id)) continue;
|
||||
if (postedThisRun >= this.maxPostsPerRun) {
|
||||
this.logger.log(
|
||||
`Hit max-per-run cap (${this.maxPostsPerRun}); deferring rest`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
if (this.postedMatchIds.has(match.id)) {
|
||||
this.lastRunResult.skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.predictAndPost(match);
|
||||
@@ -136,12 +164,15 @@ export class SocialPosterService {
|
||||
|
||||
if (!posted) {
|
||||
this.logger.warn(
|
||||
`No platform accepted post for match ${match.id}; it will be retried later`,
|
||||
`No platform accepted post for match ${match.id}; will retry later`,
|
||||
);
|
||||
this.lastRunResult.errors++;
|
||||
continue;
|
||||
}
|
||||
|
||||
this.postedMatchIds.add(match.id);
|
||||
this.lastRunResult.posted++;
|
||||
postedThisRun++;
|
||||
|
||||
// Cleanup: remove old IDs (keep last 500)
|
||||
if (this.postedMatchIds.size > 500) {
|
||||
@@ -155,6 +186,7 @@ export class SocialPosterService {
|
||||
this.logger.error(
|
||||
`Failed to process match ${match.id}: ${error.message}`,
|
||||
);
|
||||
this.lastRunResult.errors++;
|
||||
}
|
||||
|
||||
// Small delay between posts to avoid rate limits
|
||||
@@ -162,9 +194,32 @@ export class SocialPosterService {
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Cron job failed: ${error.message}`);
|
||||
this.lastRunResult.errors++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot of the poster's runtime state for the /health endpoint.
|
||||
*/
|
||||
getHealthStatus() {
|
||||
return {
|
||||
enabled: this.isEnabled,
|
||||
sports: this.sports,
|
||||
window_min_minutes: this.windowMinMinutes,
|
||||
window_max_minutes: this.windowMaxMinutes,
|
||||
max_posts_per_run: this.maxPostsPerRun,
|
||||
top_leagues_loaded: this.topLeagueIds.size,
|
||||
posted_match_count: this.postedMatchIds.size,
|
||||
last_run_at: this.lastRunAt?.toISOString() || null,
|
||||
last_run_result: this.lastRunResult,
|
||||
twitter_available: this.twitterService.available,
|
||||
meta_facebook_available: this.metaService.facebookAvailable,
|
||||
meta_instagram_available: this.metaService.instagramAvailable,
|
||||
ai_engine_url: this.aiEngineUrl,
|
||||
app_base_url: this.appBaseUrl,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get matches starting in [minMinutes, maxMinutes] from now.
|
||||
* Filtered by top leagues.
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Standalone renderer for social-poster image preview.
|
||||
* Bypasses NestJS — directly instantiates ImageRendererService with a
|
||||
* sample PredictionCardDto so we can SEE what the output looks like
|
||||
* without standing up the full app.
|
||||
*
|
||||
* Usage:
|
||||
* npx ts-node --transpile-only -r tsconfig-paths/register \
|
||||
* src/scripts/render-social-card-sample.ts
|
||||
*/
|
||||
|
||||
import { ImageRendererService } from "../modules/social-poster/image-renderer.service";
|
||||
import { PredictionCardDto } from "../modules/social-poster/dto/prediction-card.dto";
|
||||
|
||||
async function main() {
|
||||
const renderer = new ImageRendererService();
|
||||
renderer.onModuleInit();
|
||||
|
||||
// Sample 1: Basketball — like the user's Unicaja vs AEK reference image
|
||||
const basketCard: PredictionCardDto = {
|
||||
matchId: "sample-basket-001",
|
||||
sport: "basketball",
|
||||
homeTeam: "Unicaja Malaga",
|
||||
awayTeam: "AEK",
|
||||
homeLogo: "",
|
||||
awayLogo: "",
|
||||
leagueName: "Şampiyonlar Ligi",
|
||||
leagueLogo: "",
|
||||
countryName: "Avrupa",
|
||||
countryFlag: "",
|
||||
matchDate: "25 May 2026 - 21:00",
|
||||
htScore: "40-35",
|
||||
ftScore: "88-75",
|
||||
scoreConfidence: 90,
|
||||
topPicks: [
|
||||
{
|
||||
market: "Toplam Sayı: Üst 160.5",
|
||||
marketEn: "Total Points: Over 160.5",
|
||||
pick: "Üst",
|
||||
confidence: 85,
|
||||
odds: 1.85,
|
||||
},
|
||||
{
|
||||
market: "Ev Sahibi Toplam: Üst 82.5",
|
||||
marketEn: "Home Total: Over 82.5",
|
||||
pick: "Üst",
|
||||
confidence: 83,
|
||||
odds: 1.78,
|
||||
},
|
||||
{
|
||||
market: "Handikap: Ev Sahibi -5.5",
|
||||
marketEn: "Handicap: Home -5.5",
|
||||
pick: "1 (-5.5)",
|
||||
confidence: 78,
|
||||
odds: 1.92,
|
||||
},
|
||||
],
|
||||
riskLevel: "LOW",
|
||||
};
|
||||
|
||||
// Sample 2: Football — Süper Lig
|
||||
const footballCard: PredictionCardDto = {
|
||||
matchId: "sample-foot-001",
|
||||
sport: "football",
|
||||
homeTeam: "Galatasaray",
|
||||
awayTeam: "Fenerbahçe",
|
||||
homeLogo: "",
|
||||
awayLogo: "",
|
||||
leagueName: "Süper Lig",
|
||||
leagueLogo: "",
|
||||
countryName: "Türkiye",
|
||||
countryFlag: "",
|
||||
matchDate: "26 May 2026 - 20:00",
|
||||
htScore: "1-0",
|
||||
ftScore: "2-1",
|
||||
scoreConfidence: 72,
|
||||
topPicks: [
|
||||
{
|
||||
market: "Maç Sonucu",
|
||||
marketEn: "Match Result",
|
||||
pick: "1",
|
||||
confidence: 68,
|
||||
odds: 2.15,
|
||||
},
|
||||
{
|
||||
market: "Üst 2.5 Gol",
|
||||
marketEn: "Over 2.5",
|
||||
pick: "Üst",
|
||||
confidence: 61,
|
||||
odds: 1.92,
|
||||
},
|
||||
{
|
||||
market: "Karşılıklı Gol",
|
||||
marketEn: "Both Teams Score",
|
||||
pick: "Var",
|
||||
confidence: 58,
|
||||
odds: 1.75,
|
||||
},
|
||||
],
|
||||
riskLevel: "MEDIUM",
|
||||
};
|
||||
|
||||
console.log("Rendering basketball card...");
|
||||
const basketPath = await renderer.renderCard(basketCard);
|
||||
console.log(` → ${basketPath}`);
|
||||
|
||||
console.log("Rendering football card...");
|
||||
const footballPath = await renderer.renderCard(footballCard);
|
||||
console.log(` → ${footballPath}`);
|
||||
|
||||
console.log("\nDone. Open the JPGs to see the output:");
|
||||
console.log(` ${basketPath}`);
|
||||
console.log(` ${footballPath}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* 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); });
|
||||
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* V3 social card — sport icon + league badge + country flag.
|
||||
* satori (HTML/CSS → SVG) → resvg (SVG → PNG).
|
||||
*/
|
||||
|
||||
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 ASSETS_DIR = path.join(process.cwd(), "src", "modules", "social-poster", "assets");
|
||||
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;
|
||||
|
||||
function loadAsset(name: string): string {
|
||||
const p = path.join(ASSETS_DIR, name);
|
||||
if (!fs.existsSync(p)) return "";
|
||||
const svg = fs.readFileSync(p, "utf-8");
|
||||
return `data:image/svg+xml;base64,${Buffer.from(svg).toString("base64")}`;
|
||||
}
|
||||
|
||||
interface Pick { market: string; pick: string; confidence: number; odds: number; }
|
||||
interface Card {
|
||||
sport: "football" | "basketball";
|
||||
leagueName: string;
|
||||
leagueCode?: string; // e.g. "UCL", "SL"
|
||||
countryName: string;
|
||||
countryCode?: string; // "TR" | "ES" | "EU" | ...
|
||||
matchDate: string;
|
||||
homeTeam: string;
|
||||
awayTeam: string;
|
||||
htScore: string;
|
||||
ftScore: string;
|
||||
scoreConfidence: number;
|
||||
picks: Pick[];
|
||||
}
|
||||
|
||||
type N = string | { type: string; props: { style?: any; children?: any; src?: string; width?: number; height?: number } };
|
||||
const h = (type: string, style: any, ...children: N[]): N => ({
|
||||
type,
|
||||
props: { style, children: children.length === 1 ? children[0] : children },
|
||||
});
|
||||
|
||||
// ── INLINE SVG GENERATORS (no network, embedded as data URIs) ────────────
|
||||
|
||||
function dataUri(svg: string): string {
|
||||
return `data:image/svg+xml;base64,${Buffer.from(svg).toString("base64")}`;
|
||||
}
|
||||
|
||||
function ballSvg(sport: "football" | "basketball"): string {
|
||||
// Use Twemoji (Twitter's MIT-licensed emoji set) — recognized everywhere.
|
||||
return loadAsset(sport === "basketball" ? "basketball.svg" : "football.svg");
|
||||
}
|
||||
|
||||
const FLAG_MAP: Record<string, string> = {
|
||||
TR: "flag-tr.svg",
|
||||
EU: "flag-eu.svg",
|
||||
GB: "flag-gb.svg", UK: "flag-gb.svg", EN: "flag-gb.svg",
|
||||
ES: "flag-es.svg",
|
||||
IT: "flag-it.svg",
|
||||
DE: "flag-de.svg",
|
||||
FR: "flag-fr.svg",
|
||||
US: "flag-us.svg",
|
||||
};
|
||||
|
||||
function flagSvg(code: string): string {
|
||||
const c = (code || "").toUpperCase();
|
||||
const file = FLAG_MAP[c];
|
||||
if (!file) {
|
||||
// Generic dark badge with country code
|
||||
const fallback = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 40">
|
||||
<rect width="60" height="40" rx="4" fill="#374151"/>
|
||||
<text x="30" y="26" font-size="14" font-weight="bold" font-family="Arial" fill="white" text-anchor="middle">${c}</text>
|
||||
</svg>`;
|
||||
return dataUri(fallback);
|
||||
}
|
||||
return loadAsset(file);
|
||||
}
|
||||
|
||||
function leagueBadgeSvg(code: string, color: string, size = 70): string {
|
||||
// Shield/circle with league initials
|
||||
return dataUri(`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80" width="${size}" height="${size}">
|
||||
<defs>
|
||||
<radialGradient id="g" cx="35%" cy="30%">
|
||||
<stop offset="0%" stop-color="${color}"/>
|
||||
<stop offset="100%" stop-color="#1F2937"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<path d="M 40 4 L 72 14 L 72 42 Q 72 64 40 76 Q 8 64 8 42 L 8 14 Z"
|
||||
fill="url(#g)" stroke="${color}" stroke-width="2.5"/>
|
||||
<text x="40" y="52" font-size="${code.length > 3 ? 18 : 26}" font-weight="900"
|
||||
font-family="Arial" fill="white" text-anchor="middle">${code.slice(0, 4)}</text>
|
||||
</svg>`);
|
||||
}
|
||||
|
||||
// ── CARD ─────────────────────────────────────────────────────────────────
|
||||
|
||||
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 TAHMİNİ" : "FUTBOL TAHMİNİ";
|
||||
const halfLabel = isBasket ? "İLK DEVRE" : "İLK YARI";
|
||||
const leagueCode = (c.leagueCode || c.leagueName.split(" ").map(w => w[0]).join("")).toUpperCase();
|
||||
const countryCode = (c.countryCode || "EU").toUpperCase();
|
||||
|
||||
const avatar = (name: string, c1: string, c2: string) =>
|
||||
h("div", {
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
width: 180, height: 180, borderRadius: 180,
|
||||
background: `linear-gradient(135deg, ${c1} 0%, ${c2} 100%)`,
|
||||
fontSize: 110, fontWeight: 900, color: "white",
|
||||
boxShadow: "0 20px 50px rgba(0,0,0,0.55), inset 0 -6px 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: 280,
|
||||
},
|
||||
avatar(name, c1, c2),
|
||||
h("div", {
|
||||
display: "flex", marginTop: 18, fontSize: 28, fontWeight: 900,
|
||||
color: "white", textAlign: "center", lineHeight: 1.05,
|
||||
maxWidth: 280, justifyContent: "center",
|
||||
}, name),
|
||||
);
|
||||
|
||||
const scoreBlock = h("div", {
|
||||
display: "flex", flexDirection: "column", alignItems: "center",
|
||||
},
|
||||
// Sport icon (BIG, this is the visual distinction)
|
||||
h("div", { display: "flex", marginBottom: 8 },
|
||||
h("img", { width: 110, height: 110 } as any, ...[]) as any,
|
||||
),
|
||||
h("div", {
|
||||
display: "flex", fontSize: 12, color: "rgba(255,255,255,0.4)",
|
||||
letterSpacing: 4, fontWeight: 700, marginBottom: 6,
|
||||
}, "MAÇ SONU"),
|
||||
h("div", {
|
||||
display: "flex", fontSize: 92, fontWeight: 900, color: "white",
|
||||
lineHeight: 1, letterSpacing: -3,
|
||||
}, c.ftScore),
|
||||
h("div", {
|
||||
display: "flex", marginTop: 12, fontSize: 16, color: accent,
|
||||
fontWeight: 800, background: `${accent}22`, padding: "6px 14px",
|
||||
borderRadius: 10, letterSpacing: 1,
|
||||
}, `${halfLabel} ${c.htScore}`),
|
||||
);
|
||||
// Replace the placeholder img with actual ball image
|
||||
((scoreBlock as any).props.children[0] as any).props.children = {
|
||||
type: "img",
|
||||
props: { src: ballSvg(c.sport), width: 110, height: 110, style: { width: 110, height: 110 } },
|
||||
};
|
||||
|
||||
const pickRow = (p: Pick, idx: number) =>
|
||||
h("div", {
|
||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
padding: "18px 24px", marginBottom: 11,
|
||||
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: 40, height: 40, marginRight: 16, borderRadius: 8,
|
||||
background: `${accent}22`, color: accent, fontSize: 22, fontWeight: 900,
|
||||
}, String(idx + 1)),
|
||||
h("div", { display: "flex", flexDirection: "column" },
|
||||
h("div", { display: "flex", fontSize: 26, fontWeight: 800, color: "white", lineHeight: 1.1 }, p.market),
|
||||
h("div", { display: "flex", marginTop: 4, fontSize: 16, color: "rgba(255,255,255,0.5)" }, `Oran ${p.odds.toFixed(2)}`),
|
||||
),
|
||||
),
|
||||
h("div", {
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
fontSize: 34, fontWeight: 900, color: accent,
|
||||
background: `${accent}15`,
|
||||
padding: "6px 16px", borderRadius: 10,
|
||||
border: `1px solid ${accent}`,
|
||||
}, `%${p.confidence}`),
|
||||
);
|
||||
|
||||
// Helper for inline <img>
|
||||
const img = (src: string, w: number, hh: number) => ({
|
||||
type: "img",
|
||||
props: { src, width: w, height: hh, style: { width: w, height: hh } },
|
||||
} as any);
|
||||
|
||||
return h("div", {
|
||||
width: 1080, height: 1080,
|
||||
display: "flex", flexDirection: "column",
|
||||
padding: "44px 56px",
|
||||
background: "linear-gradient(180deg, #0F172A 0%, #020617 100%)",
|
||||
color: "white",
|
||||
fontFamily: "Arial",
|
||||
},
|
||||
// ── HEADER: league badge + flag + name + date ──
|
||||
h("div", {
|
||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
marginBottom: 28,
|
||||
},
|
||||
h("div", { display: "flex", alignItems: "center" },
|
||||
img(leagueBadgeSvg(leagueCode, accent), 64, 64),
|
||||
h("div", { display: "flex", flexDirection: "column", marginLeft: 16 },
|
||||
h("div", { display: "flex", alignItems: "center" },
|
||||
img(flagSvg(countryCode), 30, 20),
|
||||
h("div", {
|
||||
display: "flex", marginLeft: 10, fontSize: 14, color: "rgba(255,255,255,0.55)",
|
||||
letterSpacing: 2, fontWeight: 700,
|
||||
}, `${sportLabel} · ${c.countryName.toUpperCase()}`),
|
||||
),
|
||||
h("div", {
|
||||
display: "flex", marginTop: 4, fontSize: 28, 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: 26, paddingLeft: 6, paddingRight: 6,
|
||||
},
|
||||
teamColumn(c.homeTeam, accent, accentDark),
|
||||
scoreBlock,
|
||||
teamColumn(c.awayTeam, away, awayDark),
|
||||
),
|
||||
|
||||
// ── PICKS TITLE ──
|
||||
h("div", {
|
||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
marginBottom: 14, 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: 13, 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: 20, borderTop: "1px solid rgba(255,255,255,0.08)",
|
||||
},
|
||||
h("div", { display: "flex", alignItems: "center" },
|
||||
img(ballSvg(c.sport), 28, 28),
|
||||
h("div", { display: "flex", marginLeft: 10, fontSize: 15, color: "rgba(255,255,255,0.5)", fontWeight: 600 }, "AI Destekli Maç Tahmini"),
|
||||
),
|
||||
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, `v3-${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",
|
||||
leagueCode: "UCL",
|
||||
countryName: "Avrupa",
|
||||
countryCode: "EU",
|
||||
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",
|
||||
leagueCode: "SL",
|
||||
countryName: "Türkiye",
|
||||
countryCode: "TR",
|
||||
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); });
|
||||