fix: improve Meta ad search relevance and filters
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
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, runApifyActor } from "@/lib/apify";
|
import { importApifyAds, relevantApifyRecords, runApifyActor } from "@/lib/apify";
|
||||||
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";
|
||||||
@@ -12,6 +12,8 @@ const schema = z.object({
|
|||||||
searchTerm: z.string().trim().min(2).max(100),
|
searchTerm: z.string().trim().min(2).max(100),
|
||||||
country: z.string().trim().toUpperCase().refine((value) => AD_COUNTRY_CODES.has(value)),
|
country: z.string().trim().toUpperCase().refine((value) => AD_COUNTRY_CODES.has(value)),
|
||||||
mediaType: z.enum(["ALL", "IMAGE", "VIDEO", "MEME"]),
|
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))
|
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",
|
type: "meta-ads-library",
|
||||||
status: "RUNNING",
|
status: "RUNNING",
|
||||||
startedAt: new Date(),
|
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 {
|
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({
|
const records = await runApifyActor({
|
||||||
searchTerms: [parsed.data.searchTerm],
|
searchTerms: [parsed.data.searchTerm],
|
||||||
country: parsed.data.country,
|
country: parsed.data.country,
|
||||||
adActiveStatus: "ACTIVE",
|
adActiveStatus: parsed.data.status,
|
||||||
mediaType: parsed.data.mediaType,
|
mediaType: parsed.data.mediaType,
|
||||||
maxResults: parsed.data.maxResults,
|
maxResults: candidateLimit,
|
||||||
maxCostUsd: Math.max(0.1, Math.ceil(parsed.data.maxResults * 0.004 * 10) / 10),
|
maxCostUsd: Math.max(0.1, Math.ceil(parsed.data.maxResults * 0.004 * 10) / 10),
|
||||||
scrapeAdDetails: true,
|
scrapeAdDetails: true,
|
||||||
includeAboutPage: false
|
includeAboutPage: false
|
||||||
});
|
});
|
||||||
const result = await importApifyAds(records);
|
const relevantRecords = relevantApifyRecords(records, parsed.data.searchTerm, parsed.data.matchMode, parsed.data.maxResults);
|
||||||
if (creditsReserved && records.length < parsed.data.maxResults) {
|
const result = await importApifyAds(relevantRecords);
|
||||||
await refundQuota({ userId: user.id, metric: "api_credits_monthly", amount: parsed.data.maxResults - records.length });
|
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({
|
await prisma.ingestJob.update({
|
||||||
where: { id: job.id },
|
where: { id: job.id },
|
||||||
@@ -86,10 +92,10 @@ export async function POST(request: Request) {
|
|||||||
finishedAt: new Date(),
|
finishedAt: new Date(),
|
||||||
recordsImported: result.imported,
|
recordsImported: result.imported,
|
||||||
recordsFailed: result.failed,
|
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) {
|
} 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 });
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Card } from "@/components/ui/card";
|
|||||||
import { AdCreativeMedia } from "@/components/ad-creative-media";
|
import { AdCreativeMedia } from "@/components/ad-creative-media";
|
||||||
import { ApifyEmptySearch } from "@/components/ads/apify-empty-search";
|
import { ApifyEmptySearch } from "@/components/ads/apify-empty-search";
|
||||||
import { AD_COUNTRIES } from "@/lib/countries";
|
import { AD_COUNTRIES } from "@/lib/countries";
|
||||||
|
import { AdSearchMatchMode, adSearchRelevance, adSearchTokens } from "@/lib/ad-search";
|
||||||
|
|
||||||
export default async function AdsPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
|
export default async function AdsPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
|
||||||
const user = await requireUser();
|
const user = await requireUser();
|
||||||
@@ -17,12 +18,28 @@ export default async function AdsPage({ searchParams }: { searchParams: Promise<
|
|||||||
const niche = resolvedSearchParams.niche;
|
const niche = resolvedSearchParams.niche;
|
||||||
const mediaType = resolvedSearchParams.mediaType;
|
const mediaType = resolvedSearchParams.mediaType;
|
||||||
const country = resolvedSearchParams.country?.toUpperCase();
|
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 displayableCreativeWhere: Prisma.AdCreativeWhereInput = { url: { not: "" } };
|
||||||
const where: Prisma.AdWhereInput = { creatives: { some: displayableCreativeWhere } };
|
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 (niche) where.niche = { contains: niche, mode: "insensitive" };
|
||||||
if (mediaType) where.mediaType = mediaType as any;
|
if (mediaType) where.mediaType = mediaType as any;
|
||||||
if (country && country !== "ALL") where.countries = { has: country };
|
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;
|
const realAdsExist = await prisma.ad.count({ where: { externalAdId: { not: { startsWith: "demo_ad_" } } } }) > 0;
|
||||||
if (realAdsExist) where.externalAdId = { not: { startsWith: "demo_ad_" } };
|
if (realAdsExist) where.externalAdId = { not: { startsWith: "demo_ad_" } };
|
||||||
const ads = await prisma.ad.findMany({
|
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 },
|
creatives: { where: displayableCreativeWhere, orderBy: { position: "asc" }, take: 1 },
|
||||||
savedBy: { where: { userId: user.id } }
|
savedBy: { where: { userId: user.id } }
|
||||||
},
|
},
|
||||||
orderBy: { rankPercentile: "asc" },
|
orderBy: sort === "longest" ? { daysRunning: "desc" } : sort === "newest" ? { firstSeenAt: "desc" } : { rankPercentile: "asc" },
|
||||||
take: 48
|
take: q ? 240 : 48
|
||||||
});
|
});
|
||||||
const isAdmin = user.role === "ADMIN";
|
const isAdmin = user.role === "ADMIN";
|
||||||
const apifyPlanLimit = isAdmin || plan?.code === "PREMIUM" ? 100 : plan?.code === "STANDARD" ? 50 : plan?.code === "BASIC" ? 25 : 0;
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -48,24 +72,46 @@ export default async function AdsPage({ searchParams }: { searchParams: Promise<
|
|||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl bg-white px-4 py-3 text-sm shadow-sm">Plan: <b>{plan?.name}</b> · Free ise kartlar kilitli</div>
|
<div className="rounded-2xl bg-white px-4 py-3 text-sm shadow-sm">Plan: <b>{plan?.name}</b> · Free ise kartlar kilitli</div>
|
||||||
</div>
|
</div>
|
||||||
<form className="mb-5 grid gap-3 rounded-3xl bg-white p-4 shadow-soft md:grid-cols-2 xl:grid-cols-[1fr_190px_170px_170px_120px]">
|
<form className="mb-5 rounded-3xl bg-white p-4 shadow-soft">
|
||||||
<input name="q" defaultValue={q} placeholder="dog collar, skincare, greens..." className="rounded-2xl border border-slate-200 px-4 py-3" />
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-[1fr_220px_190px_140px]">
|
||||||
|
<input name="q" defaultValue={q} minLength={2} placeholder="Marka, ürün veya anahtar kelime..." className="rounded-2xl border border-slate-200 px-4 py-3" />
|
||||||
<select name="country" defaultValue={country || "ALL"} aria-label="Ülke" className="rounded-2xl border border-slate-200 px-4 py-3">
|
<select name="country" defaultValue={country || "ALL"} aria-label="Ülke" className="rounded-2xl border border-slate-200 px-4 py-3">
|
||||||
{AD_COUNTRIES.map(([code, label]) => <option key={code} value={code}>{label}</option>)}
|
{AD_COUNTRIES.map(([code, label]) => <option key={code} value={code}>{label}</option>)}
|
||||||
</select>
|
</select>
|
||||||
<select name="niche" defaultValue={niche || ""} className="rounded-2xl border border-slate-200 px-4 py-3">
|
|
||||||
<option value="">Tüm niche</option><option>Pets</option><option>Beauty</option><option>Supplements</option><option>Household</option>
|
|
||||||
</select>
|
|
||||||
<select name="mediaType" defaultValue={mediaType || ""} className="rounded-2xl border border-slate-200 px-4 py-3">
|
<select name="mediaType" defaultValue={mediaType || ""} className="rounded-2xl border border-slate-200 px-4 py-3">
|
||||||
<option value="">Tüm medya</option><option>VIDEO</option><option>IMAGE</option><option>CAROUSEL</option>
|
<option value="">Tüm medya</option><option>VIDEO</option><option>IMAGE</option><option>CAROUSEL</option>
|
||||||
</select>
|
</select>
|
||||||
<button className="rounded-2xl bg-slate-950 px-4 py-3 font-bold text-white">Ara</button>
|
<button className="rounded-2xl bg-slate-950 px-4 py-3 font-bold text-white">Ara</button>
|
||||||
|
</div>
|
||||||
|
<details className="mt-3 rounded-2xl border border-slate-100 bg-slate-50/70 px-4 py-3" open={Boolean(niche || minDays || language || status !== "ACTIVE" || matchMode !== "ALL_WORDS" || sort !== "relevance")}>
|
||||||
|
<summary className="cursor-pointer select-none text-sm font-black text-slate-700">Detaylı arama</summary>
|
||||||
|
<div className="mt-3 grid gap-3 md:grid-cols-2 xl:grid-cols-6">
|
||||||
|
<select name="matchMode" defaultValue={matchMode} aria-label="Kelime eşleşmesi" className="rounded-xl border border-slate-200 bg-white px-3 py-3 text-sm">
|
||||||
|
<option value="ALL_WORDS">Tüm kelimeler</option><option value="EXACT_PHRASE">Tam ifade</option>
|
||||||
|
</select>
|
||||||
|
<select name="status" defaultValue={status} aria-label="Reklam durumu" className="rounded-xl border border-slate-200 bg-white px-3 py-3 text-sm">
|
||||||
|
<option value="ACTIVE">Aktif reklamlar</option><option value="INACTIVE">Pasif reklamlar</option><option value="ALL">Tüm durumlar</option>
|
||||||
|
</select>
|
||||||
|
<select name="minDays" defaultValue={String(minDays)} aria-label="Minimum yayın süresi" className="rounded-xl border border-slate-200 bg-white px-3 py-3 text-sm">
|
||||||
|
<option value="0">Tüm süreler</option><option value="7">En az 7 gün</option><option value="30">En az 30 gün</option><option value="90">En az 90 gün</option>
|
||||||
|
</select>
|
||||||
|
<select name="niche" defaultValue={niche || ""} className="rounded-xl border border-slate-200 bg-white px-3 py-3 text-sm">
|
||||||
|
<option value="">Tüm nişler</option><option>Pets</option><option>Beauty</option><option>Supplements</option><option>Household</option>
|
||||||
|
</select>
|
||||||
|
<select name="language" defaultValue={language || ""} aria-label="Dil" className="rounded-xl border border-slate-200 bg-white px-3 py-3 text-sm">
|
||||||
|
<option value="">Tüm diller</option><option value="tr">Türkçe</option><option value="en">İngilizce</option><option value="de">Almanca</option><option value="fr">Fransızca</option><option value="es">İspanyolca</option>
|
||||||
|
</select>
|
||||||
|
<select name="sort" defaultValue={sort} aria-label="Sıralama" className="rounded-xl border border-slate-200 bg-white px-3 py-3 text-sm">
|
||||||
|
<option value="relevance">En ilgili</option><option value="newest">En yeni</option><option value="longest">En uzun süren</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
</form>
|
</form>
|
||||||
<div className="mb-5 flex flex-wrap gap-2">
|
<div className="mb-5 flex flex-wrap gap-2">
|
||||||
{["Week's biggest winners", "US winners", "Dropship Ads", "Supplements", "Top Branded"].map((x) => <span key={x} className="rounded-full bg-violet-50 px-3 py-1 text-sm font-semibold text-violet-800">{x}</span>)}
|
{["Week's biggest winners", "US winners", "Dropship Ads", "Supplements", "Top Branded"].map((x) => <span key={x} className="rounded-full bg-violet-50 px-3 py-1 text-sm font-semibold text-violet-800">{x}</span>)}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||||
{q && masked.length === 0 && <ApifyEmptySearch query={q} country={country || "ALL"} mediaType={mediaType || "ALL"} planLimit={apifyPlanLimit} />}
|
{q && masked.length === 0 && <ApifyEmptySearch query={q} country={country || "ALL"} mediaType={mediaType || "ALL"} matchMode={matchMode} status={status} planLimit={apifyPlanLimit} />}
|
||||||
{masked.map((ad: any) => (
|
{masked.map((ad: any) => (
|
||||||
<Card key={ad.id} className="relative overflow-hidden">
|
<Card key={ad.id} className="relative overflow-hidden">
|
||||||
{ad.isLocked && <div className="absolute inset-0 z-10 grid place-items-center bg-white/70 backdrop-blur-[2px]"><Link href="/pricing" className="rounded-2xl bg-violet-700 px-5 py-3 font-black text-white">Start now — Unlock winners</Link></div>}
|
{ad.isLocked && <div className="absolute inset-0 z-10 grid place-items-center bg-white/70 backdrop-blur-[2px]"><Link href="/pricing" className="rounded-2xl bg-violet-700 px-5 py-3 font-black text-white">Start now — Unlock winners</Link></div>}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useRouter } from "next/navigation";
|
|||||||
const RESULT_LIMIT = 10;
|
const RESULT_LIMIT = 10;
|
||||||
const RESULT_OPTIONS = [10, 25, 50, 100];
|
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 router = useRouter();
|
||||||
const [maxResults, setMaxResults] = useState(RESULT_LIMIT);
|
const [maxResults, setMaxResults] = useState(RESULT_LIMIT);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -24,6 +24,8 @@ export function ApifyEmptySearch({ query, country, mediaType, planLimit }: { que
|
|||||||
searchTerm: query,
|
searchTerm: query,
|
||||||
country,
|
country,
|
||||||
mediaType,
|
mediaType,
|
||||||
|
matchMode,
|
||||||
|
status,
|
||||||
maxResults
|
maxResults
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
@@ -31,7 +33,7 @@ export function ApifyEmptySearch({ query, country, mediaType, planLimit }: { que
|
|||||||
if (!response.ok) throw new Error(data.error || "APIFY_INGEST_FAILED");
|
if (!response.ok) throw new Error(data.error || "APIFY_INGEST_FAILED");
|
||||||
|
|
||||||
setError(false);
|
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();
|
router.refresh();
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
setError(true);
|
setError(true);
|
||||||
@@ -67,7 +69,7 @@ export function ApifyEmptySearch({ query, country, mediaType, planLimit }: { que
|
|||||||
{busy ? "Getiriliyor…" : planLimit === 0 ? "Planı yükselt" : "Getir"}
|
{busy ? "Getiriliyor…" : planLimit === 0 ? "Planı yükselt" : "Getir"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-xs text-slate-500">Apify · {country === "ALL" ? "Tüm ülkeler" : country} · {mediaType === "ALL" ? "Tüm medya" : mediaType} · Plan limiti: {planLimit || "erişim yok"} reklam</p>
|
<p className="mt-2 text-xs text-slate-500">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</p>
|
||||||
{message && <p className={`mt-3 text-sm font-semibold ${error ? "text-rose-600" : "text-emerald-700"}`}>{message}</p>}
|
{message && <p className={`mt-3 text-sm font-semibold ${error ? "text-rose-600" : "text-emerald-700"}`}>{message}</p>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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<T extends SearchableAd>(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);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { AdSource, AdStatus, MediaType, Prisma } from "@prisma/client";
|
import { AdSource, AdStatus, MediaType, Prisma } from "@prisma/client";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
import { AdSearchMatchMode, filterRelevantAds } from "@/lib/ad-search";
|
||||||
|
|
||||||
const APIFY_API_BASE = "https://api.apify.com/v2";
|
const APIFY_API_BASE = "https://api.apify.com/v2";
|
||||||
const DEFAULT_ACTOR_ID = "aiscraperdev~facebook-meta-ads-library-scraper";
|
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[]) {
|
export async function importApifyAds(records: JsonRecord[]) {
|
||||||
let imported = 0;
|
let imported = 0;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
|
|||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import { MediaType } from "@prisma/client";
|
import { MediaType } from "@prisma/client";
|
||||||
import { actorInput, normalizeApifyAd } from "../src/lib/apify";
|
import { actorInput, normalizeApifyAd } from "../src/lib/apify";
|
||||||
|
import { adSearchRelevance, filterRelevantAds } from "../src/lib/ad-search";
|
||||||
import { getFeatureLimit } from "../src/lib/plans";
|
import { getFeatureLimit } from "../src/lib/plans";
|
||||||
import { istemciIp, hizSiniriAsimi } from "../src/lib/rate-limit";
|
import { istemciIp, hizSiniriAsimi } from "../src/lib/rate-limit";
|
||||||
import { stripePriceFor } from "../src/lib/stripe";
|
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?.mediaType, MediaType.VIDEO);
|
||||||
assert.equal(ad?.creativeUrl, "https://video.xx.fbcdn.net/test.mp4");
|
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");
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user