237 lines
6.6 KiB
TypeScript
237 lines
6.6 KiB
TypeScript
/**
|
|
* Verify social media handles exist via lightweight API checks.
|
|
* Each check runs independently — one failure doesn't block others.
|
|
*/
|
|
|
|
export interface VerifiedChannel {
|
|
handle: string;
|
|
verified: boolean;
|
|
url?: string;
|
|
channelId?: string; // YouTube channel ID if resolved
|
|
}
|
|
|
|
export interface VerifiedChannels {
|
|
instagram: VerifiedChannel[];
|
|
youtube: VerifiedChannel | null;
|
|
facebook: VerifiedChannel | null;
|
|
naverBlog: VerifiedChannel | null;
|
|
gangnamUnni: VerifiedChannel | null;
|
|
tiktok: VerifiedChannel | null;
|
|
}
|
|
|
|
/**
|
|
* Verify an Instagram handle exists.
|
|
* Uses a lightweight fetch to the profile page.
|
|
*/
|
|
async function verifyInstagram(handle: string): Promise<VerifiedChannel> {
|
|
try {
|
|
const url = `https://www.instagram.com/${handle}/`;
|
|
const res = await fetch(url, {
|
|
method: 'GET',
|
|
headers: { 'User-Agent': 'Mozilla/5.0' },
|
|
redirect: 'follow',
|
|
});
|
|
// Instagram returns 200 for existing profiles, 404 for missing
|
|
return {
|
|
handle,
|
|
verified: res.status === 200,
|
|
url,
|
|
};
|
|
} catch {
|
|
return { handle, verified: false };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Verify a YouTube handle/channel exists using YouTube Data API v3.
|
|
*/
|
|
async function verifyYouTube(handle: string, apiKey: string): Promise<VerifiedChannel> {
|
|
try {
|
|
const YT_BASE = 'https://www.googleapis.com/youtube/v3';
|
|
const cleanHandle = handle.replace(/^@/, '');
|
|
|
|
// Try forHandle first, then forUsername
|
|
for (const param of ['forHandle', 'forUsername']) {
|
|
const res = await fetch(`${YT_BASE}/channels?part=id,snippet&${param}=${cleanHandle}&key=${apiKey}`);
|
|
const data = await res.json();
|
|
const channel = data.items?.[0];
|
|
if (channel) {
|
|
return {
|
|
handle,
|
|
verified: true,
|
|
channelId: channel.id,
|
|
url: `https://youtube.com/@${cleanHandle}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
// Try as channel ID directly (starts with UC)
|
|
if (handle.startsWith('UC')) {
|
|
const res = await fetch(`${YT_BASE}/channels?part=id&id=${handle}&key=${apiKey}`);
|
|
const data = await res.json();
|
|
if (data.items?.[0]) {
|
|
return { handle, verified: true, channelId: handle, url: `https://youtube.com/channel/${handle}` };
|
|
}
|
|
}
|
|
|
|
return { handle, verified: false };
|
|
} catch {
|
|
return { handle, verified: false };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Verify a Facebook page exists via HEAD request.
|
|
*/
|
|
async function verifyFacebook(handle: string): Promise<VerifiedChannel> {
|
|
try {
|
|
const url = `https://www.facebook.com/${handle}/`;
|
|
const res = await fetch(url, {
|
|
method: 'HEAD',
|
|
headers: { 'User-Agent': 'Mozilla/5.0' },
|
|
redirect: 'follow',
|
|
});
|
|
return { handle, verified: res.status === 200, url };
|
|
} catch {
|
|
return { handle, verified: false };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Verify Naver Blog exists.
|
|
*/
|
|
async function verifyNaverBlog(blogId: string): Promise<VerifiedChannel> {
|
|
try {
|
|
const url = `https://blog.naver.com/${blogId}`;
|
|
const res = await fetch(url, {
|
|
method: 'HEAD',
|
|
redirect: 'follow',
|
|
});
|
|
return { handle: blogId, verified: res.status === 200, url };
|
|
} catch {
|
|
return { handle: blogId, verified: false };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Find and verify gangnamunni hospital page using Firecrawl search.
|
|
*/
|
|
async function verifyGangnamUnni(
|
|
clinicName: string,
|
|
firecrawlKey: string,
|
|
hintUrl?: string,
|
|
): Promise<VerifiedChannel> {
|
|
try {
|
|
// If we already have a URL hint from Perplexity, just verify it
|
|
if (hintUrl && hintUrl.includes('gangnamunni.com/hospitals/')) {
|
|
const res = await fetch(hintUrl, { method: 'HEAD', redirect: 'follow' });
|
|
if (res.status === 200) {
|
|
return { handle: clinicName, verified: true, url: hintUrl };
|
|
}
|
|
}
|
|
|
|
// Otherwise, search with multiple fallback queries
|
|
const shortName = clinicName.replace(/성형외과|의원|병원|클리닉|피부과/g, '').trim();
|
|
const queries = [
|
|
`${clinicName} site:gangnamunni.com`,
|
|
`${shortName} 성형외과 site:gangnamunni.com`,
|
|
`${clinicName} 강남언니`,
|
|
];
|
|
|
|
for (const query of queries) {
|
|
const searchRes = await fetch('https://api.firecrawl.dev/v1/search', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${firecrawlKey}`,
|
|
},
|
|
body: JSON.stringify({ query, limit: 5 }),
|
|
});
|
|
const data = await searchRes.json();
|
|
const url = (data.data || [])
|
|
.map((r: Record<string, string>) => r.url)
|
|
.find((u: string) => u?.includes('gangnamunni.com/hospitals/'));
|
|
|
|
if (url) {
|
|
return { handle: clinicName, verified: true, url };
|
|
}
|
|
}
|
|
|
|
return { handle: clinicName, verified: false };
|
|
} catch {
|
|
return { handle: clinicName, verified: false };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Verify all discovered handles in parallel.
|
|
*/
|
|
export async function verifyAllHandles(
|
|
candidates: {
|
|
instagram: string[];
|
|
youtube: string[];
|
|
facebook: string[];
|
|
naverBlog: string[];
|
|
tiktok: string[];
|
|
},
|
|
clinicName: string,
|
|
gangnamUnniHintUrl?: string,
|
|
): Promise<VerifiedChannels> {
|
|
const YOUTUBE_API_KEY = Deno.env.get('YOUTUBE_API_KEY') || '';
|
|
const FIRECRAWL_API_KEY = Deno.env.get('FIRECRAWL_API_KEY') || '';
|
|
|
|
const tasks: Promise<void>[] = [];
|
|
const result: VerifiedChannels = {
|
|
instagram: [],
|
|
youtube: null,
|
|
facebook: null,
|
|
naverBlog: null,
|
|
gangnamUnni: null,
|
|
tiktok: null,
|
|
};
|
|
|
|
// Instagram — verify each candidate
|
|
for (const handle of candidates.instagram.slice(0, 5)) {
|
|
tasks.push(
|
|
verifyInstagram(handle).then(v => { if (v.verified) result.instagram.push(v); })
|
|
);
|
|
}
|
|
|
|
// YouTube — first candidate
|
|
if (candidates.youtube.length > 0) {
|
|
tasks.push(
|
|
verifyYouTube(candidates.youtube[0], YOUTUBE_API_KEY).then(v => { result.youtube = v; })
|
|
);
|
|
}
|
|
|
|
// Facebook — first candidate
|
|
if (candidates.facebook.length > 0) {
|
|
tasks.push(
|
|
verifyFacebook(candidates.facebook[0]).then(v => { result.facebook = v; })
|
|
);
|
|
}
|
|
|
|
// Naver Blog — first candidate
|
|
if (candidates.naverBlog.length > 0) {
|
|
tasks.push(
|
|
verifyNaverBlog(candidates.naverBlog[0]).then(v => { result.naverBlog = v; })
|
|
);
|
|
}
|
|
|
|
// 강남언니 — always try if clinicName exists
|
|
if (clinicName && FIRECRAWL_API_KEY) {
|
|
tasks.push(
|
|
verifyGangnamUnni(clinicName, FIRECRAWL_API_KEY, gangnamUnniHintUrl)
|
|
.then(v => { result.gangnamUnni = v; })
|
|
);
|
|
}
|
|
|
|
// TikTok — skip verification for now (TikTok blocks HEAD requests)
|
|
if (candidates.tiktok.length > 0) {
|
|
result.tiktok = { handle: candidates.tiktok[0], verified: false, url: `https://tiktok.com/@${candidates.tiktok[0]}` };
|
|
}
|
|
|
|
await Promise.allSettled(tasks);
|
|
return result;
|
|
}
|