781 lines
27 KiB
PHP
781 lines
27 KiB
PHP
|
|
<?php
|
|||
|
|
/**
|
|||
|
|
* 自动识别文章内分类、标签添加链接
|
|||
|
|
*
|
|||
|
|
* @package AutoTagLink
|
|||
|
|
* @author 石头厝
|
|||
|
|
* @version 1.3.0
|
|||
|
|
* @link https://www.shitoucuo.com/
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
class AutoTagLink_Plugin implements Typecho_Plugin_Interface
|
|||
|
|
{
|
|||
|
|
/**
|
|||
|
|
* 激活插件
|
|||
|
|
*/
|
|||
|
|
public static function activate()
|
|||
|
|
{
|
|||
|
|
Typecho_Plugin::factory('Widget_Abstract_Contents')->contentEx = array('AutoTagLink_Plugin', 'parseContent');
|
|||
|
|
Typecho_Plugin::factory('Widget_Abstract_Contents')->excerptEx = array('AutoTagLink_Plugin', 'parseExcerpt');
|
|||
|
|
Typecho_Plugin::factory('Widget_Archive')->header = array('AutoTagLink_Plugin', 'addHeader');
|
|||
|
|
|
|||
|
|
return _t('插件已激活,开始自动识别分类和标签!');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 禁用插件
|
|||
|
|
*/
|
|||
|
|
public static function deactivate()
|
|||
|
|
{
|
|||
|
|
return _t('插件已禁用');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 插件配置面板
|
|||
|
|
*/
|
|||
|
|
public static function config(Typecho_Widget_Helper_Form $form)
|
|||
|
|
{
|
|||
|
|
// 是否启用分类链接
|
|||
|
|
$enableCategory = new Typecho_Widget_Helper_Form_Element_Radio(
|
|||
|
|
'enableCategory',
|
|||
|
|
array('1' => '启用', '0' => '禁用'),
|
|||
|
|
'1',
|
|||
|
|
'启用分类链接',
|
|||
|
|
'是否自动为文章中的分类名称添加链接'
|
|||
|
|
);
|
|||
|
|
$form->addInput($enableCategory);
|
|||
|
|
|
|||
|
|
// 是否启用标签链接
|
|||
|
|
$enableTag = new Typecho_Widget_Helper_Form_Element_Radio(
|
|||
|
|
'enableTag',
|
|||
|
|
array('1' => '启用', '0' => '禁用'),
|
|||
|
|
'1',
|
|||
|
|
'启用标签链接',
|
|||
|
|
'是否自动为文章中的标签名称添加链接'
|
|||
|
|
);
|
|||
|
|
$form->addInput($enableTag);
|
|||
|
|
|
|||
|
|
// 链接打开方式
|
|||
|
|
$linkTarget = new Typecho_Widget_Helper_Form_Element_Select(
|
|||
|
|
'linkTarget',
|
|||
|
|
array(
|
|||
|
|
'_self' => '当前窗口打开',
|
|||
|
|
'_blank' => '新窗口打开'
|
|||
|
|
),
|
|||
|
|
'_blank',
|
|||
|
|
'链接打开方式',
|
|||
|
|
'分类和标签链接的打开方式'
|
|||
|
|
);
|
|||
|
|
$form->addInput($linkTarget);
|
|||
|
|
|
|||
|
|
// 链接格式设置
|
|||
|
|
$categoryUrlFormat = new Typecho_Widget_Helper_Form_Element_Text(
|
|||
|
|
'categoryUrlFormat',
|
|||
|
|
NULL,
|
|||
|
|
'category-{slug}.html',
|
|||
|
|
'分类链接格式',
|
|||
|
|
'分类链接格式,可用变量:{slug}分类slug<br>'
|
|||
|
|
);
|
|||
|
|
$form->addInput($categoryUrlFormat);
|
|||
|
|
|
|||
|
|
$tagUrlFormat = new Typecho_Widget_Helper_Form_Element_Text(
|
|||
|
|
'tagUrlFormat',
|
|||
|
|
NULL,
|
|||
|
|
'tag-{slug}.html',
|
|||
|
|
'标签链接格式',
|
|||
|
|
'标签链接格式,可用变量:{slug}标签slug<br>'
|
|||
|
|
);
|
|||
|
|
$form->addInput($tagUrlFormat);
|
|||
|
|
|
|||
|
|
// 样式设置
|
|||
|
|
$categoryClass = new Typecho_Widget_Helper_Form_Element_Text(
|
|||
|
|
'categoryClass',
|
|||
|
|
NULL,
|
|||
|
|
'auto-category-link',
|
|||
|
|
'分类链接CSS类名',
|
|||
|
|
'分类链接的CSS类名,可自定义样式'
|
|||
|
|
);
|
|||
|
|
$form->addInput($categoryClass);
|
|||
|
|
|
|||
|
|
// 标签链接CSS类
|
|||
|
|
$tagClass = new Typecho_Widget_Helper_Form_Element_Text(
|
|||
|
|
'tagClass',
|
|||
|
|
NULL,
|
|||
|
|
'auto-tag-link',
|
|||
|
|
'标签链接CSS类名',
|
|||
|
|
'标签链接的CSS类名,可自定义样式'
|
|||
|
|
);
|
|||
|
|
$form->addInput($tagClass);
|
|||
|
|
|
|||
|
|
// 启用默认样式
|
|||
|
|
$enableDefaultStyle = new Typecho_Widget_Helper_Form_Element_Radio(
|
|||
|
|
'enableDefaultStyle',
|
|||
|
|
array('1' => '启用', '0' => '禁用'),
|
|||
|
|
'1',
|
|||
|
|
'启用默认样式',
|
|||
|
|
'是否启用插件自带的默认CSS样式'
|
|||
|
|
);
|
|||
|
|
$form->addInput($enableDefaultStyle);
|
|||
|
|
|
|||
|
|
// 高级设置
|
|||
|
|
$minLength = new Typecho_Widget_Helper_Form_Element_Text(
|
|||
|
|
'minLength',
|
|||
|
|
NULL,
|
|||
|
|
'2',
|
|||
|
|
'最小词长',
|
|||
|
|
'只匹配长度大于等于此值的词汇(单位:字符)'
|
|||
|
|
);
|
|||
|
|
$form->addInput($minLength->addRule('isInteger', '请输入整数'));
|
|||
|
|
|
|||
|
|
// 识别间隔字数
|
|||
|
|
$skipChars = new Typecho_Widget_Helper_Form_Element_Text(
|
|||
|
|
'skipChars',
|
|||
|
|
NULL,
|
|||
|
|
'200',
|
|||
|
|
'相同词识别间隔字数',
|
|||
|
|
'识别到同一个词后,跳过接下来的字数再识别这个词(单位:字符)<br>只限制同一个词的密集出现'
|
|||
|
|
);
|
|||
|
|
$form->addInput($skipChars->addRule('isInteger', '请输入整数'));
|
|||
|
|
|
|||
|
|
// 排除标题识别
|
|||
|
|
$excludeHeaders = new Typecho_Widget_Helper_Form_Element_Checkbox(
|
|||
|
|
'excludeHeaders',
|
|||
|
|
array(
|
|||
|
|
'h1' => '排除 H1 标题',
|
|||
|
|
'h2' => '排除 H2 标题',
|
|||
|
|
'h3' => '排除 H3 标题'
|
|||
|
|
),
|
|||
|
|
array('h1', 'h2', 'h3'),
|
|||
|
|
'排除标题识别',
|
|||
|
|
'选择哪些标题标签内的文字不参与识别'
|
|||
|
|
);
|
|||
|
|
$form->addInput($excludeHeaders);
|
|||
|
|
|
|||
|
|
// 排除词汇
|
|||
|
|
$excludeWords = new Typecho_Widget_Helper_Form_Element_Textarea(
|
|||
|
|
'excludeWords',
|
|||
|
|
NULL,
|
|||
|
|
"的\n了\n在\n是\n我\n有\n和\n就\n不\n人\n都\n一\n一个",
|
|||
|
|
'排除词汇',
|
|||
|
|
'每行一个,这些词汇不会被匹配(常用于排除常见词)'
|
|||
|
|
);
|
|||
|
|
$form->addInput($excludeWords);
|
|||
|
|
|
|||
|
|
// 是否处理已有链接
|
|||
|
|
$ignoreLinks = new Typecho_Widget_Helper_Form_Element_Radio(
|
|||
|
|
'ignoreLinks',
|
|||
|
|
array('1' => '忽略', '0' => '处理'),
|
|||
|
|
'1',
|
|||
|
|
'忽略已有链接内的文本',
|
|||
|
|
'是否忽略已经是链接内的文本(建议启用)'
|
|||
|
|
);
|
|||
|
|
$form->addInput($ignoreLinks);
|
|||
|
|
|
|||
|
|
// 新增:排除特定内容(使用简单字符串匹配)
|
|||
|
|
$excludeContents = new Typecho_Widget_Helper_Form_Element_Textarea(
|
|||
|
|
'excludeContents',
|
|||
|
|
NULL,
|
|||
|
|
"[amap_marker]",
|
|||
|
|
'排除特定内容起始标记',
|
|||
|
|
'每行一个起始标记,这些标记开始的内容不会被处理<br>示例:<br>[amap_marker] - 排除地图卡片<br>[code] - 排除代码块<br>[php] - 排除PHP代码'
|
|||
|
|
);
|
|||
|
|
$form->addInput($excludeContents);
|
|||
|
|
|
|||
|
|
// 调试模式
|
|||
|
|
$debugMode = new Typecho_Widget_Helper_Form_Element_Radio(
|
|||
|
|
'debugMode',
|
|||
|
|
array('1' => '开启', '0' => '关闭'),
|
|||
|
|
'0',
|
|||
|
|
'调试模式',
|
|||
|
|
'开启后会在页面底部显示调试信息(仅管理员可见)'
|
|||
|
|
);
|
|||
|
|
$form->addInput($debugMode);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 个人用户配置面板
|
|||
|
|
*/
|
|||
|
|
public static function personalConfig(Typecho_Widget_Helper_Form $form)
|
|||
|
|
{
|
|||
|
|
// 个人设置留空
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 添加头部样式
|
|||
|
|
*/
|
|||
|
|
public static function addHeader()
|
|||
|
|
{
|
|||
|
|
$options = Helper::options()->plugin('AutoTagLink');
|
|||
|
|
|
|||
|
|
if ($options->enableDefaultStyle) {
|
|||
|
|
echo "<style>\n";
|
|||
|
|
echo self::getDefaultStyle();
|
|||
|
|
echo "</style>\n";
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 获取默认样式
|
|||
|
|
*/
|
|||
|
|
private static function getDefaultStyle()
|
|||
|
|
{
|
|||
|
|
return <<<CSS
|
|||
|
|
/* AutoTagLink 插件默认样式 */
|
|||
|
|
.auto-category-link:before {content: "[";}
|
|||
|
|
.auto-category-link:after {content: "]";}
|
|||
|
|
.auto-tag-link:before {content: "#";}
|
|||
|
|
.auto-category-link {
|
|||
|
|
display: inline-block;
|
|||
|
|
padding: 2px 2px;
|
|||
|
|
background-color: #f0f7ff;
|
|||
|
|
color: #1890ff;
|
|||
|
|
border-radius: 4px;
|
|||
|
|
text-decoration: none;
|
|||
|
|
font-size: 0.9em;
|
|||
|
|
line-height: 1.4;
|
|||
|
|
transition: all 0.3s ease;
|
|||
|
|
}
|
|||
|
|
.auto-category-link:hover {
|
|||
|
|
background-color: #1890ff;
|
|||
|
|
color: white;
|
|||
|
|
border-color: #1890ff;
|
|||
|
|
text-decoration: none;
|
|||
|
|
transform: translateY(-1px);
|
|||
|
|
}
|
|||
|
|
.auto-tag-link {
|
|||
|
|
display: inline-block;
|
|||
|
|
padding: 2px 2px;
|
|||
|
|
background-color: #f6ffed;
|
|||
|
|
color: #52c41a;
|
|||
|
|
border-radius: 12px;
|
|||
|
|
text-decoration: none;
|
|||
|
|
font-size: 0.85em;
|
|||
|
|
line-height: 1.4;
|
|||
|
|
transition: all 0.3s ease;
|
|||
|
|
}
|
|||
|
|
.auto-tag-link:hover {
|
|||
|
|
background-color: #52c41a;
|
|||
|
|
color: white;
|
|||
|
|
border-color: #52c41a;
|
|||
|
|
text-decoration: none;
|
|||
|
|
transform: translateY(-1px);
|
|||
|
|
}
|
|||
|
|
CSS;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 处理文章内容
|
|||
|
|
*/
|
|||
|
|
public static function parseContent($content, $widget, $lastResult)
|
|||
|
|
{
|
|||
|
|
$content = empty($lastResult) ? $content : $lastResult;
|
|||
|
|
|
|||
|
|
if ($widget instanceof Widget_Archive && $widget->is('single')) {
|
|||
|
|
$content = self::parseText($content, $widget);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return $content;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 处理文章摘要
|
|||
|
|
*/
|
|||
|
|
public static function parseExcerpt($content, $widget, $lastResult)
|
|||
|
|
{
|
|||
|
|
$content = empty($lastResult) ? $content : $lastResult;
|
|||
|
|
|
|||
|
|
if ($widget instanceof Widget_Archive) {
|
|||
|
|
$content = self::parseText($content, $widget);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return $content;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 解析文本并添加链接(使用简单字符串处理方法)
|
|||
|
|
*/
|
|||
|
|
private static function parseText($text, $widget)
|
|||
|
|
{
|
|||
|
|
$options = Helper::options()->plugin('AutoTagLink');
|
|||
|
|
|
|||
|
|
// 检查是否启用
|
|||
|
|
if (!$options->enableCategory && !$options->enableTag) {
|
|||
|
|
return $text;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 获取文章ID
|
|||
|
|
$cid = $widget->cid;
|
|||
|
|
if (!$cid) {
|
|||
|
|
return $text;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 调试信息
|
|||
|
|
$debugInfo = '';
|
|||
|
|
$isDebug = $options->debugMode && Typecho_Widget::widget('Widget_User')->hasLogin();
|
|||
|
|
if ($isDebug) {
|
|||
|
|
$debugInfo .= "<!-- AutoTagLink 调试信息开始 -->\n";
|
|||
|
|
$debugInfo .= "<!-- 文章ID: {$cid} -->\n";
|
|||
|
|
$debugInfo .= "<!-- 原始文本长度: " . strlen($text) . " -->\n";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 获取排除内容标记
|
|||
|
|
$excludeMarkers = array();
|
|||
|
|
if (!empty($options->excludeContents)) {
|
|||
|
|
$excludeMarkers = explode("\n", $options->excludeContents);
|
|||
|
|
$excludeMarkers = array_map('trim', $excludeMarkers);
|
|||
|
|
$excludeMarkers = array_filter($excludeMarkers);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 确保有地图卡片标记
|
|||
|
|
$hasAmapMarker = false;
|
|||
|
|
foreach ($excludeMarkers as $marker) {
|
|||
|
|
if (strpos($marker, 'amap_marker') !== false) {
|
|||
|
|
$hasAmapMarker = true;
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (!$hasAmapMarker) {
|
|||
|
|
$excludeMarkers[] = '[amap_marker]';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if ($isDebug) {
|
|||
|
|
$debugInfo .= "<!-- 排除标记数量: " . count($excludeMarkers) . " -->\n";
|
|||
|
|
foreach ($excludeMarkers as $marker) {
|
|||
|
|
$debugInfo .= "<!-- 排除标记: " . htmlspecialchars($marker) . " -->\n";
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 使用简单的字符串分割方法来保护内容
|
|||
|
|
$result = '';
|
|||
|
|
$position = 0;
|
|||
|
|
$textLength = strlen($text);
|
|||
|
|
|
|||
|
|
// 查找所有需要保护的区块
|
|||
|
|
$protectedBlocks = array();
|
|||
|
|
|
|||
|
|
foreach ($excludeMarkers as $marker) {
|
|||
|
|
$markerLength = strlen($marker);
|
|||
|
|
$searchPos = 0;
|
|||
|
|
|
|||
|
|
while (($startPos = strpos($text, $marker, $searchPos)) !== false) {
|
|||
|
|
// 查找结束标记(对于地图卡片是 [/amap_marker])
|
|||
|
|
$endMarker = '[/' . substr($marker, 1); // 将 [tag 转换为 [/tag]
|
|||
|
|
$endPos = strpos($text, $endMarker, $startPos + $markerLength);
|
|||
|
|
|
|||
|
|
if ($endPos !== false) {
|
|||
|
|
$endPos += strlen($endMarker);
|
|||
|
|
$protectedBlocks[] = array(
|
|||
|
|
'start' => $startPos,
|
|||
|
|
'end' => $endPos,
|
|||
|
|
'content' => substr($text, $startPos, $endPos - $startPos)
|
|||
|
|
);
|
|||
|
|
$searchPos = $endPos;
|
|||
|
|
} else {
|
|||
|
|
// 如果没有找到结束标记,跳过这个标记
|
|||
|
|
$searchPos = $startPos + $markerLength;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 按起始位置排序保护区块
|
|||
|
|
usort($protectedBlocks, function($a, $b) {
|
|||
|
|
return $a['start'] - $b['start'];
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if ($isDebug) {
|
|||
|
|
$debugInfo .= "<!-- 找到保护区块数量: " . count($protectedBlocks) . " -->\n";
|
|||
|
|
foreach ($protectedBlocks as $index => $block) {
|
|||
|
|
$preview = substr($block['content'], 0, 50);
|
|||
|
|
$debugInfo .= "<!-- 区块 {$index}: 位置 {$block['start']}-{$block['end']}, 预览: " . htmlspecialchars($preview) . "... -->\n";
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 处理文本:非保护部分进行替换,保护部分原样保留
|
|||
|
|
if (empty($protectedBlocks)) {
|
|||
|
|
// 如果没有需要保护的内容,直接处理整个文本
|
|||
|
|
$result = self::processContent($text, $widget, $options, $debugInfo);
|
|||
|
|
} else {
|
|||
|
|
// 逐个处理非保护部分
|
|||
|
|
$lastEnd = 0;
|
|||
|
|
$placeholderIndex = 0;
|
|||
|
|
$placeholders = array();
|
|||
|
|
|
|||
|
|
foreach ($protectedBlocks as $block) {
|
|||
|
|
// 处理保护区块前的文本
|
|||
|
|
if ($block['start'] > $lastEnd) {
|
|||
|
|
$segment = substr($text, $lastEnd, $block['start'] - $lastEnd);
|
|||
|
|
$processedSegment = self::processContent($segment, $widget, $options, $debugInfo);
|
|||
|
|
$result .= $processedSegment;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 为保护区块创建占位符
|
|||
|
|
$placeholder = "<!-- AUTOTAGLINK_PROTECTED_{$placeholderIndex} -->";
|
|||
|
|
$placeholders[$placeholder] = $block['content'];
|
|||
|
|
$result .= $placeholder;
|
|||
|
|
$placeholderIndex++;
|
|||
|
|
|
|||
|
|
$lastEnd = $block['end'];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 处理最后一段文本
|
|||
|
|
if ($lastEnd < $textLength) {
|
|||
|
|
$segment = substr($text, $lastEnd);
|
|||
|
|
$processedSegment = self::processContent($segment, $widget, $options, $debugInfo);
|
|||
|
|
$result .= $processedSegment;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 恢复占位符
|
|||
|
|
foreach ($placeholders as $placeholder => $original) {
|
|||
|
|
$result = str_replace($placeholder, $original, $result);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if ($isDebug) {
|
|||
|
|
$debugInfo .= "<!-- 最终文本长度: " . strlen($result) . " -->\n";
|
|||
|
|
$amapCount = substr_count($result, '[amap_marker]');
|
|||
|
|
$debugInfo .= "<!-- 最终文本中地图卡片标记数量: {$amapCount} -->\n";
|
|||
|
|
$debugInfo .= "<!-- AutoTagLink 调试信息结束 -->\n";
|
|||
|
|
$result .= $debugInfo;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return $result;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 处理内容(核心处理逻辑,不包含保护逻辑)
|
|||
|
|
*/
|
|||
|
|
private static function processContent($text, $widget, $options, &$debugInfo)
|
|||
|
|
{
|
|||
|
|
$cid = $widget->cid;
|
|||
|
|
$db = Typecho_Db::get();
|
|||
|
|
$isDebug = $options->debugMode && Typecho_Widget::widget('Widget_User')->hasLogin();
|
|||
|
|
|
|||
|
|
// 获取文章的分类
|
|||
|
|
$categories = array();
|
|||
|
|
if ($options->enableCategory) {
|
|||
|
|
$catQuery = $db->select('m.name', 'm.slug')
|
|||
|
|
->from('table.relationships AS r')
|
|||
|
|
->join('table.metas AS m', 'r.mid = m.mid')
|
|||
|
|
->where('r.cid = ?', $cid)
|
|||
|
|
->where('m.type = ?', 'category');
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
$catResults = $db->fetchAll($catQuery);
|
|||
|
|
|
|||
|
|
if ($isDebug) {
|
|||
|
|
$debugInfo .= "<!-- 分类查询结果: " . count($catResults) . " 个 -->\n";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
foreach ($catResults as $cat) {
|
|||
|
|
if (!empty($cat['name']) && !empty($cat['slug'])) {
|
|||
|
|
$categories[] = array(
|
|||
|
|
'name' => $cat['name'],
|
|||
|
|
'slug' => $cat['slug'],
|
|||
|
|
'url' => self::buildUrl($options->categoryUrlFormat, $cat['slug'], 'category')
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} catch (Exception $e) {
|
|||
|
|
if ($isDebug) {
|
|||
|
|
$debugInfo .= "<!-- 分类查询错误: " . $e->getMessage() . " -->\n";
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 获取文章的标签
|
|||
|
|
$tags = array();
|
|||
|
|
if ($options->enableTag) {
|
|||
|
|
$tagQuery = $db->select('m.name', 'm.slug')
|
|||
|
|
->from('table.relationships AS r')
|
|||
|
|
->join('table.metas AS m', 'r.mid = m.mid')
|
|||
|
|
->where('r.cid = ?', $cid)
|
|||
|
|
->where('m.type = ?', 'tag');
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
$tagResults = $db->fetchAll($tagQuery);
|
|||
|
|
|
|||
|
|
if ($isDebug) {
|
|||
|
|
$debugInfo .= "<!-- 标签查询结果: " . count($tagResults) . " 个 -->\n";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
foreach ($tagResults as $tag) {
|
|||
|
|
if (!empty($tag['name']) && !empty($tag['slug'])) {
|
|||
|
|
$tags[] = array(
|
|||
|
|
'name' => $tag['name'],
|
|||
|
|
'slug' => $tag['slug'],
|
|||
|
|
'url' => self::buildUrl($options->tagUrlFormat, $tag['slug'], 'tag')
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} catch (Exception $e) {
|
|||
|
|
if ($isDebug) {
|
|||
|
|
$debugInfo .= "<!-- 标签查询错误: " . $e->getMessage() . " -->\n";
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 如果没有分类和标签,直接返回
|
|||
|
|
if (empty($categories) && empty($tags)) {
|
|||
|
|
if ($isDebug) {
|
|||
|
|
$debugInfo .= "<!-- 没有找到分类和标签 -->\n";
|
|||
|
|
}
|
|||
|
|
return $text;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 排除词汇处理
|
|||
|
|
$excludeWords = explode("\n", $options->excludeWords);
|
|||
|
|
$excludeWords = array_map('trim', $excludeWords);
|
|||
|
|
$excludeWords = array_filter($excludeWords);
|
|||
|
|
|
|||
|
|
// 构建替换数组
|
|||
|
|
$replacements = array();
|
|||
|
|
|
|||
|
|
// 处理分类
|
|||
|
|
foreach ($categories as $category) {
|
|||
|
|
$name = trim($category['name']);
|
|||
|
|
if (mb_strlen($name, 'UTF-8') < $options->minLength) continue;
|
|||
|
|
if (in_array($name, $excludeWords)) continue;
|
|||
|
|
|
|||
|
|
$target = $options->linkTarget ? ' target="' . $options->linkTarget . '"' : '';
|
|||
|
|
$replace = '<a href="' . $category['url'] . '" class="' . $options->categoryClass . '"' . $target . ' title="查看分类:' . htmlspecialchars($name) . '">' . $name . '</a>';
|
|||
|
|
$replacements[$name] = array(
|
|||
|
|
'replace' => $replace,
|
|||
|
|
'type' => 'category',
|
|||
|
|
'last_pos' => -$options->skipChars
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 处理标签
|
|||
|
|
foreach ($tags as $tag) {
|
|||
|
|
$name = trim($tag['name']);
|
|||
|
|
if (mb_strlen($name, 'UTF-8') < $options->minLength) continue;
|
|||
|
|
if (in_array($name, $excludeWords)) continue;
|
|||
|
|
|
|||
|
|
$target = $options->linkTarget ? ' target="' . $options->linkTarget . '"' : '';
|
|||
|
|
$replace = '<a href="' . $tag['url'] . '" class="' . $options->tagClass . '"' . $target . ' title="查看标签:' . htmlspecialchars($name) . '">' . $name . '</a>';
|
|||
|
|
$replacements[$name] = array(
|
|||
|
|
'replace' => $replace,
|
|||
|
|
'type' => 'tag',
|
|||
|
|
'last_pos' => -$options->skipChars
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (empty($replacements)) {
|
|||
|
|
if ($isDebug) {
|
|||
|
|
$debugInfo .= "<!-- 没有符合条件的替换词汇 -->\n";
|
|||
|
|
}
|
|||
|
|
return $text;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if ($isDebug) {
|
|||
|
|
$debugInfo .= "<!-- 可替换词汇: " . count($replacements) . " 个 -->\n";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 构建正则表达式
|
|||
|
|
$patterns = array();
|
|||
|
|
foreach (array_keys($replacements) as $word) {
|
|||
|
|
$patterns[] = preg_quote($word, '/');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$pattern = '/(' . implode('|', $patterns) . ')/u';
|
|||
|
|
|
|||
|
|
// 如果设置忽略已有链接
|
|||
|
|
if ($options->ignoreLinks) {
|
|||
|
|
// 先处理标题保护
|
|||
|
|
$text = self::protectHeaders($text, $options->excludeHeaders);
|
|||
|
|
|
|||
|
|
// 分割文本,保护已有链接、代码块等
|
|||
|
|
$splitPattern = '/(<img\s[^>]*>|!\[[^\]]*\]\[[^\]]*\]|!\[[^\]]*\]\([^)]*\)|<a\s[^>]*>.*?<\/a>|<code>.*?<\/code>|<pre>.*?<\/pre>|`[^`]*`)/is';
|
|||
|
|
|
|||
|
|
$parts = preg_split($splitPattern, $text, -1, PREG_SPLIT_DELIM_CAPTURE);
|
|||
|
|
|
|||
|
|
if ($parts === false) {
|
|||
|
|
// 分割失败,使用备用方法
|
|||
|
|
$processed = self::replaceWithWordInterval($text, $pattern, $replacements, $options->skipChars, 0);
|
|||
|
|
$text = $processed['text'];
|
|||
|
|
} else {
|
|||
|
|
$result = '';
|
|||
|
|
$currentPos = 0;
|
|||
|
|
|
|||
|
|
foreach ($parts as $i => $part) {
|
|||
|
|
// 奇数索引是匹配的部分(保持原样)
|
|||
|
|
if ($i % 2 == 1) {
|
|||
|
|
$result .= $part;
|
|||
|
|
// 更新当前位置
|
|||
|
|
$currentPos += mb_strlen(strip_tags($part), 'UTF-8');
|
|||
|
|
} else {
|
|||
|
|
// 偶数索引是非匹配文本,进行替换
|
|||
|
|
$processed = self::replaceWithWordInterval($part, $pattern, $replacements, $options->skipChars, $currentPos);
|
|||
|
|
$result .= $processed['text'];
|
|||
|
|
$currentPos += $processed['char_count'];
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$text = $result;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 恢复被保护的标题
|
|||
|
|
$text = self::restoreHeaders($text);
|
|||
|
|
} else {
|
|||
|
|
// 直接替换所有匹配(带间隔限制)
|
|||
|
|
$processed = self::replaceWithWordInterval($text, $pattern, $replacements, $options->skipChars, 0);
|
|||
|
|
$text = $processed['text'];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return $text;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 保护标题内容不被替换
|
|||
|
|
*/
|
|||
|
|
private static function protectHeaders($text, $excludeHeaders)
|
|||
|
|
{
|
|||
|
|
if (!is_array($excludeHeaders) || empty($excludeHeaders)) {
|
|||
|
|
return $text;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$headerTags = array();
|
|||
|
|
foreach ($excludeHeaders as $header) {
|
|||
|
|
$headerTags[] = $header;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 为每个标题标签创建唯一的占位符
|
|||
|
|
$placeholderMap = array();
|
|||
|
|
$placeholderIndex = 0;
|
|||
|
|
|
|||
|
|
foreach ($headerTags as $tag) {
|
|||
|
|
// 匹配标题标签及其内容
|
|||
|
|
$pattern = '/<(' . $tag . ')\b[^>]*>(.*?)<\/\1>/is';
|
|||
|
|
|
|||
|
|
$text = preg_replace_callback($pattern, function($matches) use (&$placeholderMap, &$placeholderIndex) {
|
|||
|
|
$placeholder = "<!-- AUTOTAGLINK_HEADER_PLACEHOLDER_" . $placeholderIndex . " -->";
|
|||
|
|
$placeholderMap[$placeholder] = $matches[0];
|
|||
|
|
$placeholderIndex++;
|
|||
|
|
return $placeholder;
|
|||
|
|
}, $text);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 将占位符映射存储在文本中(隐藏的注释)
|
|||
|
|
if (!empty($placeholderMap)) {
|
|||
|
|
$mapJson = json_encode($placeholderMap);
|
|||
|
|
$text = "<!-- AUTOTAGLINK_HEADER_MAP: " . base64_encode($mapJson) . " -->\n" . $text;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return $text;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 恢复被保护的标题内容
|
|||
|
|
*/
|
|||
|
|
private static function restoreHeaders($text)
|
|||
|
|
{
|
|||
|
|
// 查找并提取占位符映射
|
|||
|
|
if (preg_match('/<!-- AUTOTAGLINK_HEADER_MAP: ([^ ]+) -->/', $text, $mapMatch)) {
|
|||
|
|
$mapJson = base64_decode($mapMatch[1]);
|
|||
|
|
$placeholderMap = json_decode($mapJson, true);
|
|||
|
|
|
|||
|
|
if (is_array($placeholderMap)) {
|
|||
|
|
// 替换所有占位符回原始内容
|
|||
|
|
foreach ($placeholderMap as $placeholder => $original) {
|
|||
|
|
$text = str_replace($placeholder, $original, $text);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 移除映射注释
|
|||
|
|
$text = preg_replace('/<!-- AUTOTAGLINK_HEADER_MAP: [^ ]+ -->\n?/', '', $text);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return $text;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 带有相同词间隔限制的替换函数
|
|||
|
|
*/
|
|||
|
|
private static function replaceWithWordInterval($text, $pattern, &$replacements, $skipChars, $startPos)
|
|||
|
|
{
|
|||
|
|
$result = '';
|
|||
|
|
$offset = 0;
|
|||
|
|
$currentCharPos = $startPos;
|
|||
|
|
|
|||
|
|
// 使用preg_match_all获取所有匹配位置
|
|||
|
|
if (preg_match_all($pattern, $text, $matches, PREG_OFFSET_CAPTURE)) {
|
|||
|
|
foreach ($matches[0] as $match) {
|
|||
|
|
$matchedWord = $match[0];
|
|||
|
|
$matchPos = $match[1];
|
|||
|
|
$matchLength = strlen($matchedWord);
|
|||
|
|
|
|||
|
|
// 计算匹配位置在当前文本中的字符位置
|
|||
|
|
$textBeforeMatch = substr($text, 0, $matchPos);
|
|||
|
|
$charPosInText = mb_strlen($textBeforeMatch, 'UTF-8');
|
|||
|
|
|
|||
|
|
// 实际在全文中的字符位置
|
|||
|
|
$actualCharPos = $startPos + $charPosInText;
|
|||
|
|
|
|||
|
|
// 获取这个词的替换数据
|
|||
|
|
$wordData = &$replacements[$matchedWord];
|
|||
|
|
|
|||
|
|
// 检查是否需要跳过(同一个词距离上次出现太近)
|
|||
|
|
if ($actualCharPos - $wordData['last_pos'] < $skipChars) {
|
|||
|
|
// 跳过这个匹配,直接添加到结果
|
|||
|
|
if ($matchPos > $offset) {
|
|||
|
|
$result .= substr($text, $offset, $matchPos - $offset);
|
|||
|
|
}
|
|||
|
|
$result .= $matchedWord; // 原样输出,不替换
|
|||
|
|
$offset = $matchPos + $matchLength;
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 执行替换
|
|||
|
|
if ($matchPos > $offset) {
|
|||
|
|
$result .= substr($text, $offset, $matchPos - $offset);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$result .= $wordData['replace'];
|
|||
|
|
$offset = $matchPos + $matchLength;
|
|||
|
|
|
|||
|
|
// 更新这个词的最后出现位置
|
|||
|
|
$wordData['last_pos'] = $actualCharPos;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 添加剩余文本
|
|||
|
|
if ($offset < strlen($text)) {
|
|||
|
|
$result .= substr($text, $offset);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 计算处理后的文本字符数(不包括HTML标签)
|
|||
|
|
$charCount = mb_strlen(strip_tags($result), 'UTF-8');
|
|||
|
|
|
|||
|
|
return array(
|
|||
|
|
'text' => $result,
|
|||
|
|
'char_count' => $charCount
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 构建URL
|
|||
|
|
*/
|
|||
|
|
private static function buildUrl($format, $slug, $type = 'tag')
|
|||
|
|
{
|
|||
|
|
$options = Helper::options();
|
|||
|
|
$siteUrl = rtrim($options->siteUrl, '/');
|
|||
|
|
|
|||
|
|
if (empty($slug)) {
|
|||
|
|
return '#';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 处理格式中的变量
|
|||
|
|
$url = str_replace('{slug}', urlencode($slug), $format);
|
|||
|
|
|
|||
|
|
// 确保URL是完整的
|
|||
|
|
if (strpos($url, 'http') !== 0 && strpos($url, '//') !== 0) {
|
|||
|
|
if (strpos($url, '/') === 0) {
|
|||
|
|
$url = $siteUrl . $url;
|
|||
|
|
} else {
|
|||
|
|
$url = $siteUrl . '/' . $url;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return $url;
|
|||
|
|
}
|
|||
|
|
}
|