Compare commits
2
Commits
e25d70fcd8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
931036fe62 | ||
|
|
705d94037d |
@@ -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({
|
||||
// Meta Ad Library ham medya dosyası vermez. Doğrulanan reklamların görsel/video
|
||||
// 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
|
||||
});
|
||||
// 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({
|
||||
}),
|
||||
runApifyActor({
|
||||
searchTerms: [parsed.data.searchTerm],
|
||||
country: parsed.data.country,
|
||||
adActiveStatus: parsed.data.status,
|
||||
mediaType: parsed.data.mediaType,
|
||||
maxResults: candidateLimit,
|
||||
maxResults: parsed.data.maxResults,
|
||||
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;
|
||||
});
|
||||
})
|
||||
]);
|
||||
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,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;
|
||||
|
||||
@@ -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 15–60 saniye sürer.</p>}
|
||||
{message && <p className={`mt-3 text-sm font-semibold ${error ? "text-rose-600" : "text-emerald-700"}`}>{message}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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} `));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user