mirror of
https://github.com/m1ngsama/TUT.git
synced 2025-12-24 10:51:46 +00:00
feat: Add HTML parser and content extraction
Implement HTML parser for extracting readable content: - Parse HTML structure (headings, paragraphs, lists, links) - Extract and decode HTML entities - Smart content area detection (article, main, body) - Relative URL to absolute URL conversion - Support for both absolute and relative paths - Filter out scripts, styles, and non-content elements The parser uses regex-based extraction optimized for text-heavy websites and documentation.
This commit is contained in:
parent
a2a2aa5126
commit
2fedcebc25
2 changed files with 351 additions and 0 deletions
294
src/html_parser.cpp
Normal file
294
src/html_parser.cpp
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
#include "html_parser.h"
|
||||
#include <regex>
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <sstream>
|
||||
|
||||
class HtmlParser::Impl {
|
||||
public:
|
||||
bool keep_code_blocks = true;
|
||||
bool keep_lists = true;
|
||||
|
||||
// 简单的HTML标签清理
|
||||
std::string remove_tags(const std::string& html) {
|
||||
std::string result;
|
||||
bool in_tag = false;
|
||||
for (char c : html) {
|
||||
if (c == '<') {
|
||||
in_tag = true;
|
||||
} else if (c == '>') {
|
||||
in_tag = false;
|
||||
} else if (!in_tag) {
|
||||
result += c;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// 解码HTML实体
|
||||
std::string decode_html_entities(const std::string& text) {
|
||||
std::string result = text;
|
||||
|
||||
// 常见HTML实体
|
||||
const std::vector<std::pair<std::string, std::string>> entities = {
|
||||
{" ", " "},
|
||||
{"&", "&"},
|
||||
{"<", "<"},
|
||||
{">", ">"},
|
||||
{""", "\""},
|
||||
{"'", "'"},
|
||||
{"'", "'"},
|
||||
{"—", "\u2014"},
|
||||
{"–", "\u2013"},
|
||||
{"…", "..."},
|
||||
{"“", "\u201C"},
|
||||
{"”", "\u201D"},
|
||||
{"‘", "\u2018"},
|
||||
{"’", "\u2019"}
|
||||
};
|
||||
|
||||
for (const auto& [entity, replacement] : entities) {
|
||||
size_t pos = 0;
|
||||
while ((pos = result.find(entity, pos)) != std::string::npos) {
|
||||
result.replace(pos, entity.length(), replacement);
|
||||
pos += replacement.length();
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// 提取标签内容
|
||||
std::string extract_tag_content(const std::string& html, const std::string& tag) {
|
||||
std::regex tag_regex("<" + tag + "[^>]*>([\\s\\S]*?)</" + tag + ">",
|
||||
std::regex::icase);
|
||||
std::smatch match;
|
||||
if (std::regex_search(html, match, tag_regex)) {
|
||||
return match[1].str();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// 提取所有匹配的标签
|
||||
std::vector<std::string> extract_all_tags(const std::string& html, const std::string& tag) {
|
||||
std::vector<std::string> results;
|
||||
std::regex tag_regex("<" + tag + "[^>]*>([\\s\\S]*?)</" + tag + ">",
|
||||
std::regex::icase);
|
||||
|
||||
auto begin = std::sregex_iterator(html.begin(), html.end(), tag_regex);
|
||||
auto end = std::sregex_iterator();
|
||||
|
||||
for (std::sregex_iterator i = begin; i != end; ++i) {
|
||||
std::smatch match = *i;
|
||||
results.push_back(match[1].str());
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// 提取链接
|
||||
std::vector<Link> extract_links(const std::string& html, const std::string& base_url) {
|
||||
std::vector<Link> links;
|
||||
std::regex link_regex(R"(<a\s+[^>]*href\s*=\s*["']([^"']*)["'][^>]*>([\s\S]*?)</a>)",
|
||||
std::regex::icase);
|
||||
|
||||
auto begin = std::sregex_iterator(html.begin(), html.end(), link_regex);
|
||||
auto end = std::sregex_iterator();
|
||||
|
||||
int position = 0;
|
||||
for (std::sregex_iterator i = begin; i != end; ++i) {
|
||||
std::smatch match = *i;
|
||||
Link link;
|
||||
link.url = match[1].str();
|
||||
link.text = decode_html_entities(remove_tags(match[2].str()));
|
||||
link.position = position++;
|
||||
|
||||
// 处理相对URL
|
||||
if (!link.url.empty() && link.url[0] != '#') {
|
||||
// 如果是相对路径
|
||||
if (link.url.find("://") == std::string::npos) {
|
||||
// 提取base_url的协议和域名
|
||||
std::regex base_regex(R"((https?://[^/]+)(/.*)?)", std::regex::icase);
|
||||
std::smatch base_match;
|
||||
if (std::regex_match(base_url, base_match, base_regex)) {
|
||||
std::string base_domain = base_match[1].str();
|
||||
std::string base_path = base_match[2].str();
|
||||
|
||||
if (link.url[0] == '/') {
|
||||
// 绝对路径(从根目录开始)
|
||||
link.url = base_domain + link.url;
|
||||
} else {
|
||||
// 相对路径
|
||||
// 获取当前页面的目录
|
||||
size_t last_slash = base_path.rfind('/');
|
||||
std::string current_dir = (last_slash != std::string::npos)
|
||||
? base_path.substr(0, last_slash + 1)
|
||||
: "/";
|
||||
link.url = base_domain + current_dir + link.url;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤空链接文本
|
||||
if (!link.text.empty()) {
|
||||
links.push_back(link);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return links;
|
||||
}
|
||||
|
||||
// 清理空白字符
|
||||
std::string trim(const std::string& str) {
|
||||
auto start = str.begin();
|
||||
while (start != str.end() && std::isspace(*start)) {
|
||||
++start;
|
||||
}
|
||||
|
||||
auto end = str.end();
|
||||
do {
|
||||
--end;
|
||||
} while (std::distance(start, end) > 0 && std::isspace(*end));
|
||||
|
||||
return std::string(start, end + 1);
|
||||
}
|
||||
|
||||
// 移除脚本和样式
|
||||
std::string remove_scripts_and_styles(const std::string& html) {
|
||||
std::string result = html;
|
||||
|
||||
// 移除script标签
|
||||
result = std::regex_replace(result,
|
||||
std::regex("<script[^>]*>[\\s\\S]*?</script>", std::regex::icase),
|
||||
"");
|
||||
|
||||
// 移除style标签
|
||||
result = std::regex_replace(result,
|
||||
std::regex("<style[^>]*>[\\s\\S]*?</style>", std::regex::icase),
|
||||
"");
|
||||
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
HtmlParser::HtmlParser() : pImpl(std::make_unique<Impl>()) {}
|
||||
|
||||
HtmlParser::~HtmlParser() = default;
|
||||
|
||||
ParsedDocument HtmlParser::parse(const std::string& html, const std::string& base_url) {
|
||||
ParsedDocument doc;
|
||||
doc.url = base_url;
|
||||
|
||||
// 清理HTML
|
||||
std::string clean_html = pImpl->remove_scripts_and_styles(html);
|
||||
|
||||
// 提取标题
|
||||
std::string title_content = pImpl->extract_tag_content(clean_html, "title");
|
||||
doc.title = pImpl->decode_html_entities(pImpl->trim(pImpl->remove_tags(title_content)));
|
||||
|
||||
if (doc.title.empty()) {
|
||||
std::string h1_content = pImpl->extract_tag_content(clean_html, "h1");
|
||||
doc.title = pImpl->decode_html_entities(pImpl->trim(pImpl->remove_tags(h1_content)));
|
||||
}
|
||||
|
||||
// 提取主要内容区域(article, main, 或 body)
|
||||
std::string main_content = pImpl->extract_tag_content(clean_html, "article");
|
||||
if (main_content.empty()) {
|
||||
main_content = pImpl->extract_tag_content(clean_html, "main");
|
||||
}
|
||||
if (main_content.empty()) {
|
||||
main_content = pImpl->extract_tag_content(clean_html, "body");
|
||||
}
|
||||
if (main_content.empty()) {
|
||||
main_content = clean_html;
|
||||
}
|
||||
|
||||
// 提取链接
|
||||
doc.links = pImpl->extract_links(main_content, base_url);
|
||||
|
||||
// 解析标题
|
||||
for (int level = 1; level <= 6; ++level) {
|
||||
std::string tag = "h" + std::to_string(level);
|
||||
auto headings = pImpl->extract_all_tags(main_content, tag);
|
||||
for (const auto& heading : headings) {
|
||||
ContentElement elem;
|
||||
elem.type = (level == 1) ? ElementType::HEADING1 :
|
||||
(level == 2) ? ElementType::HEADING2 : ElementType::HEADING3;
|
||||
elem.text = pImpl->decode_html_entities(pImpl->trim(pImpl->remove_tags(heading)));
|
||||
elem.level = level;
|
||||
if (!elem.text.empty()) {
|
||||
doc.elements.push_back(elem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 解析列表项
|
||||
if (pImpl->keep_lists) {
|
||||
auto list_items = pImpl->extract_all_tags(main_content, "li");
|
||||
for (const auto& item : list_items) {
|
||||
std::string text = pImpl->decode_html_entities(pImpl->trim(pImpl->remove_tags(item)));
|
||||
if (!text.empty() && text.length() > 1) {
|
||||
ContentElement elem;
|
||||
elem.type = ElementType::LIST_ITEM;
|
||||
elem.text = text;
|
||||
doc.elements.push_back(elem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 解析段落
|
||||
auto paragraphs = pImpl->extract_all_tags(main_content, "p");
|
||||
for (const auto& para : paragraphs) {
|
||||
std::string text = pImpl->decode_html_entities(pImpl->trim(pImpl->remove_tags(para)));
|
||||
if (!text.empty() && text.length() > 1) {
|
||||
ContentElement elem;
|
||||
elem.type = ElementType::PARAGRAPH;
|
||||
elem.text = text;
|
||||
doc.elements.push_back(elem);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果内容很少,尝试提取div中的文本
|
||||
if (doc.elements.size() < 3) {
|
||||
auto divs = pImpl->extract_all_tags(main_content, "div");
|
||||
for (const auto& div : divs) {
|
||||
std::string text = pImpl->decode_html_entities(pImpl->trim(pImpl->remove_tags(div)));
|
||||
if (!text.empty() && text.length() > 20) { // 忽略太短的div
|
||||
ContentElement elem;
|
||||
elem.type = ElementType::PARAGRAPH;
|
||||
elem.text = text;
|
||||
doc.elements.push_back(elem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果仍然没有内容,尝试提取整个文本
|
||||
if (doc.elements.empty()) {
|
||||
std::string all_text = pImpl->decode_html_entities(pImpl->trim(pImpl->remove_tags(main_content)));
|
||||
if (!all_text.empty()) {
|
||||
// 按换行符分割
|
||||
std::istringstream iss(all_text);
|
||||
std::string line;
|
||||
while (std::getline(iss, line)) {
|
||||
line = pImpl->trim(line);
|
||||
if (!line.empty() && line.length() > 1) {
|
||||
ContentElement elem;
|
||||
elem.type = ElementType::PARAGRAPH;
|
||||
elem.text = line;
|
||||
doc.elements.push_back(elem);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
void HtmlParser::set_keep_code_blocks(bool keep) {
|
||||
pImpl->keep_code_blocks = keep;
|
||||
}
|
||||
|
||||
void HtmlParser::set_keep_lists(bool keep) {
|
||||
pImpl->keep_lists = keep;
|
||||
}
|
||||
57
src/html_parser.h
Normal file
57
src/html_parser.h
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
enum class ElementType {
|
||||
TEXT,
|
||||
HEADING1,
|
||||
HEADING2,
|
||||
HEADING3,
|
||||
PARAGRAPH,
|
||||
LINK,
|
||||
LIST_ITEM,
|
||||
BLOCKQUOTE,
|
||||
CODE_BLOCK,
|
||||
HORIZONTAL_RULE,
|
||||
LINE_BREAK
|
||||
};
|
||||
|
||||
struct Link {
|
||||
std::string text;
|
||||
std::string url;
|
||||
int position; // 在文档中的位置(用于TAB导航)
|
||||
};
|
||||
|
||||
struct ContentElement {
|
||||
ElementType type;
|
||||
std::string text;
|
||||
std::string url; // 对于链接元素
|
||||
int level; // 对于标题元素(1-6)
|
||||
};
|
||||
|
||||
struct ParsedDocument {
|
||||
std::string title;
|
||||
std::string url;
|
||||
std::vector<ContentElement> elements;
|
||||
std::vector<Link> links;
|
||||
};
|
||||
|
||||
class HtmlParser {
|
||||
public:
|
||||
HtmlParser();
|
||||
~HtmlParser();
|
||||
|
||||
// 解析HTML并提取可读内容
|
||||
ParsedDocument parse(const std::string& html, const std::string& base_url = "");
|
||||
|
||||
// 设置是否保留代码块
|
||||
void set_keep_code_blocks(bool keep);
|
||||
|
||||
// 设置是否保留列表
|
||||
void set_keep_lists(bool keep);
|
||||
|
||||
private:
|
||||
class Impl;
|
||||
std::unique_ptr<Impl> pImpl;
|
||||
};
|
||||
Loading…
Reference in a new issue