使用 lxml 提取两个 HTML 标题之间的所有文本

Posted

技术标签:

【中文标题】使用 lxml 提取两个 HTML 标题之间的所有文本【英文标题】:Extracting all text between two HTML headings with lxml 【发布时间】:2019-09-10 21:43:48 【问题描述】:

我正在尝试在 Python 中使用 lxml 解析 html 页面。

在 HTML 中有这样的结构:

<html>
   <h5>Title</h5>
   <p>Some text <b>with</b> <i>other tags</i>.</p>
   <p>More text.</p>
   <p>More text[2].</p>

   <h5>Title[2]</h5>
   <p>Description.</p>

   <h5>Title[3]</h5>
   <p>Description[1].</p>
   <p>Description[2].</p>

   ***
   and so on...
   ***
</html>

我需要将此 HTML 解析为以下 JSON:

[
   
      "title": "Title",
      "text": "Some text with other tags.\nMore text.\nMore text[2].",
   ,
   
      "title": "Title[2]",
      "text": "Description.",
   ,
   
      "title": "Title[3]",
      "text": "Description[1].\nDescription[2]",
   
]

我可以读取所有带有标题的 h5 标签并使用以下代码将它们写入 JSON:

array = []
for title in tree.xpath('//h5/text()'):
    data = 
        "title" : title,
        "text" : ""
    
    array.append(data)

with io.open('data.json', 'w', encoding='utf8') as outfile:
    str_ = json.dumps(array,
                      indent=4, sort_keys=True,
                      separators=(',', ' : '), ensure_ascii=False)
    outfile.write(to_unicode(str_))

问题是,我不知道如何阅读&lt;h5&gt;标题之间的所有这些段落内容并将它们放入text JSON字段中。

【问题讨论】:

我唯一能想到的就是逐个标签解析所有内容并构建它的 JSON... 【参考方案1】:

要获取两个元素“之间”的所有文本,例如两个标题之间,没有其他方法:

遍历整个tree(我们将使用.iterwalk(),因为我们必须区分元素的开始和结束) 为遇到的每个标题创建一个数据项(我们称之为current_heading) 将来自的任何其他元素的所有单个文本位收集到一个列表中 每次遇到新航向时,存储迄今为止收集的数据并开始新的数据项

ElementTree 元素中的每个元素都可以有一个.text 和一个.tail

<b>This will be the .text</b> and this will be the .tail

我们必须同时收集两者,否则输出中会丢失文本。

以下使用堆栈跟踪我们在 HTML 树中的位置,因此嵌套元素的 .head.tail 以正确的顺序收集。

collected_text = []
data = []
stack = []
current_heading = 
    'title': '',
    'text': []

html_headings = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']

def normalize(strings):
    return ''.join(strings)

for event, elem in ET.iterwalk(tree, events=('start', 'end')):
    # when an element starts, collect its .text
    if event == 'start':
        stack.append(elem)

        if elem.tag in html_headings:
            # reset any collected text, b/c now we're starting to collect
            # the heading's text. There might be nested elements in it.
            collected_text = []

        if elem.text:
            collected_text.append(elem.text)

    # ...and when it ends, collect its .tail
    elif event == 'end' and elem == stack[-1]:

        # headings mark the border between data items
        if elem.tag in html_headings:
            # normalize text in the previous data item
            current_heading['text'] = normalize(current_heading['text'])

            # start new data item
            current_heading = 
                'title': normalize(collected_text),
                'text': []
            
            data.append(current_heading)
            # reset any collected text, b/c now we're starting to collect
            # the text after the the heading
            collected_text = []

        if elem.tail:
            collected_text.append(elem.tail)

        current_heading['text'] = collected_text
        stack.pop()

# normalize text in final data item
current_heading['text'] = normalize(current_heading['text'])

当我对您的示例 HTML 运行它时,我得到了这个输出(JSON 格式):

[
    
        "text" : "\n   Some text with other tags.\n   More text.\n   More text[2].\n\n   ",
        "title" : "Title"
    ,
    
        "text" : "\n   Description.\n\n   ",
        "title" : "Title[2]"
    ,
    
        "text" : "\n   Description[1].\n   Description[2].\n\n   ***\n   and so on...\n   ***\n",
        "title" : "Title[3]"
    
]

我的normalize() 函数非常简单,它保留了作为 HTML 源代码一部分的所有换行符和其他空格。如果您想要更好的结果,请编写更复杂的函数。

【讨论】:

【参考方案2】:

有一种更简单的方法可以做到这一点,只需跟踪下一个 h5 的位置,并确保选择位置较低的 p:

data = []

for h5 in doc.xpath('//h5'):
  more_h5s = h5.xpath('./following-sibling::h5')
  position = int(more_h5s[0].xpath('count(preceding-sibling::*)')) if len(more_h5s) > 0 else 999
  ps = h5.xpath('./following-sibling::p[position()<' + str(position) + ']')
  data.append(
    "title": h5.text,
    "text": "\n".join(map(lambda p: p.text_content(), ps))
  )

“关注”following-sibling::* 直到它不再是 p 可能更简单

【讨论】:

这仅适用于&lt;p&gt;s,并且仅当所有文本确实 &lt;p&gt; 中时。实际文档的结构可能不够优化。 您可以将该 p 更改为您想要的任何内容。通常我会在一个while循环中跟随“next()”,但这些在python中很笨拙 是的,但这仍然不会涵盖&lt;p&gt;(或其他)之外的文本。另外,preceding-sibling 不会停在前一个&lt;h5&gt;,所以在最后一个&lt;h5&gt;count(preceding-sibling::*) 将是&lt;h5&gt; 之前的所有 元素,所以我什至没有确定这整个position 应该如何工作? (除此之外:'AttributeError: lxml.etree._Element' object has no attribute 'text_content' p 之外的文本或可以使用 text() 选择的任何内容。 count(preceding-sibling::*) 将给出可以在 position() xpath 表达式中使用的元素的位置。我正在使用来自 lxml 的 html。【参考方案3】:

首先,根据传递的标签将元素的子元素拆分为单独的部分。

def split(element, tag):
    sections = [[]]
    for element in element:
        if element.tag == tag:
            sections.append([])
        sections[-1].append(element)
    return sections

从那里,它可以重新塑造成字典。应该执行以下操作:

data = []
for section in split(html, "h5"):
    if section and section[0].tag == "h5":
        data.append(
            
                "title": section[0].text_content(),
                "text": "\n".join(q.text_content() for q in section[1:]),
            
        )

【讨论】:

以上是关于使用 lxml 提取两个 HTML 标题之间的所有文本的主要内容,如果未能解决你的问题,请参考以下文章

BeautifulSoup - lxml 和 html5lib 解析器抓取差异

爬虫—lxml提取数据

为 html 表提取 lxml xpath

lxml(或lxml.html):打印树结构

网络爬虫 lxml提取之中国大学排名

python提取页面信息beautifulsoup正则lxml