Forráskód Böngészése

feat:列表采集标题清洗并支持PDF生成

yangyi 1 hónapja
szülő
commit
cd3f785b45

+ 66 - 1
src/main/java/space/anyi/cleaner/DetailCleaner.java

@@ -1,10 +1,17 @@
 package space.anyi.cleaner;
 
+import com.openhtmltopdf.outputdevice.helper.BaseRendererBuilder;
+import com.openhtmltopdf.pdfboxout.PdfRendererBuilder;
 import org.jsoup.Jsoup;
 import org.jsoup.nodes.Document;
 import org.jsoup.nodes.Element;
 import org.jsoup.select.Elements;
 
+import java.io.File;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.LinkedHashSet;
 import java.util.List;
@@ -13,14 +20,23 @@ import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
 /**
- * 详情页清洗与记者/编辑解析工具(静态方法)。
+ * 详情页清洗、PDF 生成与记者/编辑解析工具(静态方法)。
  *
  * <p>清洗:严格按需求移除 meta/script、id 为 hidden-box 的元素,并在 div.container
  * 的直接子 div 中仅保留 .article-title、.not-exist-media-leader、.article-content。
+ * 生成:清洗后 HTML 经 openhtmltopdf 渲染为 PDF,思源宋体挂到页面 CSS 实际使用的
+ * 字体族名下保证中文有字形可渲染。
  * 解析:从清洗后正文纯文本按子句正则提取文字/图片记者、通讯员与编辑。</p>
  */
 public class DetailCleaner {
 
+    /** 思源宋体(常规/粗体)文件路径,用于 PDF 中文字形渲染 */
+    private static final String SERIF_REGULAR = "/usr/share/fonts/yangyi/SourceHanSerifCN-Regular.ttf";
+    private static final String SERIF_BOLD = "/usr/share/fonts/yangyi/SourceHanSerifCN-Bold.ttf";
+    /** 思源宋体挂载的字体族名(覆盖页面 CSS 可能用到的中文族) */
+    private static final String[] SERIF_FAMILIES = {"CJK", "SimHei", "Microsoft YaHei", "微软雅黑",
+            "FZBiaoYaSong-GBK_YS", "sans-serif", "serif", "Arial"};
+
     /** 名称段捕获的终止标记:后续子句前缀或行尾 */
     private static final String STOP = "(?=文、|文/|图、|图/|视频/|通讯员[::]?|(?:广州日报)?新花城编辑[::]|编辑[::]|实习生[::]|$)";
     /** 记者子句:文、图/…记者:X 同时计入图文,文/ 只计文字,图/ 只计图片 */
@@ -72,6 +88,55 @@ public class DetailCleaner {
         return result.html();
     }
 
+    /**
+     * 将详情页 HTML 渲染为 PDF。
+     *
+     * <p>内部做防御性清洗(移除 video/script、head 仅留样式表、body 仅留
+     * 文章三个区块),并在 html 上追加 font-family 兜底。页面 CSS 未给 body 声明
+     * font-family,PDF 引擎需显式继承;思源宋体注册到页面 CSS 可能用到的字体族名下,
+     * 保证中文有字形可渲染。</p>
+     *
+     * @param html    详情页 HTML(通常为 {@link #clean(String)} 的产物)
+     * @param outFile PDF 输出路径(父目录不存在时自动创建)
+     * @throws IOException 写入 PDF 失败
+     */
+    public static void html2pdf(String html, Path outFile) throws IOException {
+        Document doc = Jsoup.parse(html);
+        doc.select("video, script").remove();
+        doc.head().children().not("link[rel='stylesheet']").remove();
+        doc.body().children().not("div.article-title, div.not-exist-media-leader, div.article-content").remove();
+
+        Element htmlEl = doc.selectFirst("html");
+        htmlEl.attr("style", htmlEl.attr("style") + ";font-family:'Microsoft YaHei',sans-serif");
+
+        if (outFile.toAbsolutePath().getParent() != null) {
+            Files.createDirectories(outFile.toAbsolutePath().getParent());
+        }
+        try (OutputStream os = Files.newOutputStream(outFile)) {
+            PdfRendererBuilder builder = new PdfRendererBuilder();
+            File regular = new File(SERIF_REGULAR);
+            File bold = new File(SERIF_BOLD);
+            for (String family : SERIF_FAMILIES) {
+                builder.useFont(regular, family, 400, BaseRendererBuilder.FontStyle.NORMAL, true);
+                builder.useFont(bold, family, 700, BaseRendererBuilder.FontStyle.NORMAL, true);
+            }
+            doc.outputSettings().syntax(Document.OutputSettings.Syntax.xml);
+            // jsoup XML 语法会把 &nbsp; 原样输出,而 openhtmltopdf 的 XML 解析器不认该实体,
+            // 需还原为 Unicode 不间断空格
+            builder.withHtmlContent(doc.html().replace("&nbsp;", "\u00A0"), "https://www.gz-cmc.com/");
+            builder.toStream(os);
+            builder.run();
+        }
+    }
+
+    /**
+     * 标题清洗,标题中可能包含html标签,需要去除
+     * @param title
+     * @return
+     */
+    public static String titleClean(String title){
+        return Jsoup.parse(title).text();
+    }
     /**
      * 取清洗后正文(.article-content)的纯文本。
      *

+ 2 - 1
src/main/java/space/anyi/service/ListCollectService.java

@@ -1,6 +1,7 @@
 package space.anyi.service;
 
 import space.anyi.client.GzCmcClient;
+import space.anyi.cleaner.DetailCleaner;
 import space.anyi.config.Config;
 import space.anyi.dto.ChannelAllContentsResponse;
 import space.anyi.dto.NewsItem;
@@ -108,7 +109,7 @@ public class ListCollectService {
         }
         News news = new News();
         news.setId(data.getId());
-        news.setTitle(data.getTitle());
+        news.setTitle(DetailCleaner.titleClean(data.getTitle()));
         news.setUrl(data.getUrl());
         news.setPublishTime(publishTime);
         news.setChannelId(data.getChannelId());