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
@@ -1,6 +1,8 @@
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { GeminiService } from "../gemini/gemini.service";
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.
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)
- Skor tahminini vurgula
- 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.)
- KESİNLİKLE "kesin kazanır", "garanti" gibi ifadeler KULLANMA
- "Tahminimiz", "Beklentimiz", "Analizimiz" gibi ifadeler kullan
@@ -20,13 +23,31 @@ KURALLAR:
@Injectable()
export class CaptionGeneratorService {
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.
*/
async generateCaption(card: PredictionCardDto): Promise<string> {
if (this.ollamaModel) {
const caption = await this.generateWithOllama(card);
if (caption) return caption;
}
if (!this.geminiService.isAvailable()) {
this.logger.warn("Gemini not available, using template caption");
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 {
const topPicksText = card.topPicks
.map(
@@ -64,9 +118,11 @@ export class CaptionGeneratorService {
return `Aşağıdaki maç tahmin verisini kullanarak bir sosyal medya postu oluştur:
MAÇ: ${card.homeTeam} vs ${card.awayTeam}
SPOR: ${card.sport === "basketball" ? "Basketbol" : "Futbol"}
LİG: ${card.leagueName}
ÜLKE/BÖLGE: ${card.countryName || "-"}
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}
SKOR GÜVEN: %${card.scoreConfidence}
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, "");
const homeTag = card.homeTeam.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();
}
@@ -99,11 +156,14 @@ Sadece post metnini yaz, başka hiçbir şey ekleme.`;
.replace(/\s+/g, "")
.replace(/[^a-zA-Z0-9üöçşğıİÜÖÇŞĞ]/g, "");
return `${card.homeTeam} vs ${card.awayTeam}
🎯 Tahminimiz: ${card.ftScore} (İY: ${card.htScore})
const sportLabel = card.sport === "basketball" ? "Basketbol" : "Futbol";
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}
${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 {
// ─── Match Info ───
matchId: string;
sport: "football" | "basketball";
homeTeam: string;
awayTeam: string;
homeLogo: string;
awayLogo: string;
leagueName: string;
leagueLogo?: string;
countryName?: string;
countryFlag?: string;
/** Formatted date, e.g. "01 Mar 2026 - 21:00" */
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 igUserId: string;
private readonly isEnabled: boolean;
private readonly graphApiBase = "https://graph.facebook.com/v21.0";
private readonly graphApiBase: string;
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") || "";
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);
@@ -63,11 +66,12 @@ export class MetaService {
{
url: imageUrl,
message,
published: true,
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}`);
return postId || null;
} catch (error) {
@@ -109,6 +113,7 @@ export class MetaService {
{
image_url: imageUrl,
caption,
alt_text: this.buildAltText(caption),
access_token: this.pageAccessToken,
},
);
@@ -156,7 +161,7 @@ export class MetaService {
`${this.graphApiBase}/${containerId}`,
{
params: {
fields: "status_code",
fields: "status_code,status",
access_token: this.pageAccessToken,
},
},
@@ -177,4 +182,12 @@ export class MetaService {
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
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()
export class SocialPosterService {
@@ -26,6 +31,9 @@ export class SocialPosterService {
private readonly aiEngineUrl: string;
private readonly appBaseUrl: string;
private readonly isEnabled: boolean;
private readonly sports: string[];
private readonly windowMinMinutes: number;
private readonly windowMaxMinutes: number;
private readonly postedMatchIds = new Set<string>();
private topLeagueIds: Set<string> = new Set();
@@ -44,8 +52,22 @@ export class SocialPosterService {
this.configService.get<string>("APP_BASE_URL") || "http://localhost:3000";
this.isEnabled =
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.loadPostedState();
}
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.
*/
@Cron("*/10 * * * *")
@Cron("*/15 * * * *")
async checkAndPostUpcomingMatches() {
if (!this.isEnabled) return;
try {
const matches = await this.getUpcomingMatches(25, 40); // 25-40 min window
const matches = await this.getUpcomingMatches(
this.windowMinMinutes,
this.windowMaxMinutes,
);
this.logger.log(
`📅 Found ${matches.length} upcoming matches in the window`,
);
@@ -77,7 +128,19 @@ export class SocialPosterService {
if (this.postedMatchIds.has(match.id)) continue;
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);
// Cleanup: remove old IDs (keep last 500)
@@ -87,6 +150,7 @@ export class SocialPosterService {
.slice(0, arr.length - 500)
.forEach((id) => this.postedMatchIds.delete(id));
}
this.savePostedState();
} catch (error) {
this.logger.error(
`Failed to process match ${match.id}: ${error.message}`,
@@ -113,19 +177,33 @@ export class SocialPosterService {
const minTime = now + minMinutes * 60 * 1000;
const maxTime = now + maxMinutes * 60 * 1000;
const where: any = {
sport: { in: this.sports },
mstUtc: {
gte: minTime,
lte: maxTime,
},
};
if (this.topLeagueIds.size > 0) {
where.leagueId = { in: Array.from(this.topLeagueIds) };
}
const matches = await this.prisma.liveMatch.findMany({
where: {
sport: "football",
leagueId: { in: Array.from(this.topLeagueIds) },
mstUtc: {
gte: minTime,
lte: maxTime,
},
...where,
},
include: {
homeTeam: 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";
// 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
const matchDate = this.formatMatchDate(match.mstUtc);
@@ -246,6 +327,7 @@ export class SocialPosterService {
return {
matchId: match.id,
sport,
homeTeam:
match.homeTeam?.name || prediction.match_info?.home_team || "Home",
awayTeam:
@@ -253,6 +335,12 @@ export class SocialPosterService {
homeLogo: this.resolveLogoUrl(match.homeTeam?.logoUrl || ""),
awayLogo: this.resolveLogoUrl(match.awayTeam?.logoUrl || ""),
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,
htScore,
ftScore,
@@ -266,11 +354,14 @@ export class SocialPosterService {
/**
* 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 || [];
// 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" },
OU15: { tr: "Üst 1.5 Gol", en: "Over 1.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" },
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 labels = marketLabels[bet.market] || {
@@ -302,6 +407,32 @@ export class SocialPosterService {
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.
* On the deployed server, logos exist at public/uploads/teams/...
@@ -351,7 +482,11 @@ export class SocialPosterService {
include: {
homeTeam: true,
awayTeam: true,
league: true,
league: {
include: {
country: true,
},
},
},
});
@@ -373,7 +508,11 @@ export class SocialPosterService {
include: {
homeTeam: 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);
} else {
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 {
// Step 1: Upload media via v1.1
// Step 1: Upload image media via the X media upload endpoint.
const mediaData = fs.readFileSync(imagePath);
const mediaId = await this.client.v1.uploadMedia(mediaData, {
mimeType: "image/png",
mimeType: this.getMimeType(imagePath),
});
// Step 2: Create tweet via v2
@@ -84,4 +84,12 @@ export class TwitterService {
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";
}
}