使用scrapy的XMLFeedSpider解析XML

0x00

scrapy中,内置了高效解析XML格式文本的XMLFeedSpider.但是由于对XML文档的不熟悉,还有XMLFeedSpider内部的一些坑,学起来并不像文档里介绍的那样简单.

0x01

XMLFeedSpider

先来个官网实例:

from scrapy import log
from scrapy.contrib.spiders import XMLFeedSpider
from myproject.items import TestItem

class MySpider(XMLFeedSpider):
    name = 'example.com'
    allowed_domains = ['example.com']
    start_urls = ['http://www.example.com/feed.xml']
    iterator = 'iternodes' # This is actually unnecessary, since it's the default value
    itertag = 'item'

    def parse_node(self, response, node):
        log.msg('Hi, this is a <%s> node!: %s' % (self.itertag, ''.join(node.extract())))

        item = TestItem()
        item['id'] = node.xpath('@id').extract()
        item['name'] = node.xpath('name').extract()
        item['description'] = node.xpath('description').extract()
        return item

iterator

用于确定使用哪个迭代器的string。可选项有:

  • iternodes - 一个高性能的基于正则表达式的迭代器
  • html - 使用 Selector 的迭代器。 需要注意的是该迭代器使用DOM进行分析,其需要将所有的DOM载入内存, 当数据量大的时候会产生问题。
  • xml - 使用 Selector 的迭代器。 需要注意的是该迭代器使用DOM进行分析,其需要将所有的DOM载入内存, 当数据量大的时候会产生问题。

默认值为 iternodes 。*迭代器有坑,稍后细说*

itertag

一个包含开始迭代的节点名的string。例如:

itertag = 'product'

parse_node(response, selector)

当节点符合提供的标签名时(itertag)该方法被调用。 接收到的response以及相应的 Selector 作为参数传递给该方法。 该方法返回一个 Item 对象或者 Request 对象 或者一个包含二者的可迭代对象(iterable)。

其他组件:

namespaces

一个由 (prefix, url) 元组(tuple)所组成的list。 其定义了在该文档中会被spider处理的可用的namespace。 prefix 及 uri 会被自动调用 register_namespace() 生成namespace。
您可以通过在 itertag 属性中制定节点的namespace。例如:

class YourSpider(XMLFeedSpider):
    namespaces = [('n', 'http://www.sitemaps.org/schemas/sitemap/0.9')]
    itertag = 'n:url'
    # ...

adapt_response(response)

该方法在spider分析response前被调用。您可以在response被分析之前使用该函数来修改内容(body)。 该方法接受一个response并返回一个response(可以相同也可以不同)。
感觉可以在调试的时候用这个方法来看看response的内容

process_results(response, results)

当spider返回结果(item或request)时该方法被调用。 设定该方法的目的是在结果返回给框架核心(framework core)之前做最后的处理, 例如设定item的ID。其接受一个结果的列表(list of results)及对应的response。 其结果必须返回一个结果的列表(list of results)(包含Item或者Request对象)。

0x02

xmlns

首先介绍一个xml文档标签xmlns

<Vulnerability xmlns="http://www.icasi.org/CVRF/schema/vuln/1.1" Ordinal="1573">

在这里用xpath按照其他HTML文档的样子提取不能得到这个标签包含的文档:

