[] = [
{ value: 'stats', label: 'Wargame Stats' },
@@ -92,13 +108,13 @@ export default function Home() {
const getApiUrl = (card: CardType, theme: Theme, user: string) => {
const endpoint = card === 'stats' ? 'stats' : 'most-solved';
- return `${baseUrl}/api/${endpoint}?username=${user || '사용자명'}&theme=${theme}`;
+ return `${baseUrl}/api/${endpoint}?username=${user ? encodeUsername(user) : '사용자명'}&theme=${theme}`;
};
const getPreviewUrl = (card: CardType, theme: Theme, user: string) => {
if (!user) return '';
const endpoint = card === 'stats' ? 'stats' : 'most-solved';
- return `/api/${endpoint}?username=${user}&theme=${theme}`;
+ return `/api/${endpoint}?username=${encodeUsername(user)}&theme=${theme}`;
};
const getMarkdownCode = () => {
@@ -108,8 +124,7 @@ export default function Home() {
const getHtmlCode = () => {
const altText = selectedCard === 'stats' ? 'Dreamhack Stats' : 'Dreamhack Category Chart';
- const user = confirmedUsername || '사용자명';
- return `
+ return `
`;
};
@@ -127,13 +142,17 @@ export default function Home() {
alert('사용자명을 입력해주세요.');
return;
}
- setConfirmedUsername(username);
+ setConfirmedUsername(username.trim());
setIsLoading(true);
setPreviewKey(prev => prev + 1);
};
const handleImageLoad = () => {
setIsLoading(false);
+ // 카드 요청이 서버 캐시를 채운 뒤에 조회해야 Dreamhack 검색이 중복되지 않음
+ if (!profileUrls.has(confirmedUsername)) {
+ loadProfileUrl(confirmedUsername);
+ }
};
const handleImageError = () => {
@@ -248,6 +267,11 @@ export default function Home() {
{getHtmlCode()}
+ {!profileUrl && (
+
+ 사용자ID는 Dreamhack 프로필 주소(https://dreamhack.io/users/12345)의 숫자입니다. 미리보기에 성공하면 자동으로 채워집니다.
+
+ )}
diff --git a/src/styles/Home.module.css b/src/styles/Home.module.css
index 01b0b0a..76588a7 100644
--- a/src/styles/Home.module.css
+++ b/src/styles/Home.module.css
@@ -87,6 +87,12 @@
line-height: 1.5;
}
+.codeHint {
+ margin: 0.5rem 0 0 0;
+ font-size: 0.8rem;
+ color: #666;
+}
+
.example {
margin: 0 0 2rem 0;
text-align: center;
diff --git a/src/types/index.ts b/src/types/index.ts
index dab1400..364d82a 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -54,6 +54,7 @@ export interface TgetLastRankResponse {
}
export interface TUserRankingResponse {
+ count: number;
results: TUserRankingResult[];
}
@@ -77,6 +78,12 @@ export interface TCategoryStats {
export type Theme = 'light' | 'dark';
+export interface TErrorCard {
+ title: string;
+ message: string;
+ details: string[];
+}
+
export interface ThemeColors {
background: string;
cardBackground: string;
diff --git a/src/utils/dreamhack.ts b/src/utils/dreamhack.ts
index 8a79526..711ad86 100644
--- a/src/utils/dreamhack.ts
+++ b/src/utils/dreamhack.ts
@@ -1,17 +1,57 @@
import { TgetLastRankResponse, TUserRankingResponse, TuserData, TUserRankingResult } from '../types';
-import { getUserIdFromCache, saveUserIdToCache } from './redis';
+import {
+ getUserIdFromCache,
+ saveUserIdToCache,
+ deleteUserIdFromCache,
+ isUserNotFoundCached,
+ saveUserNotFoundToCache,
+} from './redis';
+
+// Dreamhack이 응답하지 않을 때 요청 하나를 기다리는 최대 시간
+const FETCH_TIMEOUT_MS = 10000;
+// 랭킹 API는 limit을 100으로 보내도 50개까지만 돌려줌
+const SEARCH_PAGE_SIZE = 50;
+// 검색 결과는 랭킹 순이라, 흔한 문자열이 들어간 닉네임은 뒤쪽 페이지에 있을 수 있음 (최대 250명 확인)
+const SEARCH_MAX_PAGES = 5;
+// 실제 닉네임은 이보다 훨씬 짧음
+const MAX_USERNAME_LENGTH = 64;
// 네트워크 요청 시간 측정을 위한 래퍼 함수
async function measureFetch(url: string, name: string): Promise {
console.time(`🌐 ${name} 네트워크 요청`);
try {
- const response = await fetch(url);
+ const response = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
return response;
} finally {
console.timeEnd(`🌐 ${name} 네트워크 요청`);
}
}
+// 랭킹 검색 한 페이지 조회 (닉네임의 &, #, + 등이 쿼리를 깨뜨리지 않도록 인코딩)
+async function searchRanking(username: string, offset: number): Promise {
+ const params = new URLSearchParams({
+ filter: 'global',
+ limit: String(SEARCH_PAGE_SIZE),
+ offset: String(offset),
+ search: username,
+ scope: 'all',
+ name: '',
+ category: '',
+ });
+ const response = await measureFetch(
+ `https://dreamhack.io/api/v1/ranking/wargame/?${params}`,
+ `getUserId(offset=${offset})`
+ );
+ if (!response.ok) {
+ throw new Error(`Failed to search ranking: ${response.status}`);
+ }
+ return await response.json() as TUserRankingResponse;
+}
+
+function findExactUser(results: TUserRankingResult[], username: string): TUserRankingResult | undefined {
+ return results.find((user) => user.nickname === username);
+}
+
export async function getLastRank(): Promise {
try {
console.time('⚙️ getLastRank 함수 전체');
@@ -33,7 +73,13 @@ export async function getLastRank(): Promise {
}
}
+// 사용자를 찾지 못하면 null, 검색 자체가 실패하면 예외를 던짐 (오류 카드에서 둘을 구분하기 위함)
export async function getUserId(username: string): Promise {
+ // 실제 닉네임일 수 없는 긴 값은 Redis 키를 만들거나 Dreamhack을 검색하지 않음
+ if (Array.from(username).length > MAX_USERNAME_LENGTH) {
+ return null;
+ }
+
try {
console.time('⚙️ getUserId 함수 전체');
@@ -45,33 +91,50 @@ export async function getUserId(username: string): Promise {
return cachedUserId;
}
+ // 최근 검색에서 찾지 못한 닉네임이면 Dreamhack 검색을 반복하지 않음
+ if (await isUserNotFoundCached(username)) {
+ console.log(`🚫 최근 검색에서 찾지 못한 사용자입니다: ${username}`);
+ console.timeEnd('⚙️ getUserId 함수 전체');
+ return null;
+ }
+
console.log(`🔍 Redis 캐시에서 사용자 ID를 찾지 못했습니다. API 호출을 시도합니다: ${username}`);
- const response = await measureFetch(
- `https://dreamhack.io/api/v1/ranking/wargame/?filter=global&limit=100&offset=0&search=${username}&scope=all&name=&category=`,
- 'getUserId'
- );
-
- console.time('⚙️ getUserId JSON 파싱');
- const data = await response.json() as TUserRankingResponse;
- console.timeEnd('⚙️ getUserId JSON 파싱');
-
- console.time('⚙️ getUserId 사용자 검색');
- const user = data.results.find((user: TUserRankingResult) => user.nickname === username);
- console.timeEnd('⚙️ getUserId 사용자 검색');
-
+ const firstPage = await searchRanking(username, 0);
+ let user = findExactUser(firstPage.results, username);
+
+ // 첫 페이지에 없고 검색 결과가 더 있으면 다음 페이지들을 동시에 확인
+ if (!user && firstPage.count > SEARCH_PAGE_SIZE) {
+ const lastOffset = Math.min(firstPage.count, SEARCH_PAGE_SIZE * SEARCH_MAX_PAGES);
+ const offsets: number[] = [];
+ for (let offset = SEARCH_PAGE_SIZE; offset < lastOffset; offset += SEARCH_PAGE_SIZE) {
+ offsets.push(offset);
+ }
+ // 일부 페이지가 실패해도 성공한 페이지에서 찾으면 사용
+ const pages = await Promise.allSettled(offsets.map((offset) => searchRanking(username, offset)));
+ const results = pages.flatMap((page) => (page.status === 'fulfilled' ? page.value.results : []));
+ user = findExactUser(results, username);
+
+ // 못 찾았는데 실패한 페이지가 있으면 없는 사용자라고 단정할 수 없음
+ if (!user && pages.some((page) => page.status === 'rejected')) {
+ throw new Error('Failed to search some ranking pages');
+ }
+ }
+
const userId = user ? user.id : null;
-
- // 사용자 ID를 찾았다면 Redis에 저장
+
+ // 찾았으면 ID를, 못 찾았으면 미존재 사실을 Redis에 저장 (검색 자체가 실패한 경우는 저장하지 않음)
if (userId !== null) {
await saveUserIdToCache(username, userId);
+ } else {
+ await saveUserNotFoundToCache(username);
}
-
+
console.timeEnd('⚙️ getUserId 함수 전체');
return userId;
} catch (error) {
console.error('Error fetching user ID:', error);
console.timeEnd('⚙️ getUserId 함수 전체');
- return null;
+ throw error;
}
}
@@ -89,10 +152,11 @@ export async function getUserData(userId: number, username?: string): Promise ${newUserId}`);
// 새로운 사용자 ID로 재귀적으로 다시 호출
return getUserData(newUserId);
diff --git a/src/utils/encodeUsername.ts b/src/utils/encodeUsername.ts
new file mode 100644
index 0000000..584a905
--- /dev/null
+++ b/src/utils/encodeUsername.ts
@@ -0,0 +1,8 @@
+// 카드 URL의 username 값 인코딩
+// &, #, +, 공백, 괄호 등 URL·Markdown을 깨뜨리는 ASCII 문자만 인코딩하고, 한글 등은 README에서 읽기 쉽게 그대로 둠
+export function encodeUsername(username: string): string {
+ return username.replace(
+ /[^A-Za-z0-9\-_.~\u0080-]/g,
+ (char) => `%${char.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0')}`
+ );
+}
diff --git a/src/utils/generateCategorySvg.ts b/src/utils/generateCategorySvg.ts
index 2892701..8855187 100644
--- a/src/utils/generateCategorySvg.ts
+++ b/src/utils/generateCategorySvg.ts
@@ -1,27 +1,7 @@
-import { TCategoryStats, Theme, ThemeColors } from '../types';
+import { TCategoryStats, Theme } from '../types';
+import { escapeXml, themes } from './svg';
import { jetBrainsMonoFontFace } from './fonts/jetbrainsMono';
-const themes: Record = {
- light: {
- background: '#ffffff',
- cardBackground: '#f8fafc',
- border: '#e2e8f0',
- title: '#64748b',
- text: '#0f172a',
- subText: '#94a3b8',
- accent: '#3b82f6',
- },
- dark: {
- background: '#0d1117',
- cardBackground: '#21262d',
- border: '#30363d',
- title: '#8b949e',
- text: '#e6edf3',
- subText: '#7d8590',
- accent: '#58a6ff',
- },
-};
-
export function generateCategorySvg(stats: TCategoryStats, theme: Theme = 'light'): string {
const colors = themes[theme];
// SVG 크기 및 차트 설정
@@ -107,7 +87,7 @@ export function generateCategorySvg(stats: TCategoryStats, theme: Theme = 'light
const capitalizedName = category.name.charAt(0).toUpperCase() + category.name.slice(1);
legends += `
- ${capitalizedName}
+ ${escapeXml(capitalizedName)}
${percentage}%
`;
});
@@ -117,7 +97,7 @@ export function generateCategorySvg(stats: TCategoryStats, theme: Theme = 'light
const centerCircle = `
Total
- ${totalScore}
+ ${escapeXml(totalScore)}
`;
// SVG 구조 반환
diff --git a/src/utils/generateErrorSvg.ts b/src/utils/generateErrorSvg.ts
new file mode 100644
index 0000000..0ab106d
--- /dev/null
+++ b/src/utils/generateErrorSvg.ts
@@ -0,0 +1,66 @@
+import { TErrorCard, Theme } from '../types';
+import { escapeXml, themes } from './svg';
+import { jetBrainsMonoFontFace } from './fonts/jetbrainsMono';
+
+// 카드 폭(390px)을 넘지 않도록 닉네임 표시 길이 제한
+const MAX_NICKNAME_LENGTH = 30;
+
+function truncate(value: string, maxLength: number): string {
+ const chars = Array.from(value);
+ return chars.length > maxLength ? `${chars.slice(0, maxLength - 3).join('')}...` : value;
+}
+
+// README에서 깨진 이미지 대신 보여줄 오류 카드 문구
+export const errorCards = {
+ missingUsername: (title: string): TErrorCard => ({
+ title,
+ message: 'Missing username',
+ details: ['Add ?username= to the card URL.'],
+ }),
+ userNotFound: (title: string, username: string): TErrorCard => ({
+ title,
+ message: 'User not found',
+ details: [
+ `Nickname: ${truncate(username, MAX_NICKNAME_LENGTH)}`,
+ 'Nicknames are case-sensitive.',
+ 'Only users on the wargame ranking are shown.',
+ ],
+ }),
+ fetchFailed: (title: string): TErrorCard => ({
+ title,
+ message: 'Failed to load stats',
+ details: ['Could not load data from Dreamhack.', 'Please try again later.'],
+ }),
+};
+
+export function generateErrorSvg(error: TErrorCard, theme: Theme = 'light'): string {
+ const colors = themes[theme];
+
+ const details = error.details
+ .map((line, index) => `${escapeXml(line)}`)
+ .join('\n ');
+
+ return `
+
+ `;
+}
diff --git a/src/utils/generateStatsSvg.ts b/src/utils/generateStatsSvg.ts
index cd3a6f3..07fbb34 100644
--- a/src/utils/generateStatsSvg.ts
+++ b/src/utils/generateStatsSvg.ts
@@ -1,34 +1,14 @@
-import { Tstats, Theme, ThemeColors } from '../types';
+import { Tstats, Theme } from '../types';
+import { escapeXml, themes } from './svg';
import { jetBrainsMonoFontFace } from './fonts/jetbrainsMono';
-const themes: Record = {
- light: {
- background: '#ffffff',
- cardBackground: '#f8fafc',
- border: '#e2e8f0',
- title: '#64748b',
- text: '#0f172a',
- subText: '#94a3b8',
- accent: '#3b82f6',
- },
- dark: {
- background: '#0d1117',
- cardBackground: '#21262d',
- border: '#30363d',
- title: '#8b949e',
- text: '#e6edf3',
- subText: '#7d8590',
- accent: '#58a6ff',
- },
-};
-
export function generateStatsSvg(stats: Tstats, theme: Theme = 'light'): string {
const colors = themes[theme];
const rankParts = stats.wargame_rank.split('/');
const rankDisplay = rankParts.length === 2
- ? `${rankParts[0]}/${rankParts[1]}`
- : `${stats.wargame_rank}`;
+ ? `${escapeXml(rankParts[0])}/${escapeXml(rankParts[1])}`
+ : `${escapeXml(stats.wargame_rank)}`;
return `