sitemap.ts 3.2 KB

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