diff --git a/src/app/api/ads/import-apify/route.ts b/src/app/api/ads/import-apify/route.ts index 48359ae..defc723 100644 --- a/src/app/api/ads/import-apify/route.ts +++ b/src/app/api/ads/import-apify/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; import { z } from "zod"; 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 { prisma } from "@/lib/db"; import { planFromUser } from "@/lib/plans"; @@ -68,35 +68,40 @@ export async function POST(request: Request) { }); try { - const meta = await searchMetaAds({ - searchTerm: parsed.data.searchTerm, - country: parsed.data.country, - adActiveStatus: parsed.data.status, - matchMode: parsed.data.matchMode, - maxResults: parsed.data.maxResults - }); // Meta Ad Library ham medya dosyası vermez. Doğrulanan reklamların görsel/video - // 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], - country: parsed.data.country, - adActiveStatus: parsed.data.status, - mediaType: parsed.data.mediaType, - maxResults: candidateLimit, - maxCostUsd: Math.max(0.1, Math.ceil(parsed.data.maxResults * 0.004 * 10) / 10), - scrapeAdDetails: true, - 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) - .filter((record) => { - const normalized = normalizeApifyAd(record); - return normalized ? officialIds.has(normalized.externalAdId) : false; - }); + // 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, + country: parsed.data.country, + adActiveStatus: parsed.data.status, + matchMode: parsed.data.matchMode, + maxResults: parsed.data.maxResults + }), + runApifyActor({ + searchTerms: [parsed.data.searchTerm], + country: parsed.data.country, + adActiveStatus: parsed.data.status, + mediaType: parsed.data.mediaType, + maxResults: parsed.data.maxResults, + maxCostUsd: Math.max(0.1, Math.ceil(parsed.data.maxResults * 0.004 * 10) / 10), + scrapeAdDetails: true, + includeAboutPage: false + }) + ]); + const mediaSelection = selectMediaRecordsForSearch( + mediaRecords, + meta.relevant, + parsed.data.searchTerm, + parsed.data.matchMode, + parsed.data.mediaType, + parsed.data.maxResults + ); const officialResult = await importApifyAds(meta.relevant); - const mediaResult = await importApifyAds(relevantMediaRecords); - const delivered = Math.min(meta.relevant.length, relevantMediaRecords.length); + const mediaResult = await importApifyAds(mediaSelection.records); + const delivered = Math.min(parsed.data.maxResults, mediaResult.imported); if (creditsReserved && delivered < parsed.data.maxResults) { 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(), recordsImported: mediaResult.imported, 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) { 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 }); diff --git a/src/components/ads/apify-empty-search.tsx b/src/components/ads/apify-empty-search.tsx index 9a08e5b..d6a087b 100644 --- a/src/components/ads/apify-empty-search.tsx +++ b/src/components/ads/apify-empty-search.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; const RESULT_LIMIT = 10; @@ -12,6 +12,14 @@ export function ApifyEmptySearch({ query, country, mediaType, matchMode, status, const [busy, setBusy] = useState(false); const [message, setMessage] = useState(""); 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() { 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."); 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(); } catch (caught) { setError(true); @@ -68,10 +79,11 @@ export function ApifyEmptySearch({ query, country, mediaType, matchMode, status, onClick={importFromApify} 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"}

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

+ {busy &&

Meta sonuçları ve gerçek video/görseller hazırlanıyor. Bu işlem genellikle 15–60 saniye sürer.

} {message &&

{message}

} ); diff --git a/src/lib/apify.ts b/src/lib/apify.ts index e68cb68..412a91d 100644 --- a/src/lib/apify.ts +++ b/src/lib/apify.ts @@ -255,6 +255,39 @@ export function relevantApifyRecords(records: JsonRecord[], query: string, mode: 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(); + 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[]) { let imported = 0; let failed = 0; diff --git a/tests/core.test.ts b/tests/core.test.ts index f955219..ed549bf 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; 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 { getFeatureLimit } from "../src/lib/plans"; 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[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); +});