commit 02020df18e3568e5b89449db9dd8ab157fbf8e71 Author: XIGE <710062962@qq.com> Date: Mon Feb 23 17:11:42 2026 +0800 1.0 diff --git a/Plugin.php b/Plugin.php new file mode 100644 index 0000000..d89310a --- /dev/null +++ b/Plugin.php @@ -0,0 +1,781 @@ +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
' + ); + $form->addInput($categoryUrlFormat); + + $tagUrlFormat = new Typecho_Widget_Helper_Form_Element_Text( + 'tagUrlFormat', + NULL, + 'tag-{slug}.html', + '标签链接格式', + '标签链接格式,可用变量:{slug}标签slug
' + ); + $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', + '相同词识别间隔字数', + '识别到同一个词后,跳过接下来的字数再识别这个词(单位:字符)
只限制同一个词的密集出现' + ); + $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]", + '排除特定内容起始标记', + '每行一个起始标记,这些标记开始的内容不会被处理
示例:
[amap_marker] - 排除地图卡片
[code] - 排除代码块
[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 "\n"; + } + } + + /** + * 获取默认样式 + */ + private static function getDefaultStyle() + { + return <<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 .= "\n"; + $debugInfo .= "\n"; + $debugInfo .= "\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 .= "\n"; + foreach ($excludeMarkers as $marker) { + $debugInfo .= "\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 .= "\n"; + foreach ($protectedBlocks as $index => $block) { + $preview = substr($block['content'], 0, 50); + $debugInfo .= "\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 = ""; + $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 .= "\n"; + $amapCount = substr_count($result, '[amap_marker]'); + $debugInfo .= "\n"; + $debugInfo .= "\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 .= "\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 .= "\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 .= "\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 .= "\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 = '' . $name . ''; + $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 = '' . $name . ''; + $replacements[$name] = array( + 'replace' => $replace, + 'type' => 'tag', + 'last_pos' => -$options->skipChars + ); + } + + if (empty($replacements)) { + if ($isDebug) { + $debugInfo .= "\n"; + } + return $text; + } + + if ($isDebug) { + $debugInfo .= "\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 = '/(]*>|!\[[^\]]*\]\[[^\]]*\]|!\[[^\]]*\]\([^)]*\)|]*>.*?<\/a>|.*?<\/code>|
.*?<\/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 = "";
+                $placeholderMap[$placeholder] = $matches[0];
+                $placeholderIndex++;
+                return $placeholder;
+            }, $text);
+        }
+        
+        // 将占位符映射存储在文本中(隐藏的注释)
+        if (!empty($placeholderMap)) {
+            $mapJson = json_encode($placeholderMap);
+            $text = "\n" . $text;
+        }
+        
+        return $text;
+    }
+    
+    /**
+     * 恢复被保护的标题内容
+     */
+    private static function restoreHeaders($text)
+    {
+        // 查找并提取占位符映射
+        if (preg_match('//', $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('/\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;
+    }
+}
\ No newline at end of file