package fqapi import ( "fanqie/internal/common" "fanqie/internal/config" "regexp" "strings" "github.com/PuerkitoBio/goquery" ) var ( parserLogger = common.NewLogger("content_parser", "INFO", "") ) // ContentParser 内容解析器 type ContentParser struct { cfg config.ContentConfig } // NewContentParser 创建内容解析器 func NewContentParser() *ContentParser { return &ContentParser{ cfg: getContentConfig(), } } // Parse 解析内容(URL参数优先于配置) func (p *ContentParser) Parse(content string, textMode bool, imageMode bool) string { // 1. 清理XML声明和DOCTYPE content = p.cleanXMLDeclaration(content) // 2. 根据配置移除章节标题 if p.cfg.RemoveChapterTitle { content = p.removeChapterTitle(content) } // 3. 根据配置处理图片描述 if p.cfg.RemoveImageDesc { content = p.removeImageDesc(content) } // 4. 根据参数移除图片 // imageMode=false 时移除(URL 参数与配置 remove_images 已在调用方合成:URL 优先) if !imageMode { content = p.removeImages(content) } // 5. 根据参数处理内容模式 // textMode=true 时提取纯文本(URL 参数与配置 remove_html_tags 已在调用方合成:URL 优先) if textMode { return p.extractText(content) } // 6. HTML模式处理 return p.processHTMLMode(content) } // cleanXMLDeclaration 清理XML声明 func (p *ContentParser) cleanXMLDeclaration(content string) string { content = regexp.MustCompile(`<\?xml[^>]*\?>`).ReplaceAllString(content, "") content = regexp.MustCompile(`]*>`).ReplaceAllString(content, "") return strings.TrimSpace(content) } // removeChapterTitle 移除章节标题 func (p *ContentParser) removeChapterTitle(content string) string { // 移除 h1 和 h2 标签中的章节标题 content = regexp.MustCompile(`(?si)]*class="[^"]*chapter[Tt]itle[^"]*"[^>]*>.*?`).ReplaceAllString(content, "") // 同时移除 标签 content = regexp.MustCompile(`(?si)<title>.*?`).ReplaceAllString(content, "") return content } // removeImageDesc 移除图片描述 func (p *ContentParser) removeImageDesc(content string) string { return regexp.MustCompile(`(?s)]*class="pictureDesc"[^>]*>.*?

`).ReplaceAllString(content, "") } // removeImages 移除图片 func (p *ContentParser) removeImages(content string) string { return regexp.MustCompile(`(?s)]*data-fanqie-type="image"[^>]*>.*?`).ReplaceAllString(content, "") } // extractText 提取文本 func (p *ContentParser) extractText(content string) string { doc, err := goquery.NewDocumentFromReader(strings.NewReader(content)) if err != nil { parserLogger.Error("解析HTML失败: " + err.Error()) return content } finalResult := make([]string, 0) // 查找body或article body := doc.Find("body") article := body.Find("article") var elements *goquery.Selection if article.Length() > 0 { elements = article } else { elements = body } // 遍历所有直接子元素 elements.Children().Each(func(i int, item *goquery.Selection) { // 检查是否是图片div dataType, _ := item.Attr("data-fanqie-type") isImageDiv := dataType == "image" if isImageDiv { // 这是图片div if !p.cfg.RemoveImages { // 保留完整的图片div结构 divHTML, _ := goquery.OuterHtml(item) // 解码HTML实体 divHTML = decodeHTMLEntities(divHTML) // 标准化img属性 divHTML = normalizeImgAttributes(divHTML) finalResult = append(finalResult, divHTML) } return } // 普通文本元素 if item.Children().Length() == 0 { text := strings.TrimSpace(item.Text()) if text != "" { finalResult = append(finalResult, text) } } else { // 递归提取文本 item.Find("p").Each(func(j int, p *goquery.Selection) { text := strings.TrimSpace(p.Text()) if text != "" { finalResult = append(finalResult, text) } }) } }) // 拼接结果 var result string // 根据配置决定段落间的换行符 separator := "\n" if p.cfg.DoubleNewline { separator = "\n\n" } if p.cfg.AddParagraphIndent && len(finalResult) > 0 { // 添加段落缩进(两个全角空格) result = "\u3000\u3000" + strings.Join(finalResult, separator+"\u3000\u3000") } else { // 不添加缩进 result = strings.Join(finalResult, separator) } // 根据配置决定是否移除空行 if p.cfg.RemoveBlankLines { result = removeBlankLines(result) } return result } // processHTMLMode 处理HTML模式 func (p *ContentParser) processHTMLMode(content string) string { // 标准化img属性 content = normalizeImgAttributes(content) // 解码HTML实体 content = decodeHTMLEntities(content) // 根据配置决定是否移除空行 if p.cfg.RemoveBlankLines { // 对HTML内容移除空的

标签 content = regexp.MustCompile(`(?s)]*>\s*

`).ReplaceAllString(content, "") // 移除多余的空白 content = regexp.MustCompile(`\n\s*\n\s*\n`).ReplaceAllString(content, "\n\n") } return content }