內容管理系統常需對文章進行關鍵字連結、敏感詞過濾或廣告植入,但必須每個關鍵字僅替換第一次出現、不破壞 HTML 標籤屬性(如 alt、title)、支援大小寫不敏感。本文提供三種方案:preg_replace_callback 計數器、strpos+substr_replace 精確定位、以及基於 DOMDocument 的安全替換。
<?php $content = '<img src="apple.jpg" alt="Apple iPhone"> Apple 發布新款 Apple Watch'; // 目標:將 "Apple" 替換為 <a href="/tag/apple">Apple</a> // 錯誤結果:alt 屬性也被替換、所有 Apple 都被替換 // 正確結果:僅替換第一個純文本 "Apple",保留 alt 屬性 ?>
<?php
function replaceFirstOccurrence(string $subject, string $search, string $replace, bool $caseInsensitive = true): string {
$flags = $caseInsensitive ? 'i' : '';
$pattern = '/'.preg_quote($search, '/').'/'.$flags;
$count = 0;
return preg_replace_callback($pattern, function($m) use (&$count, $replace) {
return ++$count === 1 ? $replace : $m[0];
}, $subject);
}
// 批次處理多個關鍵字(各自限制一次)
function replaceKeywordsOnce(string $html, array $keywords): string {
// 先保護 HTML 屬性值
$placeholderMap = [];
$html = preg_replace_callback('/\b(alt|title|href|src)=(["\'])(.*?)\2/i', function($m) use (&$placeholderMap) {
$ph = '@@PROTECT_'.count($placeholderMap).'@@';
$placeholderMap[$ph] = $m[0];
return $ph;
}, $html);
foreach ($keywords as $kw => $link) {
$html = replaceFirstOccurrence($html, $kw, $link);
}
// 還原屬性
return strtr($html, $placeholderMap);
}
$html = '<img alt="Apple"> Apple 發布 Apple Watch';
$map = ['Apple' => '<a href="/apple">Apple</a>'];
echo replaceKeywordsOnce($html, $map);
// 輸出:<img alt="Apple"> <a href="/apple">Apple</a> 發布 Apple Watch
?>
<?php
function replaceFirstStrpos(string &$haystack, string $needle, string $replace, bool $caseInsensitive = true): bool {
$pos = $caseInsensitive ? stripos($haystack, $needle) : strpos($haystack, $needle);
if ($pos === false) return false;
$haystack = substr_replace($haystack, $replace, $pos, strlen($needle));
return true;
}
// 批次版本
function replaceKeywordsOnceStrpos(string $html, array $map): string {
// 同樣需先保護屬性,略
foreach ($map as $kw => $repl) {
replaceFirstStrpos($html, $kw, $repl, true);
}
return $html;
}
?>
<?php
function replaceInTextNodes(\DOMDocument $dom, string $search, string $replace, bool $caseInsensitive = true): int {
$xpath = new \DOMXPath($dom);
$textNodes = $xpath->query('//text()');
$replaced = 0;
foreach ($textNodes as $textNode) {
$original = $textNode->nodeValue;
$flags = $caseInsensitive ? PREG_OFFSET_CAPTURE : 0;
$pattern = '/'.preg_quote($search, '/').'/'.($caseInsensitive ? 'i' : '');
if (preg_match($pattern, $original, $matches, $flags)) {
$offset = $matches[0][1] ?? 0;
$newValue = substr_replace($original, $replace, $offset, strlen($matches[0][0]));
$textNode->nodeValue = $newValue;
$replaced++;
if ($replaced >= 1) break; // 全域僅第一次
}
}
return $replaced;
}
function replaceKeywordsDom(string $html, array $map): string {
$dom = new \DOMDocument();
$dom->loadHTML(''.$html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
foreach ($map as $kw => $repl) {
replaceInTextNodes($dom, $kw, $repl);
}
return $dom->saveHTML();
}
?>
| 方案 | 速度 | 記憶體 | HTML 安全 | 適用場景 |
|---|---|---|---|---|
| preg_replace_callback | 快 | 低 | 需手動保護屬性 | 大量文本、關鍵字少、可接受簡單保護 |
| strpos + substr_replace | 最快 | 最低 | 需手動保護屬性 | 極大文本、單關鍵字、效能敏感 |
| DOMDocument | 慢 | 高 | 原生安全 | 複雜 HTML、需嚴格保證標籤完整、關鍵字多 |
<?php
function replaceLimited(string $subject, string $search, string $replace, int $limit, bool $caseInsensitive = true): string {
if ($limit <= 0) return $subject;
$pattern = '/'.preg_quote($search, '/').'/'.($caseInsensitive ? 'i' : '');
$count = 0;
return preg_replace_callback($pattern, function($m) use (&$count, $limit, $replace) {
return ++$count <= $limit ? $replace : $m[0];
}, $subject);
}
?>