|
|
@@ -0,0 +1,181 @@
|
|
|
+package space.anyi.cleaner;
|
|
|
+
|
|
|
+import org.jsoup.Jsoup;
|
|
|
+import org.jsoup.nodes.Document;
|
|
|
+import org.jsoup.nodes.Element;
|
|
|
+import org.jsoup.select.Elements;
|
|
|
+
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.LinkedHashSet;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Set;
|
|
|
+import java.util.regex.Matcher;
|
|
|
+import java.util.regex.Pattern;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 详情页清洗与记者/编辑解析工具(静态方法)。
|
|
|
+ *
|
|
|
+ * <p>清洗:严格按需求移除 meta/script、id 为 hidden-box 的元素,并在 div.container
|
|
|
+ * 的直接子 div 中仅保留 .article-title、.not-exist-media-leader、.article-content。
|
|
|
+ * 解析:从清洗后正文纯文本按子句正则提取文字/图片记者、通讯员与编辑。</p>
|
|
|
+ */
|
|
|
+public class DetailCleaner {
|
|
|
+
|
|
|
+ /** 名称段捕获的终止标记:后续子句前缀或行尾 */
|
|
|
+ private static final String STOP = "(?=文、|文/|图、|图/|视频/|通讯员[::]?|(?:广州日报)?新花城编辑[::]|编辑[::]|实习生[::]|$)";
|
|
|
+ /** 记者子句:文、图/…记者:X 同时计入图文,文/ 只计文字,图/ 只计图片 */
|
|
|
+ private static final Pattern REPORTER = Pattern.compile("(文、图|文|图)/[^::]*?记者[::]\\s*(.*?)" + STOP);
|
|
|
+ /** 通讯员子句(冒号可省略,如 通讯员 张三) */
|
|
|
+ private static final Pattern CORRESPONDENT = Pattern.compile("通讯员[::]?\\s*(.*?)" + STOP);
|
|
|
+ /** 编辑子句(新花城编辑:X) */
|
|
|
+ private static final Pattern EDITOR = Pattern.compile("(?:广州日报)?新花城编辑[::]\\s*(.*?)" + STOP);
|
|
|
+ /** 编辑子句兜底(仅 编辑:X) */
|
|
|
+ private static final Pattern EDITOR_FALLBACK = Pattern.compile("编辑[::]\\s*(.*?)" + STOP);
|
|
|
+
|
|
|
+ private DetailCleaner() {
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 清洗详情页 HTML。
|
|
|
+ *
|
|
|
+ * <ol>
|
|
|
+ * <li>移除 meta、script 标签</li>
|
|
|
+ * <li>移除 id 或 class 为 hidden-box 的元素</li>
|
|
|
+ * <li>div.container 直接子 div 仅保留 .article-title、.not-exist-media-leader、.article-content</li>
|
|
|
+ * </ol>
|
|
|
+ *
|
|
|
+ * @param html 详情页原始 HTML
|
|
|
+ * @return 清洗后的完整 HTML
|
|
|
+ */
|
|
|
+ public static String clean(String html) {
|
|
|
+ Document doc = Jsoup.parse(html);
|
|
|
+ doc.select("meta, script").remove();
|
|
|
+ doc.select("#hidden-box").remove();
|
|
|
+ doc.select("div.hidden-box").remove();
|
|
|
+ for (Element container : doc.select("div.container")) {
|
|
|
+ Elements children = new Elements();
|
|
|
+ container.children().forEach(children::add);
|
|
|
+ for (Element child : children) {
|
|
|
+ boolean keep = child.is("div")
|
|
|
+ && (child.hasClass("article-title")
|
|
|
+ || child.hasClass("not-exist-media-leader")
|
|
|
+ || child.hasClass("article-content"));
|
|
|
+ if (!keep) {
|
|
|
+ child.remove();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return doc.html();
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 取清洗后正文(.article-content)的纯文本。
|
|
|
+ *
|
|
|
+ * @param doc 清洗后的文档
|
|
|
+ * @return 正文纯文本;无正文元素时返回空串
|
|
|
+ */
|
|
|
+ public static String articleContentText(Document doc) {
|
|
|
+ Element content = doc.selectFirst("div.article-content");
|
|
|
+ return content == null ? "" : content.text();
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 从正文纯文本解析记者/通讯员/编辑。
|
|
|
+ *
|
|
|
+ * @param text 清洗后 .article-content 的纯文本
|
|
|
+ * @return 解析结果(编辑 + 三类人员列表,无则空列表/空串)
|
|
|
+ */
|
|
|
+ public static PersonInfo parsePersons(String text) {
|
|
|
+ if (text == null || text.isBlank()) {
|
|
|
+ return new PersonInfo(null, List.of(), List.of(), List.of());
|
|
|
+ }
|
|
|
+ Set<String> textReporters = new LinkedHashSet<>();
|
|
|
+ Set<String> imageReporters = new LinkedHashSet<>();
|
|
|
+ Set<String> correspondents = new LinkedHashSet<>();
|
|
|
+
|
|
|
+ Matcher rm = REPORTER.matcher(text);
|
|
|
+ while (rm.find()) {
|
|
|
+ String prefix = rm.group(1);
|
|
|
+ List<String> names = splitNames(rm.group(2));
|
|
|
+ if (prefix.contains("图")) {
|
|
|
+ imageReporters.addAll(names);
|
|
|
+ }
|
|
|
+ if (prefix.contains("文")) {
|
|
|
+ textReporters.addAll(names);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ Matcher cm = CORRESPONDENT.matcher(text);
|
|
|
+ while (cm.find()) {
|
|
|
+ correspondents.addAll(splitNames(cm.group(1)));
|
|
|
+ }
|
|
|
+
|
|
|
+ String editor = null;
|
|
|
+ Matcher em = EDITOR.matcher(text);
|
|
|
+ if (em.find()) {
|
|
|
+ editor = firstValidName(em.group(1));
|
|
|
+ } else {
|
|
|
+ Matcher efm = EDITOR_FALLBACK.matcher(text);
|
|
|
+ if (efm.find()) {
|
|
|
+ editor = firstValidName(efm.group(1));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return new PersonInfo(editor, List.copyOf(textReporters), List.copyOf(imageReporters), List.copyOf(correspondents));
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 将名称段拆分为人名列表。
|
|
|
+ *
|
|
|
+ * <p>按 、/,/,/空白 分隔;丢弃含冒号或斜杠的杂项子句(如 实习生:X、图片由受访者提供、
|
|
|
+ * 视频/…记者),剔除 供图/供稿/提供/摄 等标注词,并剥离人名尾部的(…)括注。</p>
|
|
|
+ *
|
|
|
+ * @param segment 一个子句捕获的原始名称段
|
|
|
+ * @return 清洗后的人名列表
|
|
|
+ */
|
|
|
+ private static List<String> splitNames(String segment) {
|
|
|
+ List<String> names = new ArrayList<>();
|
|
|
+ for (String tok : segment.split("[、,,\\s]+")) {
|
|
|
+ String t = tok.trim();
|
|
|
+ if (t.isEmpty()) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if (t.contains(":") || t.contains(":") || t.contains("/")) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if (t.contains("供图") || t.contains("供稿") || t.contains("提供") || t.contains("摄")) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ int paren = t.indexOf('(');
|
|
|
+ if (paren > 0) {
|
|
|
+ t = t.substring(0, paren).trim();
|
|
|
+ }
|
|
|
+ if (!t.isEmpty()) {
|
|
|
+ names.add(t);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return names;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 取名称段的第一个合法人名(编辑字段使用)。
|
|
|
+ *
|
|
|
+ * @param segment 原始名称段
|
|
|
+ * @return 第一个人名,无则返回 null
|
|
|
+ */
|
|
|
+ private static String firstValidName(String segment) {
|
|
|
+ List<String> names = splitNames(segment);
|
|
|
+ return names.isEmpty() ? null : names.get(0);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 解析结果:编辑 + 文字记者/图片记者/通讯员。
|
|
|
+ *
|
|
|
+ * @param editor 编辑
|
|
|
+ * @param textReporters 文字记者
|
|
|
+ * @param imageReporters 图片记者
|
|
|
+ * @param correspondents 通讯员
|
|
|
+ */
|
|
|
+ public record PersonInfo(String editor, List<String> textReporters, List<String> imageReporters,
|
|
|
+ List<String> correspondents) {
|
|
|
+ }
|
|
|
+}
|