fix: return media for Meta ad searches

This commit is contained in:
2026-08-23 06:09:05 +03:00
parent e25d70fcd8
commit 705d94037d
4 changed files with 96 additions and 34 deletions
+24 -19
View File
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { currentUser } from "@/lib/auth/current-user"; import { currentUser } from "@/lib/auth/current-user";
import { importApifyAds, normalizeApifyAd, relevantApifyRecords, runApifyActor } from "@/lib/apify"; import { importApifyAds, runApifyActor, selectMediaRecordsForSearch } from "@/lib/apify";
import { searchMetaAds } from "@/lib/meta-ads"; import { searchMetaAds } from "@/lib/meta-ads";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { planFromUser } from "@/lib/plans"; import { planFromUser } from "@/lib/plans";
@@ -68,35 +68,40 @@ export async function POST(request: Request) {
}); });
try { try {
const meta = await searchMetaAds({ // Meta Ad Library ham medya dosyası vermez. Doğrulanan reklamların görsel/video
// dosyaları aynı Meta Ads Library sorgusunun medya katmanından tamamlanır.
// İki uzak çağrı paralel çalışır; resmi kimlik eşleşmeleri önceliklidir, ancak
// farklı sayfalama nedeniyle kimliği kesişmeyen ilgili medya sonuçları atılmaz.
const [meta, mediaRecords] = await Promise.all([
searchMetaAds({
searchTerm: parsed.data.searchTerm, searchTerm: parsed.data.searchTerm,
country: parsed.data.country, country: parsed.data.country,
adActiveStatus: parsed.data.status, adActiveStatus: parsed.data.status,
matchMode: parsed.data.matchMode, matchMode: parsed.data.matchMode,
maxResults: parsed.data.maxResults maxResults: parsed.data.maxResults
}); }),
// Meta Ad Library ham medya dosyası vermez. Doğrulanan reklamların görsel/video runApifyActor({
// dosyaları mevcut medya katmanından tamamlanır; Meta token'ı istemciye çıkmaz.
const candidateLimit = Math.min(parsed.data.maxResults * 3, 300);
const mediaRecords = await runApifyActor({
searchTerms: [parsed.data.searchTerm], searchTerms: [parsed.data.searchTerm],
country: parsed.data.country, country: parsed.data.country,
adActiveStatus: parsed.data.status, adActiveStatus: parsed.data.status,
mediaType: parsed.data.mediaType, mediaType: parsed.data.mediaType,
maxResults: candidateLimit, maxResults: parsed.data.maxResults,
maxCostUsd: Math.max(0.1, Math.ceil(parsed.data.maxResults * 0.004 * 10) / 10), maxCostUsd: Math.max(0.1, Math.ceil(parsed.data.maxResults * 0.004 * 10) / 10),
scrapeAdDetails: true, scrapeAdDetails: true,
includeAboutPage: false includeAboutPage: false
}); })
const officialIds = new Set(meta.relevant.map((record) => String(record.adArchiveID || ""))); ]);
const relevantMediaRecords = relevantApifyRecords(mediaRecords, parsed.data.searchTerm, parsed.data.matchMode, parsed.data.maxResults) const mediaSelection = selectMediaRecordsForSearch(
.filter((record) => { mediaRecords,
const normalized = normalizeApifyAd(record); meta.relevant,
return normalized ? officialIds.has(normalized.externalAdId) : false; parsed.data.searchTerm,
}); parsed.data.matchMode,
parsed.data.mediaType,
parsed.data.maxResults
);
const officialResult = await importApifyAds(meta.relevant); const officialResult = await importApifyAds(meta.relevant);
const mediaResult = await importApifyAds(relevantMediaRecords); const mediaResult = await importApifyAds(mediaSelection.records);
const delivered = Math.min(meta.relevant.length, relevantMediaRecords.length); const delivered = Math.min(parsed.data.maxResults, mediaResult.imported);
if (creditsReserved && delivered < parsed.data.maxResults) { if (creditsReserved && delivered < parsed.data.maxResults) {
await refundQuota({ userId: user.id, metric: "api_credits_monthly", amount: parsed.data.maxResults - delivered }); await refundQuota({ userId: user.id, metric: "api_credits_monthly", amount: parsed.data.maxResults - delivered });
} }
@@ -107,10 +112,10 @@ export async function POST(request: Request) {
finishedAt: new Date(), finishedAt: new Date(),
recordsImported: mediaResult.imported, recordsImported: mediaResult.imported,
recordsFailed: officialResult.failed + mediaResult.failed, recordsFailed: officialResult.failed + mediaResult.failed,
metadata: { userId: user.id, provider: "meta", searchTerm: parsed.data.searchTerm, country: parsed.data.country, mediaType: parsed.data.mediaType, matchMode: parsed.data.matchMode, status: parsed.data.status, maxResults: parsed.data.maxResults, received: meta.received, relevant: meta.relevant.length, mediaEnriched: mediaResult.imported } metadata: { userId: user.id, provider: "meta", searchTerm: parsed.data.searchTerm, country: parsed.data.country, mediaType: parsed.data.mediaType, matchMode: parsed.data.matchMode, status: parsed.data.status, maxResults: parsed.data.maxResults, received: meta.received, relevant: meta.relevant.length, mediaCandidates: mediaRecords.length, officialMediaMatches: mediaSelection.officialMatches, fallbackMediaMatches: mediaSelection.fallbackMatches, mediaEnriched: mediaResult.imported }
} }
}); });
return NextResponse.json({ ok: true, provider: "meta", imported: mediaResult.imported, failed: officialResult.failed + mediaResult.failed, received: meta.received, relevant: meta.relevant.length, mediaEnriched: mediaResult.imported }); return NextResponse.json({ ok: true, provider: "meta", imported: mediaResult.imported, failed: officialResult.failed + mediaResult.failed, received: meta.received, relevant: meta.relevant.length, mediaCandidates: mediaRecords.length, mediaEnriched: mediaResult.imported });
} catch (error) { } catch (error) {
if (dailyReserved) await refundQuota({ userId: user.id, metric: "ads_search_daily" }); if (dailyReserved) await refundQuota({ userId: user.id, metric: "ads_search_daily" });
if (creditsReserved) await refundQuota({ userId: user.id, metric: "api_credits_monthly", amount: parsed.data.maxResults }); if (creditsReserved) await refundQuota({ userId: user.id, metric: "api_credits_monthly", amount: parsed.data.maxResults });
+15 -3
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
const RESULT_LIMIT = 10; const RESULT_LIMIT = 10;
@@ -12,6 +12,14 @@ export function ApifyEmptySearch({ query, country, mediaType, matchMode, status,
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const [error, setError] = useState(false); const [error, setError] = useState(false);
const [waitSeconds, setWaitSeconds] = useState(0);
useEffect(() => {
if (!busy) { setWaitSeconds(0); return; }
const startedAt = Date.now();
const interval = window.setInterval(() => setWaitSeconds(Math.floor((Date.now() - startedAt) / 1000)), 1000);
return () => window.clearInterval(interval);
}, [busy]);
async function importFromApify() { async function importFromApify() {
setBusy(true); setBusy(true);
@@ -33,7 +41,10 @@ export function ApifyEmptySearch({ query, country, mediaType, matchMode, status,
if (!response.ok) throw new Error("Canlı reklam taraması başarısız oldu."); if (!response.ok) throw new Error("Canlı reklam taraması başarısız oldu.");
setError(false); setError(false);
setMessage(`Meta'da ${data.received} aday tarandı, ${data.relevant} ilgili reklam bulundu; ${data.mediaEnriched ?? data.imported ?? 0} reklamın medyası hazırlandı. Sonuçlar yenileniyor…`); const prepared = data.mediaEnriched ?? data.imported ?? 0;
setMessage(prepared > 0
? `Meta'da ${data.received} aday tarandı, ${data.relevant} ilgili reklam bulundu; ${prepared} reklamın medyası hazırlandı. Sonuçlar yenileniyor…`
: `Meta'da ${data.received} aday ve ${data.relevant} ilgili reklam bulundu; seçili ${mediaType === "ALL" ? "medya" : mediaType.toLocaleLowerCase("tr-TR")} türünde kullanılabilir kreatif bulunamadı.`);
router.refresh(); router.refresh();
} catch (caught) { } catch (caught) {
setError(true); setError(true);
@@ -68,10 +79,11 @@ export function ApifyEmptySearch({ query, country, mediaType, matchMode, status,
onClick={importFromApify} onClick={importFromApify}
className="rounded-2xl bg-violet-700 px-5 py-3 font-black text-white disabled:cursor-wait disabled:opacity-50" className="rounded-2xl bg-violet-700 px-5 py-3 font-black text-white disabled:cursor-wait disabled:opacity-50"
> >
{busy ? "Getiriliyor…" : planLimit === 0 ? "Planı yükselt" : existingCount > 0 ? "Daha fazla getir" : "Getir"} {busy ? (waitSeconds >= 8 ? "Videolar hazırlanıyor…" : "Meta taranıyor…") : planLimit === 0 ? "Planı yükselt" : existingCount > 0 ? "Daha fazla getir" : "Getir"}
</button> </button>
</div> </div>
<p className="mt-2 text-xs text-slate-500">Canlı Meta verisi · {country === "ALL" ? "Tüm dünya" : country} · {mediaType === "ALL" ? "Tüm medya" : mediaType} · {matchMode === "EXACT_PHRASE" ? "Tam ifade" : "Tüm kelimeler"} · Plan limiti: {planLimit || "erişim yok"} reklam</p> <p className="mt-2 text-xs text-slate-500">Canlı Meta verisi · {country === "ALL" ? "Tüm dünya" : country} · {mediaType === "ALL" ? "Tüm medya" : mediaType} · {matchMode === "EXACT_PHRASE" ? "Tam ifade" : "Tüm kelimeler"} · Plan limiti: {planLimit || "erişim yok"} reklam</p>
{busy && <p className="mt-2 text-xs font-semibold text-violet-700">Meta sonuçları ve gerçek video/görseller hazırlanıyor. Bu işlem genellikle 1560 saniye sürer.</p>}
{message && <p className={`mt-3 text-sm font-semibold ${error ? "text-rose-600" : "text-emerald-700"}`}>{message}</p>} {message && <p className={`mt-3 text-sm font-semibold ${error ? "text-rose-600" : "text-emerald-700"}`}>{message}</p>}
</div> </div>
); );
+33
View File
@@ -255,6 +255,39 @@ export function relevantApifyRecords(records: JsonRecord[], query: string, mode:
return filterRelevantAds(normalized, query, mode, limit).map((item) => item.record); return filterRelevantAds(normalized, query, mode, limit).map((item) => item.record);
} }
function requestedMediaMatches(mediaType: MediaType, requested: ApifyIngestInput["mediaType"]) {
if (requested === "ALL" || requested === "NONE") return mediaType !== MediaType.UNKNOWN;
if (requested === "VIDEO") return mediaType === MediaType.VIDEO;
if (requested === "IMAGE" || requested === "MEME") return mediaType === MediaType.IMAGE;
return false;
}
export function selectMediaRecordsForSearch(
records: JsonRecord[],
officialRecords: JsonRecord[],
query: string,
mode: AdSearchMatchMode,
mediaType: ApifyIngestInput["mediaType"],
limit: number
) {
const officialIds = new Set(officialRecords.map((record) => String(record.adArchiveID || "")).filter(Boolean));
const seen = new Set<string>();
const candidates = relevantApifyRecords(records, query, mode, Math.max(records.length, limit)).flatMap((record) => {
const normalized = normalizeApifyAd(record);
if (!normalized?.creativeUrl || !requestedMediaMatches(normalized.mediaType, mediaType) || seen.has(normalized.externalAdId)) return [];
seen.add(normalized.externalAdId);
return [{ record, externalAdId: normalized.externalAdId }];
});
const official = candidates.filter((candidate) => officialIds.has(candidate.externalAdId));
const fallback = candidates.filter((candidate) => !officialIds.has(candidate.externalAdId));
const selected = [...official, ...fallback].slice(0, limit);
return {
records: selected.map((candidate) => candidate.record),
officialMatches: Math.min(official.length, limit),
fallbackMatches: Math.max(0, selected.length - Math.min(official.length, limit))
};
}
export async function importApifyAds(records: JsonRecord[]) { export async function importApifyAds(records: JsonRecord[]) {
let imported = 0; let imported = 0;
let failed = 0; let failed = 0;
+13 -1
View File
@@ -1,7 +1,7 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { MediaType } from "@prisma/client"; import { MediaType } from "@prisma/client";
import { actorInput, normalizeApifyAd } from "../src/lib/apify"; import { actorInput, normalizeApifyAd, selectMediaRecordsForSearch } from "../src/lib/apify";
import { adSearchRelevance, filterRelevantAds } from "../src/lib/ad-search"; import { adSearchRelevance, filterRelevantAds } from "../src/lib/ad-search";
import { getFeatureLimit } from "../src/lib/plans"; import { getFeatureLimit } from "../src/lib/plans";
import { istemciIp, hizSiniriAsimi } from "../src/lib/rate-limit"; import { istemciIp, hizSiniriAsimi } from "../src/lib/rate-limit";
@@ -165,3 +165,15 @@ test("arama sonuçları ilgililiğe göre sıralanıp istenen sayıda kesilir",
assert.equal(result.length, 1); assert.equal(result.length, 1);
assert.equal(result[0].headline, "Translate instantly"); assert.equal(result[0].headline, "Translate instantly");
}); });
test("Meta medya seçimi resmi kimliği önceler ve ilgili medya yedeğini sıfıra düşürmez", () => {
const mediaRecords = [
{ ad_id: "fallback-1", ad_body_text: "Anında çeviri yap", ad_format: "video", video_url: "https://video.xx.fbcdn.net/fallback.mp4", page_name: "Çeviri" },
{ ad_id: "official-1", ad_body_text: "Çeviri uygulaması", ad_format: "video", video_url: "https://video.xx.fbcdn.net/official.mp4", page_name: "Dil" },
{ ad_id: "image-1", ad_body_text: "Çeviri uygulaması", ad_format: "image", image_url: "https://scontent.xx.fbcdn.net/image.jpg", page_name: "Dil" }
];
const selection = selectMediaRecordsForSearch(mediaRecords, [{ adArchiveID: "official-1" }], "çeviri", "ALL_WORDS", "VIDEO", 2);
assert.deepEqual(selection.records.map((record) => record.ad_id), ["official-1", "fallback-1"]);
assert.equal(selection.officialMatches, 1);
assert.equal(selection.fallbackMatches, 1);
});