- 新增 /rank 免签名榜单接口(多域名容错)、/bookshelf 与 /read-history(sessionid 登录态,书架支持分组过滤) - 搜索重写:翻页透传上游 passback/search_id/next_offset/corrected_query 并按 book_id 去重,15~20 位纯数字关键词直查详情(ID 直搜) - 删除 /login/check 登录校验端点(login.go、路由与文档同步移除,sessionid 说明改为直接供书架/历史使用) - 正文链路重构:singleflight 合并同章节并发回源防击穿,缓存与解析解耦统一在出口处理,decodeContentBody 按 crypt_status 分支并兜底 500003 明文 - 缓存层:SQL 持久层 TTL 与业务 TTL 解耦(不可变内容过期后由 SQL 回填),动态短缓存只走 Redis,withCache 合并并发 miss - 密钥池:KeyTs 随正文请求上传降低密钥不匹配重试,密钥超 24 小时异步刷新并防刷新风暴,last_used_at 批量落库 - 签名补 X-Helios(与 X-Ladon 同值,对齐书源实现);设备注册版本号统一走 ClientVersionCode/Name - 分类 landing 透传新版标签 selected_items;multi-detail 批量详情供书架/历史复用 - README 与 openapi.yaml 补充新接口文档
205 lines
5.6 KiB
Go
205 lines
5.6 KiB
Go
package fqapi
|
|
|
|
import (
|
|
"fanqie/internal/common"
|
|
"fanqie/internal/config"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/PuerkitoBio/goquery"
|
|
)
|
|
|
|
// imgSrcRe 匹配 img 标签的 src 属性
|
|
var imgSrcRe = regexp.MustCompile(`(?i)(<img[^>]*\ssrc=")([^"]*)(")`)
|
|
|
|
// permanentizeImgSrc 将正文插图链接替换为永久 CDN 链接(原链接带临时参数会过期)
|
|
func permanentizeImgSrc(html string) string {
|
|
return imgSrcRe.ReplaceAllStringFunc(html, func(m string) string {
|
|
parts := imgSrcRe.FindStringSubmatch(m)
|
|
if len(parts) < 3 || parts[2] == "" {
|
|
return m
|
|
}
|
|
return parts[1] + NormalizeNovelImageURL(parts[2]) + parts[3]
|
|
})
|
|
}
|
|
|
|
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(`<!DOCTYPE[^>]*>`).ReplaceAllString(content, "")
|
|
return strings.TrimSpace(content)
|
|
}
|
|
|
|
// removeChapterTitle 移除章节标题
|
|
func (p *ContentParser) removeChapterTitle(content string) string {
|
|
// 移除 h1 和 h2 标签中的章节标题
|
|
content = regexp.MustCompile(`(?si)<h[12][^>]*class="[^"]*chapter[Tt]itle[^"]*"[^>]*>.*?</h[12]>`).ReplaceAllString(content, "")
|
|
// 同时移除 <title> 标签
|
|
content = regexp.MustCompile(`(?si)<title>.*?</title>`).ReplaceAllString(content, "")
|
|
return content
|
|
}
|
|
|
|
// removeImageDesc 移除图片描述
|
|
func (p *ContentParser) removeImageDesc(content string) string {
|
|
return regexp.MustCompile(`(?s)<p[^>]*class="pictureDesc"[^>]*>.*?</p>`).ReplaceAllString(content, "")
|
|
}
|
|
|
|
// removeImages 移除图片
|
|
func (p *ContentParser) removeImages(content string) string {
|
|
return regexp.MustCompile(`(?s)<div[^>]*data-fanqie-type="image"[^>]*>.*?</div>`).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)
|
|
// 插图链接永久化
|
|
divHTML = permanentizeImgSrc(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)
|
|
|
|
// 插图链接永久化(原链接带临时参数会过期)
|
|
content = permanentizeImgSrc(content)
|
|
|
|
// 根据配置决定是否移除空行
|
|
if p.cfg.RemoveBlankLines {
|
|
// 对HTML内容移除空的<p>标签
|
|
content = regexp.MustCompile(`(?s)<p[^>]*>\s*</p>`).ReplaceAllString(content, "")
|
|
// 移除多余的空白
|
|
content = regexp.MustCompile(`\n\s*\n\s*\n`).ReplaceAllString(content, "\n\n")
|
|
}
|
|
|
|
return content
|
|
}
|