fix: reklam kreatiflerini Apify verisinden goster

This commit is contained in:
2026-08-09 07:05:56 +03:00
parent 0ade96903d
commit b7fa66d04c
8 changed files with 144 additions and 47 deletions
+2 -1
View File
@@ -5,6 +5,7 @@ import { requireUser } from "@/lib/auth/current-user";
import { maskAdForPlan } from "@/lib/locked-response";
import { planFromUser } from "@/lib/plans";
import { Card } from "@/components/ui/card";
import { AdCreativeMedia } from "@/components/ad-creative-media";
export default async function AdsPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
const user = await requireUser();
@@ -48,7 +49,7 @@ export default async function AdsPage({ searchParams }: { searchParams: Promise<
<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>}
<div className={ad.isLocked ? "locked-blur" : ""}>
<img src={ad.creatives[0]?.thumbnailUrl || "https://placehold.co/640x480"} alt="" className="mb-4 h-44 w-full rounded-2xl object-cover" />
<AdCreativeMedia creative={ad.creatives[0]} className="mb-4 h-44 w-full rounded-2xl object-cover" />
<div className="mb-2 flex items-center justify-between">
<div className="font-black">{ad.headline}</div>
<div className="rounded-full bg-emerald-50 px-2 py-1 text-xs font-bold text-emerald-700">Top %{Math.round(ad.rankPercentile || 0)}</div>
+2 -1
View File
@@ -1,6 +1,7 @@
import { prisma } from "@/lib/db";
import { requireUser } from "@/lib/auth/current-user";
import { Card } from "@/components/ui/card";
import { AdCreativeMedia } from "@/components/ad-creative-media";
export default async function SavedAdsPage() {
const user = await requireUser();
@@ -15,7 +16,7 @@ export default async function SavedAdsPage() {
<div className="space-y-2"><div className="rounded-xl bg-violet-50 p-3 font-bold text-violet-700">All Saved Ads ({saved.length})</div>{folders.map((f) => <div key={f.id} className="rounded-xl bg-slate-50 p-3 font-bold">{f.name}</div>)}</div>
</Card>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{saved.map((s) => <Card key={s.id}><img src={s.ad.creatives[0]?.thumbnailUrl || ""} className="mb-4 h-40 w-full rounded-2xl object-cover" /><div className="font-black">{s.ad.headline}</div><div className="text-sm text-slate-500">{s.ad.brandPage?.name} · {s.folder?.name || "All"}</div><p className="mt-2 line-clamp-2 text-sm">{s.ad.primaryText}</p></Card>)}
{saved.map((s) => <Card key={s.id}><AdCreativeMedia creative={s.ad.creatives[0]} className="mb-4 h-40 w-full rounded-2xl object-cover" /><div className="font-black">{s.ad.headline}</div><div className="text-sm text-slate-500">{s.ad.brandPage?.name} · {s.folder?.name || "All"}</div><p className="mt-2 line-clamp-2 text-sm">{s.ad.primaryText}</p></Card>)}
</div>
</div>
</div>
+2 -1
View File
@@ -2,6 +2,7 @@ import { notFound } from "next/navigation";
import { prisma } from "@/lib/db";
import { requireUser } from "@/lib/auth/current-user";
import { Card } from "@/components/ui/card";
import { AdCreativeMedia } from "@/components/ad-creative-media";
export default async function StoreDetailPage({ params }: { params: Promise<{ id: string }> }) {
const resolvedParams = await params;
@@ -47,7 +48,7 @@ export default async function StoreDetailPage({ params }: { params: Promise<{ id
<Card className="mt-5">
<h2 className="mb-4 text-xl font-black">Top Meta Ads</h2>
<div className="grid gap-4 md:grid-cols-3">
{ads.map((ad) => <div key={ad.id} className="rounded-2xl border border-slate-100 p-3"><img src={ad.creatives[0]?.thumbnailUrl || ""} className="mb-3 h-32 w-full rounded-xl object-cover" /><div className="font-black">{ad.headline}</div><div className="text-sm text-slate-500">Top %{ad.rankPercentile} · {ad.daysRunning} gün</div></div>)}
{ads.map((ad) => <div key={ad.id} className="rounded-2xl border border-slate-100 p-3"><AdCreativeMedia creative={ad.creatives[0]} className="mb-3 h-32 w-full rounded-xl object-cover" /><div className="font-black">{ad.headline}</div><div className="text-sm text-slate-500">Top %{ad.rankPercentile} · {ad.daysRunning} gün</div></div>)}
</div>
</Card>
<Card className="mt-5">
+25
View File
@@ -0,0 +1,25 @@
import { MediaType } from "@prisma/client";
type Creative = {
type: MediaType;
url: string;
thumbnailUrl?: string | null;
};
export function AdCreativeMedia({ creative, className }: { creative?: Creative | null; className?: string }) {
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) {
return (
<video controls playsInline preload="metadata" poster={creative.thumbnailUrl || undefined} className={classes}>
<source src={creative.url} />
Tarayıcınız video oynatmayı desteklemiyor.
</video>
);
}
return <img src={creative.thumbnailUrl || creative.url} alt="Reklam kreatifi" loading="lazy" className={classes} />;
}
+110 -41
View File
@@ -2,7 +2,7 @@ import { AdSource, AdStatus, MediaType, Prisma } from "@prisma/client";
import { prisma } from "@/lib/db";
const APIFY_API_BASE = "https://api.apify.com/v2";
const DEFAULT_ACTOR_ID = "solidcode~meta-ads-library-scraper";
const DEFAULT_ACTOR_ID = "aiscraperdev~facebook-meta-ads-library-scraper";
export type ApifyIngestInput = {
searchTerms: string[];
@@ -43,6 +43,55 @@ function stringArray(record: JsonRecord, ...keys: string[]) {
return [];
}
function nestedRecords(record: JsonRecord) {
const queue: unknown[] = [record];
const records: JsonRecord[] = [];
const seen = new Set<object>();
while (queue.length && records.length < 500) {
const value = queue.shift();
if (!value || typeof value !== "object" || seen.has(value as object)) continue;
seen.add(value as object);
if (Array.isArray(value)) {
queue.push(...value);
continue;
}
const current = value as JsonRecord;
records.push(current);
queue.push(...Object.values(current));
}
return records;
}
function safeMediaUrl(value: unknown) {
if (typeof value !== "string" || !value.trim()) return null;
try {
const url = new URL(value.trim());
return url.protocol === "https:" || url.protocol === "http:" ? url.toString() : null;
} catch {
return null;
}
}
function deepUrlValue(record: JsonRecord, ...keys: string[]) {
const records = nestedRecords(record);
for (const key of keys) {
for (const current of records) {
const value = current[key];
const direct = safeMediaUrl(value);
if (direct) return direct;
if (!Array.isArray(value)) continue;
for (const item of value) {
const nested = safeMediaUrl(item);
if (nested) return nested;
}
}
}
return null;
}
function dateValue(value: unknown) {
if (typeof value !== "string" || !value) return null;
const date = new Date(value);
@@ -50,74 +99,79 @@ function dateValue(value: unknown) {
}
function firstMediaUrl(record: JsonRecord) {
const arrays = ["videoUrls", "videos", "imageUrls", "images", "videoPreviewImageUrls"];
for (const key of arrays) {
const values = stringArray(record, key);
if (values[0]) return values[0];
}
return stringValue(record, "videoHdUrl", "videoUrl", "imageUrl", "adSnapshotUrl", "snapshotUrl");
return deepUrlValue(
record,
"videoHdUrl", "video_hd_url", "videoSdUrl", "video_sd_url", "videoUrl", "video_url", "videoUrls",
"originalImageUrl", "original_image_url", "resizedImageUrl", "resized_image_url", "imageUrl", "image_url", "imageUrls",
"videoPreviewImageUrl", "video_preview_image_url", "videoPreviewImageUrls"
);
}
function firstThumbnailUrl(record: JsonRecord) {
for (const key of ["videoPreviewImageUrls", "imageUrls", "images"]) {
const values = stringArray(record, key);
if (values[0]) return values[0];
}
return stringValue(record, "thumbnailUrl", "imageUrl", "pageProfilePictureURL");
return deepUrlValue(
record,
"videoPreviewImageUrl", "video_preview_image_url", "videoPreviewImageUrls",
"thumbnailUrl", "thumbnail_url",
"originalImageUrl", "original_image_url", "resizedImageUrl", "resized_image_url", "imageUrl", "image_url", "imageUrls"
);
}
function mappedMediaType(record: JsonRecord): MediaType {
const raw = stringValue(record, "mediaType", "media_type", "adFormat")?.toUpperCase();
const raw = stringValue(record, "mediaType", "media_type", "adFormat", "ad_format")?.toUpperCase();
if (raw === "VIDEO") return MediaType.VIDEO;
if (raw === "IMAGE" || raw === "MEME") return MediaType.IMAGE;
if (raw === "CAROUSEL" || raw === "DPA") return MediaType.CAROUSEL;
if (stringArray(record, "videoUrls", "videos").length) return MediaType.VIDEO;
if (stringArray(record, "imageUrls", "images").length) return MediaType.IMAGE;
if (deepUrlValue(record, "videoHdUrl", "video_hd_url", "videoSdUrl", "video_sd_url", "videoUrl", "video_url", "videoUrls")) return MediaType.VIDEO;
if (deepUrlValue(record, "originalImageUrl", "original_image_url", "resizedImageUrl", "resized_image_url", "imageUrl", "image_url", "imageUrls")) return MediaType.IMAGE;
return MediaType.UNKNOWN;
}
export function normalizeApifyAd(record: JsonRecord) {
const externalAdId = stringValue(record, "adArchiveID", "adArchiveId", "ad_archive_id", "id");
const externalAdId = stringValue(record, "adArchiveID", "adArchiveId", "ad_archive_id", "ad_id", "id");
if (!externalAdId) return null;
const pageId = stringValue(record, "pageID", "pageId", "page_id");
const explicitPageId = stringValue(record, "pageID", "pageId", "page_id");
const pageName = stringValue(record, "pageName", "page_name", "advertiserName") || "Bilinmeyen reklamveren";
const pageId = explicitPageId || (pageName === "Bilinmeyen reklamveren" ? null : `name:${pageName.toLocaleLowerCase("en-US")}`);
const firstSeenAt = dateValue(record.startDate ?? record.start_date ?? record.adDeliveryStartTime);
const endDate = dateValue(record.endDate ?? record.end_date ?? record.adDeliveryStopTime);
const referenceDate = endDate || new Date();
const daysRunning = firstSeenAt ? Math.max(1, Math.ceil((referenceDate.getTime() - firstSeenAt.getTime()) / 86_400_000)) : null;
const statusText = stringValue(record, "adStatus", "status", "ad_active_status")?.toUpperCase();
const statusText = stringValue(record, "adStatus", "status", "ad_active_status", "ad_status")?.toUpperCase();
const status = statusText === "ACTIVE" ? AdStatus.ACTIVE : statusText === "INACTIVE" ? AdStatus.INACTIVE : AdStatus.UNKNOWN;
const primaryText = stringValue(record, "adText", "primaryText", "bodyText") || stringArray(record, "adCreativeBodies", "ad_creative_bodies")[0] || null;
const primaryText = stringValue(record, "adText", "primaryText", "bodyText", "ad_body_text") || stringArray(record, "adCreativeBodies", "ad_creative_bodies")[0] || null;
const countries = stringArray(record, "countries", "reachedCountries", "ad_reached_countries");
const country = stringValue(record, "country", "pageCountry");
const country = stringValue(record, "country", "pageCountry", "page_country");
if (!countries.length && country) countries.push(country);
const thumbnailUrl = firstThumbnailUrl(record);
const creativeUrl = firstMediaUrl(record) || thumbnailUrl;
return {
externalAdId,
pageId,
pageName,
pageUrl: stringValue(record, "pageURL", "pageUrl"),
pageLogoUrl: stringValue(record, "pageProfilePictureURL", "pageProfilePictureUrl"),
pageUrl: stringValue(record, "pageURL", "pageUrl", "page_url"),
pageLogoUrl: stringValue(record, "pageProfilePictureURL", "pageProfilePictureUrl", "page_profile_picture_url"),
pageLikes: numberValue(record.pageLikes),
pageFollowers: numberValue(record.pageInstagramFollowers),
status,
mediaType: mappedMediaType(record),
primaryText,
headline: stringValue(record, "ctaHeadline", "headline", "title"),
description: stringValue(record, "ctaDescription", "description"),
ctaText: stringValue(record, "ctaText", "ctaType", "callToAction"),
landingUrl: stringValue(record, "ctaUrl", "landingUrl", "linkUrl"),
productUrl: stringValue(record, "adLibraryURL", "adLibraryUrl"),
headline: stringValue(record, "ctaHeadline", "headline", "title", "ad_headline"),
description: stringValue(record, "ctaDescription", "description", "ad_description"),
ctaText: stringValue(record, "ctaText", "ctaType", "callToAction", "cta_text"),
landingUrl: stringValue(record, "ctaUrl", "landingUrl", "linkUrl", "landing_page_url"),
productUrl: stringValue(record, "adLibraryURL", "adLibraryUrl", "ad_library_url"),
language: stringValue(record, "language"),
countries,
firstSeenAt,
lastSeenAt: endDate,
createdAtSource: dateValue(record.adCreationTime ?? record.ad_creation_time),
daysRunning,
daysRunning: numberValue(record.ad_active_duration_days) ?? daysRunning,
estimatedReachMin: numberValue(record.reachEstimate),
estimatedSpendMin: numberValue(record.spend),
creativeUrl: firstMediaUrl(record),
thumbnailUrl: firstThumbnailUrl(record),
creativeUrl,
thumbnailUrl,
raw: record as Prisma.InputJsonValue
};
}
@@ -144,16 +198,7 @@ export async function runApifyActor(input: ApifyIngestInput) {
const response = await fetch(`${APIFY_API_BASE}/actors/${actorId}/run-sync-get-dataset-items?${query}`, {
method: "POST",
headers: { authorization: `Bearer ${token}`, "content-type": "application/json", accept: "application/json" },
body: JSON.stringify({
searchTerms: input.searchTerms,
country: input.country,
adActiveStatus: input.adActiveStatus,
mediaType: input.mediaType,
adType: "ALL",
maxResults: input.maxResults,
scrapeAdDetails: input.scrapeAdDetails,
includeAboutPage: input.includeAboutPage
}),
body: JSON.stringify(actorInput(actorId, input)),
signal: controller.signal,
cache: "no-store"
});
@@ -169,6 +214,31 @@ export async function runApifyActor(input: ApifyIngestInput) {
}
}
function actorInput(actorId: string, input: ApifyIngestInput) {
if (actorId === "aiscraperdev~facebook-meta-ads-library-scraper") {
return {
searchQueries: input.searchTerms,
countryCode: input.country,
adStatus: input.adActiveStatus.toLowerCase(),
adType: "all",
mediaType: input.mediaType.toLowerCase(),
platform: "all",
maxResults: input.maxResults
};
}
return {
searchTerms: input.searchTerms,
country: input.country,
adActiveStatus: input.adActiveStatus,
mediaType: input.mediaType,
adType: "ALL",
maxResults: input.maxResults,
scrapeAdDetails: input.scrapeAdDetails,
includeAboutPage: input.includeAboutPage
};
}
export async function importApifyAds(records: JsonRecord[]) {
let imported = 0;
let failed = 0;
@@ -212,4 +282,3 @@ export async function importApifyAds(records: JsonRecord[]) {
}
return { imported, failed };
}