// +---------------------------------------------------------------------- declare (strict_types = 1); namespace addon\articles\service; /** * 正文渲染服务(输出期增强,不改动入库内容) * * 三件事: * 1. 图片懒加载:给 加 loading="lazy" decoding="async" * 2. WebP:浏览器支持时把站内图片替换为 .webp 副本(见 ImageService) * 3. 内链:把命中的关键词(如本文标签)替换为站内链接,利于收录与导流 * * 全部基于 DOM 操作,不会破坏原有 HTML 结构;DOM 不可用时原样返回。 */ class ContentRenderService { /** 单篇文章最多生成的内链数 */ const MAX_LINKS = 5; /** * 渲染正文 * * @param string $html 已净化的正文 * @param array $linkKeywords [关键词 => 链接地址] * @param bool $useWebp 是否启用 WebP 替换 */ public static function render(string $html, array $linkKeywords = [], bool $useWebp = false): string { $html = trim($html); if ($html === '' || !class_exists('DOMDocument')) { return $html; } $prev = libxml_use_internal_errors(true); $doc = new \DOMDocument('1.0', 'UTF-8'); $loaded = $doc->loadHTML('' . $html); libxml_clear_errors(); libxml_use_internal_errors($prev); if (!$loaded) { return $html; } $body = $doc->getElementsByTagName('body')->item(0); if (!$body) { return $html; } self::enhanceImages($doc, $useWebp); if (!empty($linkKeywords)) { self::linkKeywords($doc, $linkKeywords); } $out = ''; foreach ($body->childNodes as $child) { $out .= $doc->saveHTML($child); } return trim($out); } /** * 图片增强:懒加载 + WebP */ private static function enhanceImages(\DOMDocument $doc, bool $useWebp): void { $images = []; foreach ($doc->getElementsByTagName('img') as $img) { $images[] = $img; } foreach ($images as $img) { $img->setAttribute('loading', 'lazy'); $img->setAttribute('decoding', 'async'); if (!$useWebp) { continue; } $src = (string) $img->getAttribute('src'); $webp = ImageService::webpUrl($src); if ($webp !== '') { $img->setAttribute('src', $webp); } } } /** * 内链替换(跳过已在 内的文本,避免链接套链接) */ private static function linkKeywords(\DOMDocument $doc, array $keywords): void { $xpath = new \DOMXPath($doc); $nodes = $xpath->query('//text()[not(ancestor::a)]'); if (!$nodes || $nodes->length === 0) { return; } // 先快照,避免替换过程中结构变化影响遍历 $list = []; foreach ($nodes as $node) { $list[] = $node; } $used = []; $count = 0; foreach ($list as $node) { if ($count >= self::MAX_LINKS) { break; } $text = (string) $node->nodeValue; if (mb_strlen(trim($text), 'UTF-8') < 2) { continue; } foreach ($keywords as $kw => $url) { $kw = (string) $kw; if ($count >= self::MAX_LINKS || isset($used[$kw]) || mb_strlen($kw, 'UTF-8') < 2) { continue; } $pos = mb_stripos($text, $kw, 0, 'UTF-8'); if ($pos === false) { continue; } $before = mb_substr($text, 0, $pos, 'UTF-8'); $after = mb_substr($text, $pos + mb_strlen($kw, 'UTF-8'), null, 'UTF-8'); $a = $doc->createElement('a', htmlspecialchars($kw, ENT_QUOTES, 'UTF-8')); $a->setAttribute('href', (string) $url); $a->setAttribute('class', 'article-inner-link'); $frag = $doc->createDocumentFragment(); $frag->appendChild($doc->createTextNode($before)); $frag->appendChild($a); $frag->appendChild($doc->createTextNode($after)); $parent = $node->parentNode; if (!$parent) { continue; } $parent->replaceChild($frag, $node); $used[$kw] = true; $count++; break; // 每个文本节点只处理一个关键词 } } } }