ann_md.py
ann_md.py — HTML 预处理与 Markdown 转换
解析流水线的第一道关卡:清洗噪音 DOM → 修复链接/图片路径 → 硬编码字段提取 → 表格转 Markdown → 渠道专项修复。六件事详解见 parsegpt 主篇。
代码
# -*- coding: utf-8 -*-
"""
HTML → Markdown 转换核心模块
功能:清洗HTML → 修复链接/图片 → 表格处理 → 转MD → 格式优化 → 提取纯文本
适用:爬虫页面结构化转换、招聘/学校公告内容标准化处理
"""
import html2text
from bs4 import BeautifulSoup
import re
import sys
sys.path.append('../')
import markdown
import os
import base64
# 图片处理工具:替换图片URL为云存储地址
from parsegpt.ann_img import replace_img_urls_with_base64
# 工具类:日志、MD5、去除括号字符
from utils import ner_logger, getMD5Str, remove_brackets
# HTML/MD修复工具:黑名单清洗、格式修复、URL路径处理
from utils_html import fix_html_blacklist, fix_md, get_directory_from_url, get_directory_url
# 华为云OBS上传接口
from api.hwcloud_api import upload_file_to_obs
# 图片工具:保存Base64图片到本地
from utils_img import save_base64_img
def md_to_html(md_file_path, html_file_path):
"""
Markdown文件 转换为 HTML文件
:param md_file_path: 输入MD文件路径
:param html_file_path: 输出HTML文件路径
:return: 转换成功/失败
"""
try:
with open(md_file_path, 'r', encoding='utf-8') as md_file:
md_content = md_file.read()
# markdown库转HTML
html_content = markdown.markdown(md_content)
with open(html_file_path, 'w', encoding='utf-8') as html_file:
html_file.write(html_content)
return True
except FileNotFoundError:
print(f"文件 {md_file_path} 未找到,请检查文件路径是否正确。")
except Exception as e:
print(f"转换过程中出现错误: {e}")
return False
def html2md_with_fix(spider_data, _data, _fix_file, _md_file, _html, sch_info, _cache_dir, _hfile):
"""
【核心入口】HTML内容 → 清洗修复 → 转MD → 保存文件
完整流程:修复HTML → 表格处理 → 转MD → 格式优化 → 提取全文本
:return: 处理状态、配置数据、纯文本内容
"""
_props = {} # 存储图片/链接等扩展属性
# 1. 清洗修复HTML(垃圾标签、链接、图片、黑名单过滤)
_ok, _fix_html = fix_html(spider_data, _data, _html, sch_info, _props, _cache_dir, _hfile)
if not _ok:
return False, "", ""
# 把图片/链接属性存入配置
_data['props'] = _props
# 保存修复后的干净HTML
with open(_fix_file, "w", encoding="utf-8") as f:
f.write(_fix_html)
# 2. 处理表格并转换为MD文本
_ok, md_text = html2md_table(spider_data, _fix_html, _fix_file, _data)
if not _ok:
return False, "", ""
# 3. 修复MD格式
md_text = fix_md(md_text, _props)
# 4. 特定学校(98534)专属格式优化
md_text = common_process_sch_98534(_data, md_text)
# 保存最终MD文件
with open(_md_file, "w", encoding="utf-8") as f:
f.write(md_text)
# 5. 从修复后的HTML提取纯文本
_full_text = get_html_content(_fix_html, _props)
return True, _data, _full_text
def html2md_table(spider_data, _fix_html, _fix_file, _data):
"""
智能表格处理 + HTML转MD
规则:无合并单元格 → 调用外部工具;有合并单元格 → 直接用html2text
"""
# 默认无表格
_data["has_table"] = ""
soup = BeautifulSoup(_fix_html, 'html.parser')
tables = soup.find_all('table')
# 检测是否有合并单元格 / 空单元格
_has_colspan = False
_has_table = ""
if tables:
for table in tables:
rows = table.find_all('tr')
# 行数>3 标记有表格
if len(rows) > 3:
_has_table = "OK"
# 检查每一格
for row in rows:
cells = row.find_all(['td', 'th'])
for cell in cells:
# 有合并行/列
if 'rowspan' in cell.attrs or 'colspan' in cell.attrs:
_has_colspan = True
break
# 单元格内容为空
if not cell.text.strip():
_has_colspan = True
break
if _has_colspan:
break
if _has_colspan:
break
# 无复杂表格 → 调用外部proc_html_md处理
if not _has_colspan:
_htmlfile = _fix_file
_htmlfile_md = f"{_htmlfile}.md"
spider_data.proc_html_md(_htmlfile, _htmlfile_md)
if os.path.exists(_htmlfile_md):
with open(_htmlfile_md, "r", encoding="utf-8") as f:
_full_text = f.read()
# 清理临时文件
os.remove(_htmlfile_md)
return True, _full_text
else:
# 有复杂表格,标记
_data["colspan"] = "OK"
# 有表格,直接使用html2text转换
_data["has_table"] = _has_table
return True, html2md(_fix_html)
def common_process_sch_98534(_data, md_text):
"""
【学校专属】sch_98534 招聘页面MD格式美化
功能:把岗位信息自动加粗、格式化、规整换行
"""
if _data['channel'] in ['sch_98534']:
try:
# 匹配 ### 岗位信息 到 ### 岗位要求 之间的内容
block_re = re.compile(r'(### 岗位信息.*?)(?=### 岗位要求)', re.S)
def _repl(m: re.Match) -> str:
s = m.group(1)
# 加粗字段名
s = re.sub(r'\n-\s+', '\n\n- **', s)
s = re.sub(r':\n\n', ':**\n\n', s)
# 格式规整
s = re.sub(r'\*{3,}', '**', s)
s = re.sub(r'\n{3,}', '\n\n', s)
s = re.sub(r'(?m)^-\s***', '**', s)
s = re.sub(r'(?<=**)\n\n([^**\n-].*)', r'\1', s)
s = re.sub(r'**\n**', '**\n\n**', s)
return s
md_text = block_re.sub(_repl, md_text)
ner_logger.info(f"针对mdfile的优化,指定学校98534{md_text}")
except Exception as e:
ner_logger.error(f"针对mdfile的优化,指定学校98534 Error:\n{e}")
return md_text
def html2md(htmltext):
"""
基础HTML转MD(使用html2text库)
关闭软换行,避免格式错乱
"""
text_maker = html2text.HTML2Text()
text_maker.soft_break = '' # 关闭自动软换行
result = text_maker.handle(htmltext)
result = re.sub(r'-\n', '-', result) # 修复列表换行错乱
return result
def fix_html(spider_data, _data, htmltext, sch_info, _props, _cache_dir, _hfile):
"""
HTML清洗修复总入口
流程:解析 → 提取硬编码字段 → 清理垃圾 → 修复链接 → 修复图片 → 黑名单 → 图片上传云存储
"""
_is_external_link = ""
soup = BeautifulSoup(htmltext, 'html.parser')
# 如果页面包含body,视为外部完整页面,不做深度处理
if soup.find('body'):
_data['is_external_link'] = "OK"
# 1. 提取配置中写死的标签内容(公司名、公告名等)
find_hardcode_tag(soup, sch_info, _data, _hfile)
# 2. 清理HTML文字垃圾(黑名单关键词)
fix_html_blacklist(spider_data, soup, sch_info)
# 3. 移除无用DIV/标签
fix_html_div(spider_data, soup, sch_info, _data)
# 4. 修复a链接相对路径为绝对路径
fix_html_a(spider_data, soup, sch_info, _props, _data)
# 5. 修复img图片src相对路径/base64
fix_html_img_src(spider_data, soup, sch_info, _data, _cache_dir)
# 6. 移除黑名单图片
rm_html_blacklist(spider_data, soup, sch_info)
# 7. 图片下载 → 检测 → 上传OBS → 替换src
_ok = replace_img_urls_with_base64(spider_data, _data, soup, _props, _cache_dir)
return _ok, soup.prettify()
def find_hardcode_tag(soup, sch_info, _data, _hfile):
"""
从配置中读取固定标签选择器,提取关键信息:
公司名、公告名、 tuning文本
"""
_hardcode_tag = []
if 'detail_hd_company' in sch_info:
_hardcode_tag = sch_info['detail_hd_company'].split('|')
# 提取公司名称
for _tagstr in _hardcode_tag:
_tag = _tagstr.split("#")
company_tag = soup.find(_tag[0], class_=_tag[1])
if company_tag and len(company_tag.text.strip()) > 2:
_data['hd_company'] = remove_brackets(company_tag.text.strip())
# 从完整页面文件提取公告、公司、优化文本
fix_full_html_extract(sch_info, _data, _hfile, 'detail_hd_ann_full', 'hd_ann')
fix_full_html_extract(sch_info, _data, _hfile, 'detail_hd_company_full', 'hd_company')
fix_full_html_extract(sch_info, _data, _hfile, 'detail_tuning_classes_full', 'tuning_content')
def fix_full_html_extract(sch_info, _data, _hfile, _tag, _tk):
"""
从完整页面.html.full文件中提取指定标签内容(用于无法从正文提取的场景)
"""
if _tag in sch_info:
_full_file = f'{_hfile}.full'
ner_logger.info(f"{_tag} {_full_file}")
if os.path.exists(_full_file):
with open(_full_file, "r", encoding="utf-8") as f:
_full_text = f.read()
soup_full = BeautifulSoup(_full_text, 'html.parser')
_hardcode_ann = sch_info[f'{_tag}'].split('|')
for _tagstr in _hardcode_ann:
_tag = _tagstr.split("!")
if "#" in _tag[1]:
_tag1 = _tag[1].split("#")
ann_tag = soup_full.find(_tag[0], class_=_tag1[0])
if ann_tag:
_data[f'{_tk}'] = ann_tag.get(_tag1[1])
else:
ann_tag = soup_full.find(_tag[0], class_=_tag[1])
if ann_tag and len(ann_tag.text.strip()) > 2:
_data[f'{_tk}'] = remove_brackets(ann_tag.text.strip())
def rm_html_blacklist(spider_data, soup, sch_info):
"""
移除图片黑名单:根据URL MD5判断,直接删除img标签
"""
_domain_url = sch_info['json_domain']
for img_tag in soup.find_all('img'):
_href = img_tag.get('src')
if _href is None:
continue
_md5 = getMD5Str(_href)
ner_logger.info(f"图片 {_href},{_md5}")
if spider_data.check_url_in_blacklist(_md5):
img_tag.decompose()
ner_logger.info(f"图片在黑名单里面,已经移除掉 {_href}")
def fix_html_div(spider_data, soup, sch_info, _data):
"""
移除无用HTML标签:
按class、id、指定序号、自定义标签 移除垃圾模块
"""
_all_div_class = sch_info.get('detail_rm_classes', '').split('|')
_all_div_id = sch_info.get('detail_rm_ids', '').split('|')
_rmlist = []
_index = []
# 移除指定class的div
for div in soup.find_all('div'):
_clist = div.get('class')
if _clist:
class_str = ".".join(_clist)
for _class in _all_div_class:
if '^' in _class:
_sclass = _class.split('^')
if class_str == _sclass[0] and int(_sclass[1]) == len(_index):
_rmlist.append(div)
if class_str == _sclass[0] and div not in _index:
_index.append(div)
elif class_str == _class:
_rmlist.append(div)
# 移除指定id
for _id in _all_div_id:
if div.get('id') == _id:
_rmlist.append(div)
# 移除其他自定义标签(p/span等)
_all_oth_class = sch_info.get('detail_rm_oth_classes', '').split('|')
for _oclass in _all_oth_class:
if '!' in _oclass:
_h, _c = _oclass.split('!')
o_tags = soup.find_all(_h, class_=_c)
for tag in o_tags:
_rmlist.append(tag)
# 提取tuning内容并移除标签
_all_tuning_content = []
_all_tuning_class = sch_info.get('detail_tuning_classes', '').split('|')
for _oclass in _all_tuning_class:
if '!' in _oclass:
_h, _c = _oclass.split('!')
ner_logger.info(f"需要优化的div,other 检测 {_h} {_c}")
o_tags = soup.find_all(_h, class_=_c) or soup.find_all(_h, id=_c)
for tag in o_tags:
_all_tuning_content.append(tag.get_text(separator='\n', strip=True))
_rmlist.append(tag)
_data['tuning_content'] = _all_tuning_content
# 执行删除
for div in _rmlist:
div.decompose()
def fix_html_a(spider_data, soup, sch_info, _props, _data):
"""
修复<a>链接:
相对路径 → 绝对路径
javascript链接 → 直接删除
记录所有链接到props
"""
_props_hrefs = {}
_domain_url = sch_info['json_domain']
for a in soup.find_all('a'):
_href = a.get('href')
if not _href:
continue
# 移除脚本链接
if _href.startswith('javascript'):
a.decompose()
continue
# 绝对路径直接保留
if _href.startswith('http'):
_props_hrefs[_href] = ""
continue
# 修复相对路径 /
if _href.startswith('/'):
_href = _domain_url + _href
a['href'] = _href
_props_hrefs[_href] = ""
# 修复相对路径 ./
if _href.startswith('./'):
httpdir = get_directory_from_url(_data['full_url'])
_href = httpdir + _href[2:]
a['href'] = _href
_props_hrefs[_href] = ""
_props['hrefs'] = _props_hrefs
def fix_html_img_src(spider_data, soup, sch_info, _data, _cache_dir):
"""
修复<img>图片src:
相对路径 → 绝对路径
base64 → 保存本地 → 上传OBS
"""
_domain_url = sch_info['json_domain']
_pre_http = "https:" if not _domain_url.startswith('http:') else "http:"
for img_tag in soup.find_all('img'):
_href = img_tag.get('src')
if not _href:
continue
if _href.startswith('http'):
continue
# 修复 //domain.com
if _href.startswith('//'):
_href = _pre_http + _href
# 修复 /path
elif _href.startswith('/'):
_href = _domain_url + _href
# 修复 ../path
elif _href.startswith('../'):
_lasturl = _data.get('last_url', _data['full_url'])
httpdir = get_directory_url(_lasturl)
_href = httpdir + _href[3:]
# 修复普通相对路径
elif re.match(r'^[a-zA-Z0-9]{1,30}/', _href):
_href = f"{_domain_url}/{_href}"
# 过滤 file://
elif _href.startswith("file://"):
_href = ""
# 处理 base64 图片
elif "data:image/png;base64" in _href:
_md5 = getMD5Str(_href)
_cache_file = f"{_cache_dir}/{_md5}.png"
if save_base64_img(_cache_file, _href):
_href = upload_obs(f"{_md5}.png", _cache_file, {'Content-Type': 'image/png'})
else:
_href = ""
elif "data:image/jpeg;base64" in _href:
_md5 = getMD5Str(_href)
_cache_file = f"{_cache_dir}/{_md5}.jpeg"
if save_base64_img(_cache_file, _href):
_href = upload_obs(f"{_md5}.jpeg", _cache_file, {'Content-Type': 'image/jpeg'})
else:
_href = ""
img_tag['src'] = _href
def upload_obs(_cache_filename, _cachefile, headers):
"""
上传文件到华为云OBS
"""
_ok, _url = upload_file_to_obs(_cache_filename, _cachefile, headers)
return _url if _ok else _cachefile
def get_html_content(htmltext, _props):
"""
提取HTML纯文本:
把图片OCR文字插入到图片位置,再提取全文
"""
soup = BeautifulSoup(htmltext, 'html.parser')
# 把图片OCR文字插入到img标签后
for img_tag in soup.find_all('img'):
_href = img_tag.get('src')
_ohref = img_tag.get('q_url')
target_url = _ohref or _href
if target_url and 'img_urls' in _props:
for _url, imgmap in _props['img_urls'].items():
if _url == target_url and 'img_ocr' in imgmap:
img_tag.insert_after(imgmap['img_ocr'])
# 返回干净文本
return soup.get_text()
💬 评论