Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
201 changes: 129 additions & 72 deletions apps/website/src/hooks/useLinkPreview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,76 +10,157 @@ export interface APIResponse {
}

/**
* 필수 필드(title·image)가 채워져 있는지 확인
* isValidResponse
* APIResponse가 유효한지를 체크합니다.
* (필수 필드가 빈 문자열이면 유효하지 않다고 간주)
*/
export const isValidResponse = (res: APIResponse | null): boolean => {
return !!(res && res.title && res.image);
if (!res) return false;
return (
res.title !== '' &&
// description은 빈 문자열이어도 허용할 경우 주석 처리
res.image !== ''
);
};

// 프록시 서버 URL (CORS 헤더가 추가되어야 합니다)
// corsproxy.io 는 요청 URL을 쿼리스트링으로 전달.
// const proxyUrl = 'https://corsproxy.io/?key=****&url=';
const proxyUrl = 'https://corsproxy.io/?url='; // localhost용 프록시

/**
* extractMetaContent
* 정규표현식을 사용하여 HTML에서 meta 태그의 content 값을 추출합니다.
* property 또는 name 속성을 모두 고려합니다.
*/
const extractMetaContent = (html: string, key: string): string => {
const regex = new RegExp(
`<meta[^>]*(?:property|name)=["']${key}["'][^>]*content=["']([^"']*)["'][^>]*>`,
'i'
);
const match = html.match(regex);
return match ? match[1] : '';
};

