Compare commits

..
12 Commits
35 changed files with 1032 additions and 67 deletions
+4
View File
@@ -9,6 +9,10 @@ ADMIN_EMAILS="admin@winninghunter.local"
GOOGLE_CLIENT_ID=
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_TOKEN=
APIFY_ACTOR_ID=aiscraperdev~facebook-meta-ads-library-scraper
+4
View File
@@ -9,6 +9,10 @@ ADMIN_EMAILS="admin@winninghunter.local"
GOOGLE_CLIENT_ID=
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_TOKEN=
APIFY_ACTOR_ID=aiscraperdev~facebook-meta-ads-library-scraper
+4
View File
@@ -5,3 +5,7 @@ This version has breaking changes — APIs, conventions, and file structure may
**Keep this block, including in commits.** It is part of the project's agent setup, maintained by `next dev` for every agent that works here. If it appears as an uncommitted change, that is intentional — commit it as-is. Do not remove it to clean up a diff; it will be regenerated.
<!-- END:nextjs-agent-rules -->
## Learnings
- In async React form handlers, capture `event.currentTarget` before the first `await`; using it afterward can make a successful request appear to fail when the form is reset.
+2
View File
@@ -29,6 +29,8 @@ services:
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
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_TIKTOK_ACTOR_ID: ${APIFY_TIKTOK_ACTOR_ID:-toolzerhub~tiktok-shop-products-scraper}
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
+1
View File
@@ -1,5 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// Reklam videoları kimlik doğrulamalı aynı-origin medya rotasından akar.
reactStrictMode: true,
poweredByHeader: false,
typescript: { ignoreBuildErrors: false },
+46 -17
View File
@@ -1,14 +1,20 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { currentUser } from "@/lib/auth/current-user";
import { importApifyAds, 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";
import { checkAndConsumeQuota, refundQuota } from "@/lib/quota";
import { hizSiniriAsimi } from "@/lib/rate-limit";
import { AD_COUNTRY_CODES } from "@/lib/countries";
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))
});
@@ -53,45 +59,68 @@ export async function POST(request: Request) {
const job = await prisma.ingestJob.create({
data: {
source: "apify",
source: "meta",
type: "meta-ads-library",
status: "RUNNING",
startedAt: new Date(),
metadata: { userId: user.id, searchTerm: parsed.data.searchTerm, 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 {
const records = await runApifyActor({
// 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
}),
runApifyActor({
searchTerms: [parsed.data.searchTerm],
country: "ALL",
adActiveStatus: "ACTIVE",
mediaType: "ALL",
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 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 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(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 });
}
await prisma.ingestJob.update({
where: { id: job.id },
data: {
status: "COMPLETED",
finishedAt: new Date(),
recordsImported: result.imported,
recordsFailed: result.failed,
metadata: { userId: user.id, searchTerm: parsed.data.searchTerm, maxResults: parsed.data.maxResults, received: records.length }
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, mediaCandidates: mediaRecords.length, officialMediaMatches: mediaSelection.officialMatches, fallbackMediaMatches: mediaSelection.fallbackMatches, mediaEnriched: mediaResult.imported }
}
});
return NextResponse.json({ ok: true, ...result, received: records.length });
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 });
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 } });
return NextResponse.json({ error: code }, { status: code === "APIFY_NOT_CONFIGURED" ? 503 : 502 });
return NextResponse.json({ error: code }, { status: code.endsWith("NOT_CONFIGURED") ? 503 : 502 });
}
}
@@ -0,0 +1,39 @@
import { NextResponse } from "next/server";
import { currentUser } from "@/lib/auth/current-user";
import { prisma } from "@/lib/db";
import { fetchMedia } from "@/lib/media-proxy";
import { hizSiniriAsimi } from "@/lib/rate-limit";
export async function GET(request: Request, context: { params: Promise<{ creativeId: string }> }) {
const limited = hizSiniriAsimi(request, "medya");
if (limited) return limited;
const user = await currentUser();
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
const { creativeId } = await context.params;
const creative = await prisma.adCreative.findUnique({ where: { id: creativeId }, select: { url: true, type: true } });
if (!creative || creative.type !== "VIDEO") return NextResponse.json({ error: "MEDIA_NOT_FOUND" }, { status: 404 });
try {
const upstream = await fetchMedia(creative.url, request.headers.get("range"));
if (!upstream.ok && upstream.status !== 206) {
await upstream.body?.cancel();
return NextResponse.json({ error: "MEDIA_UNAVAILABLE" }, { status: 502 });
}
const headers = new Headers({
"content-type": upstream.headers.get("content-type") || "video/mp4",
"accept-ranges": upstream.headers.get("accept-ranges") || "bytes",
"cache-control": "private, max-age=300",
"content-disposition": "inline",
"x-content-type-options": "nosniff"
});
for (const name of ["content-length", "content-range"]) {
const value = upstream.headers.get(name);
if (value) headers.set(name, value);
}
return new Response(upstream.body, { status: upstream.status, headers });
} catch {
return NextResponse.json({ error: "MEDIA_UNAVAILABLE" }, { status: 502 });
}
}
+1 -1
View File
@@ -7,5 +7,5 @@ import { ApifyIngestForm } from "@/components/admin/apify-ingest-form";
export default async function AdminDataPage() {
await requireAdmin();
const [ads, stores, users, jobs] = await Promise.all([prisma.ad.count(), prisma.store.count(), prisma.user.count(), prisma.ingestJob.findMany({ orderBy: { createdAt: "desc" }, take: 10 })]);
return <div><h1 className="mb-2 text-3xl font-black">Veri İşleri</h1><p className="mb-6 text-slate-500">Reklam, mağaza ve ingest operasyonları.</p><AdminNav /><div className="grid gap-4 md:grid-cols-3"><Card><b className="text-3xl">{ads}</b><br />Ads</Card><Card><b className="text-3xl">{stores}</b><br />Stores</Card><Card><b className="text-3xl">{users}</b><br />Users</Card></div><Card className="mt-5"><div className="mb-4"><h2 className="text-xl font-black">Apify · Meta Ads Library</h2><p className="mt-1 text-sm text-slate-500">Anahtar kelimeyle gerçek Meta reklamlarını çekin. Her çalışmada maliyet üst sınırı uygulanır ve tekrar eden reklamlar güncellenir.</p></div><ApifyIngestForm configured={Boolean(process.env.APIFY_TOKEN)} /></Card><Card className="mt-5"><h2 className="mb-3 font-black">Ingest Jobs</h2>{jobs.map((j) => <div key={j.id} className="border-t py-3 text-sm"><b>{j.source} · {j.type}</b><div className="text-slate-500">{j.status} · {j.recordsImported} başarılı · {j.recordsFailed} hatalı</div>{j.errorMessage && <div className="text-rose-600">{j.errorMessage}</div>}</div>)}{!jobs.length && <p className="text-sm text-slate-500">Henüz ingest işi yok.</p>}</Card></div>;
return <div><h1 className="mb-2 text-3xl font-black">Veri İşleri</h1><p className="mb-6 text-slate-500">Reklam, mağaza ve ingest operasyonları.</p><AdminNav /><div className="grid gap-4 md:grid-cols-3"><Card><b className="text-3xl">{ads}</b><br />Ads</Card><Card><b className="text-3xl">{stores}</b><br />Stores</Card><Card><b className="text-3xl">{users}</b><br />Users</Card></div><Card className="mt-5"><div className="mb-4"><h2 className="text-xl font-black">Meta Ads Library</h2><p className="mt-1 text-sm text-slate-500">Anahtar kelimeyle gerçek Meta reklamlarını çekin. Her çalışmada maliyet üst sınırı uygulanır ve tekrar eden reklamlar güncellenir.</p></div><ApifyIngestForm configured={Boolean(process.env.APIFY_TOKEN)} /></Card><Card className="mt-5"><h2 className="mb-3 font-black">Ingest Jobs</h2>{jobs.map((j) => <div key={j.id} className="border-t py-3 text-sm"><b>{j.source === "apify" ? "Meta veri kaynağı" : j.source} · {j.type}</b><div className="text-slate-500">{j.status} · {j.recordsImported} başarılı · {j.recordsFailed} hatalı</div>{j.errorMessage && <div className="text-rose-600">{j.errorMessage.replace(/APIFY/g, "VERI_KAYNAGI")}</div>}</div>)}{!jobs.length && <p className="text-sm text-slate-500">Henüz ingest işi yok.</p>}</Card></div>;
}
+2 -2
View File
@@ -17,9 +17,9 @@ export default async function AdminLogsPage() {
<div className="mb-6"><h1 className="text-3xl font-black">Log Merkezi</h1><p className="mt-1 text-slate-500">Admin audit, kredi hareketleri, veri işleri ve oturum kayıtları.</p></div>
<AdminNav />
<div className="space-y-5">
<Card><h2 className="mb-4 text-xl font-black">Admin audit logları</h2><div className="space-y-2">{audits.map((log) => <div key={log.id} className="rounded-2xl bg-slate-50 p-3 text-sm"><div className="flex flex-wrap justify-between gap-2"><b>{log.action}</b><span className="text-xs text-slate-400">{log.createdAt.toLocaleString("tr-TR")}</span></div><p className="mt-1">{log.summary}</p><div className="mt-1 text-xs text-slate-500">{log.actorEmail} · {log.ipAddress || "IP yok"}</div></div>)}{!audits.length && <p className="text-sm text-slate-500">Audit kaydı yok.</p>}</div></Card>
<Card><h2 className="mb-4 text-xl font-black">Admin audit logları</h2><div className="space-y-2">{audits.map((log) => <div key={log.id} className="rounded-2xl bg-slate-50 p-3 text-sm"><div className="flex flex-wrap justify-between gap-2"><b>{log.action.replace(/APIFY/g, "META_DATA")}</b><span className="text-xs text-slate-400">{log.createdAt.toLocaleString("tr-TR")}</span></div><p className="mt-1">{log.summary.replace(/Apify/gi, "veri kaynağı")}</p><div className="mt-1 text-xs text-slate-500">{log.actorEmail} · {log.ipAddress || "IP yok"}</div></div>)}{!audits.length && <p className="text-sm text-slate-500">Audit kaydı yok.</p>}</div></Card>
<Card><h2 className="mb-4 text-xl font-black">Kredi hareketleri</h2><div className="overflow-x-auto"><table className="w-full min-w-[760px] text-left text-sm"><thead className="text-xs uppercase text-slate-500"><tr><th>Kullanıcı</th><th>Tür</th><th>Miktar</th><th>Bakiye</th><th>Neden</th><th>Tarih</th></tr></thead><tbody>{credits.map((tx) => <tr key={tx.id} className="border-t border-slate-100"><td className="py-3">{tx.user.email}</td><td>{tx.type}</td><td className={tx.amount >= 0 ? "font-bold text-emerald-600" : "font-bold text-rose-600"}>{tx.amount > 0 ? "+" : ""}{tx.amount}</td><td>{tx.balanceAfter}</td><td>{tx.reason}</td><td>{tx.createdAt.toLocaleString("tr-TR")}</td></tr>)}</tbody></table></div></Card>
<div className="grid gap-5 xl:grid-cols-2"><Card><h2 className="mb-4 text-xl font-black">Ingest işleri</h2>{ingestJobs.map((job) => <div key={job.id} className="border-t py-3 text-sm"><b>{job.source} · {job.type}</b><div className="text-slate-500">{job.status} · {job.recordsImported} başarılı · {job.recordsFailed} hatalı</div>{job.errorMessage && <div className="text-rose-600">{job.errorMessage}</div>}</div>)}</Card><Card><h2 className="mb-4 text-xl font-black">Export işleri</h2>{exportJobs.map((job) => <div key={job.id} className="border-t py-3 text-sm"><b>{job.user.email} · {job.module}</b><div className="text-slate-500">{job.status} · {job.createdAt.toLocaleString("tr-TR")}</div>{job.errorMessage && <div className="text-rose-600">{job.errorMessage}</div>}</div>)}</Card></div>
<div className="grid gap-5 xl:grid-cols-2"><Card><h2 className="mb-4 text-xl font-black">Ingest işleri</h2>{ingestJobs.map((job) => <div key={job.id} className="border-t py-3 text-sm"><b>{job.source === "apify" ? "Meta veri kaynağı" : job.source} · {job.type}</b><div className="text-slate-500">{job.status} · {job.recordsImported} başarılı · {job.recordsFailed} hatalı</div>{job.errorMessage && <div className="text-rose-600">{job.errorMessage.replace(/APIFY/g, "VERI_KAYNAGI")}</div>}</div>)}</Card><Card><h2 className="mb-4 text-xl font-black">Export işleri</h2>{exportJobs.map((job) => <div key={job.id} className="border-t py-3 text-sm"><b>{job.user.email} · {job.module}</b><div className="text-slate-500">{job.status} · {job.createdAt.toLocaleString("tr-TR")}</div>{job.errorMessage && <div className="text-rose-600">{job.errorMessage}</div>}</div>)}</Card></div>
<Card><h2 className="mb-4 text-xl font-black">Son oturumlar</h2><div className="grid gap-2 md:grid-cols-2">{sessions.map((session) => <div key={session.id} className="rounded-2xl bg-slate-50 p-3 text-sm"><b>{session.user.email}</b><div className="text-xs text-slate-500">Açılış: {session.createdAt.toLocaleString("tr-TR")} · Bitiş: {session.expiresAt.toLocaleString("tr-TR")}</div></div>)}</div></Card>
</div>
</div>
+74 -10
View File
@@ -7,6 +7,8 @@ import { planFromUser } from "@/lib/plans";
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, adSearchDatabaseTerms, adSearchRelevance } from "@/lib/ad-search";
export default async function AdsPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
const user = await requireUser();
@@ -15,39 +17,101 @@ export default async function AdsPage({ searchParams }: { searchParams: Promise<
const q = resolvedSearchParams.q?.trim();
const niche = resolvedSearchParams.niche;
const mediaType = resolvedSearchParams.mediaType;
const where: Prisma.AdWhereInput = {};
if (q) where.OR = [{ primaryText: { contains: q, mode: "insensitive" } }, { headline: { contains: q, mode: "insensitive" } }, { brandPage: { name: { contains: q, mode: "insensitive" } } }];
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) {
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;
const ads = await prisma.ad.findMany({ where, include: { brandPage: true, creatives: { take: 1 }, savedBy: { where: { userId: user.id } } }, orderBy: { rankPercentile: "asc" }, take: 48 });
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({
where,
include: {
brandPage: true,
creatives: { where: displayableCreativeWhere, orderBy: { position: "asc" }, take: 1 },
savedBy: { where: { userId: user.id } }
},
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 (
<div>
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-black">Search Meta Adlibrary</h1>
<p className="mt-1 text-slate-500">Kazanan Meta reklamlarını keyword, niche ve medya tipine göre keşfet.</p>
<p className="mt-1 text-slate-500">Kazanan Meta reklamlarını anahtar kelime, ülke, niş ve medya tipine göre keşfet.</p>
</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>
<form className="mb-5 grid gap-3 rounded-3xl bg-white p-4 shadow-soft md:grid-cols-[1fr_180px_180px_120px]">
<input name="q" defaultValue={q} placeholder="dog collar, skincare, greens..." className="rounded-2xl border border-slate-200 px-4 py-3" />
<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>
<form className="mb-5 rounded-3xl bg-white p-4 shadow-soft">
<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">
{AD_COUNTRIES.map(([code, label]) => <option key={code} value={code}>{label}</option>)}
</select>
<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>
</select>
<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>
<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>)}
</div>
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
{q && masked.length === 0 && <ApifyEmptySearch query={q} planLimit={apifyPlanLimit} />}
{q && <ApifyEmptySearch query={q} country={country || "ALL"} mediaType={mediaType || "ALL"} matchMode={matchMode} status={status} planLimit={apifyPlanLimit} existingCount={masked.length} />}
{masked.map((ad: any) => (
<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>}
+6
View File
@@ -2,6 +2,12 @@ import Link from "next/link";
import { requireUser } from "@/lib/auth/current-user";
import { DashboardMobileNav } from "@/components/dashboard-mobile-nav";
import { BrandLogo } from "@/components/brand-logo";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Dashboard",
robots: { index: false, follow: false, nocache: true }
};
const nav = [
["Ads", "/dashboard/ads"],
+1 -1
View File
@@ -28,7 +28,7 @@ export default async function TikTokShopPage({ searchParams }: { searchParams: P
const ranked = products.map((product) => ({ ...product, score: opportunityScore(product) })).sort((a, b) => b.score - a.score);
const regions = [...new Set(products.map((product) => product.region).filter(Boolean))] as string[];
return <div>
<div className="mb-6"><h1 className="text-3xl font-black">TikTok Shop</h1><p className="mt-1 text-slate-500">Apify üzerinden canlı ürün, satış, fiyat, mağaza ve değerlendirme sinyalleri.</p></div>
<div className="mb-6"><h1 className="text-3xl font-black">TikTok Shop</h1><p className="mt-1 text-slate-500">Canlı ürün, satış, fiyat, mağaza ve değerlendirme sinyalleri.</p></div>
<TikTokShopImport planLimit={planLimit} />
<form className="mb-5 grid gap-3 rounded-3xl bg-white p-4 shadow-soft md:grid-cols-[1fr_180px_120px]">
<input name="q" defaultValue={q} placeholder="Kayıtlı ürün veya mağaza ara" className="rounded-2xl border border-slate-200 px-4 py-3" />
+19 -7
View File
@@ -1,18 +1,20 @@
import type { Metadata } from "next";
import "./globals.css";
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
import { SITE_DESCRIPTION, SITE_NAME, SITE_URL } from "@/lib/seo";
export const metadata: Metadata = {
metadataBase: new URL(appUrl),
applicationName: "AdSeeQ",
metadataBase: new URL(SITE_URL),
applicationName: SITE_NAME,
title: {
default: "AdSeeQ — Reklam ve Trend Zekâsı",
template: "%s | AdSeeQ"
},
description: "Meta reklamlarını, TikTok Shop ürünlerini, yükselen trendleri ve rakip markaları yapay zekâ ile tek panelden keşfedin.",
description: SITE_DESCRIPTION,
keywords: ["reklam kütüphanesi", "Meta Ads", "TikTok Shop", "trend analizi", "marka takibi", "reklam zekâsı"],
alternates: { canonical: "/" },
alternates: {
canonical: "/",
languages: { "tr-TR": "/" }
},
openGraph: {
title: "AdSeeQ — Kazanan reklamları ve trendleri keşfedin",
description: "Meta Ads, TikTok Shop, Magic AI Trends ve Brand Tracker tek panelde.",
@@ -26,7 +28,17 @@ export const metadata: Metadata = {
title: "AdSeeQ — Reklam ve Trend Zekâsı",
description: "Kazanan reklamları, ürünleri ve rakip marka hareketlerini keşfedin."
},
robots: { index: true, follow: true }
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-image-preview": "large",
"max-snippet": -1,
"max-video-preview": -1
}
}
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
+37
View File
@@ -0,0 +1,37 @@
import { SITE_DESCRIPTION, SITE_URL } from "@/lib/seo";
const content = `# AdSeeQ
> ${SITE_DESCRIPTION}
AdSeeQ, reklam araştırması ve e-ticaret trend keşfi yapan ekipler için web tabanlı bir platformdur.
## Temel özellikler
- Meta Ads Library reklamlarını anahtar kelime ve ülkeye göre araştırma
- Medya içeren reklam kreatiflerini inceleme ve kaydetme
- TikTok Shop ürün keşfi
- Magic AI ile reklam ve ürün sinyallerini analiz etme
- Trends ile yükselen sinyalleri takip etme
- Brand Tracker ve Store Tracker ile marka ve mağaza takibi
## Resmî sayfalar
- Ana sayfa: ${SITE_URL}
- Fiyatlandırma: ${SITE_URL}/pricing
- Hesap oluşturma: ${SITE_URL}/register
- Giriş: ${SITE_URL}/login
## Ürün bilgisi
AdSeeQ ücretsiz ve ücretli planlar sunar. Güncel plan limitleri ve fiyatlar için resmî fiyatlandırma sayfasını kullanın. Özel dashboard ve API rotaları herkese açık içerik değildir.
`;
export function GET() {
return new Response(content, {
headers: {
"content-type": "text/plain; charset=utf-8",
"cache-control": "public, max-age=3600, s-maxage=86400"
}
});
}
+12
View File
@@ -0,0 +1,12 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Giriş",
description: "AdSeeQ hesabınıza giriş yapın.",
alternates: { canonical: "/login" },
robots: { index: false, follow: false, nocache: true }
};
export default function LoginLayout({ children }: { children: React.ReactNode }) {
return children;
}
+50
View File
@@ -0,0 +1,50 @@
import { ImageResponse } from "next/og";
export const alt = "AdSeeQ — Reklam ve trend zekâsı";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export default function OpenGraphImage() {
return new ImageResponse(
<div
style={{
alignItems: "center",
background: "linear-gradient(135deg, #0f172a 0%, #4c1d95 58%, #7c3aed 100%)",
color: "white",
display: "flex",
height: "100%",
justifyContent: "center",
padding: "72px",
width: "100%"
}}
>
<div style={{ display: "flex", flexDirection: "column", maxWidth: "980px" }}>
<div style={{ alignItems: "center", display: "flex", fontSize: 46, fontWeight: 800 }}>
<span
style={{
alignItems: "center",
background: "white",
borderRadius: 24,
color: "#6d28d9",
display: "flex",
height: 82,
justifyContent: "center",
marginRight: 24,
width: 82
}}
>
Q
</span>
AdSeeQ
</div>
<div style={{ fontSize: 72, fontWeight: 900, letterSpacing: "-3px", lineHeight: 1.06, marginTop: 48 }}>
Kazanan reklamları ve trendleri keşfedin
</div>
<div style={{ color: "#ddd6fe", fontSize: 30, lineHeight: 1.35, marginTop: 30 }}>
Meta Ads · TikTok Shop · Magic AI · Trends · Brand Tracker
</div>
</div>
</div>,
size
);
}
+54 -1
View File
@@ -1,9 +1,14 @@
import { LinkButton } from "@/components/ui/button";
import { BrandLogo } from "@/components/brand-logo";
import { homeFaq, homeStructuredData, serializeJsonLd } from "@/lib/seo";
export default function LandingPage() {
return (
<main className="min-h-screen overflow-hidden bg-[radial-gradient(circle_at_top_left,#ddd6fe,transparent_35%),#f8fafc]">
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: serializeJsonLd(homeStructuredData) }}
/>
<nav className="mx-auto flex max-w-7xl items-center justify-between px-6 py-6">
<BrandLogo />
<div className="flex gap-3">
@@ -20,7 +25,7 @@ export default function LandingPage() {
Kazanan reklamları ve mağazaları dakikalar içinde keşfet.
</h1>
<p className="mt-6 max-w-2xl text-lg leading-8 text-slate-600">
Meta reklam kütüphanesi, TikTok Shop ürün keşfi, Magic AI trend analizi ve rakip marka takibi tek panelde.
AdSeeQ, reklam ve e-ticaret araştırmalarını tek panelde birleştiren reklam zekâsı platformudur. Meta Ads Library reklamlarını araştırın, TikTok Shop ürünlerini keşfedin, Magic AI ile trend sinyallerini analiz edin ve rakip markaları takip edin.
</p>
<div className="mt-8 flex flex-wrap gap-3">
<LinkButton href="/register" className="px-6 py-3">Ücretsiz başla</LinkButton>
@@ -45,6 +50,54 @@ export default function LandingPage() {
</div>
</div>
</section>
<section className="mx-auto max-w-7xl px-6 py-16" aria-labelledby="features-heading">
<div className="max-w-3xl">
<p className="text-sm font-black uppercase tracking-[0.2em] text-violet-700">Tek panel, altı araştırma aracı</p>
<h2 id="features-heading" className="mt-3 text-3xl font-black tracking-tight text-slate-950 md:text-4xl">
Reklamdan trende, araştırma akışınız tek yerde
</h2>
<p className="mt-4 leading-7 text-slate-600">
Kreatifleri bulun, sinyalleri karşılaştırın ve incelemek istediğiniz reklam, mağaza ve markaları kaydedin.
</p>
</div>
<div className="mt-9 grid gap-5 md:grid-cols-2 lg:grid-cols-3">
{[
["Meta reklam araştırması", "Anahtar kelime ve ülkeye göre medya içeren reklam kreatiflerini araştırın."],
["TikTok Shop keşfi", "Ürünleri ve mağaza sinyallerini aynı araştırma akışında inceleyin."],
["Magic AI", "Kreatif açıları, hedef kitle fikirleri ve test edilebilir yaklaşımlar üretin."],
["Trends", "Yükselen reklam ve ürün sinyallerini düzenli bir görünümde takip edin."],
["Brand Tracker", "Rakip markaları kaydedin ve yeni hareketlerini tek listeden izleyin."],
["Store Tracker", "Mağazaları takip edin, benzer mağazaları ve ürün sinyallerini keşfedin."]
].map(([title, description]) => (
<article key={title} className="rounded-3xl border border-slate-200 bg-white/80 p-6 shadow-sm">
<h3 className="text-lg font-black text-slate-950">{title}</h3>
<p className="mt-2 leading-7 text-slate-600">{description}</p>
</article>
))}
</div>
</section>
<section className="border-y border-slate-200 bg-white/70" aria-labelledby="faq-heading">
<div className="mx-auto max-w-4xl px-6 py-16">
<p className="text-sm font-black uppercase tracking-[0.2em] text-violet-700">Sık sorulan sorular</p>
<h2 id="faq-heading" className="mt-3 text-3xl font-black tracking-tight text-slate-950">AdSeeQ hakkında</h2>
<div className="mt-8 space-y-4">
{homeFaq.map(({ question, answer }) => (
<details key={question} className="group rounded-2xl border border-slate-200 bg-white p-5">
<summary className="cursor-pointer list-none pr-6 font-black text-slate-950">{question}</summary>
<p className="mt-3 leading-7 text-slate-600">{answer}</p>
</details>
))}
</div>
</div>
</section>
<footer className="mx-auto flex max-w-7xl flex-col gap-4 px-6 py-10 text-sm text-slate-500 sm:flex-row sm:items-center sm:justify-between">
<BrandLogo markClassName="h-8 w-8" />
<div className="flex gap-5">
<a href="/pricing" className="hover:text-slate-950">Fiyatlandırma</a>
<a href="/register" className="hover:text-slate-950">Ücretsiz başla</a>
<a href="/login" className="hover:text-slate-950">Giriş</a>
</div>
</footer>
</main>
);
}
+12
View File
@@ -3,6 +3,18 @@ import { LinkButton } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { CheckoutButton } from "@/components/checkout-button";
import { BrandLogo } from "@/components/brand-logo";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Fiyatlandırma",
description: "AdSeeQ Free, Basic, Standard ve Premium planlarının reklam arama, mağaza takip ve kayıt limitlerini karşılaştırın.",
alternates: { canonical: "/pricing", languages: { "tr-TR": "/pricing" } },
openGraph: {
title: "AdSeeQ Fiyatlandırma",
description: "İhtiyacınıza uygun reklam ve trend araştırma planını seçin.",
url: "/pricing"
}
};
export default async function PricingPage() {
const plans = await prisma.plan.findMany({ orderBy: { sortOrder: "asc" } }).catch(() => []);
+12
View File
@@ -0,0 +1,12 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Ücretsiz Hesap Oluştur",
description: "AdSeeQ ücretsiz hesabınızı oluşturun ve reklam araştırmasına başlayın.",
alternates: { canonical: "/register" },
robots: { index: false, follow: false, nocache: true }
};
export default function RegisterLayout({ children }: { children: React.ReactNode }) {
return children;
}
+14
View File
@@ -0,0 +1,14 @@
import type { MetadataRoute } from "next";
import { SITE_URL } from "@/lib/seo";
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: ["/", "/pricing", "/llms.txt"],
disallow: ["/api/", "/dashboard/", "/login", "/register"]
},
sitemap: `${SITE_URL}/sitemap.xml`,
host: SITE_URL
};
}
+19
View File
@@ -0,0 +1,19 @@
import type { MetadataRoute } from "next";
import { SITE_URL } from "@/lib/seo";
export default function sitemap(): MetadataRoute.Sitemap {
return [
{
url: SITE_URL,
changeFrequency: "weekly",
priority: 1,
alternates: { languages: { "tr-TR": SITE_URL } }
},
{
url: `${SITE_URL}/pricing`,
changeFrequency: "monthly",
priority: 0.8,
alternates: { languages: { "tr-TR": `${SITE_URL}/pricing` } }
}
];
}
+18 -2
View File
@@ -1,21 +1,37 @@
"use client";
import { MediaType } from "@prisma/client";
import { useEffect, useState } from "react";
type Creative = {
id: string;
type: MediaType;
url: string;
thumbnailUrl?: string | null;
};
export function AdCreativeMedia({ creative, className }: { creative?: Creative | null; className?: string }) {
const [videoFailed, setVideoFailed] = useState(false);
useEffect(() => setVideoFailed(false), [creative?.id]);
const classes = className || "h-44 w-full rounded-2xl object-cover";
if (!creative) {
return <div className={`${classes} grid place-items-center bg-slate-100 text-sm font-semibold text-slate-400`}>Kreatif bulunamadı</div>;
}
if (creative.type === MediaType.VIDEO) {
if (videoFailed) {
return (
<video controls playsInline preload="metadata" poster={creative.thumbnailUrl || undefined} className={classes}>
<source src={creative.url} />
<div className={`${classes} relative overflow-hidden bg-slate-900`}>
{creative.thumbnailUrl && <img src={creative.thumbnailUrl} alt="Video önizlemesi" className="h-full w-full object-cover opacity-70" />}
<div className="absolute inset-0 grid place-items-center bg-slate-950/45 px-4 text-center text-sm font-bold text-white">
Video bağlantısı yenilenemedi. Aynı aramayı tekrar getirerek medyayı güncelleyebilirsiniz.
</div>
</div>
);
}
return (
<video controls playsInline preload="metadata" poster={creative.thumbnailUrl || undefined} className={classes} onError={() => setVideoFailed(true)}>
<source src={`/api/ads/media/${encodeURIComponent(creative.id)}`} />
Tarayıcınız video oynatmayı desteklemiyor.
</video>
);
+5 -4
View File
@@ -23,14 +23,15 @@ export function CreditAdjustForm({ userId, currentBalance }: { userId: string; c
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = new FormData(event.currentTarget);
const formElement = event.currentTarget;
const form = new FormData(formElement);
const operation = String(form.get("operation"));
const amount = Number(form.get("amount"));
if (operation === "remove" && !window.confirm(`${amount} kredi bakiyeden çıkarılsın mı?`)) return;
setBusy(true); setMessage("");
try {
const result = await postJson(`/api/admin/users/${userId}/credits`, { operation, amount, reason: form.get("reason") });
setIsError(false); setMessage(`Yeni bakiye: ${result.balance}`); event.currentTarget.reset(); router.refresh();
setIsError(false); setMessage(`Yeni bakiye: ${result.balance}`); formElement.reset(); router.refresh();
} catch (error) { setIsError(true); setMessage(error instanceof Error ? error.message : "İşlem başarısız"); }
finally { setBusy(false); }
}
@@ -95,10 +96,10 @@ export function PlanForm({ userId, currentPlan, plans }: { userId: string; curre
export function ManualPaymentForm({ users }: { users: Array<{ id: string; email: string }> }) {
const router = useRouter(); const [busy, setBusy] = useState(false); const [message, setMessage] = useState(""); const [isError, setIsError] = useState(false);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); const form = new FormData(event.currentTarget); setBusy(true); setMessage("");
event.preventDefault(); const formElement = event.currentTarget; const form = new FormData(formElement); setBusy(true); setMessage("");
try {
await postJson("/api/admin/payments", { userId: form.get("userId"), amountCents: Math.round(Number(form.get("amount")) * 100), currency: form.get("currency"), creditGranted: Number(form.get("creditGranted") || 0), description: form.get("description"), externalId: form.get("externalId") || null });
setIsError(false); setMessage("Ödeme kaydedildi"); event.currentTarget.reset(); router.refresh();
setIsError(false); setMessage("Ödeme kaydedildi"); formElement.reset(); router.refresh();
} catch (error) { setIsError(true); setMessage(error instanceof Error ? error.message : "İşlem başarısız"); }
finally { setBusy(false); }
}
+3 -4
View File
@@ -15,7 +15,7 @@ export function ApifyIngestForm({ configured }: { configured: boolean }) {
const searchTerms = String(form.get("searchTerms") || "").split(",").map((term) => term.trim()).filter(Boolean);
const maxResults = Number(form.get("maxResults"));
const maxCostUsd = Number(form.get("maxCostUsd"));
if (!window.confirm(`${searchTerms.join(", ")} için en fazla ${maxResults} reklam çekilsin mi? Apify harcama üst sınırı $${maxCostUsd.toFixed(2)}.`)) return;
if (!window.confirm(`${searchTerms.join(", ")} için en fazla ${maxResults} reklam çekilsin mi? Harcama üst sınırı $${maxCostUsd.toFixed(2)}.`)) return;
setBusy(true); setMessage("");
try {
const response = await fetch("/api/admin/ingest/apify", {
@@ -23,7 +23,7 @@ export function ApifyIngestForm({ configured }: { configured: boolean }) {
body: JSON.stringify({ searchTerms, country: form.get("country"), adActiveStatus: form.get("adActiveStatus"), mediaType: form.get("mediaType"), maxResults, maxCostUsd, scrapeAdDetails: form.get("scrapeAdDetails") === "on", includeAboutPage: form.get("includeAboutPage") === "on" })
});
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || "APIFY_INGEST_FAILED");
if (!response.ok) throw new Error("Canlı reklam taraması başarısız oldu.");
setError(false); setMessage(`${data.imported} reklam işlendi, ${data.failed} kayıt atlandı.`); router.refresh();
} catch (caught) {
setError(true); setMessage(caught instanceof Error ? caught.message : "İşlem başarısız");
@@ -44,9 +44,8 @@ export function ApifyIngestForm({ configured }: { configured: boolean }) {
<label className="flex items-center gap-2 rounded-xl bg-slate-50 px-3 py-3 text-sm font-semibold"><input name="scrapeAdDetails" type="checkbox" defaultChecked /> Yaratıcı/CTA detaylarını al</label>
<label className="flex items-center gap-2 rounded-xl bg-slate-50 px-3 py-3 text-sm font-semibold"><input name="includeAboutPage" type="checkbox" /> Reklamveren detaylarını al</label>
<label className="text-sm font-semibold text-slate-600">Harcama üst sınırı (USD)<input name="maxCostUsd" type="number" min="0.1" max="10" step="0.1" defaultValue="1" required className="mt-1 w-full rounded-xl border border-slate-200 px-3 py-3" /></label>
<button disabled={busy || !configured} className="self-end rounded-xl bg-violet-700 px-4 py-3 font-bold text-white disabled:opacity-40">{!configured ? "APIFY_TOKEN bekleniyor" : busy ? "Apify çalışıyor..." : "Reklamları çek"}</button>
<button disabled={busy || !configured} className="self-end rounded-xl bg-violet-700 px-4 py-3 font-bold text-white disabled:opacity-40">{!configured ? "Veri kaynağı ayarı bekleniyor" : busy ? "Veriler taranıyor..." : "Reklamları çek"}</button>
{message && <p className={`text-sm font-semibold lg:col-span-2 ${error ? "text-rose-600" : "text-emerald-600"}`}>{message}</p>}
</form>
);
}
+28 -10
View File
@@ -1,17 +1,25 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
const RESULT_LIMIT = 10;
const RESULT_OPTIONS = [10, 25, 50, 100];
export function ApifyEmptySearch({ query, planLimit }: { query: string; planLimit: number }) {
export function ApifyEmptySearch({ query, country, mediaType, matchMode, status, planLimit, existingCount = 0 }: { query: string; country: string; mediaType: string; matchMode: "ALL_WORDS" | "EXACT_PHRASE"; status: "ACTIVE" | "INACTIVE" | "ALL"; planLimit: number; existingCount?: number }) {
const router = useRouter();
const [maxResults, setMaxResults] = useState(RESULT_LIMIT);
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);
@@ -22,18 +30,25 @@ export function ApifyEmptySearch({ query, planLimit }: { query: string; planLimi
headers: { "content-type": "application/json" },
body: JSON.stringify({
searchTerm: query,
maxResults,
country,
mediaType,
matchMode,
status,
maxResults
})
});
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || "APIFY_INGEST_FAILED");
if (!response.ok) throw new Error("Canlı reklam taraması başarısız oldu.");
setError(false);
setMessage(`${data.imported} reklam işlendi. 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);
setMessage(caught instanceof Error ? caught.message : "Apify içe aktarması başarısız oldu.");
setMessage(caught instanceof Error ? caught.message : "Canlı reklam taraması başarısız oldu.");
} finally {
setBusy(false);
}
@@ -41,9 +56,11 @@ export function ApifyEmptySearch({ query, planLimit }: { query: string; planLimi
return (
<div className="rounded-3xl border border-dashed border-violet-200 bg-violet-50/60 p-8 text-center md:col-span-2 xl:col-span-3">
<h2 className="text-xl font-black">{query} için kayıtlı reklam bulunamadı</h2>
<h2 className="text-xl font-black">{existingCount > 0 ? `Daha fazla “${query}” reklamı getir` : `${query}için kayıtlı reklam bulunamadı`}</h2>
<p className="mx-auto mt-2 max-w-2xl text-sm text-slate-600">
Bu arama önce AdSeeQ veritabanını kontrol eder. Yeni Meta reklamlarını Apify üzerinden getirip aynı aramaya ekleyebilirsiniz.
{existingCount > 0
? `Şu anda ${existingCount} ilgili reklam gösteriliyor. Yeni Meta reklamlarını aynı detaylı filtrelerle canlı olarak tarayabilirsiniz.`
: "Bu arama önce AdSeeQ veritabanını kontrol eder. Yeni Meta reklamlarını seçtiğiniz ülke ve medya türüyle canlı olarak getirip aynı aramaya ekleyebilirsiniz."}
</p>
<div className="mx-auto mt-5 flex max-w-sm gap-2">
<label className="sr-only" htmlFor="apify-result-count">Getirilecek reklam adedi</label>
@@ -62,10 +79,11 @@ export function ApifyEmptySearch({ query, planLimit }: { query: string; planLimi
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" : "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">Apify · Plan limiti: {planLimit || "erişim yok"} reklam · Kullanılan kayıt kadar API kredisi</p>
<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>
);
+1 -1
View File
@@ -23,7 +23,7 @@ export function TikTokShopImport({ planLimit }: { planLimit: number }) {
<input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="TikTok Shop ürününü canlı ara" className="rounded-2xl border border-slate-200 px-4 py-3" />
<select value={region} onChange={(e) => setRegion(e.target.value)} className="rounded-2xl border border-slate-200 px-3"><option>US</option><option>GB</option><option>SG</option><option>MY</option><option>PH</option><option>TH</option><option>VN</option><option>ID</option></select>
<select value={count} onChange={(e) => setCount(Number(e.target.value))} className="rounded-2xl border border-slate-200 px-3">{[10,25,50].filter((v) => v <= planLimit).map((v) => <option key={v} value={v}>{v} ürün</option>)}</select>
<button type="button" onClick={run} disabled={busy || planLimit === 0 || query.trim().length < 2} className="rounded-2xl bg-slate-950 px-4 py-3 font-bold text-white disabled:opacity-50">{busy ? "Getiriliyor…" : planLimit ? "Apifydan getir" : "Planı yükselt"}</button>
<button type="button" onClick={run} disabled={busy || planLimit === 0 || query.trim().length < 2} className="rounded-2xl bg-slate-950 px-4 py-3 font-bold text-white disabled:opacity-50">{busy ? "Getiriliyor…" : planLimit ? "Canlı veriden getir" : "Planı yükselt"}</button>
{message && <p className="text-sm font-semibold text-violet-700 md:col-span-4">{message}</p>}
</div>;
}
+82
View File
@@ -0,0 +1,82 @@
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 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} `));
}
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);
}
+50 -1
View File
@@ -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";
@@ -214,7 +215,7 @@ export async function runApifyActor(input: ApifyIngestInput) {
}
}
function actorInput(actorId: string, input: ApifyIngestInput) {
export function actorInput(actorId: string, input: ApifyIngestInput) {
if (actorId === "aiscraperdev~facebook-meta-ads-library-scraper") {
return {
searchQueries: input.searchTerms,
@@ -239,6 +240,54 @@ 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);
}
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
View File
@@ -0,0 +1,19 @@
export const AD_COUNTRIES = [
["ALL", "Tüm ülkeler"],
["TR", "Türkiye"],
["US", "Amerika Birleşik Devletleri"],
["GB", "Birleşik Krallık"],
["DE", "Almanya"],
["FR", "Fransa"],
["IT", "İtalya"],
["ES", "İspanya"],
["NL", "Hollanda"],
["CA", "Kanada"],
["AU", "Avustralya"],
["AE", "Birleşik Arap Emirlikleri"],
["SA", "Suudi Arabistan"],
["IN", "Hindistan"],
["BR", "Brezilya"]
] as const;
export const AD_COUNTRY_CODES: ReadonlySet<string> = new Set(AD_COUNTRIES.map(([code]) => code));
+58
View File
@@ -0,0 +1,58 @@
import { isIP } from "node:net";
import { lookup } from "node:dns/promises";
function isPrivateIpv4(address: string) {
const parts = address.split(".").map(Number);
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return true;
return parts[0] === 10 ||
parts[0] === 127 ||
(parts[0] === 169 && parts[1] === 254) ||
(parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) ||
(parts[0] === 192 && parts[1] === 168) ||
parts[0] === 0;
}
function isPrivateIpv6(address: string) {
const normalized = address.toLowerCase();
return normalized === "::1" || normalized === "::" || normalized.startsWith("fc") || normalized.startsWith("fd") || normalized.startsWith("fe8") || normalized.startsWith("fe9") || normalized.startsWith("fea") || normalized.startsWith("feb");
}
export function isSafeMediaHostname(hostname: string) {
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
if (!normalized || normalized === "localhost" || normalized.endsWith(".localhost") || normalized.endsWith(".local") || normalized.endsWith(".internal")) return false;
const version = isIP(normalized);
if (version === 4) return !isPrivateIpv4(normalized);
if (version === 6) return !isPrivateIpv6(normalized);
return true;
}
async function assertPublicTarget(url: URL) {
if (url.protocol !== "https:" || !isSafeMediaHostname(url.hostname)) throw new Error("UNSAFE_MEDIA_URL");
const addresses = await lookup(url.hostname, { all: true, verbatim: true });
if (!addresses.length || addresses.some(({ address, family }) => family === 4 ? isPrivateIpv4(address) : isPrivateIpv6(address))) {
throw new Error("UNSAFE_MEDIA_URL");
}
}
export async function fetchMedia(urlValue: string, range: string | null) {
let url = new URL(urlValue);
for (let redirect = 0; redirect < 4; redirect += 1) {
await assertPublicTarget(url);
const response = await fetch(url, {
headers: {
...(range ? { range } : {}),
accept: "video/*,*/*;q=0.8",
referer: "https://www.facebook.com/",
"user-agent": "Mozilla/5.0 (compatible; AdSeeQMedia/1.0)"
},
redirect: "manual",
cache: "no-store",
signal: AbortSignal.timeout(30_000)
});
if (response.status < 300 || response.status >= 400) return response;
const location = response.headers.get("location");
if (!location) return response;
url = new URL(location, url);
}
throw new Error("TOO_MANY_REDIRECTS");
}
+115
View File
@@ -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 };
}
+1
View File
@@ -18,6 +18,7 @@ export const KURALLAR = {
giris: { limit: 10, sureMs: 15 * 60 * 1000 },
kayit: { limit: 5, sureMs: 60 * 60 * 1000 },
arama: { limit: 60, sureMs: 60 * 1000 },
medya: { limit: 300, sureMs: 60 * 1000 },
} as const;
export type KuralAdi = keyof typeof KURALLAR;
+85
View File
@@ -0,0 +1,85 @@
export const SITE_URL = "https://adseeq.com";
export const SITE_NAME = "AdSeeQ";
export const SITE_DESCRIPTION =
"Meta reklamlarını, TikTok Shop ürünlerini, yükselen trendleri ve rakip markaları yapay zekâ destekli tek panelden araştırın.";
export const homeFaq = [
{
question: "AdSeeQ nedir?",
answer:
"AdSeeQ; Meta reklamlarını, TikTok Shop ürünlerini, yükselen trendleri ve rakip markaları tek panelde araştırmaya yardımcı olan reklam ve trend zekâsı platformudur."
},
{
question: "AdSeeQ ile hangi reklamlar araştırılabilir?",
answer:
"Meta Ads Library kaynaklı reklamlar anahtar kelime ve ülke ölçütleriyle aranabilir; medya içeren kreatifler incelenebilir ve uygun reklamlar daha sonra değerlendirmek üzere kaydedilebilir."
},
{
question: "Magic AI ne işe yarar?",
answer:
"Magic AI, reklam ve ürün sinyallerini özetleyerek kreatif açıları, hedef kitle fikirleri ve test edilebilecek pazarlama yaklaşımları üretmeye yardımcı olur."
},
{
question: "AdSeeQ ücretsiz kullanılabilir mi?",
answer:
"Evet. Ücretsiz plan günlük sınırlı reklam araması sunar; daha yüksek arama ve takip limitleri için ücretli planlar bulunur."
}
] as const;
export const homeStructuredData = {
"@context": "https://schema.org",
"@graph": [
{
"@type": "Organization",
"@id": `${SITE_URL}/#organization`,
name: SITE_NAME,
url: SITE_URL,
logo: `${SITE_URL}/icon.svg`
},
{
"@type": "WebSite",
"@id": `${SITE_URL}/#website`,
url: SITE_URL,
name: SITE_NAME,
description: SITE_DESCRIPTION,
inLanguage: "tr-TR",
publisher: { "@id": `${SITE_URL}/#organization` }
},
{
"@type": "SoftwareApplication",
"@id": `${SITE_URL}/#software`,
name: SITE_NAME,
applicationCategory: "BusinessApplication",
applicationSubCategory: "Advertising Intelligence",
operatingSystem: "Web",
url: SITE_URL,
description: SITE_DESCRIPTION,
inLanguage: "tr-TR",
offers: {
"@type": "Offer",
price: "0",
priceCurrency: "EUR",
description: "Günlük sınırlı reklam araması içeren ücretsiz plan"
},
publisher: { "@id": `${SITE_URL}/#organization` }
},
{
"@type": "FAQPage",
"@id": `${SITE_URL}/#faq`,
mainEntity: homeFaq.map(({ question, answer }) => ({
"@type": "Question",
name: question,
acceptedAnswer: {
"@type": "Answer",
text: answer
}
}))
}
]
};
export function serializeJsonLd(value: unknown) {
return JSON.stringify(value).replace(/</g, "\\u003c");
}
+22
View File
@@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
const LEGACY_HOSTS = new Set(["kazananavci.seymata.com", "www.kazananavci.seymata.com"]);
export function proxy(request: NextRequest) {
const forwardedHost = request.headers.get("x-forwarded-host")?.split(",")[0]?.trim();
const host = (forwardedHost || request.headers.get("host") || "").split(":")[0].toLowerCase();
if (!LEGACY_HOSTS.has(host) && host !== "www.adseeq.com") {
return NextResponse.next();
}
const destination = new URL("https://adseeq.com");
destination.pathname = request.nextUrl.pathname;
destination.search = request.nextUrl.search;
return NextResponse.redirect(destination, 301);
}
export const config = {
matcher: ["/((?!_next/static|_next/image).*)"]
};
+127 -1
View File
@@ -1,11 +1,19 @@
import assert from "node:assert/strict";
import test from "node:test";
import { MediaType } from "@prisma/client";
import { normalizeApifyAd } from "../src/lib/apify";
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";
import { tikTokActorInput } from "../src/lib/apify-tiktok";
import { NextRequest } from "next/server";
import { proxy } from "../src/proxy";
import sitemap from "../src/app/sitemap";
import robots from "../src/app/robots";
import { GET as llmsTxt } from "../src/app/llms.txt/route";
import { homeStructuredData, serializeJsonLd } from "../src/lib/seo";
import { isSafeMediaHostname } from "../src/lib/media-proxy";
test("Apify reklamı metin, medya ve ülke alanlarıyla normalize edilir", () => {
const ad = normalizeApifyAd({
@@ -56,3 +64,121 @@ test("TikTok aktör girdisi resmi şemadaki alanları kullanır", () => {
keyword: "phone case", region: "US", maxItems: 10, addonProductDetails: false
});
});
test("eski alan adı yol ve sorguyu koruyarak AdSeeQ'a yönlenir", () => {
const request = new NextRequest("http://localhost/dashboard/ads?q=berber", {
headers: { "x-forwarded-host": "kazananavci.seymata.com" }
});
const response = proxy(request);
assert.equal(response.status, 301);
assert.equal(response.headers.get("location"), "https://adseeq.com/dashboard/ads?q=berber");
});
test("AdSeeQ ana alan adı yönlendirilmez", () => {
const request = new NextRequest("https://adseeq.com/dashboard/ads", {
headers: { host: "adseeq.com" }
});
const response = proxy(request);
assert.equal(response.status, 200);
assert.equal(response.headers.get("location"), null);
});
test("sitemap yalnızca indekslenebilir herkese açık sayfaları içerir", () => {
const urls = sitemap().map((entry) => entry.url);
assert.deepEqual(urls, ["https://adseeq.com", "https://adseeq.com/pricing"]);
assert.equal(new Set(urls).size, urls.length);
});
test("robots özel alanları engeller ve sitemap adresini bildirir", () => {
const value = robots();
assert.equal(value.sitemap, "https://adseeq.com/sitemap.xml");
assert.deepEqual(value.rules, {
userAgent: "*",
allow: ["/", "/pricing", "/llms.txt"],
disallow: ["/api/", "/dashboard/", "/login", "/register"]
});
});
test("llms.txt ürün kapsamını düz metin olarak açıklar", async () => {
const response = llmsTxt();
const body = await response.text();
assert.match(response.headers.get("content-type") || "", /^text\/plain/);
assert.match(body, /Meta Ads Library/);
assert.match(body, /https:\/\/adseeq\.com\/pricing/);
});
test("ana sayfa JSON-LD verisi yazılım ve SSS şemalarını içerir", () => {
const value = serializeJsonLd(homeStructuredData);
assert.match(value, /SoftwareApplication/);
assert.match(value, /FAQPage/);
assert.equal(value.includes("<"), false);
});
test("medya proxy'si yerel ve özel ağ hedeflerini reddeder", () => {
assert.equal(isSafeMediaHostname("localhost"), false);
assert.equal(isSafeMediaHostname("127.0.0.1"), false);
assert.equal(isSafeMediaHostname("10.0.0.8"), false);
assert.equal(isSafeMediaHostname("192.168.1.4"), false);
assert.equal(isSafeMediaHostname("video.xx.fbcdn.net"), true);
});
test("Meta Apify aktörüne ülke ve video filtresi aktarılır", () => {
assert.deepEqual(actorInput("aiscraperdev~facebook-meta-ads-library-scraper", {
searchTerms: ["berber"],
country: "TR",
adActiveStatus: "ACTIVE",
mediaType: "VIDEO",
maxResults: 25,
scrapeAdDetails: true,
includeAboutPage: false,
maxCostUsd: 0.2
}), {
searchQueries: ["berber"],
countryCode: "TR",
adStatus: "active",
adType: "all",
mediaType: "video",
platform: "all",
maxResults: 25
});
});
test("Apify snake_case video alanı doğrudan video kreatifi olur", () => {
const ad = normalizeApifyAd({ ad_id: "video-1", page_name: "Test", ad_format: "video", video_url: "https://video.xx.fbcdn.net/test.mp4" });
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("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" },
{ 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");
});
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);
});