o2o-infinith-demo/supabase/functions/enrich-channels/index.ts

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" } }
);
}
});