如何在没有多余换行符的情况下从 BeautifulSoup 输出 XML?

Posted

技术标签:

【中文标题】如何在没有多余换行符的情况下从 BeautifulSoup 输出 XML?【英文标题】:How to output XML from BeautifulSoup without extraneous newlines? 【发布时间】:2020-02-09 04:41:31 【问题描述】:

我正在使用 Python 和 BeautifulSoup 来解析和访问 XML 文档中的元素。我修改了几个元素的值,然后将 XML 写回到文件中。问题是更新后的 XML 文件在每个 XML 元素的文本值的开头和结尾都包含换行符,导致文件看起来像这样:

<annotation>
 <folder>
  Definitiva
 </folder>
 <filename>
  armas_229.jpg
 </filename>
 <path>
  /tmp/tmpygedczp5/handgun/images/armas_229.jpg
 </path>
 <size>
  <width>
   1800
  </width>
  <height>
   1426
  </height>
  <depth>
   3
  </depth>
 </size>
 <segmented>
  0
 </segmented>
 <object>
  <name>
   handgun
  </name>
  <pose>
   Unspecified
  </pose>
  <truncated>
   0
  </truncated>
  <difficult>
   0
  </difficult>
  <bndbox>
   <xmin>
    1001
   </xmin>
   <ymin>
    549
   </ymin>
   <xmax>
    1453
   </xmax>
   <ymax>
    1147
   </ymax>
  </bndbox>
 </object>
</annotation>

我宁愿让输出文件看起来像这样:

<annotation>
 <folder>Definitiva</folder>
 <filename>armas_229.jpg</filename>
 <path>/tmp/tmpygedczp5/handgun/images/armas_229.jpg</path>
 <size>
  <width>1800</width>
  <height>1426</height>
  <depth>3</depth>
 </size>
 <segmented>0</segmented>
 <object>
  <name>handgun</name>
  <pose>Unspecified</pose>
  <truncated>0</truncated>
  <difficult>0</difficult>
  <bndbox>
   <xmin>1001</xmin>
   <ymin>549</ymin>
   <xmax>1453</xmax>
   <ymax>1147</ymax>
  </bndbox>
 </object>
</annotation>

我打开文件并像这样得到“汤”:

    with open(pascal_xml_file_path) as pascal_file:
        pascal_contents = pascal_file.read()
    soup = BeautifulSoup(pascal_contents, "xml")

完成对文档的几个值的修改后,我使用BeautifulSoup.prettify 将文档重写回文件,如下所示:

    with open(pascal_xml_file_path, "w") as pascal_file:
        pascal_file.write(soup.prettify())

我的假设是BeautifulSoup.prettify 默认情况下会添加这些多余/无用的换行符,并且似乎没有修改这种行为的好方法。我是否错过了 BeautifulSoup 文档中的某些内容,或者我真的无法修改此行为并且需要使用另一种方法将 XML 输出到文件?也许我最好改用xml.etree.ElementTree 重写它?

【问题讨论】:

【参考方案1】:

我的假设是 BeautifulSoup.prettify 正在添加这些 默认情况下多余/无偿的换行符,并且没有出现 是修改这种行为的好方法。

它在bs4.Tagdecodedecode_contents 的两个方法中这样做。

参考:Source file on github

如果你只是需要一个临时修复,你可以猴子补丁这两种方法

这是我的实现

from bs4 import Tag, NavigableString, BeautifulSoup
from bs4.element import AttributeValueWithCharsetSubstitution, EntitySubstitution


