From acddeb7e8f26093f29586fd61a156066ab0ef134 Mon Sep 17 00:00:00 2001 From: Hikmet Date: Sat, 22 Aug 2026 08:08:46 +0300 Subject: [PATCH] fix: improve Meta ad search relevance and filters --- src/app/api/ads/import-apify/route.ts | 24 +++++---- src/app/dashboard/ads/page.tsx | 66 +++++++++++++++++++---- src/components/ads/apify-empty-search.tsx | 8 +-- src/lib/ad-search.ts | 63 ++++++++++++++++++++++ src/lib/apify.ts | 16 ++++++ tests/core.test.ts | 18 +++++++ 6 files changed, 173 insertions(+), 22 deletions(-) create mode 100644 src/lib/ad-search.ts diff --git a/src/app/api/ads/import-apify/route.ts b/src/app/api/ads/import-apify/route.ts index 7c3046c..11fd15c 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, runApifyActor } from "@/lib/apify"; +import { importApifyAds, relevantApifyRecords, runApifyActor } from "@/lib/apify"; import { prisma } from "@/lib/db"; import { planFromUser } from "@/lib/plans"; import { checkAndConsumeQuota, refundQuota } from "@/lib/quota"; @@ -12,6 +12,8 @@ const schema = z.object({ searchTerm: z.string().trim().min(2).max(100), country: z.string().trim().toUpperCase().refine((value) => AD_COUNTRY_CODES.has(value)), mediaType: z.enum(["ALL", "IMAGE", "VIDEO", "MEME"]), + matchMode: z.enum(["ALL_WORDS", "EXACT_PHRASE"]).default("ALL_WORDS"), + status: z.enum(["ACTIVE", "INACTIVE", "ALL"]).default("ACTIVE"), maxResults: z.coerce.number().int().refine((value) => [10, 25, 50, 100].includes(value)) }); @@ -60,24 +62,28 @@ export async function POST(request: Request) { type: "meta-ads-library", status: "RUNNING", startedAt: new Date(), - metadata: { userId: user.id, searchTerm: parsed.data.searchTerm, country: parsed.data.country, mediaType: parsed.data.mediaType, maxResults: parsed.data.maxResults } + 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 } } }); 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 candidateLimit = Math.min(parsed.data.maxResults * 3, 300); const records = await runApifyActor({ searchTerms: [parsed.data.searchTerm], country: parsed.data.country, - adActiveStatus: "ACTIVE", + adActiveStatus: parsed.data.status, mediaType: parsed.data.mediaType, - maxResults: parsed.data.maxResults, + maxResults: candidateLimit, maxCostUsd: Math.max(0.1, Math.ceil(parsed.data.maxResults * 0.004 * 10) / 10), scrapeAdDetails: true, includeAboutPage: false }); - const result = await importApifyAds(records); - if (creditsReserved && records.length < parsed.data.maxResults) { - await refundQuota({ userId: user.id, metric: "api_credits_monthly", amount: parsed.data.maxResults - records.length }); + 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 }); } await prisma.ingestJob.update({ where: { id: job.id }, @@ -86,10 +92,10 @@ export async function POST(request: Request) { 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, maxResults: parsed.data.maxResults, received: records.length } + 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 } } }); - return NextResponse.json({ ok: true, ...result, received: records.length }); + return NextResponse.json({ ok: true, ...result, received: records.length, relevant: relevantRecords.length }); } 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/app/dashboard/ads/page.tsx b/src/app/dashboard/ads/page.tsx index 6a82052..5419ada 100644 --- a/src/app/dashboard/ads/page.tsx +++ b/src/app/dashboard/ads/page.tsx @@ -8,6 +8,7 @@ import { Card } from "@/components/ui/card"; import { AdCreativeMedia } from "@/components/ad-creative-media"; import { ApifyEmptySearch } from "@/components/ads/apify-empty-search"; import { AD_COUNTRIES } from "@/lib/countries"; +import { AdSearchMatchMode, adSearchRelevance, adSearchTokens } from "@/lib/ad-search"; export default async function AdsPage({ searchParams }: { searchParams: Promise> }) { const user = await requireUser(); @@ -17,12 +18,28 @@ export default async function AdsPage({ searchParams }: { searchParams: Promise< const niche = resolvedSearchParams.niche; const mediaType = resolvedSearchParams.mediaType; const country = resolvedSearchParams.country?.toUpperCase(); + const status = (["ACTIVE", "INACTIVE", "ALL"] as const).includes(resolvedSearchParams.status as any) ? resolvedSearchParams.status as "ACTIVE" | "INACTIVE" | "ALL" : "ACTIVE"; + const matchMode: AdSearchMatchMode = resolvedSearchParams.matchMode === "EXACT_PHRASE" ? "EXACT_PHRASE" : "ALL_WORDS"; + const minDays = Math.max(0, Math.min(3650, Number(resolvedSearchParams.minDays) || 0)); + const language = resolvedSearchParams.language?.trim(); + const sort = (["relevance", "newest", "longest"] as const).includes(resolvedSearchParams.sort as any) ? resolvedSearchParams.sort as "relevance" | "newest" | "longest" : "relevance"; const displayableCreativeWhere: Prisma.AdCreativeWhereInput = { url: { not: "" } }; const where: Prisma.AdWhereInput = { creatives: { some: displayableCreativeWhere } }; - if (q) where.OR = [{ primaryText: { contains: q, mode: "insensitive" } }, { headline: { contains: q, mode: "insensitive" } }, { brandPage: { name: { contains: q, mode: "insensitive" } } }]; + if (q) { + const terms = matchMode === "EXACT_PHRASE" ? [q] : adSearchTokens(q); + where.AND = terms.map((term) => ({ OR: [ + { primaryText: { contains: term, mode: "insensitive" } }, + { headline: { contains: term, mode: "insensitive" } }, + { description: { contains: term, mode: "insensitive" } }, + { brandPage: { name: { contains: term, mode: "insensitive" } } } + ] })); + } if (niche) where.niche = { contains: niche, mode: "insensitive" }; if (mediaType) where.mediaType = mediaType as any; if (country && country !== "ALL") where.countries = { has: country }; + if (status !== "ALL") where.status = status; + if (minDays > 0) where.daysRunning = { gte: minDays }; + if (language) where.language = { contains: language, mode: "insensitive" }; const realAdsExist = await prisma.ad.count({ where: { externalAdId: { not: { startsWith: "demo_ad_" } } } }) > 0; if (realAdsExist) where.externalAdId = { not: { startsWith: "demo_ad_" } }; const ads = await prisma.ad.findMany({ @@ -32,12 +49,19 @@ export default async function AdsPage({ searchParams }: { searchParams: Promise< creatives: { where: displayableCreativeWhere, orderBy: { position: "asc" }, take: 1 }, savedBy: { where: { userId: user.id } } }, - orderBy: { rankPercentile: "asc" }, - take: 48 + orderBy: sort === "longest" ? { daysRunning: "desc" } : sort === "newest" ? { firstSeenAt: "desc" } : { rankPercentile: "asc" }, + take: q ? 240 : 48 }); const isAdmin = user.role === "ADMIN"; const apifyPlanLimit = isAdmin || plan?.code === "PREMIUM" ? 100 : plan?.code === "STANDARD" ? 50 : plan?.code === "BASIC" ? 25 : 0; - const masked = ads.map((ad) => maskAdForPlan({ ...ad, isSaved: ad.savedBy.length > 0 }, plan?.code, isAdmin)); + const relevantAds = q + ? ads.map((ad) => ({ ad, relevance: adSearchRelevance({ ...ad, brandName: ad.brandPage?.name }, q, matchMode) })) + .filter((item) => item.relevance > 0) + .sort((left, right) => sort === "relevance" ? right.relevance - left.relevance : 0) + .slice(0, 48) + .map((item) => item.ad) + : ads; + const masked = relevantAds.map((ad) => maskAdForPlan({ ...ad, isSaved: ad.savedBy.length > 0 }, plan?.code, isAdmin)); return (
@@ -48,24 +72,46 @@ export default async function AdsPage({ searchParams }: { searchParams: Promise<
Plan: {plan?.name} · Free ise kartlar kilitli
-
- + +
+ - +
+
+ Detaylı arama +
+ + + + + + +
+
{["Week's biggest winners", "US winners", "Dropship Ads", "Supplements", "Top Branded"].map((x) => {x})}
- {q && masked.length === 0 && } + {q && masked.length === 0 && } {masked.map((ad: any) => ( {ad.isLocked &&
Start now — Unlock winners
} diff --git a/src/components/ads/apify-empty-search.tsx b/src/components/ads/apify-empty-search.tsx index 0c5283c..705a2ab 100644 --- a/src/components/ads/apify-empty-search.tsx +++ b/src/components/ads/apify-empty-search.tsx @@ -6,7 +6,7 @@ import { useRouter } from "next/navigation"; const RESULT_LIMIT = 10; const RESULT_OPTIONS = [10, 25, 50, 100]; -export function ApifyEmptySearch({ query, country, mediaType, planLimit }: { query: string; country: string; mediaType: string; planLimit: number }) { +export function ApifyEmptySearch({ query, country, mediaType, matchMode, status, planLimit }: { query: string; country: string; mediaType: string; matchMode: "ALL_WORDS" | "EXACT_PHRASE"; status: "ACTIVE" | "INACTIVE" | "ALL"; planLimit: number }) { const router = useRouter(); const [maxResults, setMaxResults] = useState(RESULT_LIMIT); const [busy, setBusy] = useState(false); @@ -24,6 +24,8 @@ export function ApifyEmptySearch({ query, country, mediaType, planLimit }: { que searchTerm: query, country, mediaType, + matchMode, + status, maxResults }) }); @@ -31,7 +33,7 @@ export function ApifyEmptySearch({ query, country, mediaType, planLimit }: { que if (!response.ok) throw new Error(data.error || "APIFY_INGEST_FAILED"); setError(false); - setMessage(`${data.imported} reklam işlendi. Sonuçlar yenileniyor…`); + setMessage(`${data.received} aday tarandı, ${data.relevant} ilgili reklam bulundu. Sonuçlar yenileniyor…`); router.refresh(); } catch (caught) { setError(true); @@ -67,7 +69,7 @@ export function ApifyEmptySearch({ query, country, mediaType, planLimit }: { que {busy ? "Getiriliyor…" : planLimit === 0 ? "Planı yükselt" : "Getir"}
-

Apify · {country === "ALL" ? "Tüm ülkeler" : country} · {mediaType === "ALL" ? "Tüm medya" : mediaType} · Plan limiti: {planLimit || "erişim yok"} reklam

+

Apify · {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

{message &&

{message}

} ); diff --git a/src/lib/ad-search.ts b/src/lib/ad-search.ts new file mode 100644 index 0000000..6a6e7c4 --- /dev/null +++ b/src/lib/ad-search.ts @@ -0,0 +1,63 @@ +export type AdSearchMatchMode = "ALL_WORDS" | "EXACT_PHRASE"; + +type SearchableAd = { + primaryText?: string | null; + headline?: string | null; + description?: string | null; + ctaText?: string | null; + brandName?: string | null; +}; + +export function normalizeAdSearchText(value: string | null | undefined) { + return (value || "") + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .toLocaleLowerCase("en-US") + .replace(/[^\p{L}\p{N}]+/gu, " ") + .trim() + .replace(/\s+/g, " "); +} + +export function adSearchTokens(query: string) { + return [...new Set(normalizeAdSearchText(query).split(" ").filter(Boolean))]; +} + +function containsPhrase(text: string, phrase: string) { + return Boolean(text && phrase && ` ${text} `.includes(` ${phrase} `)); +} + +export function adSearchRelevance(ad: SearchableAd, query: string, mode: AdSearchMatchMode = "ALL_WORDS") { + const phrase = normalizeAdSearchText(query); + const tokens = adSearchTokens(query); + if (!phrase || tokens.length === 0) return 0; + + const fields = [ + [normalizeAdSearchText(ad.headline), 12], + [normalizeAdSearchText(ad.brandName), 10], + [normalizeAdSearchText(ad.primaryText), 7], + [normalizeAdSearchText(ad.description), 4], + [normalizeAdSearchText(ad.ctaText), 2] + ] as const; + const combined = fields.map(([text]) => text).filter(Boolean).join(" "); + const combinedTokens = new Set(combined.split(" ").filter(Boolean)); + const matches = mode === "EXACT_PHRASE" + ? fields.some(([text]) => containsPhrase(text, phrase)) + : tokens.every((token) => combinedTokens.has(token)); + if (!matches) return 0; + + let score = 1; + for (const [text, weight] of fields) { + if (containsPhrase(text, phrase)) score += weight * 3; + score += tokens.filter((token) => text.split(" ").includes(token)).length * weight; + } + return score; +} + +export function filterRelevantAds(ads: T[], query: string, mode: AdSearchMatchMode, limit: number) { + return ads + .map((ad, index) => ({ ad, index, score: adSearchRelevance(ad, query, mode) })) + .filter((item) => item.score > 0) + .sort((left, right) => right.score - left.score || left.index - right.index) + .slice(0, limit) + .map((item) => item.ad); +} diff --git a/src/lib/apify.ts b/src/lib/apify.ts index d133f40..e68cb68 100644 --- a/src/lib/apify.ts +++ b/src/lib/apify.ts @@ -1,5 +1,6 @@ import { AdSource, AdStatus, MediaType, Prisma } from "@prisma/client"; import { prisma } from "@/lib/db"; +import { AdSearchMatchMode, filterRelevantAds } from "@/lib/ad-search"; const APIFY_API_BASE = "https://api.apify.com/v2"; const DEFAULT_ACTOR_ID = "aiscraperdev~facebook-meta-ads-library-scraper"; @@ -239,6 +240,21 @@ export function actorInput(actorId: string, input: ApifyIngestInput) { }; } +export function relevantApifyRecords(records: JsonRecord[], query: string, mode: AdSearchMatchMode, limit: number) { + const normalized = records.flatMap((record) => { + const ad = normalizeApifyAd(record); + return ad ? [{ + record, + primaryText: ad.primaryText, + headline: ad.headline, + description: ad.description, + ctaText: ad.ctaText, + brandName: ad.pageName + }] : []; + }); + return filterRelevantAds(normalized, query, mode, limit).map((item) => item.record); +} + 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 a0108bf..f955219 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -2,6 +2,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 { adSearchRelevance, filterRelevantAds } from "../src/lib/ad-search"; import { getFeatureLimit } from "../src/lib/plans"; import { istemciIp, hizSiniriAsimi } from "../src/lib/rate-limit"; import { stripePriceFor } from "../src/lib/stripe"; @@ -147,3 +148,20 @@ test("Apify snake_case video alanı doğrudan video kreatifi olur", () => { assert.equal(ad?.mediaType, MediaType.VIDEO); assert.equal(ad?.creativeUrl, "https://video.xx.fbcdn.net/test.mp4"); }); + +test("reklam araması tam kelimeyi eşleştirir ve alakasız alt dizeleri dışarıda bırakır", () => { + assert.ok(adSearchRelevance({ headline: "Translate every conversation" }, "translate", "ALL_WORDS") > 0); + assert.equal(adSearchRelevance({ headline: "A translated guide" }, "translate", "ALL_WORDS"), 0); + assert.equal(adSearchRelevance({ primaryText: "Unrelated summer sale" }, "translate", "ALL_WORDS"), 0); +}); + +test("arama sonuçları ilgililiğe göre sıralanıp istenen sayıda kesilir", () => { + const ads = [ + { headline: null, primaryText: "Use translate today", brandName: "Other" }, + { headline: "Translate instantly", primaryText: "Use translate today", brandName: "Translate Pro" }, + { headline: "Unrelated", primaryText: "No matching word", brandName: "Other" } + ]; + const result = filterRelevantAds(ads, "translate", "ALL_WORDS", 1); + assert.equal(result.length, 1); + assert.equal(result[0].headline, "Translate instantly"); +});