sitemap.ts 3.0 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495
  1. import { MetadataRoute } from 'next';
  2. import { fetchBoardList, fetchAllPosts } from '@/lib/api/forum/board';
  3. const BASE_URL = 'https://antooza.com';
  4. // 게시글 수집 설정. sitemap 규격 상한은 50,000 건이라 여유가 있지만 무한 루프는 막는다.
  5. const POSTS_PER_PAGE = 100;
  6. const MAX_POST_PAGES = 50;
  7. export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  8. const now = new Date();
  9. // 정적 페이지
  10. // /login, /register 는 색인 가치가 없어 제외한다.
  11. const staticPages: MetadataRoute.Sitemap = [
  12. { url: BASE_URL, lastModified: now, changeFrequency: 'daily', priority: 1.0 },
  13. { url: `${BASE_URL}/docs`, changeFrequency: 'weekly', priority: 0.5 },
  14. { url: `${BASE_URL}/support/faq`, changeFrequency: 'monthly', priority: 0.4 },
  15. { url: `${BASE_URL}/support/guide`, changeFrequency: 'monthly', priority: 0.4 },
  16. { url: `${BASE_URL}/support/contact`, changeFrequency: 'monthly', priority: 0.3 },
  17. ];
  18. const [boardPages, postPages] = await Promise.all([fetchBoardPages(now), fetchPostPages()]);
  19. return [...staticPages, ...boardPages, ...postPages];
  20. }
  21. // 게시판 목록
  22. async function fetchBoardPages(now: Date): Promise<MetadataRoute.Sitemap> {
  23. try {
  24. const res = await fetchBoardList();
  25. if (!res.success || !res.data?.list) {
  26. return [];
  27. }
  28. return res.data.list
  29. .filter(board => board.isActive)
  30. .map(board => ({
  31. url: `${BASE_URL}/board/${board.code}`,
  32. lastModified: now,
  33. changeFrequency: 'daily' as const,
  34. priority: 0.8,
  35. }));
  36. } catch (error) {
  37. // 조용히 삼키면 sitemap 에서 게시판이 통째로 빠져도 알 수 없다.
  38. console.error('[sitemap] 게시판 목록 조회 실패', error);
  39. return [];
  40. }
  41. }
  42. // 게시글
  43. // 목록 페이지가 클라이언트 렌더링이라 초기 HTML 에 글 링크가 없다.
  44. // sitemap 에 넣지 않으면 크롤러가 개별 게시글을 발견할 경로가 없다.
  45. async function fetchPostPages(): Promise<MetadataRoute.Sitemap> {
  46. const entries: MetadataRoute.Sitemap = [];
  47. const seen = new Set<number>();
  48. try {
  49. for (let page = 1; page <= MAX_POST_PAGES; page++) {
  50. const res = await fetchAllPosts({ page, perPage: POSTS_PER_PAGE });
  51. if (!res.success || !res.data) {
  52. break;
  53. }
  54. // 공지/전체공지도 실제로 열리는 페이지이므로 함께 담는다.
  55. const posts = [...(res.data.speaker ?? []), ...(res.data.notice ?? []), ...(res.data.list ?? [])];
  56. for (const post of posts) {
  57. // 비밀글은 비로그인 사용자가 열 수 없어 제외한다.
  58. if (post.isSecret || seen.has(post.id)) {
  59. continue;
  60. }
  61. seen.add(post.id);
  62. entries.push({
  63. url: `${BASE_URL}/post/${post.id}`,
  64. lastModified: post.createdAt ? new Date(post.createdAt) : undefined,
  65. changeFrequency: 'weekly',
  66. priority: 0.6,
  67. });
  68. }
  69. // 목록이 한 페이지 분량보다 적으면 마지막 페이지다.
  70. if ((res.data.list?.length ?? 0) < POSTS_PER_PAGE) {
  71. break;
  72. }
  73. }
  74. } catch (error) {
  75. console.error('[sitemap] 게시글 조회 실패', error);
  76. }
  77. return entries;
  78. }