def decode(
    self, indent_level=None,
    eventual_encoding="utf-8", formatter="minimal"
):
    if not callable(formatter):
        formatter = self._formatter_for_name(formatter)

    attrs = []
    if self.attrs:
        for key, val in sorted(self.attrs.items()):
            if val is None:
                decoded = key
            else:
                if isinstance(val, list) or isinstance(val, tuple):
                    val = ' '.join(val)
                elif not isinstance(val, str):
                    val = str(val)
                elif (
                    isinstance(val, AttributeValueWithCharsetSubstitution)
                    and eventual_encoding is not None
                ):
                    val = val.encode(eventual_encoding)

                text = self.format_string(val, formatter)
                decoded = (
                    str(key) + '='
                    + EntitySubstitution.quoted_attribute_value(text))
            attrs.append(decoded)
    close = ''
    closeTag = ''
    prefix = ''
    if self.prefix:
        prefix = self.prefix + ":"

    if self.is_empty_element:
        close = '/'
    else:
        closeTag = '</%s%s>' % (prefix, self.name)

    pretty_print = self._should_pretty_print(indent_level)
    space = ''
    indent_space = ''
    if indent_level is not None:
        indent_space = (' ' * (indent_level - 1))
    if pretty_print:
        space = indent_space
        indent_contents = indent_level + 1
    else:
        indent_contents = None
    contents = self.decode_contents(
        indent_contents, eventual_encoding, formatter)

    if self.hidden:
        # This is the 'document root' object.
        s = contents
    else:
        s = []
        attribute_string = ''
        if attrs:
            attribute_string = ' ' + ' '.join(attrs)
        if indent_level is not None:
            # Even if this particular tag is not pretty-printed,
            # we should indent up to the start of the tag.
            s.append(indent_space)
        s.append('<%s%s%s%s>' % (
                prefix, self.name, attribute_string, close))
        has_tag_child = False
        if pretty_print:
            for item in self.children:
                if isinstance(item, Tag):
                    has_tag_child = True
                    break
            if has_tag_child:
                s.append("\n")
        s.append(contents)
        if not has_tag_child:
            s[-1] = s[-1].strip()
        if pretty_print and contents and contents[-1] != "\n":
            s.append("")
        if pretty_print and closeTag:
            if has_tag_child:
                s.append(space)
        s.append(closeTag)
        if indent_level is not None and closeTag and self.next_sibling:
            # Even if this particular tag is not pretty-printed,
            # we're now done with the tag, and we should add a
            # newline if appropriate.
            s.append("\n")
        s = ''.join(s)
    return s


def decode_contents(
    self,
    indent_level=None,
    eventual_encoding="utf-8",
    formatter="minimal"
):
    # First off, turn a string formatter into a function. This
    # will stop the lookup from happening over and over again.
    if not callable(formatter):
        formatter = self._formatter_for_name(formatter)

    pretty_print = (indent_level is not None)
    s = []
    for c in self:
        text = None
        if isinstance(c, NavigableString):
            text = c.output_ready(formatter)
        elif isinstance(c, Tag):
            s.append(
                c.decode(indent_level, eventual_encoding, formatter)
            )
        if text and indent_level and not self.name == 'pre':
            text = text.strip()
        if text:
            if pretty_print and not self.name == 'pre':
                s.append(" " * (indent_level - 1))
            s.append(text)
            if pretty_print and not self.name == 'pre':
                s.append("")
    return ''.join(s)


Tag.decode = decode
Tag.decode_contents= decode_contents

在这之后,当我做print(soup.prettify)时,输出是

<annotation>
 <folder>Definitiva</folder>
 <filename>armas_229.jpg</filename>
 <path>/tmp/tmpygedczp5/handgun/images/armas_229.jpg</path>
 <size>
  <width>1800</width>
  <height>1426</height>
  <depth>3</depth>
 </size>
 <segmented>0</segmented>
 <object>
  <name>handgun</name>
  <pose>Unspecified</pose>
  <truncated>0</truncated>
  <difficult>0</difficult>
  <bndbox>
   <xmin>1001</xmin>
   <ymin>549</ymin>
   <xmax>1453</xmax>
   <ymax>1147</ymax>
  </bndbox>
 </object>
</annotation>

我在做这个的时候做了很多假设。只是想证明这是可能的。

【讨论】:

