main
Deploy Iddaai Backend / build-and-deploy (push) Successful in 32s

This commit is contained in:
2026-05-05 17:09:11 +03:00
parent 244d8f5366
commit 5645b38f20
10 changed files with 1081 additions and 496 deletions
+2 -2
View File
@@ -154,7 +154,7 @@ def update_implied_odds(conn):
implied_draw = %s, implied_draw = %s,
implied_away = %s, implied_away = %s,
implied_over25 = %s, implied_over25 = %s,
implied_btts = %s implied_btts_yes = %s
WHERE match_id = %s WHERE match_id = %s
""", updates) """, updates)
updated += len(updates) updated += len(updates)
@@ -168,7 +168,7 @@ def update_implied_odds(conn):
implied_draw = %s, implied_draw = %s,
implied_away = %s, implied_away = %s,
implied_over25 = %s, implied_over25 = %s,
implied_btts = %s implied_btts_yes = %s
WHERE match_id = %s WHERE match_id = %s
""", updates) """, updates)
updated += len(updates) updated += len(updates)
@@ -29,7 +29,14 @@ from psycopg2.extras import RealDictCursor
from data.db import get_clean_dsn from data.db import get_clean_dsn
from models.v20_ensemble import FullMatchPrediction from models.v20_ensemble import FullMatchPrediction
from models.v25_ensemble import V25Predictor, get_v25_predictor from models.v25_ensemble import V25Predictor, get_v25_predictor
try:
from models.v27_predictor import V27Predictor, compute_divergence, compute_value_edge from models.v27_predictor import V27Predictor, compute_divergence, compute_value_edge
except ImportError:
V27Predictor = None
def compute_divergence(*args, **kwargs):
return 0.0
def compute_value_edge(*args, **kwargs):
return 0.0
from features.odds_band_analyzer import OddsBandAnalyzer from features.odds_band_analyzer import OddsBandAnalyzer
try: try:
from models.basketball_v25 import ( from models.basketball_v25 import (
@@ -246,6 +253,8 @@ class SingleMatchOrchestrator:
def _get_v27_predictor(self) -> Optional[V27Predictor]: def _get_v27_predictor(self) -> Optional[V27Predictor]:
"""Non-fatal V27 loader — returns None if models can't load.""" """Non-fatal V27 loader — returns None if models can't load."""
if V27Predictor is None:
return None
if getattr(self, "_v27", None) is not None: if getattr(self, "_v27", None) is not None:
return self._v27 return self._v27
try: try:
+27 -14
View File
@@ -1,6 +1,6 @@
# Social Poster Modülü — Otomatik Sosyal Medya Paylaşım Sistemi # Social Poster Modülü — Otomatik Sosyal Medya Paylaşım Sistemi
Son güncelleme: 1 Mart 2026 Son güncelleme: 5 Mayıs 2026
--- ---
@@ -13,11 +13,11 @@ Top liglerdeki maçların AI tahminlerini **otomatik olarak görselleştirip** I
## 2. Mimari Akış ## 2. Mimari Akış
``` ```
Cron (*/10 dk) → LiveMatch sorgusu (top_leagues.json filtresi) Cron (*/15 dk) → LiveMatch sorgusu (top_leagues.json filtresi)
→ AI Engine V20+ POST /v20plus/analyze/{match_id} → AI Engine V20+ POST /v20plus/analyze/{match_id}
→ PredictionCardDto oluştur → PredictionCardDto oluştur
→ Node Canvas ile 1080x1920 PNG render → Node Canvas ile futbol/basketbol 1080x1080 JPEG render
→ Gemini ile Türkçe caption üret Ollama/Gemini ile Türkçe SEO uyumlu caption üret
→ Twitter / Facebook / Instagram API'ye paylaş → Twitter / Facebook / Instagram API'ye paylaş
``` ```
@@ -44,20 +44,25 @@ src/modules/social-poster/
### 4.1 SocialPosterService ### 4.1 SocialPosterService
**Cron:** Her 10 dakikada bir çalışır. 2540 dakika içinde başlayacak maçları `top_leagues.json` filtresiyle bulur. **Cron:** Her 15 dakikada bir çalışır. Varsayılan olarak 2545 dakika içinde başlayacak futbol ve basketbol maçlarını `top_leagues.json` filtresiyle bulur.
**Tekrar paylaşım koruması:** Başarılı platform paylaşımı alan maç ID'leri `storage/social-poster-posted.json` içinde son 500 kayıt olarak tutulur. Servis restart sonrası aynı maç tekrar paylaşılmaz.
**Pipeline:** `predictAndPost(match)` → Tahmin al → Görsel üret → Caption üret → Paylaş **Pipeline:** `predictAndPost(match)` → Tahmin al → Görsel üret → Caption üret → Paylaş
**AI Engine İsteği:** **AI Engine İsteği:**
```typescript ```typescript
// POST — GET değil! AI Engine v20plus POST bekler. // POST — GET değil! AI Engine v20plus POST bekler.
axios.post(`${aiEngineUrl}/v20plus/analyze/${matchId}`, null, { timeout: 30000 }) axios.post(`${aiEngineUrl}/v20plus/analyze/${matchId}`, null, {
timeout: 30000,
});
``` ```
**Veri Haritalandırma (V20+ → CardDto):** **Veri Haritalandırma (V20+ → CardDto):**
| V20+ Response Alanı | CardDto Alanı | | V20+ Response Alanı | CardDto Alanı |
|---|---| | ----------------------- | ---------------------------------------------- |
| `score_prediction.ht` | `htScore` (ör: "1-1") | | `score_prediction.ht` | `htScore` (ör: "1-1") |
| `score_prediction.ft` | `ftScore` (ör: "2-1") | | `score_prediction.ft` | `ftScore` (ör: "2-1") |
| `main_pick.confidence` | `scoreConfidence` (ör: 65) | | `main_pick.confidence` | `scoreConfidence` (ör: 65) |
@@ -68,7 +73,7 @@ axios.post(`${aiEngineUrl}/v20plus/analyze/${matchId}`, null, { timeout: 30000 }
**Bet Summary Market Kodları:** **Bet Summary Market Kodları:**
| Kod | Türkçe | English | | Kod | Türkçe | English |
|---|---|---| | ------- | --------------- | ----------------- |
| MS | Maç Sonucu | Match Result | | MS | Maç Sonucu | Match Result |
| OU15 | Üst 1.5 Gol | Over 1.5 | | OU15 | Üst 1.5 Gol | Over 1.5 |
| OU25 | Üst 2.5 Gol | Over 2.5 | | OU25 | Üst 2.5 Gol | Over 2.5 |
@@ -89,6 +94,7 @@ axios.post(`${aiEngineUrl}/v20plus/analyze/${matchId}`, null, { timeout: 30000 }
**Boyut:** 1080×1920 px (Instagram Story / Reels uyumlu) **Boyut:** 1080×1920 px (Instagram Story / Reels uyumlu)
**Özellikler:** **Özellikler:**
- Koyu gradient arka plan (#0a0e27#1a1040#0d1b2a) - Koyu gradient arka plan (#0a0e27#1a1040#0d1b2a)
- Lig adı + tarih başlık satırı - Lig adı + tarih başlık satırı
- Takım logoları (200×200px) — `public/uploads/teams/` altından okunur - Takım logoları (200×200px) — `public/uploads/teams/` altından okunur
@@ -100,6 +106,7 @@ axios.post(`${aiEngineUrl}/v20plus/analyze/${matchId}`, null, { timeout: 30000 }
- Alt bilgi: "⚡ AI Powered by SuggestBet" - Alt bilgi: "⚡ AI Powered by SuggestBet"
**Logo Çözümleme:** **Logo Çözümleme:**
``` ```
1. Yerel dosya varsa → public/uploads/teams/xxx.png oku 1. Yerel dosya varsa → public/uploads/teams/xxx.png oku
2. URL http ile başlıyorsa → HTTP ile indir 2. URL http ile başlıyorsa → HTTP ile indir
@@ -119,7 +126,7 @@ Gemini API kullanarak maç verisi JSON'ından Türkçe post metni üretir.
## 5. API Endpointleri ## 5. API Endpointleri
| Method | Path | Auth | Açıklama | | Method | Path | Auth | Açıklama |
|---|---|---|---| | ------ | ------------------------------------- | ------- | ---------------------------------------------------- |
| GET | `/api/social-poster/preview/:matchId` | @Public | Sadece görsel üret + caption üret (paylaşma) | | GET | `/api/social-poster/preview/:matchId` | @Public | Sadece görsel üret + caption üret (paylaşma) |
| POST | `/api/social-poster/post/:matchId` | @Public | Görsel üret + caption üret + tüm platformlara paylaş | | POST | `/api/social-poster/post/:matchId` | @Public | Görsel üret + caption üret + tüm platformlara paylaş |
@@ -130,12 +137,18 @@ Gemini API kullanarak maç verisi JSON'ından Türkçe post metni üretir.
## 6. Environment Değişkenleri ## 6. Environment Değişkenleri
| Key | Zorunlu | Varsayılan | Açıklama | | Key | Zorunlu | Varsayılan | Açıklama |
|---|---|---|---| | --------------------------------------------- | ------- | ------------------------ | -------------------------------------------------------------------- |
| `AI_ENGINE_URL` | ✅ | `http://localhost:8000` | AI Engine base URL | | `AI_ENGINE_URL` | ✅ | `http://localhost:8000` | AI Engine base URL |
| `APP_BASE_URL` | ✅ | `http://localhost:3000` | Logo URL çözümleme için | | `APP_BASE_URL` | ✅ | `http://localhost:3000` | Meta'nın çekebileceği public görsel URL'i ve logo URL çözümleme için |
| `SOCIAL_POSTER_ENABLED` | ❌ | `false` | Cron job'ı aktif/pasif | | `SOCIAL_POSTER_ENABLED` | ❌ | `false` | Cron job'ı aktif/pasif |
| `SOCIAL_POSTER_SPORTS` | ❌ | `football,basketball` | Otomatik paylaşılacak sporlar |
| `SOCIAL_POSTER_WINDOW_MIN` | ❌ | `25` | Başlama zaman penceresi alt sınırı (dakika) |
| `SOCIAL_POSTER_WINDOW_MAX` | ❌ | `45` | Başlama zaman penceresi üst sınırı (dakika) |
| `OLLAMA_BASE_URL` | ❌ | `http://localhost:11434` | Lokal LLM endpoint'i |
| `OLLAMA_MODEL` / `SOCIAL_POSTER_OLLAMA_MODEL` | ❌ | — | Caption üretiminde kullanılacak lokal model |
| `GOOGLE_API_KEY` | ❌ | — | Gemini caption için | | `GOOGLE_API_KEY` | ❌ | — | Gemini caption için |
| Twitter API keys | ❌ | — | Twitter paylaşım için | | Twitter API keys | ❌ | — | X medya upload + `/2/tweets` paylaşımı için OAuth 1.0a user context |
| `META_GRAPH_API_VERSION` | ❌ | `v25.0` | Meta Graph API sürümü |
| Meta API keys | ❌ | — | FB/IG paylaşım için | | Meta API keys | ❌ | — | FB/IG paylaşım için |
--- ---
@@ -166,7 +179,7 @@ RUN apk add --no-cache cairo-dev pango-dev jpeg-dev giflib-dev librsvg-dev
### Port Yönetimi ### Port Yönetimi
| Servis | Port | | Servis | Port |
|---|---| | -------------- | ------------------------------------------- |
| NestJS Backend | 3000 (production: 150X) | | NestJS Backend | 3000 (production: 150X) |
| AI Engine | 8000 (dev: 8005 — Windows port kısıtlaması) | | AI Engine | 8000 (dev: 8005 — Windows port kısıtlaması) |
@@ -183,7 +196,7 @@ public/
## 9. Bilinen Sorunlar & Çözümler ## 9. Bilinen Sorunlar & Çözümler
| Sorun | Sebep | Çözüm | | Sorun | Sebep | Çözüm |
|---|---|---| | --------------------------------------- | ------------------------------------ | ----------------------------------------- |
| `WinError 10013` port erişim hatası | Windows Hyper-V port rezervasyonu | Farklı port kullan (8005) | | `WinError 10013` port erişim hatası | Windows Hyper-V port rezervasyonu | Farklı port kullan (8005) |
| `Invalid prisma.liveMatch.findUnique()` | Prisma client eskimiş | `npx prisma generate` çalıştır | | `Invalid prisma.liveMatch.findUnique()` | Prisma client eskimiş | `npx prisma generate` çalıştır |
| `405 Method Not Allowed` AI Engine | GET yerine POST gerekiyor | `axios.post()` kullan | | `405 Method Not Allowed` AI Engine | GET yerine POST gerekiyor | `axios.post()` kullan |
+8
View File
@@ -56,13 +56,21 @@ export const envSchema = z.object({
.string() .string()
.transform((val) => val === "true") .transform((val) => val === "true")
.default("false" as any), .default("false" as any),
SOCIAL_POSTER_SPORTS: z.string().default("football,basketball"),
SOCIAL_POSTER_WINDOW_MIN: z.coerce.number().default(25),
SOCIAL_POSTER_WINDOW_MAX: z.coerce.number().default(45),
SOCIAL_POSTER_OLLAMA_MODEL: z.string().optional(),
APP_BASE_URL: z.string().url().optional(),
TWITTER_API_KEY: z.string().optional(), TWITTER_API_KEY: z.string().optional(),
TWITTER_API_SECRET: z.string().optional(), TWITTER_API_SECRET: z.string().optional(),
TWITTER_ACCESS_TOKEN: z.string().optional(), TWITTER_ACCESS_TOKEN: z.string().optional(),
TWITTER_ACCESS_SECRET: z.string().optional(), TWITTER_ACCESS_SECRET: z.string().optional(),
META_GRAPH_API_VERSION: z.string().default("v25.0"),
META_PAGE_ACCESS_TOKEN: z.string().optional(), META_PAGE_ACCESS_TOKEN: z.string().optional(),
META_PAGE_ID: z.string().optional(), META_PAGE_ID: z.string().optional(),
META_IG_USER_ID: z.string().optional(), META_IG_USER_ID: z.string().optional(),
OLLAMA_BASE_URL: z.string().url().optional(),
OLLAMA_MODEL: z.string().optional(),
// Optional Features // Optional Features
ENABLE_MAIL: booleanString, ENABLE_MAIL: booleanString,
@@ -1,6 +1,8 @@
import { Injectable, Logger } from "@nestjs/common"; import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { GeminiService } from "../gemini/gemini.service"; import { GeminiService } from "../gemini/gemini.service";
import { PredictionCardDto } from "./dto/prediction-card.dto"; import { PredictionCardDto } from "./dto/prediction-card.dto";
import axios from "axios";
const SYSTEM_PROMPT = `Sen profesyonel bir spor analisti ve sosyal medya içerik üreticisisin. const SYSTEM_PROMPT = `Sen profesyonel bir spor analisti ve sosyal medya içerik üreticisisin.
Verilen maç tahmin verisini kullanarak kısa, etkili ve ilgi çekici sosyal medya postları yazıyorsun. Verilen maç tahmin verisini kullanarak kısa, etkili ve ilgi çekici sosyal medya postları yazıyorsun.
@@ -11,6 +13,7 @@ KURALLAR:
- Emoji kullan ama abartma (2-4 emoji yeterli) - Emoji kullan ama abartma (2-4 emoji yeterli)
- Skor tahminini vurgula - Skor tahminini vurgula
- Güven yüzdesini belirt - Güven yüzdesini belirt
- Lig, ülke, takım adları ve ana tahminleri SEO için doğal şekilde geçir
- İlgili hashtag'leri ekle (#PremierLeague, #SüperLig vb.) - İlgili hashtag'leri ekle (#PremierLeague, #SüperLig vb.)
- KESİNLİKLE "kesin kazanır", "garanti" gibi ifadeler KULLANMA - KESİNLİKLE "kesin kazanır", "garanti" gibi ifadeler KULLANMA
- "Tahminimiz", "Beklentimiz", "Analizimiz" gibi ifadeler kullan - "Tahminimiz", "Beklentimiz", "Analizimiz" gibi ifadeler kullan
@@ -20,13 +23,31 @@ KURALLAR:
@Injectable() @Injectable()
export class CaptionGeneratorService { export class CaptionGeneratorService {
private readonly logger = new Logger(CaptionGeneratorService.name); private readonly logger = new Logger(CaptionGeneratorService.name);
private readonly ollamaBaseUrl: string;
private readonly ollamaModel: string;
constructor(private readonly geminiService: GeminiService) {} constructor(
private readonly geminiService: GeminiService,
private readonly configService: ConfigService,
) {
this.ollamaBaseUrl =
this.configService.get<string>("OLLAMA_BASE_URL") ||
"http://localhost:11434";
this.ollamaModel =
this.configService.get<string>("OLLAMA_MODEL") ||
this.configService.get<string>("SOCIAL_POSTER_OLLAMA_MODEL") ||
"";
}
/** /**
* Generate a social media caption for a match prediction using Gemini AI. * Generate a social media caption for a match prediction using Gemini AI.
*/ */
async generateCaption(card: PredictionCardDto): Promise<string> { async generateCaption(card: PredictionCardDto): Promise<string> {
if (this.ollamaModel) {
const caption = await this.generateWithOllama(card);
if (caption) return caption;
}
if (!this.geminiService.isAvailable()) { if (!this.geminiService.isAvailable()) {
this.logger.warn("Gemini not available, using template caption"); this.logger.warn("Gemini not available, using template caption");
return this.generateFallbackCaption(card); return this.generateFallbackCaption(card);
@@ -53,6 +74,39 @@ export class CaptionGeneratorService {
} }
} }
private async generateWithOllama(card: PredictionCardDto): Promise<string> {
const prompt = `${SYSTEM_PROMPT}
${this.buildPrompt(card)}`;
try {
const response = await axios.post(
`${this.ollamaBaseUrl.replace(/\/$/, "")}/api/generate`,
{
model: this.ollamaModel,
prompt,
stream: false,
options: {
temperature: 0.7,
num_predict: 260,
},
},
{ timeout: 20000 },
);
const text = String(response.data?.response || "").trim();
if (!text) return "";
this.logger.log(
`Ollama caption generated for ${card.homeTeam} vs ${card.awayTeam}`,
);
return this.ensureHashtags(text, card);
} catch (error) {
this.logger.warn(`Ollama caption generation failed: ${error.message}`);
return "";
}
}
private buildPrompt(card: PredictionCardDto): string { private buildPrompt(card: PredictionCardDto): string {
const topPicksText = card.topPicks const topPicksText = card.topPicks
.map( .map(
@@ -64,9 +118,11 @@ export class CaptionGeneratorService {
return `Aşağıdaki maç tahmin verisini kullanarak bir sosyal medya postu oluştur: return `Aşağıdaki maç tahmin verisini kullanarak bir sosyal medya postu oluştur:
MAÇ: ${card.homeTeam} vs ${card.awayTeam} MAÇ: ${card.homeTeam} vs ${card.awayTeam}
SPOR: ${card.sport === "basketball" ? "Basketbol" : "Futbol"}
LİG: ${card.leagueName} LİG: ${card.leagueName}
ÜLKE/BÖLGE: ${card.countryName || "-"}
TARİH: ${card.matchDate} TARİH: ${card.matchDate}
İLK YARI SKOR TAHMİNİ: ${card.htScore} ${card.sport === "basketball" ? "İLK DEVRE" : "İLK YARI"} SKOR TAHMİNİ: ${card.htScore}
MAÇ SONU SKOR TAHMİNİ: ${card.ftScore} MAÇ SONU SKOR TAHMİNİ: ${card.ftScore}
SKOR GÜVEN: %${card.scoreConfidence} SKOR GÜVEN: %${card.scoreConfidence}
RİSK SEVİYESİ: ${card.riskLevel} RİSK SEVİYESİ: ${card.riskLevel}
@@ -85,7 +141,8 @@ Sadece post metnini yaz, başka hiçbir şey ekleme.`;
.replace(/[^a-zA-Z0-9üöçşğıİÜÖÇŞĞ]/g, ""); .replace(/[^a-zA-Z0-9üöçşğıİÜÖÇŞĞ]/g, "");
const homeTag = card.homeTeam.replace(/\s+/g, ""); const homeTag = card.homeTeam.replace(/\s+/g, "");
const awayTag = card.awayTeam.replace(/\s+/g, ""); const awayTag = card.awayTeam.replace(/\s+/g, "");
text += `\n\n#${leagueTag} #${homeTag} #${awayTag}`; const sportTag = card.sport === "basketball" ? "Basketbol" : "Futbol";
text += `\n\n#${leagueTag} #${homeTag} #${awayTag} #${sportTag}`;
} }
return text.trim(); return text.trim();
} }
@@ -99,11 +156,14 @@ Sadece post metnini yaz, başka hiçbir şey ekleme.`;
.replace(/\s+/g, "") .replace(/\s+/g, "")
.replace(/[^a-zA-Z0-9üöçşğıİÜÖÇŞĞ]/g, ""); .replace(/[^a-zA-Z0-9üöçşğıİÜÖÇŞĞ]/g, "");
return `${card.homeTeam} vs ${card.awayTeam} const sportLabel = card.sport === "basketball" ? "Basketbol" : "Futbol";
🎯 Tahminimiz: ${card.ftScore} (İY: ${card.htScore}) const halfLabel = card.sport === "basketball" ? "İD" : "İY";
return `${card.leagueName}${card.countryName ? ` (${card.countryName})` : ""}: ${card.homeTeam} vs ${card.awayTeam}
🎯 ${sportLabel} tahminimiz: ${card.ftScore} (${halfLabel}: ${card.htScore})
📊 Güven: %${card.scoreConfidence} 📊 Güven: %${card.scoreConfidence}
${topPick ? `🔥 ${topPick.market}: ${topPick.pick} (%${topPick.confidence})` : ""} ${topPick ? `🔥 ${topPick.market}: ${topPick.pick} (%${topPick.confidence})` : ""}
#${leagueTag} #SuggestBet #Bahis`.trim(); #${leagueTag} #${sportLabel} #MaçTahmini #iddaai`.trim();
} }
} }
@@ -21,12 +21,15 @@ export interface TopPick {
export interface PredictionCardDto { export interface PredictionCardDto {
// ─── Match Info ─── // ─── Match Info ───
matchId: string; matchId: string;
sport: "football" | "basketball";
homeTeam: string; homeTeam: string;
awayTeam: string; awayTeam: string;
homeLogo: string; homeLogo: string;
awayLogo: string; awayLogo: string;
leagueName: string; leagueName: string;
leagueLogo?: string; leagueLogo?: string;
countryName?: string;
countryFlag?: string;
/** Formatted date, e.g. "01 Mar 2026 - 21:00" */ /** Formatted date, e.g. "01 Mar 2026 - 21:00" */
matchDate: string; matchDate: string;
File diff suppressed because it is too large Load Diff
+16 -3
View File
@@ -10,13 +10,16 @@ export class MetaService {
private readonly pageId: string; private readonly pageId: string;
private readonly igUserId: string; private readonly igUserId: string;
private readonly isEnabled: boolean; private readonly isEnabled: boolean;
private readonly graphApiBase = "https://graph.facebook.com/v21.0"; private readonly graphApiBase: string;
constructor(private readonly configService: ConfigService) { constructor(private readonly configService: ConfigService) {
this.pageAccessToken = this.pageAccessToken =
this.configService.get<string>("META_PAGE_ACCESS_TOKEN") || ""; this.configService.get<string>("META_PAGE_ACCESS_TOKEN") || "";
this.pageId = this.configService.get<string>("META_PAGE_ID") || ""; this.pageId = this.configService.get<string>("META_PAGE_ID") || "";
this.igUserId = this.configService.get<string>("META_IG_USER_ID") || ""; this.igUserId = this.configService.get<string>("META_IG_USER_ID") || "";
const graphVersion =
this.configService.get<string>("META_GRAPH_API_VERSION") || "v25.0";
this.graphApiBase = `https://graph.facebook.com/${graphVersion}`;
this.isEnabled = !!(this.pageAccessToken && this.pageId); this.isEnabled = !!(this.pageAccessToken && this.pageId);
@@ -63,11 +66,12 @@ export class MetaService {
{ {
url: imageUrl, url: imageUrl,
message, message,
published: true,
access_token: this.pageAccessToken, access_token: this.pageAccessToken,
}, },
); );
const postId = response.data?.id; const postId = response.data?.post_id || response.data?.id;
this.logger.log(`✅ Facebook post published: ${postId}`); this.logger.log(`✅ Facebook post published: ${postId}`);
return postId || null; return postId || null;
} catch (error) { } catch (error) {
@@ -109,6 +113,7 @@ export class MetaService {
{ {
image_url: imageUrl, image_url: imageUrl,
caption, caption,
alt_text: this.buildAltText(caption),
access_token: this.pageAccessToken, access_token: this.pageAccessToken,
}, },
); );
@@ -156,7 +161,7 @@ export class MetaService {
`${this.graphApiBase}/${containerId}`, `${this.graphApiBase}/${containerId}`,
{ {
params: { params: {
fields: "status_code", fields: "status_code,status",
access_token: this.pageAccessToken, access_token: this.pageAccessToken,
}, },
}, },
@@ -177,4 +182,12 @@ export class MetaService {
this.logger.warn("Container wait timed out, attempting publish anyway"); this.logger.warn("Container wait timed out, attempting publish anyway");
} }
private buildAltText(caption: string): string {
return caption
.replace(/#[^\s#]+/g, "")
.replace(/\s+/g, " ")
.trim()
.slice(0, 900);
}
} }
@@ -19,6 +19,11 @@ import {
// Top leagues loaded once // Top leagues loaded once
const TOP_LEAGUES_PATH = path.join(process.cwd(), "top_leagues.json"); const TOP_LEAGUES_PATH = path.join(process.cwd(), "top_leagues.json");
const POSTED_STATE_PATH = path.join(
process.cwd(),
"storage",
"social-poster-posted.json",
);
@Injectable() @Injectable()
export class SocialPosterService { export class SocialPosterService {
@@ -26,6 +31,9 @@ export class SocialPosterService {
private readonly aiEngineUrl: string; private readonly aiEngineUrl: string;
private readonly appBaseUrl: string; private readonly appBaseUrl: string;
private readonly isEnabled: boolean; private readonly isEnabled: boolean;
private readonly sports: string[];
private readonly windowMinMinutes: number;
private readonly windowMaxMinutes: number;
private readonly postedMatchIds = new Set<string>(); private readonly postedMatchIds = new Set<string>();
private topLeagueIds: Set<string> = new Set(); private topLeagueIds: Set<string> = new Set();
@@ -44,8 +52,22 @@ export class SocialPosterService {
this.configService.get<string>("APP_BASE_URL") || "http://localhost:3000"; this.configService.get<string>("APP_BASE_URL") || "http://localhost:3000";
this.isEnabled = this.isEnabled =
this.configService.get<string>("SOCIAL_POSTER_ENABLED") === "true"; this.configService.get<string>("SOCIAL_POSTER_ENABLED") === "true";
this.sports = (
this.configService.get<string>("SOCIAL_POSTER_SPORTS") ||
"football,basketball"
)
.split(",")
.map((sport) => sport.trim())
.filter(Boolean);
this.windowMinMinutes = Number(
this.configService.get<string>("SOCIAL_POSTER_WINDOW_MIN") || 25,
);
this.windowMaxMinutes = Number(
this.configService.get<string>("SOCIAL_POSTER_WINDOW_MAX") || 45,
);
this.loadTopLeagues(); this.loadTopLeagues();
this.loadPostedState();
} }
private loadTopLeagues() { private loadTopLeagues() {
@@ -59,16 +81,45 @@ export class SocialPosterService {
} }
} }
private loadPostedState() {
try {
const data = fs.readFileSync(POSTED_STATE_PATH, "utf-8");
const ids = JSON.parse(data);
if (Array.isArray(ids)) {
ids.forEach((id) => this.postedMatchIds.add(String(id)));
}
this.logger.log(
`✅ Loaded ${this.postedMatchIds.size} posted social match IDs`,
);
} catch {
this.logger.warn("⚠️ No social poster state file found yet");
}
}
private savePostedState() {
const dir = path.dirname(POSTED_STATE_PATH);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(
POSTED_STATE_PATH,
JSON.stringify(Array.from(this.postedMatchIds).slice(-500), null, 2),
);
}
/** /**
* Cron: Every 10 minutes, check for upcoming matches. * Cron: Every 15 minutes, check for upcoming matches.
* Posts predictions 30 minutes before kickoff. * Posts predictions 30 minutes before kickoff.
*/ */
@Cron("*/10 * * * *") @Cron("*/15 * * * *")
async checkAndPostUpcomingMatches() { async checkAndPostUpcomingMatches() {
if (!this.isEnabled) return; if (!this.isEnabled) return;
try { try {
const matches = await this.getUpcomingMatches(25, 40); // 25-40 min window const matches = await this.getUpcomingMatches(
this.windowMinMinutes,
this.windowMaxMinutes,
);
this.logger.log( this.logger.log(
`📅 Found ${matches.length} upcoming matches in the window`, `📅 Found ${matches.length} upcoming matches in the window`,
); );
@@ -77,7 +128,19 @@ export class SocialPosterService {
if (this.postedMatchIds.has(match.id)) continue; if (this.postedMatchIds.has(match.id)) continue;
try { try {
await this.predictAndPost(match); const result = await this.predictAndPost(match);
const posted =
result.twitterPostId ||
result.facebookPostId ||
result.instagramPostId;
if (!posted) {
this.logger.warn(
`No platform accepted post for match ${match.id}; it will be retried later`,
);
continue;
}
this.postedMatchIds.add(match.id); this.postedMatchIds.add(match.id);
// Cleanup: remove old IDs (keep last 500) // Cleanup: remove old IDs (keep last 500)
@@ -87,6 +150,7 @@ export class SocialPosterService {
.slice(0, arr.length - 500) .slice(0, arr.length - 500)
.forEach((id) => this.postedMatchIds.delete(id)); .forEach((id) => this.postedMatchIds.delete(id));
} }
this.savePostedState();
} catch (error) { } catch (error) {
this.logger.error( this.logger.error(
`Failed to process match ${match.id}: ${error.message}`, `Failed to process match ${match.id}: ${error.message}`,
@@ -113,19 +177,33 @@ export class SocialPosterService {
const minTime = now + minMinutes * 60 * 1000; const minTime = now + minMinutes * 60 * 1000;
const maxTime = now + maxMinutes * 60 * 1000; const maxTime = now + maxMinutes * 60 * 1000;
const matches = await this.prisma.liveMatch.findMany({ const where: any = {
where: { sport: { in: this.sports },
sport: "football",
leagueId: { in: Array.from(this.topLeagueIds) },
mstUtc: { mstUtc: {
gte: minTime, gte: minTime,
lte: maxTime, lte: maxTime,
}, },
};
if (this.topLeagueIds.size > 0) {
where.leagueId = { in: Array.from(this.topLeagueIds) };
}
const matches = await this.prisma.liveMatch.findMany({
where: {
...where,
}, },
include: { include: {
homeTeam: true, homeTeam: true,
awayTeam: true, awayTeam: true,
league: true, league: {
include: {
country: true,
},
},
},
orderBy: {
mstUtc: "asc",
}, },
}); });
@@ -233,7 +311,10 @@ export class SocialPosterService {
const ftScore = score.ft || "1-1"; const ftScore = score.ft || "1-1";
// Extract best bets from bet_summary array // Extract best bets from bet_summary array
const topPicks = this.extractTopPicks(prediction); const sport = this.normalizeSport(
match.sport || prediction.match_info?.sport,
);
const topPicks = this.extractTopPicks(prediction, sport);
// Match date formatting // Match date formatting
const matchDate = this.formatMatchDate(match.mstUtc); const matchDate = this.formatMatchDate(match.mstUtc);
@@ -246,6 +327,7 @@ export class SocialPosterService {
return { return {
matchId: match.id, matchId: match.id,
sport,
homeTeam: homeTeam:
match.homeTeam?.name || prediction.match_info?.home_team || "Home", match.homeTeam?.name || prediction.match_info?.home_team || "Home",
awayTeam: awayTeam:
@@ -253,6 +335,12 @@ export class SocialPosterService {
homeLogo: this.resolveLogoUrl(match.homeTeam?.logoUrl || ""), homeLogo: this.resolveLogoUrl(match.homeTeam?.logoUrl || ""),
awayLogo: this.resolveLogoUrl(match.awayTeam?.logoUrl || ""), awayLogo: this.resolveLogoUrl(match.awayTeam?.logoUrl || ""),
leagueName: match.league?.name || prediction.match_info?.league || "", leagueName: match.league?.name || prediction.match_info?.league || "",
leagueLogo: this.resolveLogoUrl(match.league?.logoUrl || ""),
countryName:
match.league?.country?.name ||
prediction.match_info?.country ||
this.inferCountryName(match.league?.name || ""),
countryFlag: this.resolveLogoUrl(match.league?.country?.flagUrl || ""),
matchDate, matchDate,
htScore, htScore,
ftScore, ftScore,
@@ -266,11 +354,14 @@ export class SocialPosterService {
/** /**
* Extract top 3 picks sorted by confidence from the V20+ bet_summary array. * Extract top 3 picks sorted by confidence from the V20+ bet_summary array.
*/ */
private extractTopPicks(prediction: any): TopPick[] { private extractTopPicks(
prediction: any,
sport: "football" | "basketball",
): TopPick[] {
const betSummary: any[] = prediction.bet_summary || []; const betSummary: any[] = prediction.bet_summary || [];
// Market code to Turkish/English label mapping // Market code to Turkish/English label mapping
const marketLabels: Record<string, { tr: string; en: string }> = { const footballLabels: Record<string, { tr: string; en: string }> = {
MS: { tr: "Maç Sonucu", en: "Match Result" }, MS: { tr: "Maç Sonucu", en: "Match Result" },
OU15: { tr: "Üst 1.5 Gol", en: "Over 1.5" }, OU15: { tr: "Üst 1.5 Gol", en: "Over 1.5" },
OU25: { tr: "Üst 2.5 Gol", en: "Over 2.5" }, OU25: { tr: "Üst 2.5 Gol", en: "Over 2.5" },
@@ -282,6 +373,20 @@ export class SocialPosterService {
OE: { tr: "Tek/Çift", en: "Odd/Even" }, OE: { tr: "Tek/Çift", en: "Odd/Even" },
HTFT: { tr: "İY/MS", en: "HT/FT" }, HTFT: { tr: "İY/MS", en: "HT/FT" },
}; };
const basketballLabels: Record<string, { tr: string; en: string }> = {
MS: { tr: "Maç Sonucu", en: "Match Result" },
ML: { tr: "Maç Sonucu", en: "Moneyline" },
WINNER: { tr: "Maç Sonucu", en: "Winner" },
TOTAL: { tr: "Toplam Sayı", en: "Total Points" },
OU: { tr: "Toplam Sayı", en: "Total Points" },
OVER_UNDER: { tr: "Toplam Sayı", en: "Total Points" },
HANDICAP: { tr: "Handikap", en: "Handicap" },
HND: { tr: "Handikap", en: "Handicap" },
SPREAD: { tr: "Handikap", en: "Spread" },
HT: { tr: "İlk Devre Sonucu", en: "First Half Result" },
};
const marketLabels =
sport === "basketball" ? basketballLabels : footballLabels;
const candidates: TopPick[] = betSummary.map((bet) => { const candidates: TopPick[] = betSummary.map((bet) => {
const labels = marketLabels[bet.market] || { const labels = marketLabels[bet.market] || {
@@ -302,6 +407,32 @@ export class SocialPosterService {
return candidates.slice(0, 3); return candidates.slice(0, 3);
} }
private inferCountryName(leagueName: string): string {
const normalized = leagueName.toLocaleLowerCase("tr-TR");
if (normalized.includes("süper lig") || normalized.includes("türkiye")) {
return "Türkiye";
}
if (
normalized.includes("premier league") ||
normalized.includes("championship")
) {
return "İngiltere";
}
if (normalized.includes("la liga")) return "İspanya";
if (normalized.includes("serie a")) return "İtalya";
if (normalized.includes("bundesliga")) return "Almanya";
if (normalized.includes("ligue 1")) return "Fransa";
if (normalized.includes("euroleague")) return "Avrupa";
if (normalized.includes("nba")) return "ABD";
return "";
}
private normalizeSport(sport?: string): "football" | "basketball" {
return String(sport).toLowerCase() === "basketball"
? "basketball"
: "football";
}
/** /**
* Convert relative logo paths to full HTTP URLs. * Convert relative logo paths to full HTTP URLs.
* On the deployed server, logos exist at public/uploads/teams/... * On the deployed server, logos exist at public/uploads/teams/...
@@ -351,7 +482,11 @@ export class SocialPosterService {
include: { include: {
homeTeam: true, homeTeam: true,
awayTeam: true, awayTeam: true,
league: true, league: {
include: {
country: true,
},
},
}, },
}); });
@@ -373,7 +508,11 @@ export class SocialPosterService {
include: { include: {
homeTeam: true, homeTeam: true,
awayTeam: true, awayTeam: true,
league: true, league: {
include: {
country: true,
},
},
}, },
}); });
+11 -3
View File
@@ -20,7 +20,7 @@ export class TwitterService {
void this.initClient(apiKey, apiSecret, accessToken, accessSecret); void this.initClient(apiKey, apiSecret, accessToken, accessSecret);
} else { } else {
this.logger.warn( this.logger.warn(
"⚠️ Twitter API keys not configured. Set TWITTER_API_KEY, TWITTER_API_SECRET, TWITTER_ACCESS_TOKEN, TWITTER_ACCESS_SECRET", "⚠️ X/Twitter API keys not configured. Set TWITTER_API_KEY, TWITTER_API_SECRET, TWITTER_ACCESS_TOKEN, TWITTER_ACCESS_SECRET",
); );
} }
} }
@@ -64,10 +64,10 @@ export class TwitterService {
} }
try { try {
// Step 1: Upload media via v1.1 // Step 1: Upload image media via the X media upload endpoint.
const mediaData = fs.readFileSync(imagePath); const mediaData = fs.readFileSync(imagePath);
const mediaId = await this.client.v1.uploadMedia(mediaData, { const mediaId = await this.client.v1.uploadMedia(mediaData, {
mimeType: "image/png", mimeType: this.getMimeType(imagePath),
}); });
// Step 2: Create tweet via v2 // Step 2: Create tweet via v2
@@ -84,4 +84,12 @@ export class TwitterService {
return null; return null;
} }
} }
private getMimeType(imagePath: string): string {
const ext = imagePath.toLowerCase().split(".").pop();
if (ext === "jpg" || ext === "jpeg") return "image/jpeg";
if (ext === "webp") return "image/webp";
if (ext === "png") return "image/png";
return "image/jpeg";
}
} }