/* ------------------------------------------------------------------ */
/* 환경별 API ORIGIN 결정 */
/* ------------------------------------------------------------------ */
/**
* dev : https://dev.farmsystem.kr
* prod : https://farmsystem.kr
* SSR : NEXT_PUBLIC_API_ORIGIN 환경변수 우선
* cleanHTML
* HTML 문자열에서 주석을 제거합니다.
* (주석 안에 있는 OG 태그도 파싱할 수 있도록)
*/
const resolveApiOrigin = (): string => {
if (typeof process !== "undefined" && process.env.NEXT_PUBLIC_API_ORIGIN) {
return process.env.NEXT_PUBLIC_API_ORIGIN;
const cleanHTML = (html: string): string => {
return html.replace(/<!--[\s\S]*?-->/g, ''); // 주석 제거
};

/**
* parseHTML
* HTML 문자열을 파싱하여 OG 메타 태그 정보를 추출합니다.
* DOMParser와 정규표현식 fallback을 함께 사용합니다.
*/
const parseHTML = (html: string, originalUrl: string): APIResponse => {
// 먼저 주석 제거 (OG 태그가 주석에 있을 경우 보완)
const clean = cleanHTML(html);
const parser = new DOMParser();
const doc = parser.parseFromString(clean, 'text/html');

// OG 메타 태그 추출
let title =
doc.querySelector('meta[property="og:title"], meta[name="og:title"]')?.getAttribute('content') || '';
let description =
doc.querySelector('meta[property="og:description"], meta[name="og:description"]')?.getAttribute('content') || '';
let image =
doc.querySelector('meta[property="og:image"], meta[name="og:image"]')?.getAttribute('content') || '';
let siteName =
doc.querySelector('meta[property="og:site_name"], meta[name="og:site_name"]')?.getAttribute('content') || '';
let ogUrl =
doc.querySelector('meta[property="og:url"], meta[name="og:url"]')?.getAttribute('content') || originalUrl;

// fallback: 정규표현식으로 추출 (값이 비어있으면)
if (!title) {
title = extractMetaContent(clean, 'og:title');
}
if (!description) {
description = extractMetaContent(clean, 'og:description');
}
if (!image) {
image = extractMetaContent(clean, 'og:image');
}
if (typeof window !== "undefined") {
const host = window.location.hostname;
// localhost 나 *.local → 동일 오리진 프록시 사용
if (host === "localhost" || host.endsWith(".local")) return "";
// dev 스테이징 도메인
if (host.startsWith("dev.")) return "https://dev.farmsystem.kr";
if (!siteName) {
siteName = extractMetaContent(clean, 'og:site_name');
}
if (!ogUrl) {
ogUrl = originalUrl;
}

// ogUrl을 이용해 hostname 추출
let hostname = '';
try {
hostname = new URL(ogUrl).hostname;
} catch {
hostname = ''; // URL 파싱 실패 시 빈 문자열
}
// 기본: 프로덕션 도메인
return "https://farmsystem.kr";

return { title, description, image, siteName, hostname };
};

/* ------------------------------------------------------------------ */
/* useLinkPreview – Serverless JSON 응답 전용 */
/* ------------------------------------------------------------------ */
export const useLinkPreview = (
url: string,
fetcher?: (endpoint: string) => Promise<APIResponse | null>
fetcher?: (url: string) => Promise<APIResponse | null>
) => {
const [metadata, setMetadata] = useState<APIResponse | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<Error | null>(null);
const isMounted = useRef(true);
const [error, setError] = useState<Error | null>(null);

useEffect(() => {
if (!url) {
setMetadata(null);
setLoading(false);
return;
}

isMounted.current = true;
setLoading(true);

const apiOrigin = resolveApiOrigin();
const base = apiOrigin ? `${apiOrigin}/api/og?url=` : "/api/og?url="; // 로컬(dev 서버)일 때 동일 오리진 프록시 사용
const endpoint = `${base}${encodeURIComponent(url)}`;

const doFetch = async () => {
try {
const res = fetcher ? await fetcher(endpoint) : await defaultFetcher(endpoint);
if (!isMounted.current) return;
setMetadata(res);
} catch (err) {
if (!isMounted.current) return;
setError(handleApiError(err));
setMetadata(null);
} finally {
if (isMounted.current) setLoading(false);
}
};
// 프록시를 통한 요청 URL 구성
const proxyFetchUrl = proxyUrl + encodeURIComponent(url);

doFetch();
if (fetcher) {
fetcher(proxyFetchUrl)
.then((res) => {
if (isMounted.current) {
if (isValidResponse(res)) {
setMetadata(res);
} else {
setMetadata(null); // 유효하지 않으면 null 처리
}
setLoading(false);
}
})
.catch((err) => {
setError(handleApiError(err));
if (isMounted.current) {
setMetadata(null);
setLoading(false);
}
});
} else {
// 기본 fetcher: 프록시 서버를 통해 HTML 텍스트를 가져와 파싱
fetch(proxyFetchUrl)
.then((res) => res.text())
.then((html) => {
if (isMounted.current) {
try {
const parsedData = parseHTML(html, url);
setMetadata(parsedData); // OG 메타 태그 파싱한 결과를 상태로 저장
} catch (parseError) {
setError(handleApiError(parseError));
setMetadata(null);
}
setLoading(false);
}
})
.catch((err) => {
setError(handleApiError(err));
if (isMounted.current) {
setMetadata(null);
setLoading(false);
}
});
}

return () => {
isMounted.current = false;
Expand All @@ -88,27 +169,3 @@ export const useLinkPreview = (

return { metadata, loading, error };
};

export const fetchLinkPreview = async (url: string): Promise<APIResponse | null> => {
const apiOrigin = resolveApiOrigin();
const base = apiOrigin ? `${apiOrigin}/api/og?url=` : "/api/og?url=";
const endpoint = `${base}${encodeURIComponent(url)}`;
return defaultFetcher(endpoint);
};

/* ------------------------------------------------------------------ */
/* 기본 fetcher – serverless JSON */
/* ------------------------------------------------------------------ */
const defaultFetcher = async (endpoint: string): Promise<APIResponse | null> => {
const res = await fetch(endpoint, { credentials: "omit" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data: APIResponse = await res.json();

// hostname 보완(없을 경우)
if (!data.hostname) {
try {
data.hostname = new URL(endpoint.split("url=")[1]).hostname;
} catch {/* ignore */}
}
return data;
};
5 changes: 3 additions & 2 deletions apps/website/src/services/blog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export const getBlogPageList = async (
const url = `blogs/page?${queryString}`;


const response = await apiConfig.get<BlogPage>(url);
return response.data;
const response = await apiConfig.get<{ data: BlogPage }>(url);
console.log(response.data.data);
return response.data.data;
};
84 changes: 79 additions & 5 deletions apps/website/src/stores/useLinkPreviewStore.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { create } from 'zustand';
import { fetchLinkPreview, APIResponse } from '@/hooks/useLinkPreview';
import { APIResponse, isValidResponse } from '@/hooks/useLinkPreview';

interface LinkPreviewState {
previewMap: Map<string, APIResponse>;
Expand All @@ -8,22 +8,95 @@ interface LinkPreviewState {
getPreviewBatch: (urls: string[]) => Promise<void>;
}

// 프록시 서버 URL (useLinkPreview와 동일)
const proxyUrl = 'https://corsproxy.io/?url=';

/**
* extractMetaContent
* 정규표현식을 사용하여 HTML에서 meta 태그의 content 값을 추출합니다.
*/
const extractMetaContent = (html: string, key: string): string => {
const regex = new RegExp(
`<meta[^>]*(?:property|name)=["']${key}["'][^>]*content=["']([^"']*)["'][^>]*>`,
'i'
);
const match = html.match(regex);
return match ? match[1] : '';
};

/**
* cleanHTML
* HTML 문자열에서 주석을 제거합니다.
*/
const cleanHTML = (html: string): string => {
return html.replace(/<!--[\s\S]*?-->/g, '');
};

/**
* parseHTML
* HTML 문자열을 파싱하여 OG 메타 태그 정보를 추출합니다.
*/
const parseHTML = (html: string, originalUrl: string): APIResponse => {
const clean = cleanHTML(html);
const parser = new DOMParser();
const doc = parser.parseFromString(clean, 'text/html');

// OG 메타 태그 추출
let title =
doc.querySelector('meta[property="og:title"], meta[name="og:title"]')?.getAttribute('content') || '';
let description =
doc.querySelector('meta[property="og:description"], meta[name="og:description"]')?.getAttribute('content') || '';
let image =
doc.querySelector('meta[property="og:image"], meta[name="og:image"]')?.getAttribute('content') || '';
let siteName =
doc.querySelector('meta[property="og:site_name"], meta[name="og:site_name"]')?.getAttribute('content') || '';
let ogUrl =
doc.querySelector('meta[property="og:url"], meta[name="og:url"]')?.getAttribute('content') || originalUrl;

// fallback: 정규표현식으로 추출
if (!title) title = extractMetaContent(clean, 'og:title');
if (!description) description = extractMetaContent(clean, 'og:description');
if (!image) image = extractMetaContent(clean, 'og:image');
if (!siteName) siteName = extractMetaContent(clean, 'og:site_name');
if (!ogUrl) ogUrl = originalUrl;

// hostname 추출
let hostname = '';
try {
hostname = new URL(ogUrl).hostname;
} catch {
hostname = '';
}

return { title, description, image, siteName, hostname };
};


export const useLinkPreviewStore = create<LinkPreviewState>((set, get) => ({
previewMap: new Map(),
loadingMap: new Map(),
getPreview: async (url: string) => {
if (get().previewMap.has(url)) return;

set(state => {
const loadingMap = new Map(state.loadingMap);
loadingMap.set(url, true);
return { ...state, loadingMap };
});

try {
const metadata = await fetchLinkPreview(url);
if (metadata) {
// 프록시를 통한 요청 URL 구성
const proxyFetchUrl = proxyUrl + encodeURIComponent(url);

const response = await fetch(proxyFetchUrl);
const html = await response.text();

const parsedData = parseHTML(html, url);

if (isValidResponse(parsedData)) {
set(state => {
const previewMap = new Map(state.previewMap);
previewMap.set(url, metadata);
previewMap.set(url, parsedData);
const loadingMap = new Map(state.loadingMap);
loadingMap.set(url, false);
return { ...state, previewMap, loadingMap };
Expand All @@ -35,7 +108,8 @@ export const useLinkPreviewStore = create<LinkPreviewState>((set, get) => ({
return { ...state, loadingMap };
});
}
} catch {
} catch (error) {
console.error('Error fetching link preview:', error);
set(state => {
const loadingMap = new Map(state.loadingMap);
loadingMap.set(url, false);
Expand Down