非常好的工作,感谢@Bitto Bennichan。我可能会使用 ElementTree 重写我的代码,因为我希望不会像这样“脱离预订”,而且我认为 BeautifulSoup 并没有真正给我带来 ElementTree 不提供的东西(我使用 BS,因为它被用于我为这个任务而关注的教程)。不错的技巧,赞!【参考方案2】:

考虑XSLT 与Python 的第三方模块lxml(您可能已经拥有BeautifulSoup 集成)。具体来说,调用identity transform照原样复制XML,然后在所有文本节点上运行normalize-space()模板。

XSLT (另存为 .xsl、特殊的 .xml 文件或嵌入字符串)

<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
    <xsl:output indent="yes"/>
    <xsl:strip-space elements="*"/>

    <!-- IDENTITY TRANSFORM -->
    <xsl:template match="@*|node()">
      <xsl:copy>
        <xsl:apply-templates select="@*|node()"/>
      </xsl:copy>
    </xsl:template>

    <!-- RUN normalize-space() ON ALL TEXT NODES -->
    <xsl:template match="text()">
        <xsl:copy-of select="normalize-space()"/>
    </xsl:template>            
</xsl:stylesheet>

Python

import lxml.etree as et

# LOAD FROM STRING OR PARSE FROM FILE
str_xml = '''...'''    
str_xsl = '''...'''

doc = et.fromstring(str_xml)
style = et.fromstring(str_xsl)

# INITIALIZE TRANSFORMER AND RUN 
transformer = et.XSLT(style)
result = transformer(doc)

# PRINT TO SCREEN
print(result)

# SAVE TO DISK
with open('Output.xml', 'wb') as f:
     f.write(result)

Rextester demo

【讨论】:

谢谢@Parfait,这很有帮助。我希望有一些开箱即用的东西,而不是采用这样的方法,因为这似乎与重写我的代码以使用 ElementTree 而不是 BeautifulSoup 一样多。这假设 ElementTree 的输出将被合理简洁地格式化,不同于默认的 BeautifulSoup 输出,我想只有一种方法可以找出答案(即停止懒惰并使用 ElementTree 进行重写)。无论如何,再次感谢您的帮助。 嗯...在 .xml 旁边保存一个单独的 .xsl(或作为演示显示的字符串嵌入)工作量太大?在 Python 中没有任何循环或重写树?不过明白了。我不知道您的整个过程只是为了回答标题问题。我只使用lxml 来满足我所有的 XML 需求(甚至是 html),它是一个完全兼容的 XPath 1.0 和 XSLT 1.0 库。祝你好运!也许这可以帮助未来的读者。 事实证明,使用 ElementTree 重写我的代码很简单,并且当我使用 ElementTree.write(file_path) 时,XML 格式看起来符合预期/期望。无论如何,这对我来说似乎是最简单/最简单的...... 很高兴听到!很高兴它成功了。随意回答自己的问题。希望它不涉及循环。 经过多一点测试,结果发现我错了,事情并没有这么简单。在我重写 XML 之前,我需要使用 strip() 使用文本值更新所有元素,以删除无关的空格和换行符。尽管如此 xml.etree.ElementTree 似乎更容易使用,所以我开始使用它而不是 BeautifulSoup 来做这类事情。【参考方案3】:

如果我改用 xml.etree.ElementTree 而不是 BeautifulSoup,那么得到我想要的缩进是很直接的。例如,下面是一些读取 XML 文件的代码,清除文本元素中的任何换行符/空格,然后将树写入 XML 文件。

import argparse
from xml.etree import ElementTree


# ------------------------------------------------------------------------------
def reformat(
        input_xml: str,
        output_xml: str,
):
    tree = ElementTree.parse(input_xml)

    # remove extraneous newlines and whitespace from text elements
    for element in tree.getiterator():
        if element.text:
            element.text = element.text.strip()

    # write the updated XML into the annotations output directory
    tree.write(output_xml)


