Initial commit: AI platform app (server, views, lib, data, deploy docs)
Made-with: Cursor
This commit is contained in:
97
lib/link-preview.js
Normal file
97
lib/link-preview.js
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* 외부 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 = [
|
||||
/<meta\s[^>]*property=["']og:image["'][^>]*content=["']([^"']+)["']/i,
|
||||
/<meta\s[^>]*content=["']([^"']+)["'][^>]*property=["']og:image["']/i,
|
||||
/<meta\s[^>]*name=["']twitter:image:src["'][^>]*content=["']([^"']+)["']/i,
|
||||
/<meta\s[^>]*content=["']([^"']+)["'][^>]*name=["']twitter:image:src["']/i,
|
||||
/<meta\s[^>]*name=["']twitter:image["'][^>]*content=["']([^"']+)["']/i,
|
||||
/<meta\s[^>]*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<string|null>} 절대 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 };
|
||||
Reference in New Issue
Block a user