参考: https://docs.python.org/zh-tw/3.11/library/xml.etree.elementtree.html

背景

ElementTree XML API主要是xml.etree.ElementTree模块,该模块实现了一个简单高效的API,用于解析和创建XML数据。

本博客内容,是使用xml.etree.ElementTree(以下简称ET)的简短教程,目标是演示模块的一些构建块和基本概念,再加上我自己的一个实战数据库清洗实例。

另外,如果xml入门了,其实学html也就简单了。

XML树和元素

XML是一种继承性的分层数据格式,最自然的表示方法是使用树。

为此,ET有两个类:

  • ElementTree将整个XML文档表示为1个树
  • Element表示该树中的单个节点

与整个文档的交互(读写文件)通常是在ElementTree级别完成的,与单个XML元素及其子元素的交互是在Element级别完成的。

解析XML

我将使用我本人的一个示例数据来进行演示,

此处使用vscode拓展来展示xml格式数据的文本内容:

内容展示如下:

然后这个xml格式的chart参考官网:https://www.ideal-db.org/chart.html

可以看到这个数据格式比较复杂:

主要格式如下

我们可以通过从文件中读取来导入此数据:

import xml.etree.ElementTree as ET
tree = ET.parse("该xml文件路径")
root = tree.getroot()

除了从文件中读取xml,我们还可以直接从字符串中解析,

也就是对1个xml内容的文本文档进行解析,

官方教程示例:https://docs.python.org/zh-tw/3.11/library/xml.etree.elementtree.html

效果如下:

总而言之,两种解析方法:

方法 输入 返回
ET.parse(file) XML 文件 ElementTree对象
ET.fromstring(xml_str) XML 字符串 根节点 Element

所以:

  • parse要多一步:.getroot()
  • fromstring直接返回root

fromstring()将XML 从字符串直接解析为Element,该元素是已解析树的根元素。 其他解析函数可能会创建一个ElementTree。 确切信息请查阅文档。

然后作为Element,root具有标签和属性字典:

root.tag, root.attrib, len(root)

因为我这爬的是一个数据库

还有可以迭代的子节点,然后子节点同样有标签和属性字典:

for child in root:
    print(child.tag, child.attrib)

比如说这一段,我想打印每个entry的标签+属性+文本内容,

for child in root:
    # 打印标签和属性
    print(f"标签: {child.tag}, 属性: {child.attrib}")
    
    # 打印这个节点里的文本内容
    if child.text is not None:
        print(f"内容: {child.text.strip()}")
    
    print("-" * 40)

比如说,我们能够看到这个数据库的版本是去年2月更新的,如果对照官方数据库更新时间戳发现比较旧,我们就可以换新的数据。

然后子级是可以嵌套的,我们可以通过索引访问特定的子级节点:

比如说root[0]就是version节点

注意:并非XML 输入的所有元素都将作为解析树的元素结束。 目前,此模块跳过输入中的任何XML 注释、处理指令和文档类型声明。 然而,使用这个模块的API 而不是从XML 文本解析构建的树可以包含注释和处理指令,生成XML 输出时同样包含这些注释和处理指令。 可以通过将自定义TreeBuilder实例传递给XMLParser构造器来访问文档类型声明。

用于非阻塞解析的拉取API

官方文档参考:https://docs.python.org/zh-tw/3.11/library/xml.etree.elementtree.html#pull-api-for-non-blocking-parsing

简单来讲 Python xml.etree.ElementTree 里的「非阻塞、增量式 XML 解析」,重点是 XMLPullParser


1,普通解析的问题

  • 普通 parse / fromstring 都是一次性读完整个文档才返回结果。
  • 大文件、网络流会阻塞、占内存

2,推送式 API(XMLParser)的问题

  • XMLParser 是推送式:解析器主动把数据推给你的回调函数。
  • 不够灵活,不好控制流程。

3,XMLPullParser 是什么?

拉取式、非阻塞、增量解析 API

  • 我用 feed() 一段一段喂 XML 数据(比如从网络分片接收)。
  • 我用 read_events() 主动去拉解析结果(start/end 事件 + Element 对象)。
  • 不用等全部读完,边收边解析,不阻塞

特点:

  • 适合:网络套接字、流式读取、不能阻塞的场景
  • 能拿到完整的 Element 对象,不是裸字符串。

举个例子:

parser = XMLPullParser(['start', 'end'])

parser.feed('<mytag>sometext')   # 喂第一段
→ 读到 start 事件

parser.feed(' more text</mytag>') # 喂第二段
→ 读到 end 事件,拿到完整元素

4,和 iterparse 的区别

  • XMLPullParser完全非阻塞,适合网络流。
  • iterparse会阻塞读取,但也能增量解析、省内存,适合超大本地 XML 文件

5,flush() 作用

  • flush() 强制立即解析已喂的数据,降低延迟
  • 要注意安全说明。

总而言之,XMLPullParser就是一个:我们可以一段段喂数据、一段段取解析结果,不阻塞、还能拿到完整Element的流式XML解析器。

查找感兴趣的元素

Element有一些很有效的方法,可以帮助递归遍历其下的所有子树(包括子级,子级的子级,等等),比如说Element.iter():

我们的xml结构大致如下

