python-xextract icon indicating copy to clipboard operation
python-xextract copied to clipboard

Reusing parsed lxml trees

Open levic opened this issue 1 year ago • 0 comments

While it's possible to use Prefix to narrow the scope of what you're parsing, that really only works if the shape of the data is predictable ahead of time.

It doesn't appear to be possible to avoid causing lxml to reparse the entire document when handling children.

An example:

data.html:

<html>
  <body>

    <div class="category">
      <h1>Fruit</h1>
      <p><div class="count">3</div>results</p>
      <div class="product">Apple</div>
      <div class="product">Pear</div>
      <div class="product">Orange</div>
    </div>

    <div class="category">
      <h1>Vegetables</h1>
      <p><div class="count">2</div>result</p>
      <div class="product">Potato</div>
      <div class="product">Pumpkin</div>
    </div>

  </body>
</html>
#!/usr/bin/env python3
from lxml.etree import tostring

from xextract import Element, String

html = open('data.html').read()

category_elements = Element(css='.category').parse(html)
for category_element in category_elements:
    # this does not work because XPathExtractor.get_root()
    # assumes body is a string
    #extractor = HtmlXPathExtractor(category_element)

    # this works because `BaseParser.parse()` has a special case for
    # `body` values that are already an `XPathExtractor`
    # but it is inefficient: we convert an lxml element into a string
    # and then re-parse it
    extractor = HtmlXPathExtractor(tostring(category_element))

    category_name = String(css='h1', quant=1).parse(extractor)
    product_count = int(String(css='.count', quant=1).parse(extractor))

    # note that `product_count` here was dynamically extracted from the html
    product_names = String(css='.product', quant=product_count).parse(extractor)

    print(f"Category '{category_name}' contains {product_count} products:")
    for product_name in product_names:
        print(f"  {product_name}")

The important part here is that we want to validate that the number of products matches the product count string. For a file this small it obviously doesn't make much a difference, but consider a very large file.

One workaround is to create a custom parser:

import lxml.etree
class ElementHtmlXPathExtractor(HtmlXPathExtractor):
    def _get_root(self, body):
        if isinstance(body, lxml.etree._Element):
            return body
        return super()._get_root()


# and then in our loop we can do:
for category_element in category_elements:
    ...
    extractor = ElementHtmlXPathExtractor(category_element)
    ...

It would be nice if the _get_root() special case check for _Element was integrated into XPathExtractor.

levic avatar Feb 06 '23 07:02 levic