001/*
002 *  Copyright (c) 2023-2026, Agents-Flex (fuhai999@gmail.com).
003 *  <p>
004 *  Licensed under the Apache License, Version 2.0 (the "License");
005 *  you may not use this file except in compliance with the License.
006 *  You may obtain a copy of the License at
007 *  <p>
008 *  http://www.apache.org/licenses/LICENSE-2.0
009 *  <p>
010 *  Unless required by applicable law or agreed to in writing, software
011 *  distributed under the License is distributed on an "AS IS" BASIS,
012 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 *  See the License for the specific language governing permissions and
014 *  limitations under the License.
015 */
016package com.agentsflex.core.file2text.extractor.impl;
017
018import com.agentsflex.core.file2text.extractor.FileExtractor;
019import com.agentsflex.core.file2text.source.DocumentSource;
020import com.agentsflex.core.file2text.util.EncodingDetectUtil;
021import org.jsoup.Jsoup;
022import org.jsoup.nodes.Document;
023import org.jsoup.nodes.Element;
024import org.jsoup.nodes.Node;
025import org.jsoup.select.Elements;
026
027import java.io.IOException;
028import java.io.InputStream;
029import java.util.*;
030import java.util.concurrent.ConcurrentHashMap;
031import java.util.regex.Pattern;
032
033/**
034 * 增强版 HTML 文档提取器
035 * 支持可配置的噪音过滤规则(含中文网站常见广告)
036 */
037public class HtmlExtractor implements FileExtractor {
038
039    private static final Set<String> SUPPORTED_MIME_TYPES;
040    private static final Set<String> SUPPORTED_EXTENSIONS;
041
042    static {
043        Set<String> mimeTypes = new HashSet<>();
044        mimeTypes.add("text/html");
045        mimeTypes.add("application/xhtml+xml");
046        SUPPORTED_MIME_TYPES = Collections.unmodifiableSet(mimeTypes);
047
048        Set<String> extensions = new HashSet<>();
049        extensions.add("html");
050        extensions.add("htm");
051        extensions.add("xhtml");
052        extensions.add("mhtml");
053        SUPPORTED_EXTENSIONS = Collections.unmodifiableSet(extensions);
054    }
055
056    // 噪音过滤规则
057    private static final Set<String> DEFAULT_SELECTORS = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(
058        "script", "style", "noscript",
059        "nav", "header", "footer", "aside",
060        "iframe", "embed", "object", "video", "audio",
061        ".ads", ".advertisement", ".ad-", "ad:",
062        ".sidebar", ".sider", ".widget", ".module",
063        ".breadcrumb", ".pager", ".pagination",
064        ".share", ".social", ".like", ".subscribe",
065        ".cookie", ".consent", ".banner", ".popup",
066        "[data-ad]", "[data-testid*='ad']", "[data-type='advertisement']"
067    )));
068
069    private static final Set<String> CLASS_KEYWORDS = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(
070        "ad", "adv", "advertisement", "banner", "sponsor",
071        "sidebar", "sider", "widget", "module", "recommend",
072        "related", "similar", "youlike", "hot", "tuijian",
073        "share", "social", "like", "follow", "subscribe",
074        "cookie", "consent", "popup", "modal", "dialog",
075        "footer", "nav", "breadcrumb", "pager", "pagination"
076    )));
077
078    private static final Pattern ID_CLASS_PATTERN = Pattern.compile("(?i)\\b(" +
079        String.join("|",
080            "ad", "adv", "advertisement", "banner", "sponsor",
081            "sidebar", "sider", "widget", "module", "tuijian",
082            "share", "social", "like", "follow", "subscribe",
083            "cookie", "consent", "popup", "modal", "dialog",
084            "footer", "nav", "breadcrumb", "pager", "pagination"
085        ) + ")\\b"
086    );
087
088    // 可动态添加的自定义规则
089    private static final Set<String> CUSTOM_SELECTORS = ConcurrentHashMap.newKeySet();
090    private static final Set<String> CUSTOM_CLASS_KEYWORDS = ConcurrentHashMap.newKeySet();
091
092    /**
093     * 添加自定义噪音选择器(CSS 选择器)
094     */
095    public static void addCustomSelector(String selector) {
096        CUSTOM_SELECTORS.add(selector);
097    }
098
099    /**
100     * 添加自定义 class/id 关键词
101     */
102    public static void addCustomKeyword(String keyword) {
103        CUSTOM_CLASS_KEYWORDS.add(keyword.toLowerCase());
104    }
105
106    @Override
107    public boolean supports(DocumentSource source) {
108        String mimeType = source.getMimeType();
109        String fileName = source.getFileName();
110
111        if (mimeType != null && SUPPORTED_MIME_TYPES.contains(mimeType.toLowerCase())) {
112            return true;
113        }
114
115        if (fileName != null) {
116            String ext = getExtension(fileName);
117            if (ext != null && SUPPORTED_EXTENSIONS.contains(ext.toLowerCase())) {
118                return true;
119            }
120        }
121
122        return false;
123    }
124
125    @Override
126    public String extractText(DocumentSource source) throws IOException {
127        try (InputStream is = source.openStream()) {
128            String html = EncodingDetectUtil.readToString(is);
129            if (html.trim().isEmpty()) {
130                return "";
131            }
132
133            Document doc = Jsoup.parse(html);
134            doc.outputSettings().prettyPrint(false);
135
136            StringBuilder text = new StringBuilder();
137
138            extractTitle(doc, text);
139            extractBodyContent(doc, text);
140
141            return text.toString().trim();
142        } catch (Exception e) {
143            throw new IOException("Failed to parse HTML: " + e.getMessage(), e);
144        }
145    }
146
147    private void extractTitle(Document doc, StringBuilder text) {
148        Elements titleEl = doc.select("title");
149        if (!titleEl.isEmpty()) {
150            String title = titleEl.first().text().trim();
151            if (!title.isEmpty()) {
152                text.append(title).append("\n\n");
153            }
154        }
155    }
156
157    private void extractBodyContent(Document doc, StringBuilder text) {
158        Element body = doc.body();
159        if (body == null) return;
160
161        // 1. 移除已知噪音元素(CSS 选择器)
162        removeElementsBySelectors(body);
163
164        // 2. 移除 class/id 包含关键词的元素
165        removeElementsWithKeywords(body);
166
167        // 3. 遍历剩余节点
168        for (Node node : body.childNodes()) {
169            appendNodeText(node, text, 0);
170        }
171    }
172
173    /**
174     * 使用 CSS 选择器移除噪音
175     */
176    private void removeElementsBySelectors(Element body) {
177        List<String> allSelectors = new ArrayList<>(DEFAULT_SELECTORS);
178        allSelectors.addAll(CUSTOM_SELECTORS);
179
180        for (String selector : allSelectors) {
181            try {
182                body.select(selector).remove();
183            } catch (Exception e) {
184                // 忽略无效选择器
185            }
186        }
187    }
188
189    /**
190     * 移除 class 或 id 包含关键词的元素
191     */
192    private void removeElementsWithKeywords(Element body) {
193        // 合并默认和自定义关键词
194        Set<String> keywords = new HashSet<>(CLASS_KEYWORDS);
195        keywords.addAll(CUSTOM_CLASS_KEYWORDS);
196
197        // 使用 DFS 遍历所有元素
198        Deque<Element> stack = new ArrayDeque<>();
199        stack.push(body);
200
201        while (!stack.isEmpty()) {
202            Element el = stack.pop();
203
204            // 检查 class 或 id 是否匹配
205            String classes = el.className().toLowerCase();
206            String id = el.id().toLowerCase();
207
208            for (String keyword : keywords) {
209                if (classes.contains(keyword) || id.contains(keyword)) {
210                    el.remove(); // 移除整个元素
211                    break;
212                }
213            }
214
215            // 匹配正则模式
216            if (ID_CLASS_PATTERN.matcher(classes).find() ||
217                ID_CLASS_PATTERN.matcher(id).find()) {
218                el.remove();
219                continue;
220            }
221
222            // 将子元素加入栈
223            for (Element child : el.children()) {
224                stack.push(child);
225            }
226        }
227    }
228
229
230    private String repeat(String string, int times) {
231        if (times <= 0) return "";
232        StringBuilder sb = new StringBuilder();
233        for (int i = 0; i < times; i++) {
234            sb.append(string);
235        }
236        return sb.toString();
237    }
238
239    // 节点文本提取
240    private void appendNodeText(Node node, StringBuilder text, int level) {
241        if (node == null) return;
242
243        if (node instanceof org.jsoup.nodes.TextNode) {
244            String txt = ((org.jsoup.nodes.TextNode) node).text().trim();
245            if (!txt.isEmpty()) {
246                text.append(txt).append(" ");
247            }
248        } else if (node instanceof Element) {
249            Element el = (Element) node;
250            String tagName = el.tagName().toLowerCase();
251
252            if (tagName.matches("h[1-6]")) {
253                text.append("\n")
254                    .append(repeat("##", Integer.parseInt(tagName.substring(1))))
255                    .append(el.text().trim())
256                    .append("\n\n");
257            } else if (tagName.equals("p")) {
258                String paraText = el.text().trim();
259                if (!paraText.isEmpty()) {
260                    text.append(paraText).append("\n\n");
261                }
262            } else if (tagName.equals("li")) {
263                text.append("- ").append(el.text().trim()).append("\n");
264            } else if (tagName.equals("table")) {
265                extractTable(el, text);
266                text.append("\n");
267            } else if (tagName.equals("br")) {
268                text.append("\n");
269            } else if (tagName.equals("a")) {
270                String href = el.attr("href");
271                String textPart = el.text().trim();
272                if (!textPart.isEmpty()) {
273                    text.append(textPart);
274                    if (!href.isEmpty() && !href.equals(textPart)) {
275                        text.append(" [").append(href).append("]");
276                    }
277                    text.append(" ");
278                }
279            } else if (isBlockLevel(tagName)) {
280                text.append("\n");
281                for (Node child : el.childNodes()) {
282                    appendNodeText(child, text, level + 1);
283                }
284                text.append("\n");
285            } else {
286                for (Node child : el.childNodes()) {
287                    appendNodeText(child, text, level);
288                }
289            }
290        }
291    }
292
293    private boolean isBlockLevel(String tagName) {
294        Set<String> blockTags = new HashSet<>(Arrays.asList(
295            "div", "p", "h1", "h2", "h3", "h4", "h5", "h6",
296            "ul", "ol", "li", "table", "tr", "td", "th",
297            "blockquote", "pre", "section", "article", "figure"
298        ));
299        return blockTags.contains(tagName);
300    }
301
302    private void extractTable(Element table, StringBuilder text) {
303        text.append("[Table Start]\n");
304        Elements rows = table.select("tr");
305        for (Element row : rows) {
306            Elements cells = row.select("td, th");
307            List<String> cellTexts = new ArrayList<>();
308            for (Element cell : cells) {
309                cellTexts.add(cell.text().trim());
310            }
311            text.append(String.join(" | ", cellTexts)).append("\n");
312        }
313        text.append("[Table End]\n");
314    }
315
316
317    @Override
318    public int getOrder() {
319        return 12;
320    }
321
322    private String getExtension(String fileName) {
323        if (fileName == null || !fileName.contains(".")) return null;
324        int lastDot = fileName.lastIndexOf('.');
325        return fileName.substring(lastDot + 1);
326    }
327}