Files
AutoTagLink/Plugin.php
2026-02-23 17:11:42 +08:00

781 lines
27 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
}
}