Compare commits

...
2 Commits
Author SHA1 Message Date
hikmet4434 931036fe62 fix: match Turkish search accents 2026-08-23 06:12:49 +03:00
hikmet4434 705d94037d fix: return media for Meta ad searches 2026-08-23 06:09:05 +03:00
6 changed files with 129 additions and 43 deletions
+35 -30
View File
@@ -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 });
+8 -8
View File
@@ -8,7 +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";
import { AdSearchMatchMode, adSearchDatabaseTerms, adSearchRelevance } from "@/lib/ad-search";
export default async function AdsPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
const user = await requireUser();
@@ -26,13 +26,13 @@ export default async function AdsPage({ searchParams }: { searchParams: Promise<
const displayableCreativeWhere: Prisma.AdCreativeWhereInput = { url: { not: "" } };
const where: Prisma.AdWhereInput = { creatives: { some: displayableCreativeWhere } };
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" } } }
] }));
const terms = adSearchDatabaseTerms(q, matchMode);
where.AND = terms.map((variants) => ({ OR: variants.flatMap((term) => [
{ primaryText: { contains: term, mode: "insensitive" as const } },
{ headline: { contains: term, mode: "insensitive" as const } },
{ description: { contains: term, mode: "insensitive" as const } },
{ brandPage: { name: { contains: term, mode: "insensitive" as const } } }
]) }));
}
if (niche) where.niche = { contains: niche, mode: "insensitive" };
if (mediaType) where.mediaType = mediaType as any;
+15 -3
View File
@@ -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"}
</button>
</div>
<p className="mt-2 text-xs text-slate-500">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</p>
{busy && <p className="mt-2 text-xs font-semibold text-violet-700">Meta sonuçları ve gerçek video/görseller hazırlanıyor. Bu işlem genellikle 1560 saniye sürer.</p>}
{message && <p className={`mt-3 text-sm font-semibold ${error ? "text-rose-600" : "text-emerald-700"}`}>{message}</p>}
</div>
);
+19
View File
@@ -22,6 +22,25 @@ export function adSearchTokens(query: string) {
return [...new Set(normalizeAdSearchText(query).split(" ").filter(Boolean))];
}
function preserveAdSearchText(value: string | null | undefined) {
return (value || "")
.normalize("NFKC")
.toLocaleLowerCase("tr-TR")
.replace(/[^\p{L}\p{N}]+/gu, " ")
.trim()
.replace(/\s+/g, " ");
}
export function adSearchDatabaseTerms(query: string, mode: AdSearchMatchMode) {
const preserved = preserveAdSearchText(query);
const ascii = normalizeAdSearchText(query);
if (mode === "EXACT_PHRASE") return [[...new Set([preserved, ascii].filter(Boolean))]];
const preservedTokens = preserved.split(" ").filter(Boolean);
const asciiTokens = ascii.split(" ").filter(Boolean);
return asciiTokens.map((token, index) => [...new Set([preservedTokens[index], token].filter(Boolean))]);
}
function containsPhrase(text: string, phrase: string) {
return Boolean(text && phrase && ` ${text} `.includes(` ${phrase} `));
}
+33
View File
@@ -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<string>();
const candidates = relevantApifyRecords(records, query, mode, Math.max(records.length, limit)).flatMap((record) => {
const normalized = normalizeApifyAd(record);
if (!normalized?.creativeUrl || !requestedMediaMatches(normalized.mediaType, mediaType) || seen.has(normalized.externalAdId)) return [];
seen.add(normalized.externalAdId);
return [{ record, externalAdId: normalized.externalAdId }];
});
const official = candidates.filter((candidate) => officialIds.has(candidate.externalAdId));
const fallback = candidates.filter((candidate) => !officialIds.has(candidate.externalAdId));
const selected = [...official, ...fallback].slice(0, limit);
return {
records: selected.map((candidate) => candidate.record),
officialMatches: Math.min(official.length, limit),
fallbackMatches: Math.max(0, selected.length - Math.min(official.length, limit))
};
}
export async function importApifyAds(records: JsonRecord[]) {
let imported = 0;
let failed = 0;
+19 -2
View File
@@ -1,8 +1,8 @@
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 { actorInput, normalizeApifyAd, selectMediaRecordsForSearch } from "../src/lib/apify";
import { adSearchDatabaseTerms, 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";
@@ -155,6 +155,11 @@ test("reklam araması tam kelimeyi eşleştirir ve alakasız alt dizeleri dışa
assert.equal(adSearchRelevance({ primaryText: "Unrelated summer sale" }, "translate", "ALL_WORDS"), 0);
});
test("veritabanı araması Türkçe karakterli ve ASCII yazımları birlikte tarar", () => {
assert.deepEqual(adSearchDatabaseTerms("çeviri", "ALL_WORDS"), [["çeviri", "ceviri"]]);
assert.deepEqual(adSearchDatabaseTerms("hızlı çeviri", "EXACT_PHRASE"), [["hızlı çeviri", "hızlı ceviri"]]);
});
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" },
@@ -165,3 +170,15 @@ test("arama sonuçları ilgililiğe göre sıralanıp istenen sayıda kesilir",
assert.equal(result.length, 1);
assert.equal(result[0].headline, "Translate instantly");
});
test("Meta medya seçimi resmi kimliği önceler ve ilgili medya yedeğini sıfıra düşürmez", () => {
const mediaRecords = [
{ ad_id: "fallback-1", ad_body_text: "Anında çeviri yap", ad_format: "video", video_url: "https://video.xx.fbcdn.net/fallback.mp4", page_name: "Çeviri" },
{ ad_id: "official-1", ad_body_text: "Çeviri uygulaması", ad_format: "video", video_url: "https://video.xx.fbcdn.net/official.mp4", page_name: "Dil" },
{ ad_id: "image-1", ad_body_text: "Çeviri uygulaması", ad_format: "image", image_url: "https://scontent.xx.fbcdn.net/image.jpg", page_name: "Dil" }
];
const selection = selectMediaRecordsForSearch(mediaRecords, [{ adArchiveID: "official-1" }], "çeviri", "ALL_WORDS", "VIDEO", 2);
assert.deepEqual(selection.records.map((record) => record.ad_id), ["official-1", "fallback-1"]);
assert.equal(selection.officialMatches, 1);
assert.equal(selection.fallbackMatches, 1);
});