This commit is contained in:
2026-04-16 17:21:48 +03:00
parent c8fa4c442d
commit c8e7e4e927
116 changed files with 3720 additions and 4197 deletions
@@ -1,6 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { GeminiService } from '../gemini/gemini.service';
import { PredictionCardDto } from './dto/prediction-card.dto';
import { Injectable, Logger } from "@nestjs/common";
import { GeminiService } from "../gemini/gemini.service";
import { PredictionCardDto } from "./dto/prediction-card.dto";
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.
@@ -28,7 +28,7 @@ export class CaptionGeneratorService {
*/
async generateCaption(card: PredictionCardDto): Promise<string> {
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);
}
@@ -48,7 +48,7 @@ export class CaptionGeneratorService {
);
return caption;
} catch (error) {
this.logger.error('Gemini caption generation failed', error);
this.logger.error("Gemini caption generation failed", error);
return this.generateFallbackCaption(card);
}
}
@@ -59,7 +59,7 @@ export class CaptionGeneratorService {
(p, i) =>
`${i + 1}. ${p.market} (${p.marketEn}) — ${p.pick} — Güven: %${p.confidence} — Oran: ${p.odds}`,
)
.join('\n');
.join("\n");
return `Aşağıdaki maç tahmin verisini kullanarak bir sosyal medya postu oluştur:
@@ -79,12 +79,12 @@ 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('#')) {
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, '');
.replace(/\s+/g, "")
.replace(/[^a-zA-Z0-9üöçşğıİÜÖÇŞĞ]/g, "");
const homeTag = card.homeTeam.replace(/\s+/g, "");
const awayTag = card.awayTeam.replace(/\s+/g, "");
text += `\n\n#${leagueTag} #${homeTag} #${awayTag}`;
}
return text.trim();
@@ -96,13 +96,13 @@ Sadece post metnini yaz, başka hiçbir şey ekleme.`;
private generateFallbackCaption(card: PredictionCardDto): string {
const topPick = card.topPicks[0];
const leagueTag = card.leagueName
.replace(/\s+/g, '')
.replace(/[^a-zA-Z0-9üöçşğıİÜÖÇŞĞ]/g, '');
.replace(/\s+/g, "")
.replace(/[^a-zA-Z0-9üöçşğıİÜÖÇŞĞ]/g, "");
return `${card.homeTeam} vs ${card.awayTeam}
🎯 Tahminimiz: ${card.ftScore} (İY: ${card.htScore})
📊 Güven: %${card.scoreConfidence}
${topPick ? `🔥 ${topPick.market}: ${topPick.pick} (%${topPick.confidence})` : ''}
${topPick ? `🔥 ${topPick.market}: ${topPick.pick} (%${topPick.confidence})` : ""}
#${leagueTag} #SuggestBet #Bahis`.trim();
}
@@ -42,7 +42,7 @@ export interface PredictionCardDto {
topPicks: TopPick[];
// ─── Risk ───
riskLevel: 'LOW' | 'MEDIUM' | 'HIGH' | 'EXTREME';
riskLevel: "LOW" | "MEDIUM" | "HIGH" | "EXTREME";
// ─── Raw prediction JSON (for Gemini caption) ───
rawPrediction?: Record<string, any>;
@@ -1,17 +1,17 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import * as fs from 'fs';
import * as path from 'path';
import axios from 'axios';
import { createCanvas, loadImage } from 'canvas';
import { PredictionCardDto } from './dto/prediction-card.dto';
import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
import * as fs from "fs";
import * as path from "path";
import axios from "axios";
import { createCanvas, loadImage } from "canvas";
import { PredictionCardDto } from "./dto/prediction-card.dto";
@Injectable()
export class ImageRendererService implements OnModuleInit {
private readonly logger = new Logger(ImageRendererService.name);
private readonly outputDir = path.join(
process.cwd(),
'public',
'predictions',
"public",
"predictions",
);
onModuleInit() {
@@ -53,8 +53,8 @@ export class ImageRendererService implements OnModuleInit {
try {
// Case 1: Local relative path → read from public/ directory
if (url.startsWith('/')) {
const localPath = path.join(process.cwd(), 'public', url);
if (url.startsWith("/")) {
const localPath = path.join(process.cwd(), "public", url);
if (fs.existsSync(localPath)) {
this.logger.debug(`Loading logo from local file: ${localPath}`);
return await loadImage(localPath);
@@ -66,9 +66,9 @@ export class ImageRendererService implements OnModuleInit {
}
// Case 2: Full HTTP/HTTPS URL → fetch directly
if (url.startsWith('http')) {
if (url.startsWith("http")) {
const response = await axios.get(url, {
responseType: 'arraybuffer',
responseType: "arraybuffer",
timeout: 5000,
});
return await loadImage(response.data);
@@ -133,14 +133,14 @@ export class ImageRendererService implements OnModuleInit {
const width = 1080;
const height = 1920;
const canvas = createCanvas(width, height);
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext("2d");
// Background Gradient
const bgGrad = ctx.createLinearGradient(0, 0, width, height);
bgGrad.addColorStop(0, '#0a0e27');
bgGrad.addColorStop(0.35, '#1a1040');
bgGrad.addColorStop(0.7, '#0d1b2a');
bgGrad.addColorStop(1, '#0a0e27');
bgGrad.addColorStop(0, "#0a0e27");
bgGrad.addColorStop(0.35, "#1a1040");
bgGrad.addColorStop(0.7, "#0d1b2a");
bgGrad.addColorStop(1, "#0a0e27");
ctx.fillStyle = bgGrad;
ctx.fillRect(0, 0, width, height);
@@ -148,12 +148,12 @@ export class ImageRendererService implements OnModuleInit {
ctx.save();
ctx.translate(width / 2, height / 2);
ctx.rotate((-35 * Math.PI) / 180);
ctx.fillStyle = 'rgba(255, 255, 255, 0.05)';
ctx.font = '900 100px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = "rgba(255, 255, 255, 0.05)";
ctx.font = "900 100px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
const wmLine =
'iddaai.com iddaai.com iddaai.com iddaai.com iddaai.com iddaai.com iddaai.com iddaai.com';
"iddaai.com iddaai.com iddaai.com iddaai.com iddaai.com iddaai.com iddaai.com iddaai.com";
for (let i = -15; i <= 15; i++) {
ctx.fillText(wmLine, 0, i * 180);
}
@@ -163,14 +163,14 @@ export class ImageRendererService implements OnModuleInit {
const paddingX = 80;
// Header
ctx.fillStyle = 'rgba(255, 255, 255, 0.7)';
ctx.font = '600 28px sans-serif';
ctx.textAlign = 'left';
ctx.fillStyle = "rgba(255, 255, 255, 0.7)";
ctx.font = "600 28px sans-serif";
ctx.textAlign = "left";
ctx.fillText(data.leagueName.toUpperCase(), paddingX, 120);
ctx.fillStyle = 'rgba(255, 255, 255, 0.45)';
ctx.font = '400 22px sans-serif';
ctx.textAlign = 'right';
ctx.fillStyle = "rgba(255, 255, 255, 0.45)";
ctx.font = "400 22px sans-serif";
ctx.textAlign = "right";
ctx.fillText(data.matchDate, width - paddingX, 120);
// Teams Section
@@ -184,31 +184,31 @@ export class ImageRendererService implements OnModuleInit {
if (awayImg)
ctx.drawImage(awayImg, (width / 4) * 3 - 100, currentY, 200, 200);
ctx.fillStyle = 'rgba(255, 255, 255, 0.15)';
ctx.font = '900 56px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('VS', width / 2, currentY + 110);
ctx.fillStyle = "rgba(255, 255, 255, 0.15)";
ctx.font = "900 56px sans-serif";
ctx.textAlign = "center";
ctx.fillText("VS", width / 2, currentY + 110);
currentY += 250;
ctx.fillStyle = '#ffffff';
ctx.font = '700 36px sans-serif';
ctx.textAlign = 'center';
ctx.fillStyle = "#ffffff";
ctx.font = "700 36px sans-serif";
ctx.textAlign = "center";
ctx.fillText(data.homeTeam, width / 4, currentY);
ctx.fillText(data.awayTeam, (width / 4) * 3, currentY);
// Divider: Skore Prediction
currentY += 140;
const drawSectionTitle = (y: number, text: string) => {
ctx.textAlign = 'center';
ctx.fillStyle = 'rgba(255, 255, 255, 0.5)';
ctx.font = '600 22px sans-serif';
ctx.textAlign = "center";
ctx.fillStyle = "rgba(255, 255, 255, 0.5)";
ctx.font = "600 22px sans-serif";
ctx.fillText(text, width / 2, y + 8);
const txtWidth = ctx.measureText(text).width;
const grad = ctx.createLinearGradient(paddingX, y, width - paddingX, y);
grad.addColorStop(0, 'rgba(120, 80, 255, 0)');
grad.addColorStop(0.5, 'rgba(120, 80, 255, 0.6)');
grad.addColorStop(1, 'rgba(120, 80, 255, 0)');
grad.addColorStop(0, "rgba(120, 80, 255, 0)");
grad.addColorStop(0.5, "rgba(120, 80, 255, 0.6)");
grad.addColorStop(1, "rgba(120, 80, 255, 0)");
ctx.fillStyle = grad;
ctx.fillRect(
@@ -225,7 +225,7 @@ export class ImageRendererService implements OnModuleInit {
);
};
drawSectionTitle(currentY, 'SKOR TAHMİNİ / SCORE PREDICTION');
drawSectionTitle(currentY, "SKOR TAHMİNİ / SCORE PREDICTION");
// Scores
currentY += 80;
@@ -235,20 +235,20 @@ export class ImageRendererService implements OnModuleInit {
const ftX = width / 2 + 24;
// HT Box
ctx.fillStyle = 'rgba(255, 255, 255, 0.04)';
ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)';
ctx.fillStyle = "rgba(255, 255, 255, 0.04)";
ctx.strokeStyle = "rgba(255, 255, 255, 0.08)";
ctx.lineWidth = 2;
this.fillRoundRect(ctx, htX, currentY, scoreBoxWidth, scoreBoxHeight, 20);
this.strokeRoundRect(ctx, htX, currentY, scoreBoxWidth, scoreBoxHeight, 20);
ctx.fillStyle = 'rgba(255, 255, 255, 0.45)';
ctx.font = '600 20px sans-serif';
ctx.fillText('İLK YARI', htX + scoreBoxWidth / 2, currentY + 40);
ctx.fillStyle = 'rgba(255, 255, 255, 0.25)';
ctx.font = '400 16px sans-serif';
ctx.fillText('Half Time', htX + scoreBoxWidth / 2, currentY + 65);
ctx.fillStyle = '#ffffff';
ctx.font = '900 80px sans-serif';
ctx.fillStyle = "rgba(255, 255, 255, 0.45)";
ctx.font = "600 20px sans-serif";
ctx.fillText("İLK YARI", htX + scoreBoxWidth / 2, currentY + 40);
ctx.fillStyle = "rgba(255, 255, 255, 0.25)";
ctx.font = "400 16px sans-serif";
ctx.fillText("Half Time", htX + scoreBoxWidth / 2, currentY + 65);
ctx.fillStyle = "#ffffff";
ctx.font = "900 80px sans-serif";
ctx.fillText(data.htScore, htX + scoreBoxWidth / 2, currentY + 160);
// FT Box
@@ -258,19 +258,19 @@ export class ImageRendererService implements OnModuleInit {
ftX + scoreBoxWidth,
currentY + scoreBoxHeight,
);
ftGrad.addColorStop(0, 'rgba(120, 80, 255, 0.15)');
ftGrad.addColorStop(1, 'rgba(0, 200, 255, 0.1)');
ftGrad.addColorStop(0, "rgba(120, 80, 255, 0.15)");
ftGrad.addColorStop(1, "rgba(0, 200, 255, 0.1)");
ctx.fillStyle = ftGrad;
ctx.strokeStyle = 'rgba(120, 80, 255, 0.3)';
ctx.strokeStyle = "rgba(120, 80, 255, 0.3)";
this.fillRoundRect(ctx, ftX, currentY, scoreBoxWidth, scoreBoxHeight, 20);
this.strokeRoundRect(ctx, ftX, currentY, scoreBoxWidth, scoreBoxHeight, 20);
ctx.fillStyle = 'rgba(255, 255, 255, 0.45)';
ctx.font = '600 20px sans-serif';
ctx.fillText('MAÇ SONU', ftX + scoreBoxWidth / 2, currentY + 40);
ctx.fillStyle = 'rgba(255, 255, 255, 0.25)';
ctx.font = '400 16px sans-serif';
ctx.fillText('Full Time', ftX + scoreBoxWidth / 2, currentY + 65);
ctx.fillStyle = "rgba(255, 255, 255, 0.45)";
ctx.font = "600 20px sans-serif";
ctx.fillText("MAÇ SONU", ftX + scoreBoxWidth / 2, currentY + 40);
ctx.fillStyle = "rgba(255, 255, 255, 0.25)";
ctx.font = "400 16px sans-serif";
ctx.fillText("Full Time", ftX + scoreBoxWidth / 2, currentY + 65);
// Score text gradient
const txtGrad = ctx.createLinearGradient(
@@ -279,15 +279,15 @@ export class ImageRendererService implements OnModuleInit {
ftX,
currentY + 160,
);
txtGrad.addColorStop(0, '#9b6fff');
txtGrad.addColorStop(1, '#00c8ff');
txtGrad.addColorStop(0, "#9b6fff");
txtGrad.addColorStop(1, "#00c8ff");
ctx.fillStyle = txtGrad;
ctx.font = '900 80px sans-serif';
ctx.font = "900 80px sans-serif";
ctx.fillText(data.ftScore, ftX + scoreBoxWidth / 2, currentY + 160);
// Confidence badge
ctx.fillStyle = '#0a0e27';
ctx.strokeStyle = 'rgba(120, 80, 255, 0.6)';
ctx.fillStyle = "#0a0e27";
ctx.strokeStyle = "rgba(120, 80, 255, 0.6)";
this.fillRoundRect(
ctx,
ftX + scoreBoxWidth / 2 - 80,
@@ -304,8 +304,8 @@ export class ImageRendererService implements OnModuleInit {
40,
20,
);
ctx.fillStyle = '#b89dff';
ctx.font = '800 20px sans-serif';
ctx.fillStyle = "#b89dff";
ctx.font = "800 20px sans-serif";
ctx.fillText(
`🎯 %${data.scoreConfidence}`,
ftX + scoreBoxWidth / 2,
@@ -314,13 +314,13 @@ export class ImageRendererService implements OnModuleInit {
// Divider: Picks
currentY += scoreBoxHeight + 100;
drawSectionTitle(currentY, 'EN İYİ TAHMİNLER / BEST PICKS');
drawSectionTitle(currentY, "EN İYİ TAHMİNLER / BEST PICKS");
// Picks rendering
currentY += 80;
data.topPicks.forEach((pick, index) => {
ctx.fillStyle = 'rgba(255, 255, 255, 0.03)';
ctx.strokeStyle = 'rgba(255, 255, 255, 0.06)';
ctx.fillStyle = "rgba(255, 255, 255, 0.03)";
ctx.strokeStyle = "rgba(255, 255, 255, 0.06)";
this.fillRoundRect(
ctx,
paddingX,
@@ -338,18 +338,18 @@ export class ImageRendererService implements OnModuleInit {
16,
);
ctx.fillStyle = 'rgba(255, 255, 255, 0.3)';
ctx.font = '700 28px sans-serif';
ctx.textAlign = 'left';
ctx.fillStyle = "rgba(255, 255, 255, 0.3)";
ctx.font = "700 28px sans-serif";
ctx.textAlign = "left";
ctx.fillText(String(index + 1), paddingX + 30, currentY + 58);
ctx.fillStyle = '#ffffff';
ctx.font = '600 26px sans-serif';
ctx.fillStyle = "#ffffff";
ctx.font = "600 26px sans-serif";
ctx.fillText(pick.market, paddingX + 80, currentY + 45);
const marketWidth = ctx.measureText(pick.market).width;
ctx.fillStyle = 'rgba(255, 255, 255, 0.35)';
ctx.font = '400 18px sans-serif';
ctx.fillStyle = "rgba(255, 255, 255, 0.35)";
ctx.font = "400 18px sans-serif";
ctx.fillText(
`(${pick.marketEn})`,
paddingX + 80 + marketWidth + 10,
@@ -357,7 +357,7 @@ export class ImageRendererService implements OnModuleInit {
);
// Pick Bar bg
ctx.fillStyle = 'rgba(255, 255, 255, 0.06)';
ctx.fillStyle = "rgba(255, 255, 255, 0.06)";
const barMaxWidth = width - 2 * paddingX - 220;
this.fillRoundRect(ctx, paddingX + 80, currentY + 65, barMaxWidth, 12, 6);
@@ -369,15 +369,15 @@ export class ImageRendererService implements OnModuleInit {
paddingX + 80 + barMaxWidth,
0,
);
barGrad.addColorStop(0, '#7850ff');
barGrad.addColorStop(1, '#00c8ff');
barGrad.addColorStop(0, "#7850ff");
barGrad.addColorStop(1, "#00c8ff");
ctx.fillStyle = barGrad;
this.fillRoundRect(ctx, paddingX + 80, currentY + 65, fillWidth, 12, 6);
// Confidence text
ctx.fillStyle = '#b89dff';
ctx.font = '900 32px sans-serif';
ctx.textAlign = 'right';
ctx.fillStyle = "#b89dff";
ctx.font = "900 32px sans-serif";
ctx.textAlign = "right";
ctx.fillText(`%${pick.confidence}`, width - paddingX - 30, currentY + 58);
currentY += 124;
@@ -385,41 +385,41 @@ export class ImageRendererService implements OnModuleInit {
// Footer
currentY = height - 80;
ctx.fillStyle = 'rgba(255, 255, 255, 0.4)';
ctx.font = '700 26px sans-serif';
ctx.textAlign = 'left';
ctx.fillText('⚡ AI Powered by SuggestBet', paddingX, currentY);
ctx.fillStyle = "rgba(255, 255, 255, 0.4)";
ctx.font = "700 26px sans-serif";
ctx.textAlign = "left";
ctx.fillText("⚡ AI Powered by SuggestBet", paddingX, currentY);
let riskBg, riskColor, riskBorder;
switch (data.riskLevel) {
case 'LOW':
riskBg = 'rgba(0, 200, 100, 0.15)';
riskColor = '#4ade80';
riskBorder = 'rgba(0, 200, 100, 0.3)';
case "LOW":
riskBg = "rgba(0, 200, 100, 0.15)";
riskColor = "#4ade80";
riskBorder = "rgba(0, 200, 100, 0.3)";
break;
case 'MEDIUM':
riskBg = 'rgba(255, 200, 0, 0.12)';
riskColor = '#fbbf24';
riskBorder = 'rgba(255, 200, 0, 0.25)';
case "MEDIUM":
riskBg = "rgba(255, 200, 0, 0.12)";
riskColor = "#fbbf24";
riskBorder = "rgba(255, 200, 0, 0.25)";
break;
case 'HIGH':
riskBg = 'rgba(255, 100, 50, 0.12)';
riskColor = '#f97316';
riskBorder = 'rgba(255, 100, 50, 0.25)';
case "HIGH":
riskBg = "rgba(255, 100, 50, 0.12)";
riskColor = "#f97316";
riskBorder = "rgba(255, 100, 50, 0.25)";
break;
case 'EXTREME':
riskBg = 'rgba(255, 50, 50, 0.15)';
riskColor = '#ef4444';
riskBorder = 'rgba(255, 50, 50, 0.3)';
case "EXTREME":
riskBg = "rgba(255, 50, 50, 0.15)";
riskColor = "#ef4444";
riskBorder = "rgba(255, 50, 50, 0.3)";
break;
default:
riskBg = 'rgba(255, 255, 255, 0.1)';
riskColor = '#ffffff';
riskBorder = 'rgba(255, 255, 255, 0.3)';
riskBg = "rgba(255, 255, 255, 0.1)";
riskColor = "#ffffff";
riskBorder = "rgba(255, 255, 255, 0.3)";
}
const riskText = `RISK: ${data.riskLevel}`;
ctx.font = '800 20px sans-serif';
ctx.font = "800 20px sans-serif";
const riskWidth = ctx.measureText(riskText).width;
ctx.fillStyle = riskBg;
ctx.strokeStyle = riskBorder;
@@ -441,11 +441,11 @@ export class ImageRendererService implements OnModuleInit {
);
ctx.fillStyle = riskColor;
ctx.textAlign = 'center';
ctx.textAlign = "center";
ctx.fillText(riskText, width - paddingX - riskWidth / 2 - 24, currentY + 3);
// Save Output directly using the buffer
const buffer = canvas.toBuffer('image/png');
const buffer = canvas.toBuffer("image/png");
fs.writeFileSync(outPath, buffer);
}
@@ -454,9 +454,9 @@ export class ImageRendererService implements OnModuleInit {
*/
getImageUrl(filePath: string): string {
const relativePath = path.relative(
path.join(process.cwd(), 'public'),
path.join(process.cwd(), "public"),
filePath,
);
return `/${relativePath.replace(/\\/g, '/')}`;
return `/${relativePath.replace(/\\/g, "/")}`;
}
}
+18 -18
View File
@@ -1,6 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import axios from "axios";
@Injectable()
export class MetaService {
@@ -10,21 +10,21 @@ export class MetaService {
private readonly pageId: string;
private readonly igUserId: string;
private readonly isEnabled: boolean;
private readonly graphApiBase = 'https://graph.facebook.com/v21.0';
private readonly graphApiBase = "https://graph.facebook.com/v21.0";
constructor(private readonly configService: ConfigService) {
this.pageAccessToken =
this.configService.get<string>('META_PAGE_ACCESS_TOKEN') || '';
this.pageId = this.configService.get<string>('META_PAGE_ID') || '';
this.igUserId = this.configService.get<string>('META_IG_USER_ID') || '';
this.configService.get<string>("META_PAGE_ACCESS_TOKEN") || "";
this.pageId = this.configService.get<string>("META_PAGE_ID") || "";
this.igUserId = this.configService.get<string>("META_IG_USER_ID") || "";
this.isEnabled = !!(this.pageAccessToken && this.pageId);
if (this.isEnabled) {
this.logger.log('✅ Meta API client initialized');
this.logger.log("✅ Meta API client initialized");
} else {
this.logger.warn(
'⚠️ Meta API not configured. Set META_PAGE_ACCESS_TOKEN, META_PAGE_ID, META_IG_USER_ID',
"⚠️ Meta API not configured. Set META_PAGE_ACCESS_TOKEN, META_PAGE_ID, META_IG_USER_ID",
);
}
}
@@ -53,7 +53,7 @@ export class MetaService {
imageUrl: string,
): Promise<string | null> {
if (!this.facebookAvailable) {
this.logger.warn('Facebook not available, skipping post');
this.logger.warn("Facebook not available, skipping post");
return null;
}
@@ -98,7 +98,7 @@ export class MetaService {
imageUrl: string,
): Promise<string | null> {
if (!this.instagramAvailable) {
this.logger.warn('Instagram not available, skipping post');
this.logger.warn("Instagram not available, skipping post");
return null;
}
@@ -115,7 +115,7 @@ export class MetaService {
const containerId = containerResponse.data?.id;
if (!containerId) {
throw new Error('No container ID returned');
throw new Error("No container ID returned");
}
// Wait for container processing (IG needs a few seconds)
@@ -156,25 +156,25 @@ export class MetaService {
`${this.graphApiBase}/${containerId}`,
{
params: {
fields: 'status_code',
fields: "status_code",
access_token: this.pageAccessToken,
},
},
);
const status = response.data?.status_code;
if (status === 'FINISHED') return;
if (status === 'ERROR') {
throw new Error('Container processing failed');
if (status === "FINISHED") return;
if (status === "ERROR") {
throw new Error("Container processing failed");
}
} catch (error) {
if (error.message === 'Container processing failed') throw error;
if (error.message === "Container processing failed") throw error;
}
// Wait 2 seconds before checking again
await new Promise((resolve) => setTimeout(resolve, 2000));
}
this.logger.warn('Container wait timed out, attempting publish anyway');
this.logger.warn("Container wait timed out, attempting publish anyway");
}
}
@@ -1,25 +1,25 @@
import { Controller, Post, Param, Get, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Controller, Post, Param, Get, UseGuards } from "@nestjs/common";
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { SocialPosterService } from './social-poster.service';
import { Roles } from '../../common/decorators';
import { RolesGuard } from '../auth/guards/auth.guards';
import { SocialPosterService } from "./social-poster.service";
import { Roles } from "../../common/decorators";
import { RolesGuard } from "../auth/guards/auth.guards";
@ApiTags('Social Poster')
@ApiTags("Social Poster")
@ApiBearerAuth()
@UseGuards(RolesGuard)
@Roles('admin')
@Controller('social-poster')
@Roles("admin")
@Controller("social-poster")
export class SocialPosterController {
constructor(private readonly socialPosterService: SocialPosterService) {}
@Get('preview/:matchId')
async previewCard(@Param('matchId') matchId: string) {
@Get("preview/:matchId")
async previewCard(@Param("matchId") matchId: string) {
return this.socialPosterService.renderPreview(matchId);
}
@Post('post/:matchId')
async postMatch(@Param('matchId') matchId: string) {
@Post("post/:matchId")
async postMatch(@Param("matchId") matchId: string) {
return this.socialPosterService.manualPost(matchId);
}
}
@@ -1,14 +1,14 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { ScheduleModule } from "@nestjs/schedule";
import { SocialPosterService } from './social-poster.service';
import { ImageRendererService } from './image-renderer.service';
import { CaptionGeneratorService } from './caption-generator.service';
import { TwitterService } from './twitter.service';
import { MetaService } from './meta.service';
import { SocialPosterService } from "./social-poster.service";
import { ImageRendererService } from "./image-renderer.service";
import { CaptionGeneratorService } from "./caption-generator.service";
import { TwitterService } from "./twitter.service";
import { MetaService } from "./meta.service";
import { SocialPosterController } from './social-poster.controller';
import { SocialPosterController } from "./social-poster.controller";
/**
* Social Poster Module
@@ -1,24 +1,24 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../../database/prisma.service';
import axios from 'axios';
import * as fs from 'fs';
import * as path from 'path';
import { Injectable, Logger } from "@nestjs/common";
import { Cron } from "@nestjs/schedule";
import { ConfigService } from "@nestjs/config";
import { PrismaService } from "../../database/prisma.service";
import axios from "axios";
import * as fs from "fs";
import * as path from "path";
import { ImageRendererService } from './image-renderer.service';
import { CaptionGeneratorService } from './caption-generator.service';
import { TwitterService } from './twitter.service';
import { MetaService } from './meta.service';
import { ImageRendererService } from "./image-renderer.service";
import { CaptionGeneratorService } from "./caption-generator.service";
import { TwitterService } from "./twitter.service";
import { MetaService } from "./meta.service";
import {
PredictionCardDto,
TopPick,
SocialPostResult,
} from './dto/prediction-card.dto';
} from "./dto/prediction-card.dto";
// 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");
@Injectable()
export class SocialPosterService {
@@ -38,24 +38,24 @@ export class SocialPosterService {
private readonly metaService: MetaService,
) {
this.aiEngineUrl =
this.configService.get<string>('AI_ENGINE_URL') ||
'http://localhost:8000';
this.configService.get<string>("AI_ENGINE_URL") ||
"http://localhost:8000";
this.appBaseUrl =
this.configService.get<string>('APP_BASE_URL') || 'http://localhost:3000';
this.configService.get<string>("APP_BASE_URL") || "http://localhost:3000";
this.isEnabled =
this.configService.get<string>('SOCIAL_POSTER_ENABLED') === 'true';
this.configService.get<string>("SOCIAL_POSTER_ENABLED") === "true";
this.loadTopLeagues();
}
private loadTopLeagues() {
try {
const data = fs.readFileSync(TOP_LEAGUES_PATH, 'utf-8');
const data = fs.readFileSync(TOP_LEAGUES_PATH, "utf-8");
const ids = JSON.parse(data);
this.topLeagueIds = new Set(ids);
this.logger.log(`✅ Loaded ${this.topLeagueIds.size} top league IDs`);
} catch {
this.logger.warn('⚠️ Could not load top_leagues.json');
this.logger.warn("⚠️ Could not load top_leagues.json");
}
}
@@ -63,7 +63,7 @@ export class SocialPosterService {
* Cron: Every 10 minutes, check for upcoming matches.
* Posts predictions 30 minutes before kickoff.
*/
@Cron('*/10 * * * *')
@Cron("*/10 * * * *")
async checkAndPostUpcomingMatches() {
if (!this.isEnabled) return;
@@ -115,7 +115,7 @@ export class SocialPosterService {
const matches = await this.prisma.liveMatch.findMany({
where: {
sport: 'football',
sport: "football",
leagueId: { in: Array.from(this.topLeagueIds) },
mstUtc: {
gte: minTime,
@@ -144,7 +144,7 @@ export class SocialPosterService {
// Step 1: Get prediction from AI Engine
const prediction = await this.getPrediction(matchId);
if (!prediction) {
throw new Error('No prediction returned from AI Engine');
throw new Error("No prediction returned from AI Engine");
}
// Step 2: Build prediction card data
@@ -194,9 +194,9 @@ export class SocialPosterService {
this.logger.log(
`✅ Posted: ${match.homeTeam?.name} vs ${match.awayTeam?.name} ` +
`[TW: ${result.twitterPostId ? '✅' : '❌'}, ` +
`FB: ${result.facebookPostId ? '✅' : '❌'}, ` +
`IG: ${result.instagramPostId ? '✅' : '❌'}]`,
`[TW: ${result.twitterPostId ? "✅" : "❌"}, ` +
`FB: ${result.facebookPostId ? "✅" : "❌"}, ` +
`IG: ${result.instagramPostId ? "✅" : "❌"}]`,
);
return result;
@@ -229,8 +229,8 @@ export class SocialPosterService {
): PredictionCardDto {
// V20+ returns score_prediction.ft / .ht
const score = prediction.score_prediction || {};
const htScore = score.ht || '0-0';
const ftScore = score.ft || '1-1';
const htScore = score.ht || "0-0";
const ftScore = score.ft || "1-1";
// Extract best bets from bet_summary array
const topPicks = this.extractTopPicks(prediction);
@@ -247,18 +247,18 @@ export class SocialPosterService {
return {
matchId: match.id,
homeTeam:
match.homeTeam?.name || prediction.match_info?.home_team || 'Home',
match.homeTeam?.name || prediction.match_info?.home_team || "Home",
awayTeam:
match.awayTeam?.name || prediction.match_info?.away_team || 'Away',
homeLogo: this.resolveLogoUrl(match.homeTeam?.logoUrl || ''),
awayLogo: this.resolveLogoUrl(match.awayTeam?.logoUrl || ''),
leagueName: match.league?.name || prediction.match_info?.league || '',
match.awayTeam?.name || prediction.match_info?.away_team || "Away",
homeLogo: this.resolveLogoUrl(match.homeTeam?.logoUrl || ""),
awayLogo: this.resolveLogoUrl(match.awayTeam?.logoUrl || ""),
leagueName: match.league?.name || prediction.match_info?.league || "",
matchDate,
htScore,
ftScore,
scoreConfidence,
topPicks,
riskLevel: prediction.risk?.level || 'MEDIUM',
riskLevel: prediction.risk?.level || "MEDIUM",
rawPrediction: prediction,
};
}
@@ -271,16 +271,16 @@ export class SocialPosterService {
// Market code to Turkish/English label mapping
const marketLabels: Record<string, { tr: string; en: string }> = {
MS: { tr: 'Maç Sonucu', en: 'Match Result' },
OU15: { tr: 'Üst 1.5 Gol', en: 'Over 1.5' },
OU25: { tr: 'Üst 2.5 Gol', en: 'Over 2.5' },
OU35: { tr: 'Üst 3.5 Gol', en: 'Over 3.5' },
BTTS: { tr: 'Karşılıklı Gol', en: 'Both Teams Score' },
DC: { tr: 'Çifte Şans', en: 'Double Chance' },
HT: { tr: 'İlk Yarı Sonucu', en: 'Half Time Result' },
HT_OU05: { tr: 'İY 0.5 Üst/Alt', en: 'HT Over/Under 0.5' },
OE: { tr: 'Tek/Çift', en: 'Odd/Even' },
HTFT: { tr: 'İY/MS', en: 'HT/FT' },
MS: { tr: "Maç Sonucu", en: "Match Result" },
OU15: { tr: "Üst 1.5 Gol", en: "Over 1.5" },
OU25: { tr: "Üst 2.5 Gol", en: "Over 2.5" },
OU35: { tr: "Üst 3.5 Gol", en: "Over 3.5" },
BTTS: { tr: "Karşılıklı Gol", en: "Both Teams Score" },
DC: { tr: "Çifte Şans", en: "Double Chance" },
HT: { tr: "İlk Yarı Sonucu", en: "Half Time Result" },
HT_OU05: { tr: "İY 0.5 Üst/Alt", en: "HT Over/Under 0.5" },
OE: { tr: "Tek/Çift", en: "Odd/Even" },
HTFT: { tr: "İY/MS", en: "HT/FT" },
};
const candidates: TopPick[] = betSummary.map((bet) => {
@@ -308,11 +308,11 @@ export class SocialPosterService {
* Locally during dev, we fetch them from the deployed server via APP_BASE_URL.
*/
private resolveLogoUrl(logoUrl: string): string {
if (!logoUrl) return '';
if (!logoUrl) return "";
// Already a full URL
if (logoUrl.startsWith('http')) return logoUrl;
if (logoUrl.startsWith("http")) return logoUrl;
// Relative path → check local first, otherwise make full URL
const localPath = path.join(process.cwd(), 'public', logoUrl);
const localPath = path.join(process.cwd(), "public", logoUrl);
if (fs.existsSync(localPath)) return logoUrl; // Keep relative, renderer reads local
// Not local → prepend base URL for remote fetch
return `${this.appBaseUrl}${logoUrl}`;
@@ -321,24 +321,24 @@ export class SocialPosterService {
private formatMatchDate(mstUtc: number | bigint): string {
const d = new Date(Number(mstUtc));
const months = [
'Oca',
'Şub',
'Mar',
'Nis',
'May',
'Haz',
'Tem',
'Ağu',
'Eyl',
'Eki',
'Kas',
'Ara',
"Oca",
"Şub",
"Mar",
"Nis",
"May",
"Haz",
"Tem",
"Ağu",
"Eyl",
"Eki",
"Kas",
"Ara",
];
const day = String(d.getDate()).padStart(2, '0');
const day = String(d.getDate()).padStart(2, "0");
const month = months[d.getMonth()];
const year = d.getFullYear();
const hour = String(d.getHours()).padStart(2, '0');
const min = String(d.getMinutes()).padStart(2, '0');
const hour = String(d.getHours()).padStart(2, "0");
const min = String(d.getMinutes()).padStart(2, "0");
return `${day} ${month} ${year} - ${hour}:${min}`;
}
@@ -383,7 +383,7 @@ export class SocialPosterService {
const prediction = await this.getPrediction(matchId);
if (!prediction) {
throw new Error('No prediction returned from AI Engine');
throw new Error("No prediction returned from AI Engine");
}
const card = this.buildCardFromPrediction(match, prediction);
+13 -13
View File
@@ -1,6 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as fs from 'fs';
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import * as fs from "fs";
@Injectable()
export class TwitterService {
@@ -9,18 +9,18 @@ export class TwitterService {
private isEnabled = false;
constructor(private readonly configService: ConfigService) {
const apiKey = this.configService.get<string>('TWITTER_API_KEY');
const apiSecret = this.configService.get<string>('TWITTER_API_SECRET');
const accessToken = this.configService.get<string>('TWITTER_ACCESS_TOKEN');
const apiKey = this.configService.get<string>("TWITTER_API_KEY");
const apiSecret = this.configService.get<string>("TWITTER_API_SECRET");
const accessToken = this.configService.get<string>("TWITTER_ACCESS_TOKEN");
const accessSecret = this.configService.get<string>(
'TWITTER_ACCESS_SECRET',
"TWITTER_ACCESS_SECRET",
);
if (apiKey && apiSecret && accessToken && accessSecret) {
void this.initClient(apiKey, apiSecret, accessToken, accessSecret);
} else {
this.logger.warn(
'⚠️ Twitter API keys not configured. Set TWITTER_API_KEY, TWITTER_API_SECRET, TWITTER_ACCESS_TOKEN, TWITTER_ACCESS_SECRET',
"⚠️ Twitter API keys not configured. Set TWITTER_API_KEY, TWITTER_API_SECRET, TWITTER_ACCESS_TOKEN, TWITTER_ACCESS_SECRET",
);
}
}
@@ -32,7 +32,7 @@ export class TwitterService {
accessSecret: string,
) {
try {
const { TwitterApi } = await import('twitter-api-v2');
const { TwitterApi } = await import("twitter-api-v2");
this.client = new TwitterApi({
appKey: apiKey,
appSecret: apiSecret,
@@ -40,9 +40,9 @@ export class TwitterService {
accessSecret,
});
this.isEnabled = true;
this.logger.log('✅ Twitter API client initialized');
this.logger.log("✅ Twitter API client initialized");
} catch (error) {
this.logger.error('Failed to initialize Twitter client', error);
this.logger.error("Failed to initialize Twitter client", error);
}
}
@@ -59,7 +59,7 @@ export class TwitterService {
*/
async postWithImage(text: string, imagePath: string): Promise<string | null> {
if (!this.available) {
this.logger.warn('Twitter not available, skipping post');
this.logger.warn("Twitter not available, skipping post");
return null;
}
@@ -67,7 +67,7 @@ export class TwitterService {
// Step 1: Upload media via v1.1
const mediaData = fs.readFileSync(imagePath);
const mediaId = await this.client.v1.uploadMedia(mediaData, {
mimeType: 'image/png',
mimeType: "image/png",
});
// Step 2: Create tweet via v2