Files
AutoTagLink/Plugin.php

781 lines
27 KiB
PHP
Raw Permalink Normal View History

2026-02-23 17:11:42 +08:00
<?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;
}
}