response.xpath(//Vulnerability[@xmlns])

原因在于xmlns是命名空间的标签,而*用于标示命名空间的地址不会被解析器用于查找信息。其惟一的作用是赋予命名空间一个惟一的名称。*详见W3C-XML

XMLFeedSpider爬坑之旅

XMLFeedSpider解析带xmlns标签的文档

根据官方例子,构造XMLFeedSpider类:

class CVEspider(XMLFeedSpider):
    name = 'CVEspider'
    allowed_domains = 'cve.mitre.org'
    start_urls = [
        'https://cve.mitre.org/data/downloads/allitems-cvrf-year-1999.xml'
    ]
    iterator = 'iternodes'
    itertag = 'Vulnerability'

    def parse_node(self, response, node):
        # prase codes....

坑 · No.1

运行发现程序根本进不到parse_node方法,而且报错: IndexError: list index out of range
定位到报错文件:scrapy.utils.iterators.xmliter()

def xmliter(obj, nodename):
    """Return a iterator of Selector's over all nodes of a XML document,
       given the name of the node to iterate. Useful for parsing XML feeds.

    obj can be:
    - a Response object
    - a unicode string
    - a string encoded as utf-8
    """
    nodename_patt = re.escape(nodename)

    HEADER_START_RE = re.compile(r'^(.*?)<\s*%s(?:\s|>)' % nodename_patt, re.S)
    HEADER_END_RE = re.compile(r'<\s*/%s\s*>' % nodename_patt, re.S)
    text = _body_or_str(obj)

    header_start = re.search(HEADER_START_RE, text)
    header_start = header_start.group(1).strip() if header_start else ''
    header_end = re_rsearch(HEADER_END_RE, text)
    header_end = text[header_end[1]:].strip() if header_end else ''

    r = re.compile(r'<%(np)s[\s>].*?</%(np)s>' % {'np': nodename_patt}, re.DOTALL)
    for match in r.finditer(text):
        nodetext = header_start + match.group() + header_end
        yield Selector(text=nodetext, type='xml').xpath('//' + nodename)[0]

报错原因就是

yield Selector(text=nodetext, type='xml').xpath('//' + nodename)[0]

这句代码中xpath('//' + nodename)并不能获取到有xmlns标签的XML文档。一个可行的解决办法是重构xmliter()方法,将最后一句改为:

yield Selector(text=nodetext, type='xml').xpath('//*[local-name()="%s"]' % nodename)[0]

这里的local-name()方法作用是获取不包含namespace的前缀。
然而重构scrapy的底层代码并不优雅。
更好的方法是使用xml迭代器+namespace

class CVEspider(XMLFeedSpider):
    name = 'CVEspider'
    allowed_domains = 'cve.mitre.org'
    start_urls = [
        'https://cve.mitre.org/data/downloads/allitems-cvrf-year-1999.xml'
    ]
    iterator = 'xml'
    namespaces = [
        ("vuln", "http://www.icasi.org/CVRF/schema/vuln/1.1")
    ]
    itertag = 'vuln:Vulnerability'

    def parse_node(self, response, node):
       # prase codes...

坑 · No.2

parse_node方法中的node是符合itertag的一个节点。然而坑爹的是node会为它的子节点都加上itertag的namespace。直接打开xml文档是看不到这些子节点上的xmlns标签的!所以要在解析时加上namespace

cve_item['CVE_id'] = node.xpath("//CVE/text()").extract()[0]  # 不能正确解析
cve_item['CVE_id'] = node.xpath("vuln:CVE/text()").extract()[0]  # 可以正确解析
cve_item['CVE_id'] = node.xpath("//*[local-name()='CVE']/text()").extract()[0] # 可以正确解析

对于多重嵌套的节点,要在每个节点标签上都加上namespace

cve_item['published_at'] = self.convert_time(node.xpath("vuln:Notes/vuln:Note[@Title='Published']/text()").extract()[0])

0x03

之前解析xml文档都没注意过xmlns标签,而这些问题,都是因为这个标签所引起的。
总结一下:

  1. XMLFeedSpider解析XML文档时,如果文档含有xmlns标签,最好使用iterator = 'xml'这个迭代器。
  2. parse_node方法中的node会为它的所有子节点加上namespace,解析时要加上相应的namespace

0x04

references:
Google Groups "scrapy-users" group

stackoverflow:xml-namespace-breaking-my-xpath

Comments
Write a Comment
  • 阿东 reply

    不同的节点有不通的命名空间,这个在定义itertag时该如何定义啊