// +---------------------------------------------------------------------- declare (strict_types = 1); namespace addon\articles\controller; use addon\articles\model\Article; use addon\articles\model\ArticleCategory; use addon\articles\model\ArticleTopic; use think\facade\Request; /** * 站点地图(前台 SEO 用) * * 文章数 ≤ 5000:sitemap.xml 直接输出全部 URL(urlset) * 文章数 > 5000:sitemap.xml 输出 sitemap index,文章拆到 sitemap-.xml 分片 * (单文件 URL 上限 5 万条、体积 50MB,这里按 5000 条分片留足余量) * * 收录范围:插件首页 / 分类 / 专题 / 已发布且未删除的文章(草稿与待审不收录) * * @author ywxapp */ class Sitemap extends ArticlesFrontend { /** 单个 sitemap 文件最多包含的 URL 数 */ const MAX_PER_FILE = 5000; /** * /articles/sitemap.xml */ public function index() { $base = Request::domain(); $total = Article::scope('normal')->count(); // 首页 / 分类 / 专题(条目少,始终放在索引或主文件中) $static = [ ['loc' => $base . (string) url('articles/index/index'), 'priority' => '1.0', 'lastmod' => date('Y-m-d')], ['loc' => $base . (string) url('articles/topic/index'), 'priority' => '0.7'], ]; foreach (ArticleCategory::allEnable() as $c) { $static[] = [ 'loc' => $base . (string) url('articles/category/index', ['id' => $c->id]), 'priority' => '0.6', ]; } foreach (ArticleTopic::scope('enable')->select() as $t) { $static[] = [ 'loc' => $base . (string) url('articles/topic/' . $t->id), 'priority' => '0.7', ]; } // 文章过多 → 输出 sitemap index(静态条目放主文件,文章分片) if ($total > self::MAX_PER_FILE) { $pages = (int) ceil($total / self::MAX_PER_FILE); $maps = $static; for ($i = 1; $i <= $pages; $i++) { // 分片文件 URL 手工拼接:url() 会追加 .html 后缀,不能用于 .xml $maps[] = [ 'loc' => $base . '/articles/sitemap-' . $i . '.xml', 'lastmod' => date('Y-m-d'), 'priority' => '0.8', ]; } return response(self::renderIndex($maps), 200, ['Content-Type' => 'application/xml; charset=utf-8']); } // 常规:单文件输出(静态 + 全部文章) $items = $static; foreach (Article::scope('normal')->field('id,update_at')->order('id', 'desc')->select() as $a) { $items[] = [ 'loc' => $base . (string) url('articles/read/' . $a->id), 'lastmod' => date('Y-m-d', (int) $a->update_at ?: time()), 'priority' => '0.8', ]; } return response(self::renderUrlset($items), 200, ['Content-Type' => 'application/xml; charset=utf-8']); } /** * 文章分片:/articles/sitemap-.xml */ public function chunk($page = 1) { $page = max(1, (int) $page); $base = Request::domain(); $total = Article::scope('normal')->count(); $pages = max(1, (int) ceil($total / self::MAX_PER_FILE)); if ($page > $pages) { // 越界分片返回空 urlset,避免搜索引擎误判为错误 return response(self::renderUrlset([]), 200, ['Content-Type' => 'application/xml; charset=utf-8']); } $articles = Article::scope('normal') ->field('id,update_at') ->order('id', 'desc') ->page($page, self::MAX_PER_FILE) ->select(); $items = []; foreach ($articles as $a) { $items[] = [ 'loc' => $base . (string) url('articles/read/' . $a->id), 'lastmod' => date('Y-m-d', (int) $a->update_at ?: time()), 'priority' => '0.8', ]; } return response(self::renderUrlset($items), 200, ['Content-Type' => 'application/xml; charset=utf-8']); } /** * 渲染 urlset */ private static function renderUrlset(array $items): string { $xml = '' . "\n" . '' . "\n"; foreach ($items as $item) { $xml .= " \n"; $xml .= ' ' . self::esc($item['loc'] ?? '') . "\n"; if (!empty($item['lastmod'])) { $xml .= ' ' . $item['lastmod'] . "\n"; } $xml .= ' ' . ($item['priority'] ?? '0.5') . "\n"; $xml .= " \n"; } return $xml . ''; } /** * 渲染 sitemap index */ private static function renderIndex(array $items): string { $xml = '' . "\n" . '' . "\n"; foreach ($items as $item) { $xml .= " \n"; $xml .= ' ' . self::esc($item['loc'] ?? '') . "\n"; if (!empty($item['lastmod'])) { $xml .= ' ' . $item['lastmod'] . "\n"; } $xml .= " \n"; } return $xml . ''; } private static function esc(string $value): string { return htmlspecialchars($value, ENT_XML1 | ENT_QUOTES, 'UTF-8'); } }