@@ -10,76 +10,157 @@ export interface APIResponse {
1010}
1111
1212/**
13- * 필수 필드(title·image)가 채워져 있는지 확인
13+ * isValidResponse
14+ * APIResponse가 유효한지를 체크합니다.
15+ * (필수 필드가 빈 문자열이면 유효하지 않다고 간주)
1416 */
1517export const isValidResponse = ( res : APIResponse | null ) : boolean => {
16- return ! ! ( res && res . title && res . image ) ;
18+ if ( ! res ) return false ;
19+ return (
20+ res . title !== '' &&
21+ // description은 빈 문자열이어도 허용할 경우 주석 처리
22+ res . image !== ''
23+ ) ;
24+ } ;
25+
26+ // 프록시 서버 URL (CORS 헤더가 추가되어야 합니다)
27+ // corsproxy.io 는 요청 URL을 쿼리스트링으로 전달.
28+ // const proxyUrl = 'https://corsproxy.io/?key=****&url=';
29+ const proxyUrl = 'https://corsproxy.io/?url=' ; // localhost용 프록시
30+
31+ /**
32+ * extractMetaContent
33+ * 정규표현식을 사용하여 HTML에서 meta 태그의 content 값을 추출합니다.
34+ * property 또는 name 속성을 모두 고려합니다.
35+ */
36+ const extractMetaContent = ( html : string , key : string ) : string => {
37+ const regex = new RegExp (
38+ `<meta[^>]*(?:property|name)=["']${ key } ["'][^>]*content=["']([^"']*)["'][^>]*>` ,
39+ 'i'
40+ ) ;
41+ const match = html . match ( regex ) ;
42+ return match ? match [ 1 ] : '' ;
1743} ;
1844
19- /* ------------------------------------------------------------------ */
20- /* 환경별 API ORIGIN 결정 */
21- /* ------------------------------------------------------------------ */
2245/**
23- * dev : https://dev.farmsystem.kr
24- * prod : https://farmsystem.kr
25- * SSR : NEXT_PUBLIC_API_ORIGIN 환경변수 우선
46+ * cleanHTML
47+ * HTML 문자열에서 주석을 제거합니다.
48+ * (주석 안에 있는 OG 태그도 파싱할 수 있도록)
2649 */
27- const resolveApiOrigin = ( ) : string => {
28- if ( typeof process !== "undefined" && process . env . NEXT_PUBLIC_API_ORIGIN ) {
29- return process . env . NEXT_PUBLIC_API_ORIGIN ;
50+ const cleanHTML = ( html : string ) : string => {
51+ return html . replace ( / < ! - - [ \s \S ] * ?- - > / g, '' ) ; // 주석 제거
52+ } ;
53+
54+ /**
55+ * parseHTML
56+ * HTML 문자열을 파싱하여 OG 메타 태그 정보를 추출합니다.
57+ * DOMParser와 정규표현식 fallback을 함께 사용합니다.
58+ */
59+ const parseHTML = ( html : string , originalUrl : string ) : APIResponse => {
60+ // 먼저 주석 제거 (OG 태그가 주석에 있을 경우 보완)
61+ const clean = cleanHTML ( html ) ;
62+ const parser = new DOMParser ( ) ;
63+ const doc = parser . parseFromString ( clean , 'text/html' ) ;
64+
65+ // OG 메타 태그 추출
66+ let title =
67+ doc . querySelector ( 'meta[property="og:title"], meta[name="og:title"]' ) ?. getAttribute ( 'content' ) || '' ;
68+ let description =
69+ doc . querySelector ( 'meta[property="og:description"], meta[name="og:description"]' ) ?. getAttribute ( 'content' ) || '' ;
70+ let image =
71+ doc . querySelector ( 'meta[property="og:image"], meta[name="og:image"]' ) ?. getAttribute ( 'content' ) || '' ;
72+ let siteName =
73+ doc . querySelector ( 'meta[property="og:site_name"], meta[name="og:site_name"]' ) ?. getAttribute ( 'content' ) || '' ;
74+ let ogUrl =
75+ doc . querySelector ( 'meta[property="og:url"], meta[name="og:url"]' ) ?. getAttribute ( 'content' ) || originalUrl ;
76+
77+ // fallback: 정규표현식으로 추출 (값이 비어있으면)
78+ if ( ! title ) {
79+ title = extractMetaContent ( clean , 'og:title' ) ;
80+ }
81+ if ( ! description ) {
82+ description = extractMetaContent ( clean , 'og:description' ) ;
83+ }
84+ if ( ! image ) {
85+ image = extractMetaContent ( clean , 'og:image' ) ;
3086 }
31- if ( typeof window !== "undefined" ) {
32- const host = window . location . hostname ;
33- // localhost 나 *.local → 동일 오리진 프록시 사용
34- if ( host === "localhost" || host . endsWith ( ".local" ) ) return "" ;
35- // dev 스테이징 도메인
36- if ( host . startsWith ( "dev." ) ) return "https://dev.farmsystem.kr" ;
87+ if ( ! siteName ) {
88+ siteName = extractMetaContent ( clean , 'og:site_name' ) ;
89+ }
90+ if ( ! ogUrl ) {
91+ ogUrl = originalUrl ;
92+ }
93+
94+ // ogUrl을 이용해 hostname 추출
95+ let hostname = '' ;
96+ try {
97+ hostname = new URL ( ogUrl ) . hostname ;
98+ } catch {
99+ hostname = '' ; // URL 파싱 실패 시 빈 문자열
37100 }
38- // 기본: 프로덕션 도메인
39- return "https://farmsystem.kr" ;
101+
102+ return { title , description , image , siteName , hostname } ;
40103} ;
41104
42- /* ------------------------------------------------------------------ */
43- /* useLinkPreview – Serverless JSON 응답 전용 */
44- /* ------------------------------------------------------------------ */
45105export const useLinkPreview = (
46106 url : string ,
47- fetcher ?: ( endpoint : string ) => Promise < APIResponse | null >
107+ fetcher ?: ( url : string ) => Promise < APIResponse | null >
48108) => {
49109 const [ metadata , setMetadata ] = useState < APIResponse | null > ( null ) ;
50110 const [ loading , setLoading ] = useState < boolean > ( true ) ;
51- const [ error , setError ] = useState < Error | null > ( null ) ;
52111 const isMounted = useRef ( true ) ;
112+ const [ error , setError ] = useState < Error | null > ( null ) ;
53113
54114 useEffect ( ( ) => {
55- if ( ! url ) {
56- setMetadata ( null ) ;
57- setLoading ( false ) ;
58- return ;
59- }
60-
61115 isMounted . current = true ;
62116 setLoading ( true ) ;
63117
64- const apiOrigin = resolveApiOrigin ( ) ;
65- const base = apiOrigin ? `${ apiOrigin } /api/og?url=` : "/api/og?url=" ; // 로컬(dev 서버)일 때 동일 오리진 프록시 사용
66- const endpoint = `${ base } ${ encodeURIComponent ( url ) } ` ;
67-
68- const doFetch = async ( ) => {
69- try {
70- const res = fetcher ? await fetcher ( endpoint ) : await defaultFetcher ( endpoint ) ;
71- if ( ! isMounted . current ) return ;
72- setMetadata ( res ) ;
73- } catch ( err ) {
74- if ( ! isMounted . current ) return ;
75- setError ( handleApiError ( err ) ) ;
76- setMetadata ( null ) ;
77- } finally {
78- if ( isMounted . current ) setLoading ( false ) ;
79- }
80- } ;
118+ // 프록시를 통한 요청 URL 구성
119+ const proxyFetchUrl = proxyUrl + encodeURIComponent ( url ) ;
81120
82- doFetch ( ) ;
121+ if ( fetcher ) {
122+ fetcher ( proxyFetchUrl )
123+ . then ( ( res ) => {
124+ if ( isMounted . current ) {
125+ if ( isValidResponse ( res ) ) {
126+ setMetadata ( res ) ;
127+ } else {
128+ setMetadata ( null ) ; // 유효하지 않으면 null 처리
129+ }
130+ setLoading ( false ) ;
131+ }
132+ } )
133+ . catch ( ( err ) => {
134+ setError ( handleApiError ( err ) ) ;
135+ if ( isMounted . current ) {
136+ setMetadata ( null ) ;
137+ setLoading ( false ) ;
138+ }
139+ } ) ;
140+ } else {
141+ // 기본 fetcher: 프록시 서버를 통해 HTML 텍스트를 가져와 파싱
142+ fetch ( proxyFetchUrl )
143+ . then ( ( res ) => res . text ( ) )
144+ . then ( ( html ) => {
145+ if ( isMounted . current ) {
146+ try {
147+ const parsedData = parseHTML ( html , url ) ;
148+ setMetadata ( parsedData ) ; // OG 메타 태그 파싱한 결과를 상태로 저장
149+ } catch ( parseError ) {
150+ setError ( handleApiError ( parseError ) ) ;
151+ setMetadata ( null ) ;
152+ }
153+ setLoading ( false ) ;
154+ }
155+ } )
156+ . catch ( ( err ) => {
157+ setError ( handleApiError ( err ) ) ;
158+ if ( isMounted . current ) {
159+ setMetadata ( null ) ;
160+ setLoading ( false ) ;
161+ }
162+ } ) ;
163+ }
83164
84165 return ( ) => {
85166 isMounted . current = false ;
@@ -88,27 +169,3 @@ export const useLinkPreview = (
88169
89170 return { metadata, loading, error } ;
90171} ;
91-
92- export const fetchLinkPreview = async ( url : string ) : Promise < APIResponse | null > => {
93- const apiOrigin = resolveApiOrigin ( ) ;
94- const base = apiOrigin ? `${ apiOrigin } /api/og?url=` : "/api/og?url=" ;
95- const endpoint = `${ base } ${ encodeURIComponent ( url ) } ` ;
96- return defaultFetcher ( endpoint ) ;
97- } ;
98-
99- /* ------------------------------------------------------------------ */
100- /* 기본 fetcher – serverless JSON */
101- /* ------------------------------------------------------------------ */
102- const defaultFetcher = async ( endpoint : string ) : Promise < APIResponse | null > => {
103- const res = await fetch ( endpoint , { credentials : "omit" } ) ;
104- if ( ! res . ok ) throw new Error ( `HTTP ${ res . status } ` ) ;
105- const data : APIResponse = await res . json ( ) ;
106-
107- // hostname 보완(없을 경우)
108- if ( ! data . hostname ) {
109- try {
110- data . hostname = new URL ( endpoint . split ( "url=" ) [ 1 ] ) . hostname ;
111- } catch { /* ignore */ }
112- }
113- return data ;
114- } ;
0 commit comments