fix: add country filters and reliable ad video streaming

This commit is contained in:
2026-08-21 16:25:44 +03:00
parent fdff6a0a26
commit 5b498aaefd
11 changed files with 198 additions and 15 deletions
+7 -4
View File
@@ -6,9 +6,12 @@ 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"]),
maxResults: z.coerce.number().int().refine((value) => [10, 25, 50, 100].includes(value))
});
@@ -57,16 +60,16 @@ export async function POST(request: Request) {
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, maxResults: parsed.data.maxResults }
}
});
try {
const records = await runApifyActor({
searchTerms: [parsed.data.searchTerm],
country: "ALL",
country: parsed.data.country,
adActiveStatus: "ACTIVE",
mediaType: "ALL",
mediaType: parsed.data.mediaType,
maxResults: parsed.data.maxResults,
maxCostUsd: Math.max(0.1, Math.ceil(parsed.data.maxResults * 0.004 * 10) / 10),
scrapeAdDetails: true,
@@ -83,7 +86,7 @@ export async function POST(request: Request) {
finishedAt: new Date(),
recordsImported: result.imported,
recordsFailed: result.failed,
metadata: { userId: user.id, searchTerm: parsed.data.searchTerm, maxResults: parsed.data.maxResults, received: records.length }
metadata: { userId: user.id, searchTerm: parsed.data.searchTerm, country: parsed.data.country, mediaType: parsed.data.mediaType, maxResults: parsed.data.maxResults, received: records.length }
}
});
return NextResponse.json({ ok: true, ...result, received: records.length });
@@ -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 });
}
}
+11 -3
View File
@@ -7,6 +7,7 @@ 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";
export default async function AdsPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
const user = await requireUser();
@@ -15,11 +16,15 @@ export default async function AdsPage({ searchParams }: { searchParams: Promise<
const q = resolvedSearchParams.q?.trim();
const niche = resolvedSearchParams.niche;
const mediaType = resolvedSearchParams.mediaType;
const country = resolvedSearchParams.country?.toUpperCase();
const displayableCreativeWhere: Prisma.AdCreativeWhereInput = { url: { not: "" } };
const where: Prisma.AdWhereInput = { creatives: { some: displayableCreativeWhere } };
if (q) where.OR = [{ primaryText: { contains: q, mode: "insensitive" } }, { headline: { contains: q, mode: "insensitive" } }, { brandPage: { name: { contains: q, mode: "insensitive" } } }];
if (niche) where.niche = { contains: niche, mode: "insensitive" };
if (mediaType) where.mediaType = mediaType as any;
if (country && country !== "ALL") where.countries = { has: country };
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: {
@@ -39,12 +44,15 @@ export default async function AdsPage({ searchParams }: { searchParams: Promise<
<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]">
<form className="mb-5 grid gap-3 rounded-3xl bg-white p-4 shadow-soft md:grid-cols-2 xl:grid-cols-[1fr_190px_170px_170px_120px]">
<input name="q" defaultValue={q} placeholder="dog collar, skincare, greens..." 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="niche" defaultValue={niche || ""} className="rounded-2xl border border-slate-200 px-4 py-3">
<option value="">Tüm niche</option><option>Pets</option><option>Beauty</option><option>Supplements</option><option>Household</option>
</select>
@@ -57,7 +65,7 @@ export default async function AdsPage({ searchParams }: { searchParams: Promise<
{["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 && masked.length === 0 && <ApifyEmptySearch query={q} country={country || "ALL"} mediaType={mediaType || "ALL"} planLimit={apifyPlanLimit} />}
{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>}
+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 (
<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}>
<source src={creative.url} />
<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>
);
+6 -4
View File
@@ -6,7 +6,7 @@ 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, planLimit }: { query: string; country: string; mediaType: string; planLimit: number }) {
const router = useRouter();
const [maxResults, setMaxResults] = useState(RESULT_LIMIT);
const [busy, setBusy] = useState(false);
@@ -22,7 +22,9 @@ export function ApifyEmptySearch({ query, planLimit }: { query: string; planLimi
headers: { "content-type": "application/json" },
body: JSON.stringify({
searchTerm: query,
maxResults,
country,
mediaType,
maxResults
})
});
const data = await response.json().catch(() => ({}));
@@ -43,7 +45,7 @@ export function ApifyEmptySearch({ query, planLimit }: { query: string; planLimi
<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>
<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.
Bu arama önce AdSeeQ veritabanını kontrol eder. Yeni Meta reklamlarını seçtiğiniz ülke ve medya türüyle Apify üzerinden 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>
@@ -65,7 +67,7 @@ export function ApifyEmptySearch({ query, planLimit }: { query: string; planLimi
{busy ? "Getiriliyor…" : planLimit === 0 ? "Planı yükselt" : "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">Apify · {country === "ALL" ? "Tüm ülkeler" : country} · {mediaType === "ALL" ? "Tüm medya" : mediaType} · Plan limiti: {planLimit || "erişim yok"} reklam</p>
{message && <p className={`mt-3 text-sm font-semibold ${error ? "text-rose-600" : "text-emerald-700"}`}>{message}</p>}
</div>
);
+1 -1
View File
@@ -214,7 +214,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,
+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");
}
+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;