From b7fa66d04cbe76249556ba499d50734e37ee26b4 Mon Sep 17 00:00:00 2001 From: Hikmet Date: Sun, 9 Aug 2026 07:05:56 +0300 Subject: [PATCH] fix: reklam kreatiflerini Apify verisinden goster --- .env.example | 2 +- .env.production.example | 2 +- docker-compose.yml | 2 +- src/app/dashboard/ads/page.tsx | 3 +- src/app/dashboard/saved-ads/page.tsx | 3 +- src/app/dashboard/stores/[id]/page.tsx | 3 +- src/components/ad-creative-media.tsx | 25 ++++ src/lib/apify.ts | 151 ++++++++++++++++++------- 8 files changed, 144 insertions(+), 47 deletions(-) create mode 100644 src/components/ad-creative-media.tsx diff --git a/.env.example b/.env.example index b9bb015..d92d2d3 100644 --- a/.env.example +++ b/.env.example @@ -11,4 +11,4 @@ GOOGLE_CLIENT_SECRET= # Apify Meta Ads ingestion (runtime secret; do not expose to the browser) APIFY_TOKEN= -APIFY_ACTOR_ID=solidcode~meta-ads-library-scraper +APIFY_ACTOR_ID=aiscraperdev~facebook-meta-ads-library-scraper diff --git a/.env.production.example b/.env.production.example index 4e1ace6..9c8ec67 100644 --- a/.env.production.example +++ b/.env.production.example @@ -11,7 +11,7 @@ GOOGLE_CLIENT_SECRET= # Apify Meta Ads ingestion (runtime secret; do not expose to the browser) APIFY_TOKEN= -APIFY_ACTOR_ID=solidcode~meta-ads-library-scraper +APIFY_ACTOR_ID=aiscraperdev~facebook-meta-ads-library-scraper # For docker-compose POSTGRES_PASSWORD="change-this-long-random-password" diff --git a/docker-compose.yml b/docker-compose.yml index 7ecf9b3..77d53cd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,7 +30,7 @@ services: GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-} APIFY_TOKEN: ${APIFY_TOKEN:-} - APIFY_ACTOR_ID: ${APIFY_ACTOR_ID:-solidcode~meta-ads-library-scraper} + APIFY_ACTOR_ID: ${APIFY_ACTOR_ID:-aiscraperdev~facebook-meta-ads-library-scraper} ports: - "${PORT:-3000}:3000" command: sh -c "npx prisma db push && npm run start" diff --git a/src/app/dashboard/ads/page.tsx b/src/app/dashboard/ads/page.tsx index 7782eba..9434e8f 100644 --- a/src/app/dashboard/ads/page.tsx +++ b/src/app/dashboard/ads/page.tsx @@ -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> }) { const user = await requireUser(); @@ -48,7 +49,7 @@ export default async function AdsPage({ searchParams }: { searchParams: Promise< {ad.isLocked &&
Start now — Unlock winners
}
- +
{ad.headline}
Top %{Math.round(ad.rankPercentile || 0)}
diff --git a/src/app/dashboard/saved-ads/page.tsx b/src/app/dashboard/saved-ads/page.tsx index a436127..9015e96 100644 --- a/src/app/dashboard/saved-ads/page.tsx +++ b/src/app/dashboard/saved-ads/page.tsx @@ -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() {
All Saved Ads ({saved.length})
{folders.map((f) =>
{f.name}
)}
- {saved.map((s) =>
{s.ad.headline}
{s.ad.brandPage?.name} · {s.folder?.name || "All"}

{s.ad.primaryText}

)} + {saved.map((s) =>
{s.ad.headline}
{s.ad.brandPage?.name} · {s.folder?.name || "All"}

{s.ad.primaryText}

)}
diff --git a/src/app/dashboard/stores/[id]/page.tsx b/src/app/dashboard/stores/[id]/page.tsx index c2a9600..3754da4 100644 --- a/src/app/dashboard/stores/[id]/page.tsx +++ b/src/app/dashboard/stores/[id]/page.tsx @@ -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

Top Meta Ads

- {ads.map((ad) =>
{ad.headline}
Top %{ad.rankPercentile} · {ad.daysRunning} gün
)} + {ads.map((ad) =>
{ad.headline}
Top %{ad.rankPercentile} · {ad.daysRunning} gün
)}
diff --git a/src/components/ad-creative-media.tsx b/src/components/ad-creative-media.tsx new file mode 100644 index 0000000..f7f7f60 --- /dev/null +++ b/src/components/ad-creative-media.tsx @@ -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
Kreatif bulunamadı
; + } + + if (creative.type === MediaType.VIDEO) { + return ( + + ); + } + + return Reklam kreatifi; +} diff --git a/src/lib/apify.ts b/src/lib/apify.ts index d3d9d42..2a750ea 100644 --- a/src/lib/apify.ts +++ b/src/lib/apify.ts @@ -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(); + + 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 }; } -