/** * 외부 URL의 HTML에서 Open Graph / Twitter 카드 이미지 URL 추출 (link.ncue.net 등과 유사한 미리보기) */ const OG_FETCH_TIMEOUT_MS = 15000; const DEFAULT_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"; function decodeBasicHtmlEntities(s) { return (s || "") .replace(/&/g, "&") .replace(/"/g, '"') .replace(/'/g, "'") .replace(/</g, "<") .replace(/>/g, ">"); } function resolveUrl(src, baseHref) { try { const t = (src || "").trim(); if (!t) return null; return new URL(t, baseHref).href; } catch { return null; } } function extractOgImageFromHtml(html) { if (!html || typeof html !== "string") return null; const patterns = [ /]*property=["']og:image["'][^>]*content=["']([^"']+)["']/i, /]*content=["']([^"']+)["'][^>]*property=["']og:image["']/i, /]*name=["']twitter:image:src["'][^>]*content=["']([^"']+)["']/i, /]*content=["']([^"']+)["'][^>]*name=["']twitter:image:src["']/i, /]*name=["']twitter:image["'][^>]*content=["']([^"']+)["']/i, /]*content=["']([^"']+)["'][^>]*name=["']twitter:image["']/i, ]; for (const p of patterns) { const m = html.match(p); if (m && m[1]) return decodeBasicHtmlEntities(m[1].trim()); } return null; } /** * @param {string} pageUrl * @returns {Promise} 절대 URL의 og:image, 없으면 null */ async function fetchOpenGraphImageUrl(pageUrl) { const normalized = (pageUrl || "").trim(); if (!normalized) return null; let base; try { base = new URL(normalized); } catch { return null; } if (base.protocol !== "http:" && base.protocol !== "https:") return null; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), OG_FETCH_TIMEOUT_MS); try { const res = await fetch(base.href, { redirect: "follow", signal: controller.signal, headers: { "User-Agent": DEFAULT_UA, Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7", }, }); if (!res.ok) return null; const ct = (res.headers.get("content-type") || "").toLowerCase(); if (!ct.includes("text/html") && !ct.includes("application/xhtml")) { return null; } const html = await res.text(); const raw = extractOgImageFromHtml(html); if (!raw) return null; const abs = resolveUrl(raw, base.href); if (!abs) return null; let imgUrl; try { imgUrl = new URL(abs); } catch { return null; } if (imgUrl.protocol !== "http:" && imgUrl.protocol !== "https:") return null; return imgUrl.href; } catch { return null; } finally { clearTimeout(timer); } } module.exports = { fetchOpenGraphImageUrl };