diff --git a/.env.example b/.env.example index c61c7e5..00d149c 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,10 @@ ADMIN_EMAILS="admin@winninghunter.local" GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= +# Meta Ad Library (server-side only; never expose this token to the browser) +META_ACCESS_TOKEN= +META_GRAPH_VERSION=v23.0 + # Apify Meta Ads ingestion (runtime secret; do not expose to the browser) APIFY_TOKEN= APIFY_ACTOR_ID=aiscraperdev~facebook-meta-ads-library-scraper diff --git a/.env.production.example b/.env.production.example index 167d244..d905611 100644 --- a/.env.production.example +++ b/.env.production.example @@ -9,6 +9,10 @@ ADMIN_EMAILS="admin@winninghunter.local" GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= +# Meta Ad Library (server-side only; never expose this token to the browser) +META_ACCESS_TOKEN= +META_GRAPH_VERSION=v23.0 + # Apify Meta Ads ingestion (runtime secret; do not expose to the browser) APIFY_TOKEN= APIFY_ACTOR_ID=aiscraperdev~facebook-meta-ads-library-scraper diff --git a/docker-compose.yml b/docker-compose.yml index 8d47db0..dc12c89 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,6 +29,8 @@ services: ADMIN_EMAILS: ${ADMIN_EMAILS:-} GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-} + META_ACCESS_TOKEN: ${META_ACCESS_TOKEN:-} + META_GRAPH_VERSION: ${META_GRAPH_VERSION:-v23.0} APIFY_TOKEN: ${APIFY_TOKEN:-} APIFY_TIKTOK_ACTOR_ID: ${APIFY_TIKTOK_ACTOR_ID:-toolzerhub~tiktok-shop-products-scraper} OPENAI_API_KEY: ${OPENAI_API_KEY:-} diff --git a/src/app/api/ads/import-apify/route.ts b/src/app/api/ads/import-apify/route.ts index 11fd15c..48359ae 100644 --- a/src/app/api/ads/import-apify/route.ts +++ b/src/app/api/ads/import-apify/route.ts @@ -1,7 +1,8 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { currentUser } from "@/lib/auth/current-user"; -import { importApifyAds, relevantApifyRecords, runApifyActor } from "@/lib/apify"; +import { importApifyAds, normalizeApifyAd, relevantApifyRecords, runApifyActor } from "@/lib/apify"; +import { searchMetaAds } from "@/lib/meta-ads"; import { prisma } from "@/lib/db"; import { planFromUser } from "@/lib/plans"; import { checkAndConsumeQuota, refundQuota } from "@/lib/quota"; @@ -58,7 +59,7 @@ export async function POST(request: Request) { const job = await prisma.ingestJob.create({ data: { - source: "apify", + source: "meta", type: "meta-ads-library", status: "RUNNING", startedAt: new Date(), @@ -67,10 +68,17 @@ export async function POST(request: Request) { }); try { - // Meta bazen görsel OCR'ı veya CTA metni nedeniyle gevşek sonuçlar döndürür. - // İstenen sayıyı doldurabilmek için aynı maliyet tavanıyla daha geniş bir aday havuzu taranır. + 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 records = await runApifyActor({ + const mediaRecords = await runApifyActor({ searchTerms: [parsed.data.searchTerm], country: parsed.data.country, adActiveStatus: parsed.data.status, @@ -80,27 +88,34 @@ export async function POST(request: Request) { scrapeAdDetails: true, includeAboutPage: false }); - const relevantRecords = relevantApifyRecords(records, parsed.data.searchTerm, parsed.data.matchMode, parsed.data.maxResults); - const result = await importApifyAds(relevantRecords); - if (creditsReserved && relevantRecords.length < parsed.data.maxResults) { - await refundQuota({ userId: user.id, metric: "api_credits_monthly", amount: parsed.data.maxResults - relevantRecords.length }); + 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; + }); + const officialResult = await importApifyAds(meta.relevant); + const mediaResult = await importApifyAds(relevantMediaRecords); + const delivered = Math.min(meta.relevant.length, relevantMediaRecords.length); + if (creditsReserved && delivered < parsed.data.maxResults) { + await refundQuota({ userId: user.id, metric: "api_credits_monthly", amount: parsed.data.maxResults - delivered }); } await prisma.ingestJob.update({ where: { id: job.id }, data: { status: "COMPLETED", finishedAt: new Date(), - recordsImported: result.imported, - recordsFailed: result.failed, - metadata: { userId: user.id, 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: records.length, relevant: relevantRecords.length } + 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 } } }); - return NextResponse.json({ ok: true, ...result, received: records.length, relevant: relevantRecords.length }); + 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 }); } 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 }); - const code = error instanceof Error && /^APIFY_[A-Z0-9_]+$/.test(error.message) ? error.message : "APIFY_INGEST_FAILED"; + const code = error instanceof Error && /^(META|APIFY)_[A-Z0-9_]+$/.test(error.message) ? error.message : "META_INGEST_FAILED"; await prisma.ingestJob.update({ where: { id: job.id }, data: { status: "FAILED", finishedAt: new Date(), errorMessage: code } }); - return NextResponse.json({ error: code }, { status: code === "APIFY_NOT_CONFIGURED" ? 503 : 502 }); + return NextResponse.json({ error: code }, { status: code.endsWith("NOT_CONFIGURED") ? 503 : 502 }); } } diff --git a/src/components/ads/apify-empty-search.tsx b/src/components/ads/apify-empty-search.tsx index 8877e33..9a08e5b 100644 --- a/src/components/ads/apify-empty-search.tsx +++ b/src/components/ads/apify-empty-search.tsx @@ -33,7 +33,7 @@ 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(`${data.received} aday tarandı, ${data.relevant} ilgili reklam bulundu. Sonuçlar yenileniyor…`); + 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…`); router.refresh(); } catch (caught) { setError(true); diff --git a/src/lib/meta-ads.ts b/src/lib/meta-ads.ts new file mode 100644 index 0000000..82983a9 --- /dev/null +++ b/src/lib/meta-ads.ts @@ -0,0 +1,115 @@ +import { AdSearchMatchMode, filterRelevantAds } from "@/lib/ad-search"; + +const META_GRAPH_VERSION = process.env.META_GRAPH_VERSION?.trim() || "v23.0"; +const META_GRAPH_BASE = `https://graph.facebook.com/${META_GRAPH_VERSION}`; + +type MetaArchiveAd = { + id?: string; + page_id?: string; + page_name?: string; + ad_creation_time?: string; + ad_delivery_start_time?: string; + ad_delivery_stop_time?: string; + ad_creative_bodies?: string[]; + ad_creative_link_titles?: string[]; + ad_creative_link_descriptions?: string[]; + languages?: string[]; + publisher_platforms?: string[]; +}; + +type MetaArchiveResponse = { + data?: MetaArchiveAd[]; + paging?: { next?: string }; + error?: { code?: number; message?: string; type?: string }; +}; + +export type MetaAdsInput = { + searchTerm: string; + country: string; + adActiveStatus: "ACTIVE" | "INACTIVE" | "ALL"; + matchMode: AdSearchMatchMode; + maxResults: number; +}; + +function accessToken() { + const token = process.env.META_ACCESS_TOKEN?.trim(); + if (!token) throw new Error("META_NOT_CONFIGURED"); + return token; +} + +function safeErrorCode(status: number, payload: MetaArchiveResponse) { + const apiCode = payload.error?.code; + return apiCode ? `META_API_${apiCode}` : `META_HTTP_${status}`; +} + +function toIngestRecord(ad: MetaArchiveAd, country: string, requestedStatus: MetaAdsInput["adActiveStatus"]): Record | null { + if (!ad.id) return null; + return { + adArchiveID: ad.id, + pageID: ad.page_id, + pageName: ad.page_name, + adText: ad.ad_creative_bodies?.[0], + headline: ad.ad_creative_link_titles?.[0], + description: ad.ad_creative_link_descriptions?.[0], + adStatus: requestedStatus === "ALL" ? (ad.ad_delivery_stop_time ? "INACTIVE" : "ACTIVE") : requestedStatus, + countries: country === "ALL" ? [] : [country], + startDate: ad.ad_delivery_start_time, + endDate: ad.ad_delivery_stop_time, + adCreationTime: ad.ad_creation_time, + language: ad.languages?.[0], + publisherPlatforms: ad.publisher_platforms, + // ad_snapshot_url access_token içerir. Sadece herkese açık Ad Library adresi saklanır. + adLibraryURL: `https://www.facebook.com/ads/library/?id=${encodeURIComponent(ad.id)}` + }; +} + +export async function searchMetaAds(input: MetaAdsInput) { + const token = accessToken(); + const candidateLimit = Math.min(Math.max(input.maxResults * 3, 25), 300); + const params = new URLSearchParams({ + search_terms: input.searchTerm, + search_type: input.matchMode === "EXACT_PHRASE" ? "KEYWORD_EXACT_PHRASE" : "KEYWORD_UNORDERED", + ad_reached_countries: JSON.stringify([input.country]), + ad_active_status: input.adActiveStatus, + ad_type: "ALL", + fields: [ + "id", "page_id", "page_name", "ad_creation_time", "ad_delivery_start_time", "ad_delivery_stop_time", + "ad_creative_bodies", "ad_creative_link_titles", "ad_creative_link_descriptions", "languages", "publisher_platforms" + ].join(","), + limit: String(Math.min(candidateLimit, 100)), + access_token: token + }); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 30_000); + const records: MetaArchiveAd[] = []; + let nextUrl: string | undefined = `${META_GRAPH_BASE}/ads_archive?${params}`; + + try { + while (nextUrl && records.length < candidateLimit) { + const response = await fetch(nextUrl, { cache: "no-store", signal: controller.signal }); + const payload = await response.json().catch(() => ({})) as MetaArchiveResponse; + if (!response.ok || payload.error) throw new Error(safeErrorCode(response.status, payload)); + records.push(...(payload.data || [])); + nextUrl = payload.paging?.next; + } + } catch (error) { + if (error instanceof Error && error.name === "AbortError") throw new Error("META_TIMEOUT"); + throw error; + } finally { + clearTimeout(timeout); + } + + const normalized = records.flatMap((ad) => { + const record = toIngestRecord(ad, input.country, input.adActiveStatus); + return record ? [{ + record, + primaryText: typeof record.adText === "string" ? record.adText : null, + headline: typeof record.headline === "string" ? record.headline : null, + description: typeof record.description === "string" ? record.description : null, + brandName: typeof record.pageName === "string" ? record.pageName : null + }] : []; + }); + const relevant = filterRelevantAds(normalized, input.searchTerm, input.matchMode, input.maxResults).map((item) => item.record); + return { received: records.length, relevant }; +}