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;
    }
}