@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user