SitemapController.php 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145
  1. <?php
  2. namespace App\Http\Controllers;
  3. use Illuminate\Support\Facades\Cache;
  4. use Illuminate\Support\Facades\DB;
  5. /**
  6. * sitemap.xml 생성
  7. *
  8. * 기존에는 public/sitemap.xml 정적 파일에 9 건만 손으로 적어두었다.
  9. * 목록 페이지와 로그인/회원가입만 들어 있고, 실제 색인 가치가 있는
  10. * 영화 상세와 게시글이 한 건도 없었다.
  11. *
  12. * 공개 판정 조건은 Board 모델의 기존 공개 목록 쿼리와 동일하게 맞춘다.
  13. * (BRD.is_display = 1, PST.is_delete = 0, PST.is_secret = 0, PST.is_personal = 0)
  14. */
  15. class SitemapController extends Controller
  16. {
  17. private const CACHE_KEY = 'sitemap.xml';
  18. private const CACHE_SECONDS = 3600;
  19. // sitemap 규격 상한은 파일당 50,000 건이다. 여유를 두고 자른다.
  20. private const MAX_URLS = 45000;
  21. private int $count = 0;
  22. private bool $truncated = false;
  23. public function index()
  24. {
  25. $xml = Cache::remember(self::CACHE_KEY, self::CACHE_SECONDS, function () {
  26. return $this->build();
  27. });
  28. return response($xml, 200)->header('Content-Type', 'application/xml; charset=utf-8');
  29. }
  30. private function build(): string
  31. {
  32. // 레이아웃의 canonical(url()->current())과 같은 방식으로 요청 기준 주소를 쓴다.
  33. $baseUrl = rtrim(url('/'), '/');
  34. $today = date('Y-m-d');
  35. $xml = '<?xml version="1.0" encoding="UTF-8"?>' . PHP_EOL;
  36. $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . PHP_EOL;
  37. // 고정 페이지. 로그인/회원가입/비밀번호 재설정은 색인 가치가 없어 제외한다.
  38. $xml .= $this->url("{$baseUrl}/", $today, 'daily', '1.0');
  39. foreach (['rank', 'search', 'trailer', 'review', 'company'] as $section) {
  40. $xml .= $this->url("{$baseUrl}/movie/{$section}", $today, 'daily', '0.8');
  41. }
  42. $xml .= $this->url("{$baseUrl}/tag", $today, 'daily', '0.6');
  43. // 게시판 목록
  44. $boards = DB::table('tb_board')
  45. ->where('is_display', '=', 1)
  46. ->orderBy('sort')
  47. ->pluck('code');
  48. foreach ($boards as $code) {
  49. $xml .= $this->url("{$baseUrl}/board/{$code}", $today, 'daily', '0.8');
  50. }
  51. // 게시글. 공지/전체공지도 실제로 열리는 페이지이므로 함께 넣는다.
  52. DB::table('tb_post')
  53. ->join('tb_board', 'tb_board.id', '=', 'tb_post.board_id')
  54. ->where('tb_board.is_display', '=', 1)
  55. ->where('tb_post.is_delete', '=', 0)
  56. ->where('tb_post.is_secret', '=', 0)
  57. ->where('tb_post.is_personal', '=', 0)
  58. ->select('tb_board.code', 'tb_post.id', 'tb_post.updated_at', 'tb_post.created_at')
  59. ->orderBy('tb_post.id')
  60. ->chunk(1000, function ($posts) use (&$xml, $baseUrl, $today) {
  61. foreach ($posts as $post) {
  62. $modified = $post->updated_at ?: $post->created_at;
  63. $lastModified = $modified ? date('Y-m-d', strtotime($modified)) : $today;
  64. if (!$this->append($xml, "{$baseUrl}/board/{$post->code}/{$post->id}", $lastModified, 'weekly', '0.6')) {
  65. return false;
  66. }
  67. }
  68. return true;
  69. });
  70. // 영화 상세.
  71. // 뷰가 route('movie.search.show', base64_encode($row->movie_cd)) 로 링크를 만들므로 같은 규칙을 쓴다.
  72. DB::table('tb_movie')
  73. ->select('movie_cd')
  74. ->orderBy('movie_cd')
  75. ->chunk(1000, function ($movies) use (&$xml, $baseUrl, $today) {
  76. foreach ($movies as $movie) {
  77. if (!$movie->movie_cd) {
  78. continue;
  79. }
  80. $path = rawurlencode(base64_encode($movie->movie_cd));
  81. if (!$this->append($xml, "{$baseUrl}/movie/search/{$path}", $today, 'weekly', '0.7')) {
  82. return false;
  83. }
  84. }
  85. return true;
  86. });
  87. $xml .= '</urlset>' . PHP_EOL;
  88. if ($this->truncated) {
  89. // 조용히 잘리면 커버리지가 줄어든 것을 알 수 없다.
  90. logger()->warning('[sitemap] URL 상한(' . self::MAX_URLS . ')에 도달해 일부가 제외되었습니다.');
  91. }
  92. return $xml;
  93. }
  94. /**
  95. * 상한을 지키며 URL 을 덧붙인다. 상한에 닿으면 false 를 반환해 순회를 멈춘다.
  96. */
  97. private function append(string &$xml, string $location, string $lastModified, string $changeFrequency, string $priority): bool
  98. {
  99. if ($this->count >= self::MAX_URLS) {
  100. $this->truncated = true;
  101. return false;
  102. }
  103. $xml .= $this->url($location, $lastModified, $changeFrequency, $priority);
  104. return true;
  105. }
  106. private function url(string $location, string $lastModified, string $changeFrequency, string $priority): string
  107. {
  108. $this->count++;
  109. return ' <url>' . PHP_EOL
  110. . ' <loc>' . htmlspecialchars($location, ENT_XML1) . '</loc>' . PHP_EOL
  111. . ' <lastmod>' . $lastModified . '</lastmod>' . PHP_EOL
  112. . ' <changefreq>' . $changeFrequency . '</changefreq>' . PHP_EOL
  113. . ' <priority>' . $priority . '</priority>' . PHP_EOL
  114. . ' </url>' . PHP_EOL;
  115. }
  116. }