feat: use Meta API for ad discovery
This commit is contained in:
@@ -9,6 +9,10 @@ ADMIN_EMAILS="admin@winninghunter.local"
|
|||||||
GOOGLE_CLIENT_ID=
|
GOOGLE_CLIENT_ID=
|
||||||
GOOGLE_CLIENT_SECRET=
|
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 Meta Ads ingestion (runtime secret; do not expose to the browser)
|
||||||
APIFY_TOKEN=
|
APIFY_TOKEN=
|
||||||
APIFY_ACTOR_ID=aiscraperdev~facebook-meta-ads-library-scraper
|
APIFY_ACTOR_ID=aiscraperdev~facebook-meta-ads-library-scraper
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ ADMIN_EMAILS="admin@winninghunter.local"
|
|||||||
GOOGLE_CLIENT_ID=
|
GOOGLE_CLIENT_ID=
|
||||||
GOOGLE_CLIENT_SECRET=
|
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 Meta Ads ingestion (runtime secret; do not expose to the browser)
|
||||||
APIFY_TOKEN=
|
APIFY_TOKEN=
|
||||||
APIFY_ACTOR_ID=aiscraperdev~facebook-meta-ads-library-scraper
|
APIFY_ACTOR_ID=aiscraperdev~facebook-meta-ads-library-scraper
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ services:
|
|||||||
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
|
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
|
||||||
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
||||||
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
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_TOKEN: ${APIFY_TOKEN:-}
|
||||||
APIFY_TIKTOK_ACTOR_ID: ${APIFY_TIKTOK_ACTOR_ID:-toolzerhub~tiktok-shop-products-scraper}
|
APIFY_TIKTOK_ACTOR_ID: ${APIFY_TIKTOK_ACTOR_ID:-toolzerhub~tiktok-shop-products-scraper}
|
||||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
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, 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 { prisma } from "@/lib/db";
|
||||||
import { planFromUser } from "@/lib/plans";
|
import { planFromUser } from "@/lib/plans";
|
||||||
import { checkAndConsumeQuota, refundQuota } from "@/lib/quota";
|
import { checkAndConsumeQuota, refundQuota } from "@/lib/quota";
|
||||||
@@ -58,7 +59,7 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
const job = await prisma.ingestJob.create({
|
const job = await prisma.ingestJob.create({
|
||||||
data: {
|
data: {
|
||||||
source: "apify",
|
source: "meta",
|
||||||
type: "meta-ads-library",
|
type: "meta-ads-library",
|
||||||
status: "RUNNING",
|
status: "RUNNING",
|
||||||
startedAt: new Date(),
|
startedAt: new Date(),
|
||||||
@@ -67,10 +68,17 @@ export async function POST(request: Request) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Meta bazen görsel OCR'ı veya CTA metni nedeniyle gevşek sonuçlar döndürür.
|
const meta = await searchMetaAds({
|
||||||
// İstenen sayıyı doldurabilmek için aynı maliyet tavanıyla daha geniş bir aday havuzu taranır.
|
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 candidateLimit = Math.min(parsed.data.maxResults * 3, 300);
|
||||||
const records = await runApifyActor({
|
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,
|
||||||
@@ -80,27 +88,34 @@ export async function POST(request: Request) {
|
|||||||
scrapeAdDetails: true,
|
scrapeAdDetails: true,
|
||||||
includeAboutPage: false
|
includeAboutPage: false
|
||||||
});
|
});
|
||||||
const relevantRecords = relevantApifyRecords(records, parsed.data.searchTerm, parsed.data.matchMode, parsed.data.maxResults);
|
const officialIds = new Set(meta.relevant.map((record) => String(record.adArchiveID || "")));
|
||||||
const result = await importApifyAds(relevantRecords);
|
const relevantMediaRecords = relevantApifyRecords(mediaRecords, parsed.data.searchTerm, parsed.data.matchMode, parsed.data.maxResults)
|
||||||
if (creditsReserved && relevantRecords.length < parsed.data.maxResults) {
|
.filter((record) => {
|
||||||
await refundQuota({ userId: user.id, metric: "api_credits_monthly", amount: parsed.data.maxResults - relevantRecords.length });
|
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({
|
await prisma.ingestJob.update({
|
||||||
where: { id: job.id },
|
where: { id: job.id },
|
||||||
data: {
|
data: {
|
||||||
status: "COMPLETED",
|
status: "COMPLETED",
|
||||||
finishedAt: new Date(),
|
finishedAt: new Date(),
|
||||||
recordsImported: result.imported,
|
recordsImported: mediaResult.imported,
|
||||||
recordsFailed: result.failed,
|
recordsFailed: officialResult.failed + mediaResult.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 }
|
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) {
|
} 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 });
|
||||||
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 } });
|
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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.");
|
if (!response.ok) throw new Error("Canlı reklam taraması başarısız oldu.");
|
||||||
|
|
||||||
setError(false);
|
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();
|
router.refresh();
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
setError(true);
|
setError(true);
|
||||||
|
|||||||
@@ -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<string, unknown> | 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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user