<?xml version="1.0" encoding="UTF-8"?>
<IDEAL>
  <version>20250201</version>
  <IDEAL_entry>
    <date>
      <pdb_dataset>20180803</pdb_dataset>
      <uniprot_dataset>20180425</uniprot_dataset>
      <ideal_dataset>20250201</ideal_dataset>
    </date>
    <idp_id>IID00001</idp_id>
    <General>
      <name>Nuclear receptor subfamily 5 group A member 2</name>
      <synonym>Alpha-1-fetoprotein transcription factor</synonym>
      <synonym>B1-binding factor</synonym>
      <synonym>CYP7A promoter-binding factor</synonym>
      <synonym>Hepatocytic transcription factor</synonym>
      <synonym>Liver receptor homolog 1</synonym>
      <source_organism>Homo sapiens</source_organism>
      <sequence_length>541</sequence_length>
      <uniprot>Q00482</uniprot>
      <uniprot>B4E2P3</uniprot>
      <uniprot>O95642</uniprot>
      <uniprot>Q147U3</uniprot>
      <function>Nuclear receptor that acts as a key metabolic sensor by regulating the expression of genes involved in bile acid synthesis, cholesterol homeostasis</function>
      <subcellular_location>
        <location_name>Nucleus.</location_name>
        <location_ac>SL-0191</location_ac>
        <location_llps>unkown</location_llps>
      </subcellular_location>
      <motif>
        <motif_name>FTZ-F1 box</motif_name>
        <motif_region>
          <motif_region_start>155</motif_region_start>
          <motif_region_end>184</motif_region_end>
        </motif_region>
      </motif>
      <gene_symbol>NR5A2</gene_symbol>
      <entry_name>Nuclear receptor subfamily 5 gro</entry_name>
      <sequence>MSSNSDTGDLQESLK HGLTPI GAGLPDRH GSPIPAR GRLVMLPKVETEALGARSHGEQGGQMPENMQVSQFKMVNYSYDEDLEELCPVCGDKVSGYHYGLLTCESCKGFFKRTVQNNKRYTCIENQNCQIDKTQKRKRCPCYRFQKC</sequence>
    </General>
    <Region>
      <region_id>1</region_id>
      <chain_id>1yokaA</chain_id>
      <region_start>300</region_start>
      <region_end>334</region_end>
      <order_disorder>order</order_disorder>
      <condition_id>1</condition_id>
    </Region>
  </IDEAL_entry>
</IDEAL>

按照上图所示,比如说我们可以只遍历某一类标签,比如说所有<idp_id>,

for idp_id in root.iter("idp_id"):
    print(idp_id.text, "\n")

Element.findall()仅查找当前元素的直接子元素中带有指定标签的元素。Element.find()找带有特定标签的第一个子级,然后可以用Element.TEXT访问元素的文本内容,Element.get访问元素的属性。

比如说我想访问每一个IDEAL entry下面general条目其中motif项的motif_region信息,

我就可以

for motif_region in root.findall("IDEAL_entry/General/motif/motif_region"):
    start = motif_region.find("motif_region_start").text
    end = motif_region.find("motif_region_end").text
    print(f"{start}-{end}\n")

通过使用XPath,可以更精确地指定要查找的元素。

然后对于我的这个例子,我知道root节点也就是IDEAL节点下有很多直接子节点,

其中比较重要的好似IDEAL_entry,也就相当于是每一个具体的我们需要知道的条目数据。

然后我们一开始是不知道有哪些子节点的,一般如果数据不是很大的话,可以尝试遍历所有的<IDEAL_entry>并打印其所有子节点内容,

比如说我们这里就可以直接打印前5个entry,看看具体每一个entry里面都有哪些内容、树状结构是怎么组织的。

# 遍历所有 IDEAL_entry
for entry in root.findall('IDEAL_entry')[:5]:  # 只看前5个条目
    print(f"=== IDEAL_entry 开始 ===")
    # 递归遍历 entry 下的所有子节点(包括子子节点)
    elem_count = 0
    for elem in entry.iter('*'):
        if elem.text and elem.text.strip():  # 只打印有文本内容的节点
            print(f"{elem.tag}: {elem.text.strip()}")
            elem_count += 1
    print(f"=== IDEAL_entry 结束 (共 {elem_count} 个元素) ===\n")
    print(f"=== IDEAL_entry 结束 ===\n")

XML解析的核心工具函数、属性

前面提到的几个核心函数,Element.iter/findall/find/get,以及几个核心属性,element.tag/attrib/text等,是解析XML的核心工具,此处还是展开来比对讲讲。