# ------------------------------------------------------------------------------
if __name__ == "__main__":

    # parse the command line arguments
    args_parser = argparse.ArgumentParser()
    args_parser.add_argument(
        "--in",
        required=True,
        type=str,
        help="file path of original XML",
    )
    args_parser.add_argument(
        "--out",
        required=True,
        type=str,
        help="file path of reformatted XML",
    )
    args = vars(args_parser.parse_args())

    reformat(
        args["in"],
        args["out"],
    )

【讨论】:

【参考方案4】:

我写了一个代码来做美化,没有任何额外的库。

美化逻辑

# Recursive function (do not call this method)
def _get_prettified(tag, curr_indent, indent):
    out =  ''
    for x in tag.find_all(recursive=False):
        if len(x.find_all()) == 0:
            content = x.string.strip(' \n')
        else:
            content = '\n' + _get_prettified(x, curr_indent + ' ' * indent, indent) + curr_indent
    
        attrs = ' '.join([f'k="v"' for k,v in x.attrs.items()])
        out += curr_indent + ('<%s %s>' % (x.name, attrs) if len(attrs) > 0 else '<%s>' % x.name) + content + '</%s>\n' % x.name
    
    return out 
    
# Call this method
def get_prettified(tag, indent):
    return _get_prettified(tag, '', indent);

您的意见

source = """<annotation>
 <folder>
  Definitiva
 </folder>
 <filename>
  armas_229.jpg
 </filename>
 <path>
  /tmp/tmpygedczp5/handgun/images/armas_229.jpg
 </path>
 <size>
  <width>
   1800
  </width>
  <height>
   1426
  </height>
  <depth>
   3
  </depth>
 </size>
 <segmented>
  0
 </segmented>
 <object>
  <name>
   handgun
  </name>
  <pose>
   Unspecified
  </pose>
  <truncated>
   0
  </truncated>
  <difficult>
   0
  </difficult>
  <bndbox>
   <xmin>
    1001
   </xmin>
   <ymin>
    549
   </ymin>
   <xmax>
    1453
   </xmax>
   <ymax>
    1147
   </ymax>
  </bndbox>
 </object>
</annotation>"""

输出

bs = BeautifulSoup(source, 'html.parser')
output = get_prettified(bs, indent=2)
print(output)

# Prints following
<annotation>
  <folder>Definitiva</folder>
  <filename>armas_229.jpg</filename>
  <path>/tmp/tmpygedczp5/handgun/images/armas_229.jpg</path>
  <size>
    <width>1800</width>
    <height>1426</height>
    <depth>3</depth>
  </size>
  <segmented>0</segmented>
  <object>
    <name>handgun</name>
    <pose>Unspecified</pose>
    <truncated>0</truncated>
    <difficult>0</difficult>
    <bndbox>
      <xmin>1001</xmin>
      <ymin>549</ymin>
      <xmax>1453</xmax>
      <ymax>1147</ymax>
    </bndbox>
  </object>
</annotation>

在此处运行您的代码:https://replit.com/@bikcrum/BeautifulSoup-Prettifier

【讨论】:

我尝试了您的代码,它在 html 解析器 bs = BeautifulSoup(source, 'html.parser') 上运行良好,但将所有标签都转换为小写。当我使用bs = BeautifulSoup(source, 'xml') 时,它给出了正确的大小写标签,但在行之间产生了间隙。

以上是关于如何在没有多余换行符的情况下从 BeautifulSoup 输出 XML?的主要内容,如果未能解决你的问题,请参考以下文章

如何在没有开发人员帐户的情况下从 .app 文件构建 .ipa 文件?

如何在没有 \n 的情况下从 txt 中获取特定行(Python)

如何在没有 OutOfMemory 错误的情况下从 FileInputStream 获取字节数组

如何在没有“可选”的情况下从 plist 打印字符串?

如何在没有终端提示的情况下从 IPython 会话中复制

如何在没有互联网连接的情况下从位置获取地址