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