@@ -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
@@ -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. 25–40 dakika içinde başlayacak maçları `top_leagues.json` filtresiyle bulur.
|
**Cron:** Her 15 dakikada bir çalışır. Varsayılan olarak 25–45 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 |
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user