fix: add country filters and reliable ad video streaming
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
|
// Reklam videoları kimlik doğrulamalı aynı-origin medya rotasından akar.
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
poweredByHeader: false,
|
poweredByHeader: false,
|
||||||
typescript: { ignoreBuildErrors: false },
|
typescript: { ignoreBuildErrors: false },
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ import { prisma } from "@/lib/db";
|
|||||||
import { planFromUser } from "@/lib/plans";
|
import { planFromUser } from "@/lib/plans";
|
||||||
import { checkAndConsumeQuota, refundQuota } from "@/lib/quota";
|
import { checkAndConsumeQuota, refundQuota } from "@/lib/quota";
|
||||||
import { hizSiniriAsimi } from "@/lib/rate-limit";
|
import { hizSiniriAsimi } from "@/lib/rate-limit";
|
||||||
|
import { AD_COUNTRY_CODES } from "@/lib/countries";
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
searchTerm: z.string().trim().min(2).max(100),
|
searchTerm: z.string().trim().min(2).max(100),
|
||||||
|
country: z.string().trim().toUpperCase().refine((value) => AD_COUNTRY_CODES.has(value)),
|
||||||
|
mediaType: z.enum(["ALL", "IMAGE", "VIDEO", "MEME"]),
|
||||||
maxResults: z.coerce.number().int().refine((value) => [10, 25, 50, 100].includes(value))
|
maxResults: z.coerce.number().int().refine((value) => [10, 25, 50, 100].includes(value))
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -57,16 +60,16 @@ export async function POST(request: Request) {
|
|||||||
type: "meta-ads-library",
|
type: "meta-ads-library",
|
||||||
status: "RUNNING",
|
status: "RUNNING",
|
||||||
startedAt: new Date(),
|
startedAt: new Date(),
|
||||||
metadata: { userId: user.id, searchTerm: parsed.data.searchTerm, 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 {
|
try {
|
||||||
const records = await runApifyActor({
|
const records = await runApifyActor({
|
||||||
searchTerms: [parsed.data.searchTerm],
|
searchTerms: [parsed.data.searchTerm],
|
||||||
country: "ALL",
|
country: parsed.data.country,
|
||||||
adActiveStatus: "ACTIVE",
|
adActiveStatus: "ACTIVE",
|
||||||
mediaType: "ALL",
|
mediaType: parsed.data.mediaType,
|
||||||
maxResults: parsed.data.maxResults,
|
maxResults: parsed.data.maxResults,
|
||||||
maxCostUsd: Math.max(0.1, Math.ceil(parsed.data.maxResults * 0.004 * 10) / 10),
|
maxCostUsd: Math.max(0.1, Math.ceil(parsed.data.maxResults * 0.004 * 10) / 10),
|
||||||
scrapeAdDetails: true,
|
scrapeAdDetails: true,
|
||||||
@@ -83,7 +86,7 @@ export async function POST(request: Request) {
|
|||||||
finishedAt: new Date(),
|
finishedAt: new Date(),
|
||||||
recordsImported: result.imported,
|
recordsImported: result.imported,
|
||||||
recordsFailed: result.failed,
|
recordsFailed: result.failed,
|
||||||
metadata: { userId: user.id, searchTerm: parsed.data.searchTerm, 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 });
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { planFromUser } from "@/lib/plans";
|
|||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { AdCreativeMedia } from "@/components/ad-creative-media";
|
import { AdCreativeMedia } from "@/components/ad-creative-media";
|
||||||
import { ApifyEmptySearch } from "@/components/ads/apify-empty-search";
|
import { ApifyEmptySearch } from "@/components/ads/apify-empty-search";
|
||||||
|
import { AD_COUNTRIES } from "@/lib/countries";
|
||||||
|
|
||||||
export default async function AdsPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
|
export default async function AdsPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
|
||||||
const user = await requireUser();
|
const user = await requireUser();
|
||||||
@@ -15,11 +16,15 @@ export default async function AdsPage({ searchParams }: { searchParams: Promise<
|
|||||||
const q = resolvedSearchParams.q?.trim();
|
const q = resolvedSearchParams.q?.trim();
|
||||||
const niche = resolvedSearchParams.niche;
|
const niche = resolvedSearchParams.niche;
|
||||||
const mediaType = resolvedSearchParams.mediaType;
|
const mediaType = resolvedSearchParams.mediaType;
|
||||||
|
const country = resolvedSearchParams.country?.toUpperCase();
|
||||||
const displayableCreativeWhere: Prisma.AdCreativeWhereInput = { url: { not: "" } };
|
const displayableCreativeWhere: Prisma.AdCreativeWhereInput = { url: { not: "" } };
|
||||||
const where: Prisma.AdWhereInput = { creatives: { some: displayableCreativeWhere } };
|
const where: Prisma.AdWhereInput = { creatives: { some: displayableCreativeWhere } };
|
||||||
if (q) where.OR = [{ primaryText: { contains: q, mode: "insensitive" } }, { headline: { contains: q, mode: "insensitive" } }, { brandPage: { name: { contains: q, mode: "insensitive" } } }];
|
if (q) 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 (niche) where.niche = { contains: niche, mode: "insensitive" };
|
||||||
if (mediaType) where.mediaType = mediaType as any;
|
if (mediaType) where.mediaType = mediaType as any;
|
||||||
|
if (country && country !== "ALL") where.countries = { has: country };
|
||||||
|
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({
|
const ads = await prisma.ad.findMany({
|
||||||
where,
|
where,
|
||||||
include: {
|
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 className="mb-6 flex flex-wrap items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-black">Search Meta Adlibrary</h1>
|
<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>
|
||||||
<div className="rounded-2xl bg-white px-4 py-3 text-sm shadow-sm">Plan: <b>{plan?.name}</b> · Free ise kartlar kilitli</div>
|
<div className="rounded-2xl bg-white px-4 py-3 text-sm shadow-sm">Plan: <b>{plan?.name}</b> · Free ise kartlar kilitli</div>
|
||||||
</div>
|
</div>
|
||||||
<form className="mb-5 grid gap-3 rounded-3xl bg-white p-4 shadow-soft md:grid-cols-[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" />
|
<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">
|
<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>
|
<option value="">Tüm niche</option><option>Pets</option><option>Beauty</option><option>Supplements</option><option>Household</option>
|
||||||
</select>
|
</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>)}
|
{["Week's biggest winners", "US winners", "Dropship Ads", "Supplements", "Top Branded"].map((x) => <span key={x} className="rounded-full bg-violet-50 px-3 py-1 text-sm font-semibold text-violet-800">{x}</span>)}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||||
{q && masked.length === 0 && <ApifyEmptySearch query={q} planLimit={apifyPlanLimit} />}
|
{q && masked.length === 0 && <ApifyEmptySearch query={q} country={country || "ALL"} mediaType={mediaType || "ALL"} planLimit={apifyPlanLimit} />}
|
||||||
{masked.map((ad: any) => (
|
{masked.map((ad: any) => (
|
||||||
<Card key={ad.id} className="relative overflow-hidden">
|
<Card key={ad.id} className="relative overflow-hidden">
|
||||||
{ad.isLocked && <div className="absolute inset-0 z-10 grid place-items-center bg-white/70 backdrop-blur-[2px]"><Link href="/pricing" className="rounded-2xl bg-violet-700 px-5 py-3 font-black text-white">Start now — Unlock winners</Link></div>}
|
{ad.isLocked && <div className="absolute inset-0 z-10 grid place-items-center bg-white/70 backdrop-blur-[2px]"><Link href="/pricing" className="rounded-2xl bg-violet-700 px-5 py-3 font-black text-white">Start now — Unlock winners</Link></div>}
|
||||||
|
|||||||
@@ -1,21 +1,37 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { MediaType } from "@prisma/client";
|
import { MediaType } from "@prisma/client";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
type Creative = {
|
type Creative = {
|
||||||
|
id: string;
|
||||||
type: MediaType;
|
type: MediaType;
|
||||||
url: string;
|
url: string;
|
||||||
thumbnailUrl?: string | null;
|
thumbnailUrl?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AdCreativeMedia({ creative, className }: { creative?: Creative | null; className?: string }) {
|
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";
|
const classes = className || "h-44 w-full rounded-2xl object-cover";
|
||||||
if (!creative) {
|
if (!creative) {
|
||||||
return <div className={`${classes} grid place-items-center bg-slate-100 text-sm font-semibold text-slate-400`}>Kreatif bulunamadı</div>;
|
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 (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 (
|
return (
|
||||||
<video controls playsInline preload="metadata" poster={creative.thumbnailUrl || undefined} className={classes}>
|
<video controls playsInline preload="metadata" poster={creative.thumbnailUrl || undefined} className={classes} onError={() => setVideoFailed(true)}>
|
||||||
<source src={creative.url} />
|
<source src={`/api/ads/media/${encodeURIComponent(creative.id)}`} />
|
||||||
Tarayıcınız video oynatmayı desteklemiyor.
|
Tarayıcınız video oynatmayı desteklemiyor.
|
||||||
</video>
|
</video>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useRouter } from "next/navigation";
|
|||||||
const RESULT_LIMIT = 10;
|
const RESULT_LIMIT = 10;
|
||||||
const RESULT_OPTIONS = [10, 25, 50, 100];
|
const RESULT_OPTIONS = [10, 25, 50, 100];
|
||||||
|
|
||||||
export function ApifyEmptySearch({ query, planLimit }: { query: string; planLimit: number }) {
|
export function ApifyEmptySearch({ query, country, mediaType, planLimit }: { query: string; country: string; mediaType: string; planLimit: number }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [maxResults, setMaxResults] = useState(RESULT_LIMIT);
|
const [maxResults, setMaxResults] = useState(RESULT_LIMIT);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -22,7 +22,9 @@ export function ApifyEmptySearch({ query, planLimit }: { query: string; planLimi
|
|||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
searchTerm: query,
|
searchTerm: query,
|
||||||
maxResults,
|
country,
|
||||||
|
mediaType,
|
||||||
|
maxResults
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
const data = await response.json().catch(() => ({}));
|
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">
|
<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">“{query}” için kayıtlı reklam bulunamadı</h2>
|
||||||
<p className="mx-auto mt-2 max-w-2xl text-sm text-slate-600">
|
<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>
|
</p>
|
||||||
<div className="mx-auto mt-5 flex max-w-sm gap-2">
|
<div className="mx-auto mt-5 flex max-w-sm gap-2">
|
||||||
<label className="sr-only" htmlFor="apify-result-count">Getirilecek reklam adedi</label>
|
<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"}
|
{busy ? "Getiriliyor…" : planLimit === 0 ? "Planı yükselt" : "Getir"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>}
|
{message && <p className={`mt-3 text-sm font-semibold ${error ? "text-rose-600" : "text-emerald-700"}`}>{message}</p>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+1
-1
@@ -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") {
|
if (actorId === "aiscraperdev~facebook-meta-ads-library-scraper") {
|
||||||
return {
|
return {
|
||||||
searchQueries: input.searchTerms,
|
searchQueries: input.searchTerms,
|
||||||
|
|||||||
@@ -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));
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ export const KURALLAR = {
|
|||||||
giris: { limit: 10, sureMs: 15 * 60 * 1000 },
|
giris: { limit: 10, sureMs: 15 * 60 * 1000 },
|
||||||
kayit: { limit: 5, sureMs: 60 * 60 * 1000 },
|
kayit: { limit: 5, sureMs: 60 * 60 * 1000 },
|
||||||
arama: { limit: 60, sureMs: 60 * 1000 },
|
arama: { limit: 60, sureMs: 60 * 1000 },
|
||||||
|
medya: { limit: 300, sureMs: 60 * 1000 },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type KuralAdi = keyof typeof KURALLAR;
|
export type KuralAdi = keyof typeof KURALLAR;
|
||||||
|
|||||||
+37
-1
@@ -1,7 +1,7 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import { MediaType } from "@prisma/client";
|
import { MediaType } from "@prisma/client";
|
||||||
import { normalizeApifyAd } from "../src/lib/apify";
|
import { actorInput, normalizeApifyAd } from "../src/lib/apify";
|
||||||
import { getFeatureLimit } from "../src/lib/plans";
|
import { getFeatureLimit } from "../src/lib/plans";
|
||||||
import { istemciIp, hizSiniriAsimi } from "../src/lib/rate-limit";
|
import { istemciIp, hizSiniriAsimi } from "../src/lib/rate-limit";
|
||||||
import { stripePriceFor } from "../src/lib/stripe";
|
import { stripePriceFor } from "../src/lib/stripe";
|
||||||
@@ -12,6 +12,7 @@ import sitemap from "../src/app/sitemap";
|
|||||||
import robots from "../src/app/robots";
|
import robots from "../src/app/robots";
|
||||||
import { GET as llmsTxt } from "../src/app/llms.txt/route";
|
import { GET as llmsTxt } from "../src/app/llms.txt/route";
|
||||||
import { homeStructuredData, serializeJsonLd } from "../src/lib/seo";
|
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", () => {
|
test("Apify reklamı metin, medya ve ülke alanlarıyla normalize edilir", () => {
|
||||||
const ad = normalizeApifyAd({
|
const ad = normalizeApifyAd({
|
||||||
@@ -111,3 +112,38 @@ test("ana sayfa JSON-LD verisi yazılım ve SSS şemalarını içerir", () => {
|
|||||||
assert.match(value, /FAQPage/);
|
assert.match(value, /FAQPage/);
|
||||||
assert.equal(value.includes("<"), false);
|
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");
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user