工具类型 名称 核心作用 关键特点
遍历函数 Element.iter(tag) 递归遍历当前元素下所有层级的指定标签元素(子级、孙级、曾孙级…) 1. 递归(穿透所有层级);2. 可指定标签,不指定则遍历所有元素;3. 返回迭代器
查找函数 Element.findall() 仅查找当前元素的直接子元素中符合指定标签 / XPpath 的元素 1. 非递归(只查一级);2. 返回列表;3. 支持 XPath 精准定位
查找函数 Element.find() 仅查找当前元素的直接子元素中符合指定标签 / XPpath 的第一个元素 1. 非递归;2. 返回单个元素(无则返回 None);3. 支持 XPath
属性函数 Element.get(key) 获取当前元素的指定属性值(XML 标签上的键值对,如 <a id="123">
中的 id)
1. 只查当前元素的属性;2. 无该属性则返回 None
核心属性 Element.tag 获取当前元素的标签名(如 <IDEAL_entry>
的 tag 是 “IDEAL_entry”)
只读属性,返回字符串
核心属性 Element.attrib 获取当前元素的所有属性,返回字典(如 <entry id="001">
{"id":"001"}
只读属性,返回字典,可通过键取值(如 elem.attrib["id"]
核心属性 Element.text 获取当前元素的文本内容(标签包裹的纯文本,如 <name>张三</name>
的 “张三”)
无文本则返回 None,需用 strip()
去除首尾空格

关键区别在于:

  • 递归 vs 非递归iter() 是唯一能穿透所有层级的遍历工具,findall()/find() 只查直接子级;
  • 返回形式iter() 返回迭代器、findall() 返回列表、find() 返回单个元素;
  • 属性 vs 内容tag/attrib/get() 操作「标签本身」,text 操作「标签包裹的文本」;

基于我前面所提供的xml结构文件,我们此处再演示一下这些工具的具体用法。

Case1: Element.iter () —— 递归查找所有层级的指定元素

场景:想获取XML中所有的uniprot标签(不管它在哪个层级,是root节点的下一级子节点、还是递归的子节点)

# 递归遍历所以uniprot元素
for uniprot in root.iter("uniprot"):
    print(f"Uniprot ID: {uniprot.text.strip()}")

IDEAL_entry本身没有文本内容,会返回None

我们想要的是直接的文本内容

展示所有子元素标签内容如下:

# 拓展:遍历所有IDEAL_entry下的所有子元素(不指定标签)
for elem in root.iter("IDEAL_entry"):
    print(f"\nEntry标签: {elem.tag}, 属性: {elem.attrib}")
    # 遍历当前entry下的所有子元素(递归,不指定标签)
    for child in elem.iter():
        if child.text and child.text.strip():  # 只打印有文本内容的元素
            print(f"  子元素标签: {child.tag}, 内容: {child.text.strip()}")

root.iter()是找到所有子元素(不指定标签),所有层级都展示,其实打印全文。

root.iter(“uniprot”)会穿透IDEAL->IDEAL_entry->General层级,找到所有uniprot,这就是iter最核心的价值。

Case2:Element.findall()—— 只查直接子元素的指定标签

场景:想获取根节点下的所有直接子元素(直接子节点)<IDEAL_entry>。

# 查找root的直接子元素中所有IDEAL_entry
entries = root.findall("IDEAL_entry")
print(f"总共有 {len(entries)} 个 IDEAL_entry")
entries

# 查找root的直接子元素中所有IDEAL_entry
entries = root.findall("IDEAL_entry")
print(f"总共有 {len(entries)} 个 IDEAL_entry")

# 遍历每一个entry,查找其直接子元素General
for entry in entries[:5]: # 只看前5个条目
    general = entry.findall("General")[0] # 获取第一个General元素, 或者用find
    name = general.find("name").text.strip() # General的直接子元素name
    print(f"Entry {entry.find('idp_id').text.strip()}: name = {name}")

# 易错点:findall 不递归!
# 错误示例:想从root直接findall('uniprot') → 找不到,因为uniprot不是root的直接子元素
wrong_uniprots = root.findall('uniprot')
print(f"root.findall('uniprot') 结果: {wrong_uniprots}")  # 输出 []

root.findall(“IDEAL_entry”)能找到,因为IDEAL_entry是root的直接子元素;

但是root.findall(“uniprot”)找不到,因为uniprot在General下,不是直接子元素。

Case3:Element.find()—— 只查直接子元素的第一个指定标签

场景:想快速获取第一个<IDEAL_entry>的文本。

# 找到root下第一个IDEAL_entry
first_entry = root.find("IDEAL_entry")
# 找到first_entry下第一个General
general = first_entry.find("General")
# 找到General下第一个name
name = general.find("name").text.strip()

print(f"第一个Entry的名称: {name}")

正好对应上了:

然后关于查找的话,可以通过使用XPath,更精确地指定要查找的元素。

# 拓展:结合XPath精确查找
# 用find+XPath 直接找第1个IDEAL_entry 下的motif_name
motif_name = root.find("IDEAL_entry[1]/General/motif/motif_name")
print(f"第1个IDEAL_entry的motif_name: {motif_name.text.strip()}")

总而言之,find()是“找第一个”的快捷方式,适合我们确定目标元素只有1个的场景,另外结合XPath能大幅提升查找精确度,细节如下:

XPath语法

此处需要简单解释一下XPath的概念,XPath不是简单的相对路径,是XML专属的查询语言。

XPath(XML Path Language)是专门用于在 XML 文档中定位节点的查询语言,我们可以把它理解为:

  • 像操作系统的「文件路径」(比如 C:/folder/file.txt),但功能更强;
  • 支持「绝对路径」「相对路径」「条件筛选」「位置筛选」等;
  • 不是 Python 专属,是 XML 解析的通用标准(Java、JavaScript 解析 XML 也用 XPath)。

它和普通文件路径的核心区别在于:

特征 普通文件路径 XPath
层级穿透 只能写固定层级 支持 //
穿透任意层级
位置筛选 支持 [1]
[last()]
条件筛选 支持 [@id="001"]
路径类型 绝对 / 相对 绝对 / 相对 / 递归

比如说前面的XPath路径就是“IDEAL_entry[1]/General/motif/motif_name”,

我们可以改成".//IDEAL_entry[1]/General/motif/motif_name",

效果还是:

首先是核心符号的含义

符号 作用
/ 子节点分隔符(严格层级,只找直接子节点)
// 递归下降(穿透任意层级,找所有后代节点)
. 当前节点(根节点调用时,.//
等价于 //
,更规范)
[n] 位置筛选(取第 n 个匹配的节点,XPath 下标从 1 开始,不是 0)

综上我们可以对该XPath解析表达式理解如下:

  • .//:从「当前节点(root)」开始,递归查找所有层级中符合条件的节点(穿透任意子级);
  • IDEAL_entry[1]:找到所有 IDEAL_entry 节点中第 1 个(XPath 下标从 1 开始);
  • /General:取这个 IDEAL_entry直接子节点General
  • /motif:取 General直接子节点motif
  • /motif_name:取 motif直接子节点motif_name

前面我用的是直接写IDEAL_entry开头,为什么不用//呢?

两种写法在我的例子中都是可以的。

关键在于root是节点,IDEAL_entry是root的直接子节点,所以我直接写IDEAL_entry[1]本身能够找到;

但是如果我的XML结构变化,比如说IDEAL_entry被包在里,

比如说结构:

<IDEAL><data><IDEAL_entry>...</IDEAL_entry></data></IDEAL>

这个时候直接写IDEAL_entry[1]就找不到了,而.//IDEAL_entry[1]依然能够穿透找到。

我们可以举一个简单的例子来展示一下(结合.//,使用非根节点):

# 此处须使用相对路径,因为find是从当前节点开始查找的
name = root.find(".//General[1]/name")
print(f"第1个IDEAL_entry的General name: {name.text.strip()}")

结合我们前面的XML场景,整理一些最常用的XPath写法:

需求 XPath 表达式 说明
递归找所有 uniprot .//uniprot 穿透所有层级找 uniprot
找 root 直接子节点 version version
./version
./
表示当前节点的直接子节点
找第一个 entry 的所有 synonym .//IDEAL_entry[1]//synonym 先找第一个 entry,再递归找 synonym
找最后一个 entry 的 name .//IDEAL_entry[last()]/General/name last()
取最后一个匹配节点
# 1, 查找所有uniprot节点
uniprots = root.findall(".//uniprot")
print(f"共找到 {len(uniprots)} 个uniprot节点")
for i, uniprot in enumerate(uniprots[:5]):  # 只看前5个
    print(f"uniprot {i+1}: {uniprot.text.strip()}")

# 2, 查找所有version节点
versions = root.findall("version")
print(f"共找到 {len(versions)} 个version节点")
print(versions[0].text.strip() if versions else "未找到version节点")

# 3, 查找第1个IDEAL_entry下的所有synonym节点
synonyms = root.findall(".//IDEAL_entry[1]//synonym")
print(f"第1个IDEAL_entry的synonym节点数量: {len(synonyms)}")
for i, synonym in enumerate(synonyms[:5]):  # 只看前5个
    print(f"synonym {i+1}: {synonym.text.strip()}")

# 4, 查找最后一个IDEAL_entry下的General/name节点
name = root.find(".//IDEAL_entry[last()]/General/name")
print(f"最后一个IDEAL_entry的General name: {name.text.strip() if name is not None else '未找到'}")

带属性筛选的XPath写法

然后就是一些带属性筛选的写法,比较高频:

如果我们的XML有属性,比如说 <IDEAL_entry id=“IID00001”> ,那么就可以精确筛选:

# 找 id="IID00001" 的 entry 下的 name
entry_name = root.find('.//IDEAL_entry[@id="IID00001"]/General/name')
print(entry_name.text.strip())  # 输出对应名称

# 找所有 id 包含 "IID000" 的 entry(模糊匹配)
# 注:xml.etree 仅支持基础 XPath,模糊匹配需用 lxml 库
entries = root.findall('.//IDEAL_entry[contains(@id, "IID000")]')

当然我此处提供的xml文档中,IDEAL_entry并没有属性,只有子节点。

比如说idp_id,就是IDEAL_entry的子节点,并不是其属性。

如果我们按照节点的属性的查找语法去查找子节点,那么会报错

entry_name = root.find('.//IDEAL_entry[@idp_id="IID00001"]/General/name')
print(entry_name.text.strip())

这两个场景是不同的:

<!-- 场景1:idp_id是IDEAL_entry的【子节点】(我们的真实结构) -->
<IDEAL_entry>
  <idp_id>IID00001</idp_id>  <!-- 子节点:在IDEAL_entry内部,是独立的标签 -->
  <General>...</General>
</IDEAL_entry>

<!-- 场景2:如果idp_id是IDEAL_entry的【属性】(我们前面的写法) -->
<IDEAL_entry idp_id="IID00001">  <!-- 属性:在IDEAL_entry标签内,是键值对 -->
  <General>...</General>
</IDEAL_entry>

简单来说:匹配 XML 节点的**属性子节点**是 XML 解析中最核心的两个概念,新手很容易混淆 。

维度 匹配属性 匹配子节点
XML 结构特征 属性是节点标签内的键="值"
,属于节点的 “属性信息”
子节点是节点内部的独立标签,属于节点的 “子元素”
XPath 语法 节点[@属性名="属性值"]
(带@
和方括号)
节点[子节点名="子节点文本值"]
(无@
我们前面的写法 IDEAL_entry[@idp_id="IID00001"]
找子节点 —— ❌
IDEAL_entry[idp_id="IID00001"]
找子节点 —— ✅
访问值的方式 节点.get("属性名") 子节点.text
(先找到子节点)

补充:如何快速判断1个键值对是属性还是子节点?

  1. 看位置:如果 “键值对” 写在<节点名 ...>里面(标签的尖括号内),就是属性
  2. 看形式:如果是<xxx>值</xxx>这种独立标签,且嵌套在父节点内部,就是子节点
  3. 代码验证
    • 想获取属性:node.get("属性名")(返回字符串或 None);
    • 想获取子节点:node.find("子节点名")(返回节点对象或 None)。

简单总结:

  1. 语法核心区别:匹配属性用 [@属性名="值"](带@),匹配子节点用 [子节点名="值"](无@);
  2. 结构核心区别:属性是节点标签内的键值对,子节点是节点内部的独立标签;
  3. 前面示例的问题:把 “子节点 idp_id” 误判为 “属性 idp_id”,导致 XPath 语法错误,找不到节点。

记住这个核心:@ 是属性的专属标识,看到@就对应节点标签内的键值对,没@就对应子节点的文本值。

所以改过来之后是:

entry_name = root.find('.//IDEAL_entry[idp_id="IID00001"]/General/name')
print(entry_name.text.strip())

还有一些多条件筛选的写法

# 找第一个 entry 中 motif_region_start > 150 的 motif_name
# 注:xml.etree 不支持数值比较,需用 lxml 库
# 示例(lxml 写法):
from lxml import etree
tree = etree.fromstring(xml_content)
motif = tree.xpath('.//IDEAL_entry[1]//motif_region_start[text() > 150]/../motif_name')[0]
print(motif.text)

一些常用的XPath函数

函数 作用 示例
last() 取最后一个节点 .//IDEAL_entry[last()]
position() 取指定位置节点 .//IDEAL_entry[position()<3]
contains() 模糊匹配属性 / 文本 .//IDEAL_entry[contains(@id,"IID")]
text() 匹配节点文本 .//name[text()="Test Protein"]

另外基于我们前面的xml,一些高频的用法(形式上可借鉴,内容上没check过,可能报错)

# 示例1:提取所有 IDEAL_entry 的 id 和对应的 uniprot 列表
for entry in root.findall('.//IDEAL_entry'):
    entry_id = entry.get('id')
    # 递归找当前 entry 下的所有 uniprot
    uniprots = [u.text.strip() for u in entry.findall('.//uniprot')]
    print(f"Entry {entry_id}: Uniprot列表 = {uniprots}")

# 示例2:找所有有 motif 的 entry 名称
motif_entries = root.findall('.//IDEAL_entry[.//motif]')  # 有motif子节点的entry
for entry in motif_entries:
    name = entry.find('.//name').text.strip()
    print(f"包含motif的Entry名称: {name}")

# 示例3:取第二个 entry 的第一个 uniprot
second_uniprot = root.find('.//IDEAL_entry[2]//uniprot[1]')
print(f"第二个Entry的第一个Uniprot: {second_uniprot.text.strip()}")
  • XPath 核心:是 XML 节点查询语言,支持递归(//)、精准层级(/)、位置筛选([1]/>last())、属性筛选([@key="value"]);
  • 关键符号
  • // = 递归穿透所有层级,/ = 仅直接子节点;
  • [n] = 取第 n 个节点(XPath 下标从 1 开始);
  • .// = 从当前节点递归查找(通用且容错);
  • 通用写法原则
  • 不确定层级 → 用 .//目标节点
  • 确定层级 → 用 层级1/层级2/目标节点
  • 需筛选 → 加 [条件](属性 / 位置 / 文本)。
XPath总结
  • XPath 核心:是 XML 节点查询语言,支持递归(//)、精准层级(/)、位置筛选([1]/last())、属性筛选([@key="value"]);
  • 关键符号
    • // = 递归穿透所有层级,/ = 仅直接子节点;
    • [n] = 取第 n 个节点(XPath 下标从 1 开始);
    • .// = 从当前节点递归查找(通用且容错);
  • 通用写法原则
    • 不确定层级 → 用 .//目标节点
    • 确定层级 → 用 层级1/层级2/目标节点
    • 需筛选 → 加 [条件](属性 / 位置 / 文本),注意区分节点属性和子节点
XPath补充

参考官方xml部分补充的XPath内容:https://docs.python.org/zh-tw/3.11/library/xml.etree.elementtree.html#xpath-support

支持的一些XPath语法:

Case4:Element.get()—— 获取元素的属性值

场景:想获取<IDEAL_entry>下某个带有节点内键值对值的标签上的id属性;

节点的属性和子节点的键值对我们前面已经比较过了,本质上.get其实就是获取1个字典或者说键值对的一种防御性编程写法。

get() 是获取单个属性的最优方式,比直接取 attrib[‘id’] 更安全(不会因属性不存在报错)。

因为我们前面xml文本中并没有直接在节点内注明键值对的标签,所以例子略

Case5:tag/attrib/text属性的组合使用

场景:完整提取一个<IDEAL_entry>的所有关键信息

entry = root.find('IDEAL_entry')
print(f"元素标签名: {entry.tag}")  # 输出 IDEAL_entry
print(f"元素所有属性: {entry.attrib}")  # 输出 {}
print(f"元素id属性: {entry.get('id')}")  # 输出 None

# 提取子元素文本
general = entry.find('General')
name = general.find('name').text.strip()  # 提取name文本
synonym = general.find('synonym').text.strip()  # 提取synonym文本
print(f"名称: {name}")
print(f"别名: {synonym}")

简单来说:

  • 遍历 / 查找核心区别iter() 递归查所有层级,findall()/find() 只查直接子级;
  • 属性 vs 内容tag/attrib/get() 操作标签本身的属性 / 名称,ext 操作标签包裹的文本;
  • 使用场景
  • 想找所有层级的某类元素 → 用 iter()
  • 想精准找直接子元素的某类元素(多个)→ 用 findall(), 用XPath可以突破层级限制
  • 想找直接子元素的第一个目标元素 → 用 find()
  • 想获取元素属性 → 用 get()(安全)或 attrib(字典);
  • 想获取元素文本 → 用 text(记得 strip() 去空格)

实例分析:我的一个例子

xml文档还是前面那个,背景如下:

我有1个数据库(官方链接:https://www.ideal-db.org/)需要挖掘,但是官方并没有直接提供REST API之类接口,所以我们没法直接下载;

除了爬虫爬取之外,官方还提供了一个直接的打包压缩的xml格式数据,

鉴于前后两次获取数据的时间比较长,发现数据库又更新了,所以现在手头上有两个xml格式数据,现在想要解析之后相互比较一下,看选哪一个,然后深入解析,获取想要的数据。

  • 目的1:对于每一个数据库xml,需要解析的内容,每一个entry条目要获取其 accession号、关联的uniprot序列号(可能有多个)、disorder无序区域(起止坐标)
  • 目的2:比较这两个新旧数据库xml之间上述内容的异同,节点键值对的异同,相同的部分就不需要修改代码逻辑,不同的部分需要修改
import xml.etree.ElementTree as ET
tree_new = ET.parse("IDEAL_20250908_02Mar2026.xml") # 更新之后的版本内容
tree_old = ET.parse("IDEAL_17Apr24.xml") # 更新之前的版本内容
root_new = tree_new.getroot()
root_old = tree_old.getroot()

部分内容展示如下:

其中XML格式官网组织参考如下:https://www.ideal-db.org/chart.html(该XML格式内容对应最新版本的数据库组织,对旧版本的数据不一定兼容)

我们先来看一下这两个xml格式内容数据上的一些区别(键值对展开),先看相同idp_id的那些条目entry:

# 遍历所有 IDEAL_entry: root_old
entry = root_old.find('IDEAL_entry[idp_id="IID00001"]')  # 只看 idp_id 为 IID00001 的条目
print(f"=== IDEAL_entry old 开始 ===")
# 递归遍历 entry 下的所有子节点(包括子子节点)
elem_count = 0
for elem in entry.iter('*'):
    if elem.text and elem.text.strip():  # 只打印有文本内容的节点
        print(f"{elem.tag}: {elem.text.strip()}")
        elem_count += 1
print(f"=== IDEAL_entry old 结束 (共 {elem_count} 个元素) ===\n")

# 遍历所有 IDEAL_entry: root_new
entry = root_new.find('IDEAL_entry[idp_id="IID00001"]')  # 只看 idp_id 为 IID00001 的条目
print(f"=== IDEAL_entry new 开始 ===")
# 递归遍历 entry 下的所有子节点(包括子子节点)
elem_count = 0
for elem in entry.iter('*'):
    if elem.text and elem.text.strip():  # 只打印有文本内容的节点
        print(f"{elem.tag}: {elem.text.strip()}")
        elem_count += 1
print(f"=== IDEAL_entry new 结束 (共 {elem_count} 个元素) ===\n")

很直观地,我们能够看到即使是对于同一条目,数据库也会进行更新,可能是对附属的子节点的键值对进行增删修改之类。

我们暂时以idp_id为"IID00001"的IDEAL_entry为例,进行模板解析展示。

对照官网的最新xml数据组织表,

  • accession即idp_id,直接在每一个条目的直接子节点下,IDEAL_entry/idp_id

  • uniprot id即IDEAL_entry/General/uniprot,不在每个条目的直接子节点下,我们可以用XPath语法

  • disorder区域注释:我们需要找到每一个条目标注的蛋白质无序区域的起始与终点坐标:
    但是这个区域的注释就有很多可疑选项了
    • IDEAL_entry/Region/order_disorder

- IDEAL_entry/Function_pros/disorder_location

- 其他预测器部分: AlphaFold, NeProc, SEG等

我们先按照固定的entry,展示同1个entry的这3个区域来比较看看:

首先是Region这个IDEAL_entry下的直接子类,我们可以看到其实这个子节点不像General一样,General是每一个IDEAL_entry只有1个子节点,但是Region有很多个子节点,所以我们需要findall;

而且我们可以发现,只有order_disorder属性为disorder的Region子节点才是我们需要的子节点,然后提取这个子节点的region_start以及region_end值

经过比对发现,Region的结构组成格式在新数据库以及旧数据库中是格式不变的,我们的代码逻辑可以一致:

entry = root_old.find('IDEAL_entry[idp_id="IID00001"]')  # 只看 idp_id 为 IID00001 的条目

idr_count = 0
for regions in entry.findall(".//Region"):
    this_region_order_disorder_status = regions.find("order_disorder").text
    this_region_start = regions.find("region_start").text
    this_region_end = regions.find("region_end").text
    if this_region_order_disorder_status == "disorder":
        print(f"Region {this_region_start}-{this_region_end} is disordered.")
        idr_count += 1
print(f"Total IDRs in this entry: {idr_count}")

从结果上来看,光光是IID00001这个entry就有51个无序disorder region

当然,我们也可以直接依据节点的order_disorder子节点内容筛选目标节点,没必要每一个节点的内容都解析出来再判断:
也就是下面这种写法,直接用Region[order_disorder=‘disorder’]键值对框定目标节点

entry = root_old.find('IDEAL_entry[idp_id="IID00001"]')  # 只看 idp_id 为 IID00001 的条目

idr_count = 0
for regions in entry.findall(".//Region[order_disorder='disorder']"):
    this_region_start = regions.find("region_start").text
    this_region_end = regions.find("region_end").text
    print(f"Region {this_region_start}-{this_region_end} is disordered.")
    idr_count += 1
print(f"Total IDRs in this entry: {idr_count}")

我们接着看更新之后的数据库的该条目:代码逻辑一样

entry = root_new.find('IDEAL_entry[idp_id="IID00001"]')  # 只看 idp_id 为 IID00001 的条目

idr_count = 0
for regions in entry.findall(".//Region[order_disorder='disorder']"):
    this_region_start = regions.find("region_start").text
    this_region_end = regions.find("region_end").text
    print(f"Region {this_region_start}-{this_region_end} is disordered.")
    idr_count += 1
print(f"Total IDRs in this entry: {idr_count}")

依然是51个条目,

我们接着再看IDEAL_entry/Function_pros/disorder_location这个条目的信息;

首先是旧数据库:

我们可以明显看到,没有disorder的region,这和前面的Region节点获取的信息完全不一致!

而且旧的数据库根本没有Function_pros这个节点!

然后是更新之后的数据库:

新的数据库中的Function_pros节点是对应旧数据库中的Function节点的,而且直接就没有disorder_location这个子条目了!

从这个例子可以看到,Function/Function_pros是新旧数据库之间对应的条目,但是这个条目无论是新旧数据库,都没有合适地描述出来,至少这个条目中认为是没有无序区域注释的。

然后就是其余预测器预测的无序区域部分,这一部分在新的数据库中是有的,在旧数据库中是不兼容的

在新数据库中就在Function_pros的下一个同级节点中,也是每一个IDEAL_entry的末尾部分。

对于这个数据,我们其实是完全用不上的,因为我们的目的是为了获取真实的无序区域坐标数据,而不是说预测结果。

综上所述,能用的结果只有IDEAL_entry/(Function_pros/Function)/disorder_location条目,以及 IDEAL_entry/Region/order_disorder条目。

但是显然后者提供的信息比前者多。

为了能够更加公正公平地做出决策,我们用实际数据库中的注释,结合经验来判断。

还是用IID00001这个entry为例,

这是新数据库中的Region条目:

比如说我们随便拿第2个条目335-337为例,是disorder的

我们可以多关注一下附加的信息:

chain id是1yokA,是PDB相关的;

然后condition id是1,我们先看看condition本身是什么样的条目:

可以看到condition应该是获取结构方面实验无序区域来源证据的条目

下面是新数据库中的Pros相关条目:

是没有区域范围信息的。

我们可以先看看条目,关键在于这里的pros怎么理解,是否可能是因为pros的定义所以决定其注释在同一个entry内有所不同?

参考官网注释:https://www.ideal-db.org/

所以Pros其实是disorder-to-order也就是执行DOR的IDR片段,也就是说是特殊的无序区域,是无序区域IDR的一个子集。

我们再看看数据库里关于这个条目是怎么存储的:

https://www.ideal-db.org//ideal.php?id=IID00001

这个注释就很奇怪了,有least rule以及majority rule,看来是注释之间有矛盾,所以有少数以及多数之分。

然后我们前面的注释是335-337,可以看到,这一段区域在majority注释中是被标注为order的。

同时我们观察到least rule中有disorder和conflict重合的部分,大概是disorder和order重合了。

另外我们注意到pros和order、disorder的注释是并行的,虽然从定义上来讲pros是属于IDR区域的(当然这个其实也很容易搞混,DOR的区域在转换前是无序的,在转换后是有序的,那是不是按照least rule是conflict的?那是不是conflict的我们可以拿来作为候选的pros?但是其实不是,我们前面看到了在conflict的注释和pros又是并行的,而且相互之间没有重叠,而且pros的条目看细节是order_location兼disorder_location的,又是混杂的数据)。

那问题来了,我们为什么不获取无序区域全集,而是要获取1个特殊的无序区域子集呢?

这个其实就看自己的研究目的了。

如果我们的研究目的是“大网捞鱼”,也就是不错放任何一个,那么我们就可以直接获取所有的区域注释,

把真正的过滤、筛选步骤放在后面。

也就是说,我们在挖掘数据库方面是coarse-grained的,根本目的其实不是为了直接敲定精细的 IDR 边界,而是为了获取具有无序潜力的accession号(比如说有这个区域注释的entry号)。真正精细的下游在后面。

所以,为什么在前面数据库挖掘中混入了大量的噪音,但最终不会影响结果——因为在这段不折叠序列如果根本没有对应的 PDB 结构,或者在 PDB 结构中并没有其他链与它结合,后续的三维结构距离计算/过滤步骤会直接将其剔除。

也就是有的放矢,轻重缓急,也就是不同步骤调整战略。

也就是上游遵循“大网捞鱼”的广义搜索,最大化我们的初始搜索空间,防止漏掉候选数据,也就是说第一步其实是在胡乱捞数据,只求覆盖面广;然后下游我们会一一剔除。

所以,策略最终还是看全局处理流程定的:

  • 算力足的话,我们取region列作为候选数据,务求覆盖面广
  • 算力不足,或者我们的目的是为了获取高置信度、多个证据来源限制的region,我们取function中的pros
  • 当然,在写脚本的时候,只要输出能够统一,其实我们完全可以两个都要,只不过是在参数中加一个脚本罢了

那么还是前面那个例子:

我就可以写一个大致的脚本如下,

首先是Region条目中筛选的,我要看看每一个条目的无序区域范围有哪些,以及总共有多少个条目

# IDEAL_entry/Region[order_disorder='disorder']/
entry_count = 0
for entry in root_new.findall("IDEAL_entry"):
    idp_id = entry.find("idp_id").text
    uniprot_id = ",".join([uniprot.text for uniprot in entry.findall("General/uniprot")])
    regions = []
    for region in entry.findall(".//Region[order_disorder='disorder']"):
        if region.find("region_start") is None or region.find("region_end") is None:
            continue
        this_region_start = region.find("region_start").text
        this_region_end = region.find("region_end").text
        this_region = f"{this_region_start}-{this_region_end}"
        regions.append(this_region)
    
    if regions:
        print("*"*20)
        print(f"IDP ID: {idp_id}")
        print(f"Uniprot ID: {uniprot_id}")
        print(f"Disorder Regions: {",".join(regions)}")
        entry_count += 1

print(f"Total entries with disorder regions: {entry_count}")

可以看到,总共是1206个条目

参考:https://www.ideal-db.org//history.html

其实可以看到,每一个条目如果都算1个uniprot,就算有交集,其实这个量级也差不多是全部的数据库条目了。

但是这里我们注意到一个问题,就是不同的region坐标之间有交集,所以这个坐标该如何处理?

我们可以取并集,或取交集,因为坐标到后面肯定是唯一的;所以这一块我们可以再加一个参数位。

为什么会有这么多重叠和碎片化的区段?

在 IDEAL 数据库中,每一个 <Region type="disorder"> 通常代表某一次特定结构实验(通常是某个具体的 PDB 结构)中缺失的电子密度区域。

  • 同一个蛋白质(Uniprot ID)可能被不同的实验室解析了多次(对应多个 PDB ID)。
  • 在不同的结晶条件、不同的配体结合状态或不同的序列截短下,这根多肽链表现出的“无序/柔性”边界会有细微变化。
  • 比如对于 IID00005,某篇文献里 109-111 没解析出来,另一篇里 108-111 没解析出来,这就生成了两条独立的 Region 记录。

我们的做法是取并集(合并重叠或相邻的区间)

  • 取并集(Union): 只要在某一次实验中被观察到是高度柔性/无序的,我们就认为该序列片段具有内在无序潜能。这也是几乎所有无序数据库(如 MobiDB)合成一致性特征(Consensus)的默认方式。
  • 取交集将会丢失大量真实数据: 如果取交集,意味着只有在所有实验中都无法被解析的区域才算 IDR。但如果某个 IDP 在某次实验中恰好结合了抗体或者配体被诱导折叠(变成了有序结构),取交集就会把这根真实的 IDR 完全抹杀。

然后另外一种就是直接从disorder location中获取,这个就比较简单了,

# IDEAL_entry/Function_pros/disorder_location
entry_count = 0
for entry in root_new.findall("IDEAL_entry"):
    idp_id = entry.find("idp_id").text
    uniprot_id = ",".join([uniprot.text for uniprot in entry.findall("General/uniprot")])
    regions = []
    for pros in entry.findall(".//Function_pros[pros_type='verified']/disorder_location"):
        if pros.find("disorder_region_start") is None or pros.find("disorder_region_end") is None:
            continue
        this_region_start = pros.find("disorder_region_start").text
        this_region_end = pros.find("disorder_region_end").text
        this_region = f"{this_region_start}-{this_region_end}"
        regions.append(this_region)
    
    if regions:
        print("*"*20)
        print(f"IDP ID: {idp_id}")
        print(f"Uniprot ID: {uniprot_id}")
        print(f"Disorder Regions: {",".join(regions)}")
        entry_count += 1

print(f"Total entries with verified disorder_location: {entry_count}")

这个子集就比较少了。

另外,我们同样注意到,这个区域之间是有交集的

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