Skip to content

Commit 9a8e1ea

Browse files
authored
Merge pull request #314 from DguFarmSystem/fix/#309
[Fix] 블로그/프로젝트 서비스 type 오류 수정
2 parents b98a0aa + 58be16a commit 9a8e1ea

File tree

3 files changed

+211
-79
lines changed

3 files changed

+211
-79
lines changed

apps/website/src/hooks/useLinkPreview.ts

Lines changed: 129 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -10,76 +10,157 @@ export interface APIResponse {
1010
}
1111

1212
/**
13-
* 필수 필드(title·image)가 채워져 있는지 확인
13+
* isValidResponse
14+
* APIResponse가 유효한지를 체크합니다.
15+
* (필수 필드가 빈 문자열이면 유효하지 않다고 간주)
1416
*/
1517
export 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-
/* ------------------------------------------------------------------ */
45105
export 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-
};

apps/website/src/services/blog.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export const getBlogPageList = async (
5151
const url = `blogs/page?${queryString}`;
5252

5353

54-
const response = await apiConfig.get<BlogPage>(url);
55-
return response.data;
54+
const response = await apiConfig.get<{ data: BlogPage }>(url);
55+
console.log(response.data.data);
56+
return response.data.data;
5657
};

apps/website/src/stores/useLinkPreviewStore.ts

Lines changed: 79 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { create } from 'zustand';
2-
import { fetchLinkPreview, APIResponse } from '@/hooks/useLinkPreview';
2+
import { APIResponse, isValidResponse } from '@/hooks/useLinkPreview';
33

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

11+
// 프록시 서버 URL (useLinkPreview와 동일)
12+
const proxyUrl = 'https://corsproxy.io/?url=';
13+
14+
/**
15+
* extractMetaContent
16+
* 정규표현식을 사용하여 HTML에서 meta 태그의 content 값을 추출합니다.
17+
*/
18+
const extractMetaContent = (html: string, key: string): string => {
19+
const regex = new RegExp(
20+
`<meta[^>]*(?:property|name)=["']${key}["'][^>]*content=["']([^"']*)["'][^>]*>`,
21+
'i'
22+
);
23+
const match = html.match(regex);
24+
return match ? match[1] : '';
25+
};
26+
27+
/**
28+
* cleanHTML
29+
* HTML 문자열에서 주석을 제거합니다.
30+
*/
31+
const cleanHTML = (html: string): string => {
32+
return html.replace(/<!--[\s\S]*?-->/g, '');
33+
};
34+
35+
/**
36+
* parseHTML
37+
* HTML 문자열을 파싱하여 OG 메타 태그 정보를 추출합니다.
38+
*/
39+
const parseHTML = (html: string, originalUrl: string): APIResponse => {
40+
const clean = cleanHTML(html);
41+
const parser = new DOMParser();
42+
const doc = parser.parseFromString(clean, 'text/html');
43+
44+
// OG 메타 태그 추출
45+
let title =
46+
doc.querySelector('meta[property="og:title"], meta[name="og:title"]')?.getAttribute('content') || '';
47+
let description =
48+
doc.querySelector('meta[property="og:description"], meta[name="og:description"]')?.getAttribute('content') || '';
49+
let image =
50+
doc.querySelector('meta[property="og:image"], meta[name="og:image"]')?.getAttribute('content') || '';
51+
let siteName =
52+
doc.querySelector('meta[property="og:site_name"], meta[name="og:site_name"]')?.getAttribute('content') || '';
53+
let ogUrl =
54+
doc.querySelector('meta[property="og:url"], meta[name="og:url"]')?.getAttribute('content') || originalUrl;
55+
56+
// fallback: 정규표현식으로 추출
57+
if (!title) title = extractMetaContent(clean, 'og:title');
58+
if (!description) description = extractMetaContent(clean, 'og:description');
59+
if (!image) image = extractMetaContent(clean, 'og:image');
60+
if (!siteName) siteName = extractMetaContent(clean, 'og:site_name');
61+
if (!ogUrl) ogUrl = originalUrl;
62+
63+
// hostname 추출
64+
let hostname = '';
65+
try {
66+
hostname = new URL(ogUrl).hostname;
67+
} catch {
68+
hostname = '';
69+
}
70+
71+
return { title, description, image, siteName, hostname };
72+
};
73+
74+
1175
export const useLinkPreviewStore = create<LinkPreviewState>((set, get) => ({
1276
previewMap: new Map(),
1377
loadingMap: new Map(),
1478
getPreview: async (url: string) => {
1579
if (get().previewMap.has(url)) return;
80+
1681
set(state => {
1782
const loadingMap = new Map(state.loadingMap);
1883
loadingMap.set(url, true);
1984
return { ...state, loadingMap };
2085
});
86+
2187
try {
22-
const metadata = await fetchLinkPreview(url);
23-
if (metadata) {
88+
// 프록시를 통한 요청 URL 구성
89+
const proxyFetchUrl = proxyUrl + encodeURIComponent(url);
90+
91+
const response = await fetch(proxyFetchUrl);
92+
const html = await response.text();
93+
94+
const parsedData = parseHTML(html, url);
95+
96+
if (isValidResponse(parsedData)) {
2497
set(state => {
2598
const previewMap = new Map(state.previewMap);
26-
previewMap.set(url, metadata);
99+
previewMap.set(url, parsedData);
27100
const loadingMap = new Map(state.loadingMap);
28101
loadingMap.set(url, false);
29102
return { ...state, previewMap, loadingMap };
@@ -35,7 +108,8 @@ export const useLinkPreviewStore = create<LinkPreviewState>((set, get) => ({
35108
return { ...state, loadingMap };
36109
});
37110
}
38-
} catch {
111+
} catch (error) {
112+
console.error('Error fetching link preview:', error);
39113
set(state => {
40114
const loadingMap = new Map(state.loadingMap);
41115
loadingMap.set(url, false);

0 commit comments

Comments
 (0)