ElementTreeやlxmlで名前空間を含むXMLの要素を取得する
PythonでElementTreeやlxmlを使って名前空間つきのXMLから要素を取得しようとしても、思い通りに取得できないことがあります。これはよくあるハマりどころですが、あまりまとまった情報がないのでまとめておきます。
Python 3.6.0で検証しました。
目次:
解決したい問題
まず前提としてXMLの名前空間について確認した後、解決したい問題について述べます。
前提知識: XMLの名前空間
1つのXML文書に複数の語彙を混ぜた場合、タグ名などが衝突してしまう可能性があるので、それらの意味を明確にするのが名前空間です。
XMLの名前空間はURI(ここではhttp://www.w3.org/2005/Atom
)で表され、XML文書内では名前空間に属する要素や属性を接頭辞(ここではatom:
)をつけて表します。
<atom:feed xmlns:atom="http://www.w3.org/2005/Atom"> ... <atom:title>orangain flavor</atom:title> ... </atom:feed>
xmlns属性でデフォルト名前空間として指定した名前空間は接頭辞が不要になるので、以下のようにも書けます。
<feed xmlns="http://www.w3.org/2005/Atom"> ... <title>orangain flavor</title> ... </feed>
当ブログのAtomのソースもこのような形になっています。
参考: XML名前空間の簡単な説明
問題: 名前空間を含むXMLから意図した要素を取得できない
さて、このようなXML文書のtitle
要素を取得しようとして、以下のように書いても要素が取得できません。
from urllib.request import urlopen from xml.etree import ElementTree f = urlopen('http://orangain.hatenablog.com/feed') xml = f.read() root = ElementTree.fromstring(xml) title = root.find('./title') # title要素を取得するつもりがNoneとなる print(title) # None
以降では、この問題への対応方法を見ていきます。(RSSやAtomなどのフィードに限って言えば、feedparserのようなライブラリを使えば生のXMLを意識する必要はありませんが、ここでは名前空間を持つXMLの一例としてAtomを取り上げます。)
対応方法
基本的な対応方針としては以下の2つを行います。
注意点は、デフォルト名前空間であっても必ず適当な接頭辞をつけなくてはいけないということです。「名前空間なし」と「デフォルト名前空間」は完全に別のものと認識されます。
ElementTree(標準ライブラリ)の場合
ElementTreeのfind()
やfindall()
では以下のようになります。
from urllib.request import urlopen from xml.etree import ElementTree f = urlopen('http://orangain.hatenablog.com/feed') xml = f.read() root = ElementTree.fromstring(xml) title = root.find('./atom:title', {'atom': 'http://www.w3.org/2005/Atom'}) # 第2引数に名前空間のマッピングを指定 print(title) # <Element '{http://www.w3.org/2005/Atom}title' at 0x108e545e8>
ちなみに以下のようにXPath内に直接名前空間のURIを書くこともできます。これはElementTreeのAPIで使用できる特殊な書式であり、有効なXPathではないため、後述するlxmlのxpath()
では使用できません。(そもそもElementTreeではXPathのサブセットのみ使用できます。)
title = root.find('./{http://www.w3.org/2005/Atom}title') # 名前空間のURIをXPathに含める
lxml(サードパーティライブラリ)の場合
lxml 3.8.0で検証しました。
lxmlのxpath()
では以下のようになります。なおnamespaces
はキーワード引数でしか指定できません。
from urllib.request import urlopen from lxml import etree f = urlopen('http://orangain.hatenablog.com/feed') xml = f.read() root = etree.fromstring(xml) title = root.xpath('./atom:title', namespaces={'atom': 'http://www.w3.org/2005/Atom'})[0] print(title) # <Element {http://www.w3.org/2005/Atom}title at 0x108ab8e08>
(lxmlにもElementTree互換のfind()
などがあり、ElementTreeの場合と同様の書き方ができます。が、lxmlで敢えてXPathのサブセットしか使えないこれらのAPIを使うメリットは少ないでしょう。詳しくはあとで触れます。)
XPathにデフォルト名前空間を指定したい
さて、名前空間を指定すれば取得できるのはわかりましたが、XPathに名前空間を含めるのは正直面倒です。XMLではデフォルト名前空間によって簡潔に書けるようになっていますが、XPathでも同様にできないのでしょうか。
結論から言えばXPath 1.0ではできません。XPath 1.0の仕様として、名前空間を指定しない要素は常に「名前空間なし」の要素を意味し、デフォルト名前空間のようなものを指定することはできません。lxmlのFAQにもこの旨が書かれています。
XPath 2.0以降ではデフォルト名前空間を指定できるようになっているようですが、XPath 2.0以降は1.0から大きく変わってXQueryという仕様のサブセットになっています。世の中の実装も多くはXPath 1.0のままであり、lxmlで使われているlibxml2でもXPath 2.0をサポートする予定はないそうです。
参考: objective c - Does libxml2 support XPath 2.0 or not? - Stack Overflow
諦めてXPathに名前空間を含めましょう、ということなのですが、微妙な感じの対処法を2つ紹介しておきます。
実はlxmlのfind()やfindall()ではデフォルト名前空間を指定できる
実はlxmlのfind()
やfindall()
はElementTreeのそれから拡張されており、デフォルト名前空間を指定できます。名前空間マッピングでキーをNone
にすると、それがデフォルト名前空間として使用されます。
title = root.find('./title', {None: 'http://www.w3.org/2005/Atom'})
さらに、lxmlではroot.nsmap
でドキュメントの名前空間マッピングを取得できるため、以下のように書けます。
title = root.find('./title', root.nsmap)
また、lxmlでは名前空間のURIにワイルドカードを指定できるので、以下のようにも書けます。
title = root.find('./{*}title')
これらの書き方だと名前空間のURIを意識しなくてよいので嬉しいですが、ElementTreeからの拡張についてドキュメント化されていないこと、lxmlでfind()
を使うメリットが少ないことから、微妙な感じです。
よく使うであろうxpath()
では、名前空間マッピングのキーをNoneにするとエラーになるので注意が必要です。
名前空間の宣言を消してしまう(雑)
非常に雑ですが、XMLの文字列から名前空間の宣言を消してしまうという方法もあります。以下のようにデフォルト名前空間の宣言を消せば、名前空間を気にせずに要素を取得できるというわけです。
from urllib.request import urlopen from xml.etree import ElementTree import re f = urlopen('http://orangain.hatenablog.com/feed') xml = f.read() root = ElementTree.fromstring(re.sub(rb'xmlns=".*?"', b'', xml, count=1)) title = root.find('./title') print(title) # <Element 'title' at 0x108308598>
書き捨てのスクリプトであれば、これで十分な場合もあるでしょう。誤って意図しない箇所を置換してしまう可能性もあるので、自己責任でどうぞ。
デフォルト名前空間だけでなく、他の名前空間もある場合は、すべての名前空間を消す必要があります。正規表現では厳しくなってくるので、一度パースしてから書き換えるのが無難です。詳しくは以下のページを参考にしてください。
ちなみにこのページにも「XMLの文字列を正規表現で書き換えるなんてとんでもない!」というコメントがあります。
まとめ
名前空間を含むXMLに対してXPathを使用する際は、面倒ですが名前空間を1つ1つ指定しましょう。
Pythonクローリング&スクレイピングでElementTreeを扱った箇所では、シンプルさを優先して名前空間のついたAtomの代わりに名前空間のないRSS 2.0からデータを取得しました。よくあるハマりどころなので、名前空間のついたXMLを扱う方法もどこかに書いておけばよかったなと思い、記事にしました。
参考
- lxmlでXMLパースしたらnamespaceではまった - YAMAGUCHI::weblog
- Pythonで名前空間付きのXMLを操作する(ElementTree) - Qiita
- RSS1.0を検索する際の名前空間の指定 - stogの日記
- lxmlでXMLをパースする - ふたこもり
- Issue 18304: ElementTree – provide a way to ignore namespace in tags and searches - Python tracker
- python - lxml etree xmlparser remove unwanted namespace - Stack Overflow