263 lines
9.1 KiB
TypeScript
263 lines
9.1 KiB
TypeScript
import "@supabase/functions-js/edge-runtime.d.ts";
|
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
|
|
|
const corsHeaders = {
|
|
"Access-Control-Allow-Origin": "*",
|
|
"Access-Control-Allow-Headers":
|
|
"authorization, x-client-info, apikey, content-type",
|
|
};
|
|
|
|
const APIFY_BASE = "https://api.apify.com/v2";
|
|
|
|
interface EnrichRequest {
|
|
reportId: string;
|
|
clinicName: string;
|
|
instagramHandle?: string;
|
|
youtubeChannelId?: string;
|
|
address?: string;
|
|
}
|
|
|
|
async function runApifyActor(
|
|
actorId: string,
|
|
input: Record<string, unknown>,
|
|
token: string
|
|
): Promise<unknown[]> {
|
|
const res = await fetch(
|
|
`${APIFY_BASE}/acts/${actorId}/runs?token=${token}&waitForFinish=120`,
|
|
{
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(input),
|
|
}
|
|
);
|
|
const run = await res.json();
|
|
const datasetId = run.data?.defaultDatasetId;
|
|
if (!datasetId) return [];
|
|
|
|
const itemsRes = await fetch(
|
|
`${APIFY_BASE}/datasets/${datasetId}/items?token=${token}&limit=20`
|
|
);
|
|
return itemsRes.json();
|
|
}
|
|
|
|
Deno.serve(async (req) => {
|
|
if (req.method === "OPTIONS") {
|
|
return new Response("ok", { headers: corsHeaders });
|
|
}
|
|
|
|
try {
|
|
const { reportId, clinicName, instagramHandle, youtubeChannelId, address } =
|
|
(await req.json()) as EnrichRequest;
|
|
|
|
const APIFY_TOKEN = Deno.env.get("APIFY_API_TOKEN");
|
|
if (!APIFY_TOKEN) throw new Error("APIFY_API_TOKEN not configured");
|
|
|
|
const enrichment: Record<string, unknown> = {};
|
|
|
|
// Run all enrichment tasks in parallel
|
|
const tasks = [];
|
|
|
|
// 1. Instagram Profile
|
|
if (instagramHandle) {
|
|
tasks.push(
|
|
(async () => {
|
|
const items = await runApifyActor(
|
|
"apify~instagram-profile-scraper",
|
|
{ usernames: [instagramHandle], resultsLimit: 12 },
|
|
APIFY_TOKEN
|
|
);
|
|
const profile = (items as Record<string, unknown>[])[0];
|
|
if (profile && !profile.error) {
|
|
enrichment.instagram = {
|
|
username: profile.username,
|
|
followers: profile.followersCount,
|
|
following: profile.followsCount,
|
|
posts: profile.postsCount,
|
|
bio: profile.biography,
|
|
isBusinessAccount: profile.isBusinessAccount,
|
|
externalUrl: profile.externalUrl,
|
|
latestPosts: ((profile.latestPosts as Record<string, unknown>[]) || [])
|
|
.slice(0, 12)
|
|
.map((p) => ({
|
|
type: p.type,
|
|
likes: p.likesCount,
|
|
comments: p.commentsCount,
|
|
caption: (p.caption as string || "").slice(0, 200),
|
|
timestamp: p.timestamp,
|
|
})),
|
|
};
|
|
}
|
|
})()
|
|
);
|
|
}
|
|
|
|
// 2. Google Maps / Place Reviews
|
|
if (clinicName || address) {
|
|
tasks.push(
|
|
(async () => {
|
|
const searchQuery = `${clinicName} ${address || "강남"}`;
|
|
const items = await runApifyActor(
|
|
"compass~crawler-google-places",
|
|
{
|
|
searchStringsArray: [searchQuery],
|
|
maxCrawledPlacesPerSearch: 1,
|
|
language: "ko",
|
|
maxReviews: 10,
|
|
},
|
|
APIFY_TOKEN
|
|
);
|
|
const place = (items as Record<string, unknown>[])[0];
|
|
if (place) {
|
|
enrichment.googleMaps = {
|
|
name: place.title,
|
|
rating: place.totalScore,
|
|
reviewCount: place.reviewsCount,
|
|
address: place.address,
|
|
phone: place.phone,
|
|
website: place.website,
|
|
category: place.categoryName,
|
|
openingHours: place.openingHours,
|
|
topReviews: ((place.reviews as Record<string, unknown>[]) || [])
|
|
.slice(0, 10)
|
|
.map((r) => ({
|
|
stars: r.stars,
|
|
text: (r.text as string || "").slice(0, 200),
|
|
publishedAtDate: r.publishedAtDate,
|
|
})),
|
|
};
|
|
}
|
|
})()
|
|
);
|
|
}
|
|
|
|
// 3. YouTube Channel (using YouTube Data API v3)
|
|
if (youtubeChannelId) {
|
|
const YOUTUBE_API_KEY = Deno.env.get("YOUTUBE_API_KEY");
|
|
if (YOUTUBE_API_KEY) {
|
|
tasks.push(
|
|
(async () => {
|
|
const YT_BASE = "https://www.googleapis.com/youtube/v3";
|
|
|
|
// Resolve handle/username to channel ID
|
|
let channelId = youtubeChannelId;
|
|
if (channelId.startsWith("@") || !channelId.startsWith("UC")) {
|
|
// Use forHandle for @handles, forUsername for legacy usernames
|
|
const param = channelId.startsWith("@") ? "forHandle" : "forUsername";
|
|
const handle = channelId.startsWith("@") ? channelId.slice(1) : channelId;
|
|
const lookupRes = await fetch(
|
|
`${YT_BASE}/channels?part=id&${param}=${handle}&key=${YOUTUBE_API_KEY}`
|
|
);
|
|
const lookupData = await lookupRes.json();
|
|
channelId = lookupData.items?.[0]?.id || "";
|
|
}
|
|
|
|
if (!channelId) return;
|
|
|
|
// Step 1: Get channel statistics & snippet (1 quota unit)
|
|
const channelRes = await fetch(
|
|
`${YT_BASE}/channels?part=snippet,statistics,brandingSettings&id=${channelId}&key=${YOUTUBE_API_KEY}`
|
|
);
|
|
const channelData = await channelRes.json();
|
|
const channel = channelData.items?.[0];
|
|
|
|
if (!channel) return;
|
|
|
|
const stats = channel.statistics || {};
|
|
const snippet = channel.snippet || {};
|
|
|
|
// Step 2: Get recent/popular videos (100 quota units)
|
|
const searchRes = await fetch(
|
|
`${YT_BASE}/search?part=snippet&channelId=${channelId}&order=viewCount&type=video&maxResults=10&key=${YOUTUBE_API_KEY}`
|
|
);
|
|
const searchData = await searchRes.json();
|
|
const videoIds = (searchData.items || [])
|
|
.map((item: Record<string, unknown>) => (item.id as Record<string, string>)?.videoId)
|
|
.filter(Boolean)
|
|
.join(",");
|
|
|
|
// Step 3: Get video details — views, likes, duration (1 quota unit)
|
|
let videos: Record<string, unknown>[] = [];
|
|
if (videoIds) {
|
|
const videosRes = await fetch(
|
|
`${YT_BASE}/videos?part=snippet,statistics,contentDetails&id=${videoIds}&key=${YOUTUBE_API_KEY}`
|
|
);
|
|
const videosData = await videosRes.json();
|
|
videos = videosData.items || [];
|
|
}
|
|
|
|
enrichment.youtube = {
|
|
channelId,
|
|
channelName: snippet.title,
|
|
handle: snippet.customUrl || youtubeChannelId,
|
|
description: snippet.description?.slice(0, 500),
|
|
publishedAt: snippet.publishedAt,
|
|
thumbnailUrl: snippet.thumbnails?.default?.url,
|
|
subscribers: parseInt(stats.subscriberCount || "0", 10),
|
|
totalViews: parseInt(stats.viewCount || "0", 10),
|
|
totalVideos: parseInt(stats.videoCount || "0", 10),
|
|
videos: videos.slice(0, 10).map((v) => {
|
|
const vs = v.statistics as Record<string, string> || {};
|
|
const vSnippet = v.snippet as Record<string, unknown> || {};
|
|
const vContent = v.contentDetails as Record<string, string> || {};
|
|
return {
|
|
title: vSnippet.title,
|
|
views: parseInt(vs.viewCount || "0", 10),
|
|
likes: parseInt(vs.likeCount || "0", 10),
|
|
comments: parseInt(vs.commentCount || "0", 10),
|
|
date: vSnippet.publishedAt,
|
|
duration: vContent.duration,
|
|
url: `https://www.youtube.com/watch?v=${(v.id as string)}`,
|
|
thumbnail: (vSnippet.thumbnails as Record<string, Record<string, string>>)?.medium?.url,
|
|
};
|
|
}),
|
|
};
|
|
})()
|
|
);
|
|
}
|
|
}
|
|
|
|
await Promise.allSettled(tasks);
|
|
|
|
// Save enrichment data to Supabase
|
|
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
|
const supabaseKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
|
const supabase = createClient(supabaseUrl, supabaseKey);
|
|
|
|
if (reportId) {
|
|
// Get existing report
|
|
const { data: existing } = await supabase
|
|
.from("marketing_reports")
|
|
.select("report")
|
|
.eq("id", reportId)
|
|
.single();
|
|
|
|
if (existing) {
|
|
const updatedReport = {
|
|
...existing.report,
|
|
channelEnrichment: enrichment,
|
|
enrichedAt: new Date().toISOString(),
|
|
};
|
|
|
|
await supabase
|
|
.from("marketing_reports")
|
|
.update({ report: updatedReport, updated_at: new Date().toISOString() })
|
|
.eq("id", reportId);
|
|
}
|
|
}
|
|
|
|
return new Response(
|
|
JSON.stringify({
|
|
success: true,
|
|
data: enrichment,
|
|
enrichedAt: new Date().toISOString(),
|
|
}),
|
|
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
|
);
|
|
} catch (error) {
|
|
return new Response(
|
|
JSON.stringify({ success: false, error: error.message }),
|
|
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
|
);
|
|
}
|
|
});
|