--- title: "[S2-T1] 文档加载与预处理(Loader / Parser / Chunking)实现详解" author: Python 开发工程师 last-reviewed-at: 2026-05-08 source-commit: HEAD (origin/main, MemoryBear) scope: api/app/core/rag/{crawler, integrations, deepdoc, nlp, models, utils, app/naive.py, common/token_utils.py} --- ## 0. 一句话定位 把"任意来源、任意格式"的原始资料,沉淀为带元数据、可被 Embedding/索引消费的标准化 **Chunk** 序列;这一段是 RAG 召回质量的"硬天花板"——它做不好,下游再多优化都救不回来。 ## 1. 设计目标与适用场景 | 目标 | 落地策略 | |---|---| | 多源接入(爬虫 / 飞书 / 语雀 / 本地文件) | `crawler/`、`integrations/feishu`、`integrations/yuque` 三套 SDK,均落到本地文件后再走统一 `chunk()` 入口 | | 多格式解析(PDF/Word/Excel/PPT/HTML/MD/JSON/TXT/图片/音视频) | `app/naive.py:chunk()` 单一编排入口,按扩展名分派到 `deepdoc/parser/*` 与 `app/{audio,picture}.py` | | 复杂 PDF 还原(表格、图、版面) | `RAGPdfParser` + OCR + 版面识别 + TSR + XGBoost 段落连接模型 | | 长文 Chunking 既保语义又控 token | `naive_merge` / `naive_merge_docx` / `hierarchical_merge` / `tree_merge` 多种策略,统一以 `cl100k_base` 计算 token | | 同一篇资料的多模态(图 + 文 + 表) | `tokenize_chunks_with_images`、`tokenize_table` 把图片/表格作为附属信息挂在 chunk 上 | | 健壮性 | 鉴权 token 缓存、退避重试、robots.txt 合规、编码自动嗅探、嵌入文件递归解构 | 适用于:私有知识库、企业文档库、技术资料归档;不适用于:实时流式数据、对端到端延迟<200ms 的场景(OCR 与版面识别是 CPU/GPU 重负载)。 ## 2. 术语表 - **Section**:解析器吐出的中间结构 `(text, position_or_layout)` 元组列表,是 Chunking 之前的"原料"。 - **Chunk**:最终交给 Embedding 的文本片段,一般 ≤ `chunk_token_num` 个 token(默认 128–512)。 - **Token**:用 `tiktoken.cl100k_base` 编码后得到的 BPE token 数(与 OpenAI gpt-4 同口径)。 - **Layout**:页面区块类别(title / text / figure / table / equation 等),由 YOLOv10 检测。 - **TSR**:Table Structure Recognition,复杂表格行/列/合并单元格的结构还原。 - **OCR**:文字检测 + 文字识别两阶段的图像字符抽取。 - **Embed file**:内嵌在 docx/xlsx/pptx 内部的子文件(如 docx 里嵌的 Excel),需递归解析。 ## 3. 实现概览(数据流图) ```mermaid flowchart LR subgraph Loader["Loader / 多源接入"] A1[本地文件] --> CHUNK A2[Web 站点] --> WC[WebCrawler
BFS 同域] A3[飞书云文档] --> FS[FeishuAPIClient
导出/下载] A4[语雀知识库] --> YQ[YuqueAPIClient
raw markdown] WC --> CD[CrawledDocument
title+content] FS --> LF[本地文件] YQ --> LF CD --> CHUNK LF --> CHUNK end subgraph Parser["Parser / 格式分派"] CHUNK[app/naive.py: chunk] --> EX[extract_embed_file
嵌入文件递归] CHUNK -->|.pdf| PARSERS[PARSERS dict
deepdoc/mineru/textln/plaintext] CHUNK -->|.docx| DOCX[Docx/RAGDocxParser] CHUNK -->|.xlsx/.csv| XLS[RAGExcelParser] CHUNK -->|.md| MD[Markdown/RAGMarkdownParser] CHUNK -->|.html| HTML[RAGHtmlParser] CHUNK -->|.json/.jsonl| JSON[RAGJsonParser] CHUNK -->|.txt/code| TXT[RAGTxtParser] CHUNK -->|.ppt/.pptx| LO[LibreOffice
convert_to_pdf] CHUNK -->|.doc| TIKA[Apache Tika] CHUNK -->|图片/音视频| MM[picture/audio
vision_llm_chunk] LO --> PARSERS PARSERS --> OCR[OCR + LayoutRecognizer + TSR] DOCX --> SEC[(sections)] XLS --> SEC MD --> SEC HTML --> SEC JSON --> SEC TXT --> SEC OCR --> SEC TIKA --> SEC end subgraph Chunking["Chunking / 切分 + 索引化"] SEC --> NM{有图片?} NM -->|否| NM1[naive_merge] NM -->|是·docx| NM2[naive_merge_docx] NM -->|是·md| NM3[naive_merge_with_images] NM1 --> TC[tokenize_chunks] NM2 --> TCI[tokenize_chunks_with_images] NM3 --> TCI TT[tokenize_table] --> ESDOC TC --> ESDOC[(ES Doc
content_with_weight
content_ltks
page_num_int
position_int
image)] TCI --> ESDOC end ``` ## 4. Loader 章节 ### 4.1 Web Crawler(`crawler/`) - **入口**:`WebCrawler(entry_url, max_pages, delay_seconds, timeout_seconds, user_agent, include_patterns, exclude_patterns)`,源码 `api/app/core/rag/crawler/web_crawler.py:19`。 - **架构**:BFS(`deque` + `visited_urls`)+ 五个独立组件:`URLNormalizer` / `RobotsParser` / `RateLimiter` / `HTTPFetcher` / `ContentExtractor`,全部通过组合而非继承装配,便于替换。 - **同域限制**:`URLNormalizer.is_same_domain()` 强制只爬入口域名,避免无界扩散(`url_normalizer.py:102-124`)。 - **去重**:`URLNormalizer.normalize()` 做:小写 host、去 fragment、去默认端口、剥离 utm_*/fbclid/gclid 等追踪参数、按字母序排 query。`url_normalizer.py:28-100`。 - **robots.txt 合规**:`RobotsParser.can_fetch()` 与 `get_crawl_delay()`,使用 stdlib 的 `urllib.robotparser`,每域名缓存。robots.txt 拉取失败时**默认允许**(permissive fallback),`robots_parser.py:60-69`。 - **限速**:`RateLimiter` 默认 1s/请求,`set_delay()` 可被 `Crawl-delay` 动态覆盖(上限 60s 防呆);`backoff(2.0)` 用于 429/503 指数退避,`rate_limiter.py:38-58`。 - **HTTP 重试**:`HTTPFetcher` 内置 `max_retries=3`,退避 `1s → 2s → 4s`;429 与 503 显式触发重试,404/4xx 立即返回不重试,5xx 重试到耗尽。`http_fetcher.py:54-180`。 - **编码处理**:`HTTPFetcher._get_decoded_content` 五级回退:HTML meta charset → response.encoding(跳过 ISO-8859-1) → UTF-8 → GBK/Big5/Shift-JIS/EUC-KR 等 → latin-1 with errors='replace'。`http_fetcher.py:182-248`。 - **正文抽取**:`ContentExtractor.extract` 基于 `lxml`:移除 `script/style/nav/header/footer/aside`,按 `
/
` → `[role=main]` → `class/id =~ content|main|article|post` → `` 顺序找主体;用 `is_static_content` 检测"脚本多文本少"的 SPA 页面并直接跳过。`content_extractor.py:24-72`。 - **错误统计**:`stats.error_breakdown` 记录每种错误类型的计数,便于事后分析。`web_crawler.py:210-215`。 ```python # api/app/core/rag/crawler/web_crawler.py:103-145(节选) while self.url_queue and self.pages_processed < self.max_pages: url = self.url_queue.popleft() if url in self.visited_urls: continue self.visited_urls.add(url) if not self.robots_parser.can_fetch(url): # robots.txt self.stats['skipped'] += 1; continue self.rate_limiter.wait() # 限速 fetch_result = self.http_fetcher.fetch(url) # 重试 + 退避 if not fetch_result.success: self._record_error(fetch_result.error or "Unknown error"); continue content_type = fetch_result.headers.get('Content-Type', '').lower() if not any(s in content_type for s in ['text/html', 'application/xhtml+xml']): self.stats['skipped'] += 1; continue # 非 HTML 跳过 extracted = self.content_extractor.extract(fetch_result.content, url) if not extracted.is_static: self.stats['skipped'] += 1; continue # JS-only 站点跳过 ``` ### 4.2 飞书集成(`integrations/feishu/`) - **入口**:`FeishuAPIClient(app_id, app_secret, api_base_url, timeout, max_retries)`,`integrations/feishu/client.py:24`,是异步客户端(`httpx.AsyncClient`),用 `async with` 管理生命周期。 - **鉴权**:`tenant_access_token` 模式,`get_tenant_access_token()` 用 `cachetools.TTLCache(maxsize=1, ttl=7200-300)` 缓存(飞书原生 2 小时有效,提前 5 分钟失效)+ `asyncio.Lock` 双检锁防并发请求 token。`client.py:51-127`。 - **文件类型分派**:`download_document` 按 `document.type` 分两条路径: - **在线文档(doc/docx/sheet/bitable)**:`_export_file` 走"创建导出任务 → 轮询 ticket → 下载 file_token"三步,最多轮询 10 次、间隔 2s,超时抛 `FeishuAPIError`。`client.py:311-406`。 - **附件文件(file/slides)**:`_download_file` 直接 GET `/drive/v1/files/{token}/download`,从 `Content-Disposition` 解析 `filename*=UTF-8''xxx` 编码文件名。`client.py:408-452`。 - **限流与重试**:装饰器 `@with_retry`(`feishu/retry.py:124-137`)。`RetryStrategy.RETRYABLE_ERRORS = (FeishuNetworkError, FeishuRateLimitError, httpx.TimeoutException/ConnectError/ReadError)`,`MAX_RETRIES=3`,退避 `[1, 2, 4]s`;HTTP 429/502/503/5xx 重试,4xx(除 429)不重试;飞书业务码 `99991400/99991401`(限流码)也强制重试。`feishu/retry.py:24-76`。 - **错误模型**:精细化异常树 `FeishuAuthError / FeishuAPIError / FeishuNotFoundError / FeishuPermissionError / FeishuRateLimitError / FeishuNetworkError / FeishuDataError`,调用方据此决定告警级别。`feishu/exceptions.py:1-46`。 - **分页与递归**:`list_folder_files` 单页(page_size=200);`list_all_folder_files(recursive=True)` 自动展开子文件夹。`client.py:226-269`。 ```python # api/app/core/rag/integrations/feishu/client.py:78-120(鉴权 + 双检锁缓存) cached_token = self._token_cache.get("access_token") if cached_token: return cached_token async with self._token_lock: cached_token = self._token_cache.get("access_token") if cached_token: return cached_token response = await self._http_client.post( "/auth/v3/tenant_access_token/internal", json={"app_id": self.app_id, "app_secret": self.app_secret}) data = response.json() if data.get("code") != 0: raise FeishuAuthError(f"Authentication failed: {data.get('msg')}", error_code=str(data.get("code")), details=data) token = data.get("tenant_access_token") self._token_cache["access_token"] = token return token ``` ### 4.3 语雀集成(`integrations/yuque/`) - **入口**:`YuqueAPIClient(user_id, token, api_base_url, timeout, max_retries)`,`integrations/yuque/client.py:27`。 - **鉴权**:个人 PAT,HTTP header `X-Auth-Token`,无需 OAuth/token 刷新(语雀的 token 是长期 token),故没有 token 缓存层。`client.py:55-66`。 - **API 三段式**:`get_user_repos()` → `get_repo_docs(book_id)` → `get_doc_detail(id, raw=1)`;`get_doc_detail` 用 `params={"raw": 1}` 拉原始 markdown。`client.py:119-291`。 - **格式分派(download_document)**:根据 `doc.format` 决定本地文件后缀: - `markdown` / `lake` → `.md`(lake 也按 markdown 保存,因为 lake 在 raw 模式下输出兼容 md) - `html` → `.html` - `lakesheet` → `.xlsx`,需 `zlib.decompress(bytes(sheet_data, 'latin-1'))` 解压后由 `generate_excel_from_sheet` 用 openpyxl 重建工作簿(含字体、对齐、颜色、合并单元格)。`client.py:293-545`。 - **限流与重试**:与飞书同构,`yuque/retry.py:21-118`,`RetryStrategy` 配置一致;HTTP 状态码 401→`YuqueAuthError`、403→`YuquePermissionError`、404→`YuqueNotFoundError`、429→`YuqueRateLimitError`,由 `_handle_api_error` 统一翻译。`client.py:73-117`。 - **健壮性**:`get_user_repos`/`get_repo_docs` 对单条数据 `try/except` 跳过坏记录而不整体失败(容忍语雀 schema 漂移)。`client.py:158-160, 221-223`。 ### 4.4 本地文件(`app/naive.py:chunk`) - 是所有 Loader 的最终汇入口;接收 `filename` 与 `binary` 两种入参,二者互斥(推荐 `binary`,源码内 `extract_embed_file` 显式不支持 path 模式,详见 `app/naive.py:541`)。 - **嵌入文件递归**:根调用(`is_root=True`)会先用 `extract_embed_file()` 抽出 docx/xlsx/pptx 内部嵌入的子文件(通过 zip 名单 `word/embeddings/`、`xl/embeddings/`、`ppt/embeddings/` 或 OLE 容器的 `Ole10Native`),逐个递归 `chunk()`,结果合入 `embed_res`。`utils/file_utils.py:69-130` + `app/naive.py:533-552`。 - **超链接深挖**:`parser_config.analyze_hyperlink=True` 时,docx/pdf 内部超链接经 `extract_links_from_docx` / `extract_links_from_pdf` 抽出后,每条 URL 调用 `extract_html` 拉回 HTML 二进制并递归 `chunk(url, html_bytes, is_root=False)`。`app/naive.py:556-566, 793-803`。 - **callback 进度上报**:`chunk(..., callback=progress_callback)`,约定 `callback(prog: float, msg: str)`,关键节点:0.05(嵌入抽取)/ 0.1(开始解析)/ 0.6(OCR 完)/ 0.63(版面)/ 0.65(表格)/ 0.67(合并)/ 0.8(解析完)。 ## 5. Parser 章节 ### 5.1 总分派器(`app/naive.py`) `chunk()` 是入口,按文件扩展名走分支: ```python # api/app/core/rag/app/naive.py:97-102 PARSERS = { "deepdoc": by_deepdoc, "mineru": by_mineru, "textln": by_textln, "plaintext": by_plaintext, # default } # api/app/core/rag/app/naive.py:553-764 if re.search(r"\.docx$", filename, re.IGNORECASE): ... elif re.search(r"\.pdf$", filename, re.IGNORECASE): ... # 走 PARSERS dict elif re.search(r"\.(pptx|ppt?)$", ...): ... # LibreOffice → pdf elif re.search(r"\.(da|wav|mp3|...)$", ...): ... # app/audio.py elif re.search(r"\.(png|jpeg|...)$", ...): ... # app/picture.py elif re.search(r"\.(csv|xlsx?)$", ...): ExcelParser elif re.search(r"\.(txt|py|js|java|...)$", ...): TxtParser elif re.search(r"\.(md|markdown)$", ...): Markdown(MarkdownParser 子类) elif re.search(r"\.(htm|html)$", ...): HtmlParser elif re.search(r"\.(json|jsonl|ldjson)$", ...): JsonParser elif re.search(r"\.doc$", ...): tika # Apache Tika via JVM ``` PDF 的 `parser_config.layout_recognize` 决定底层走哪条 PDF 引擎,默认 `DeepDOC`: | layout_recognize | 引擎 | 调用 | 适用 | |---|---|---|---| | `DeepDOC` | `Pdf(RAGPdfParser)` | `by_deepdoc` | 复杂版面、扫描件 | | `Plain Text` | `PlainParser` | `by_plaintext` | 纯文本 PDF,速度快 | | `MinerU` | `MinerUParser` | `by_mineru` | 高质量结构化(外部进程或 HTTP) | | `TextLn` | `TextLnParser` | `by_textln` | TextIn API(云端付费) | | 任意(含 `vision_model`) | `VisionParser` | `by_plaintext` 分支 | 多模态 LLM 直读 | ### 5.2 PDF 解析(`deepdoc/parser/pdf_parser.py`,1387 行) `RAGPdfParser` 是大头,调用栈: ```python # api/app/core/rag/app/naive.py:373-412 (Pdf.__call__ 节选) self.__images__(filename if not binary else binary, zoomin, from_page, to_page, callback) callback(0.6, f"OCR finished") self._layouts_rec(zoomin) # 版面识别 callback(0.63, "Layout analysis") self._table_transformer_job(zoomin) # TSR callback(0.65, "Table analysis") self._text_merge(zoomin=zoomin) # 文本合并 self._extract_table_figure(...) # 提取表与图 self._naive_vertical_merge() self._concat_downward() # XGBoost 段落连接(updown_concat_xgb) self._final_reading_order_merge() return [(b["text"], self._line_tag(b, zoomin)) for b in self.boxes], tbls ``` 要点: - **OCR**:`OCR()`(`deepdoc/vision/ocr.py:522`)= `TextDetector` + `TextRecognizer` 组合;`pdfplumber` 把每一页 `to_image(resolution=72*zoomin=216).annotated`,再过 OCR。`pdf_parser.py:1006-1122`。 - **版面识别**:`LayoutRecognizer4YOLOv10`(默认,10 个 label:title / Text / Reference / Figure / Figure caption / Table / Table caption / Equation 等),或 `AscendLayoutRecognizer`(Ascend NPU),由 `LAYOUT_RECOGNIZER_TYPE` 环境变量切换。`pdf_parser.py:53-67` + `vision/layout_recognizer.py:147-160`。 - **表格结构识别**:`TableStructureRecognizer`(`vision/table_structure_recognizer.py`),裁出 table 区域后把行/列重组成 HTML;与 docx 的"按上下文找最近标题"风格一致。`pdf_parser.py:178-220`。 - **段落连接模型**:`updown_cnt_mdl`(XGBoost),输入是上下相邻两块的 31 维特征(句末是否标点、x0 距离、行内 token 数、字号差、layout_type 等),决定要不要把下一块续到上一块。`pdf_parser.py:113-156` + `pdf_parser.py:70-83`(模型从 HuggingFace `InfiniFlow/text_concat_xgb_v1.0` 拉)。 - **位置标签**:每个文本块带 `@@\t\t\t\t##` 的位置 tag,`remove_tag()` 用 `re.sub(r"@@[\t0-9.-]+?##", "", txt)` 去掉,`extract_positions()` 反解。`pdf_parser.py:1219-1229`。 - **GPU 加速**:通过 `pip_install_torch()` + `torch.cuda.is_available()` 把 XGBoost 推到 CUDA;`PARALLEL_DEVICES > 1` 时用 `trio.CapacityLimiter` 做多卡并行。`pdf_parser.py:50-77, 1095-1106`。 - **HuggingFace 模型分发**:`InfiniFlow/text_concat_xgb_v1.0` 通过 `snapshot_download` 拉到 `res/` 目录;推荐 `export HF_ENDPOINT=https://hf-mirror.com` 解决国内拉取慢,`deepdoc/README.md:42`。 #### 5.2.1 备选 PDF 引擎 - **`PlainParser`**(`pdf_parser.py:1300`):`pypdf.PdfReader` 直接 `extract_text()`,每行一段 + 解析 outline 目录;无 OCR、无版面、无图,纯文本极快。 - **`VisionParser`**(`pdf_parser.py:1334`):把每一页转成 PIL.Image,整页直接喂给 `vision_model`(`QWenCV` / `AzureGptV4` 等),让多模态 LLM "看图说话"产出 markdown。`@@page\tx0\tx1\ty0\ty1##` 位置 tag 由 `(0, 0, width/zoomin, 0, height/zoomin)` 占位生成(即整页矩形),方便下游对齐 chunk 与原图。 - **`MinerUParser`**(`mineru_parser.py:41`):调用外部 `mineru` 进程(CLI 模式)或 `MINERU_APISERVER`(HTTP 模式,默认 `host.docker.internal:9987`),后端可选 `pipeline / vlm-http-client / vlm-transformers / vlm-vllm-engine`;输出 zip 解压后融合为 sections + tables。`naive.py:45-62`。 - **`TextLnParser`**(`app/textin_parser.py`):合合 TextIn 云端 PDF→Markdown 服务,需要 `TEXTLN_APP_ID/SECRET_CODE`。 ### 5.3 Word 解析(`deepdoc/parser/docx_parser.py` + `naive.py:Docx`) 两层: - **底层 `RAGDocxParser`**(`docx_parser.py:9-123`):`python-docx`+`pandas` 读段落与表格;表格内容经 `__compose_table_content` 做"列类型推断"(日期 Dt / 数字 Nu / 中文人名 Nr / 英文 En 等 11 类正则),自动识别多行表头并把单元格拼成 `表头:值` 格式,保证表格在 chunk 中也能被关键词检索。 - **上层 `Docx(RAGDocxParser)`**(`naive.py:105-323`):把段落里的图片用 `python-docx` 的 `xpath('.//pic:pic')` 抽出,挂到对应 paragraph;表格区域用 `__get_nearest_title` 上溯到 7 级标题构造层级路径作为 `Table Location: A > B > C`,这是检索时定位表格上下文的关键。 - **超链接抽取**:`extract_links_from_docx` 遍历 `document.part.rels`,过滤 reltype 为 hyperlink 的关系,得到链接集合。`utils/file_utils.py:133-154`。 - **`to_markdown`**:可选回退路径,使用 `mammoth.convert_to_html` + `markdownify`,图片嵌成 `data:` base64 URL。`naive.py:325-366`。 - **NULL 关系修复**:上层 `Docx` 用 `load_from_xml_v2` monkey-patch 掉 `_SerializedRelationships.load_from_xml`,跳过 `../NULL` 与 `NULL` target 以绕过 python-docx#1105 已知 bug。`naive.py:493-506, 569`。 ### 5.4 Excel/CSV 解析(`deepdoc/parser/excel_parser.py`) - **多引擎容错**:`_load_excel_to_workbook` 先看魔数:`PK\x03\x04`(OOXML)或 `\xd0\xcf\x11\xe0`(OLE2)。openpyxl 失败回退 `pandas.read_excel`,再失败回退 `engine="calamine"`;非 Excel 头则当 CSV 处理(`pd.read_csv(on_bad_lines='skip')` 容忍坏行)。`excel_parser.py:18-53`。 - **非法字符清洗**:`ILLEGAL_CHARACTERS_RE = re.compile(r"[\000-\010]|[\013-\014]|[\016-\037]")`,`_clean_dataframe` 把所有字符串单元格里的控制字符替换成空格,避免写入 Workbook 报错。`excel_parser.py:13, 56-62`。 - **三种输出形态**: - `__call__()`:每行 → `表头1:值1\n表头2:值2\n...\n——SheetName`,作为一个 section(一个 chunk)。`excel_parser.py:203-246`。 - `html()`:每 256 行打包成一张 ``,header 复用,便于检索时整块召回。`excel_parser.py:144-187`。 - `markdown()`:`df.to_markdown(index=False)`,整个表一段。 - **图片抽取**:`_extract_images_from_worksheet` 通过 `ws._images` 的 anchor.row/col 还原图片位置,输出 `single_cell` / `multi_cell` span_type 元数据。`excel_parser.py:98-142`。 - **重要:Excel 不走 `naive_merge`**:`naive.py:678-680` 显式注释"Excel 每行直接作为一个 chunk,不经过 naive_merge 避免被 delimiter 拆分"——直接 `tokenize_chunks(chunks, ...)`。 ### 5.5 Markdown 解析(`deepdoc/parser/markdown_parser.py`) - **表格抽取**:`extract_tables_and_remainder` 用三个正则按顺序剥离:标准 GFM 边框表格 → 无边框表格 → HTML `
...
`(含 `` 包装),每张表单独成一个 chunk,剩余正文继续走 element 抽取。`markdown_parser.py:10-106`。 - **元素抽取**:`MarkdownElementExtractor.extract_elements(delimiter)` 按行扫描,识别 `header(#~######)` / `code_block(```)` / `list_block(-/*/+/数字.)` / `blockquote(>)` / `text_block`,每种元素用对应私有方法收集起止行号。`markdown_parser.py:109-277`。 - **图片嵌入**:当传入 `vision_model` 时,naive.py 会对每个 section 调 `markdown_parser.get_pictures()`(HTTP 下载或本地路径打开),把图片合并 `concat_img` 后丢给 `VisionFigureParser` 让 LLM 描述图片,描述文本拼回 section 末尾。`naive.py:697-709`。 - **超链接深挖**:`get_hyperlink_urls(soup)` + `analyze_hyperlink=True` 触发递归 chunk。`naive.py:716-720`。 ### 5.6 HTML 解析(`deepdoc/parser/html_parser.py`) - **预清洗**:BeautifulSoup html5lib,移除 `