最近在帮朋友整理一批链上转账记录,遇到了一个特别典型的活儿:从一堆混乱文本里把比特币地址精确地捞出来。源数据有网页抓来的、有聊天记录导出的、还有PDF转出来的纯文本,格式乱得让人头大。折腾下来我发现,用Python正则表达式做一轮粗筛,再用Base58Check和Bech32做一轮校验确认,两层配合基本能做到既不漏掉地址,也不混进垃圾字符串。这篇文章就把这套完整流程拆开讲透,从地址格式到正则可复用写法,再到校验代码和踩坑记录,全部一次性给到位。
不管你是刚接触Python入门阶段的新手,还是做爬虫、数据分析、日志处理的老手,这套思路都能拿过去直接改改用。后面所有代码我都放在一个完整的例子里,你只要把文本换掉就能跑。
1. 内容整体设计与思路拆解
1.1 在哪些真实场景里需要“提取比特币地址”
先说需求来源。比特币地址本质上是一个字符串,但它不是随便一段字符就能当地址使用的,它带有一套固定的编码规则,这就给正则表达式留下了很大的发挥空间。
我遇到的需求主要有三类。第一类是爬虫数据清洗,抓取区块浏览器或第三方钱包页面时,HTML页面里除了Address字段,经常还混着交易哈希、区块高度、备注文本,直接提取会带进来大量噪音,需要先把“看起来像地址”的片段筛出来。第二类是聊天记录和论坛帖子里收集打款地址,比如社群公告、电报群消息、微博评论,用户发的地址往往夹杂着“地址:”“请转到这里”这类自然语言,散落在文本各个位置。第三类是日志和报表审计,从服务端日志或导出的Excel数据里找出所有交易相关地址,用于后续归集和统计。
这类需求有个共同点:你不知道地址在文本的哪个位置,也不确定目标地址是哪种格式,只能靠“格式特征”去文本里扫描。这恰好是正则表达式最擅长的领域。
1.2 为什么选择“粗筛加校验”的两段式设计
很多人第一次写地址提取脚本,上来就是一行re.findall(r'[13][A-Za-z0-9]{25,34}', text),跑出来的结果惨不忍睹。原因很简单:比特币地址虽然长得像一串字母数字,但它有很多隐藏规则,比如Base58字符集里根本没有0、O、I、l这4个字符,光这一点就能过滤掉大量误匹配。
但正则再精确,也只能做到“格式上像地址”,没法做到“真的是地址”。地址后面还跟着4字节校验和,校验和算不对,再像也是无效字符串。所以我的方案是两段式:第一段用正则把候选地址一个个捞出来,第二段对每个候选做编码规则校验。前者控制召回率,后者控制准确率。
这个设计很像筛沙子:正则是一张粗网,先把大颗粒留在网面上;校验是一张细网,把混进来的碎石再筛一遍。只靠粗网,会有大量假地址混进来;只靠细网,面对几万行文本,每次都对每个候选做完整解码校验,性能也会吃亏。先粗筛后精校验,两边都能兼顾。
2. 比特币地址格式分类与识别难点
2.1 三种主流地址格式的差异
要写好正则,首先得把比特币地址的“长相”搞清楚。目前主流地址分三类:P2PKH、P2SH、Bech32系列。它们的开头字符不同、编码方式不同、用途也不同。
| 地址类型 | 开头字符 | 常见长度 | 编码方式 | 典型用途 |
|---|---|---|---|---|
| P2PKH | 1 | 26-35位 | Base58Check | 最早的收款地址,现在仍在大量使用 |
| P2SH | 3 | 26-35位 | Base58Check | 多签地址、隔离见证兼容地址 |
| Bech32 | bc1 | 14-90位 | Bech32 | 原生SegWit地址,费率更低 |
| Bech32m | bc1p | 62位左右 | Bech32m | Taproot地址,2021年后出现 |
其中1开头和3开头的地址都用了Base58Check编码,字符集完全一样,所以正则可以把它们合并处理。而bc1开头的地址用的是另一套Bech32编码,字符集、大小写规则都和Base58不同,必须单独写正则。
我见过不少人拿一套正则打天下,结果bc1地址全部漏掉,这是最典型的问题。地址格式本身在演进,正则也要跟上。
2.2 Base58字符集与正则字符类的对应关系
Base58这个名字很直白,就是从Base64字符集里去掉了一些容易混淆的字符。具体来说是去掉了数字0、大写O、大写I、小写l这4个字符。这样设计是为了让人们肉眼阅读和手抄地址时减少错误,因为0和O、1和l在普通字体里实在太像了。
去掉这4个字符后,Base58的字符集就是:
123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz数字部分少了0,所以是1到9;大写字母里少了I和O,所以是A到Z剔除I和O;小写字母里少了l,所以是a到z剔除l。
在正则里,这个字符集对应的字符类需要分段写。常用的写法是:
PATTERN_LEGACY = r"[13][1-9A-HJ-NP-Za-km-z]{25,34}"拆开看:第一位是1或3;后面的[1-9A-HJ-NP-Za-km-z]就是Base58字符集的正则写法。这里的A-HJ-NP-Z表示从A到H,跳过I,再从J到N,跳过O,最后P到Z;a-km-z表示从a到k,跳过l,再从m到z。数字部分是1-9,不含0。
2.3 Bech32字符集与大小写规则
Bech32是隔离见证地址使用的编码格式,字符集和Base58完全不同。它从人类易读性的角度重新选了一批字符,具体是:
qpzry9x8gf2tvdw0s3jn54khce6mua7l这个字符集只有32个字符,好处是排序上避开了形近字符。它排除了b、i、o、1这4个字符,但保留了0和l,这一点和Base58正好相反,写正则时特别容易搞混。
Bech32地址在书写时有个硬性规则:要么全小写,要么全大写,不能大小写混用。实际场景中几乎都是小写,因为钱包默认生成小写地址。所以正则里直接写成bc1开头是最省事的,如果担心输入里有全大写地址,可以在预处理阶段统一调成小写。
Bech32地址还有个特殊点:bc1p开头的是Taproot地址,它用的是Bech32m编码,校验常数和Bech32不同。如果只写正则不管校验,两类地址都能匹配到;但如果做了校验,必须区分处理。这个细节后面专门讲。
3. 核心正则写法与实操要点
3.1 先看一眼“错误示范”有多容易踩坑
很多教程会给出一个看起来很合理、实际上有问题的正则:
result = re.findall(r"[13][a-zA-Z0-9]{25,34}", text)这个写法最大的问题是没有排除0、O、I、l这4个字符。一个包含数字0、大写O、大写I、小写l的随机字符串,长度又在26到35之间,就会被误判成候选地址。在爬虫抓取的页面里,这种字符串数量非常多,后面再做校验会平白多出大量无谓计算。
另一个常见问题是边界处理。如果不加工整边界,\b,很容易匹配到一个长字符串的中间片段。比如一段文字里写着abc1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa...,如果前面的abc紧跟地址开头,正则可能从bc1A...或者更靠后的位置开始匹配,得到残缺字符串。所以使用\b或在正则两侧加正向/反向断言很重要。
我在实际项目里的建议是,先把文本做一轮清洗,统一换行、去掉多余空白、把全角字符转半角,再用正则去跑,能规避掉一大堆边界问题。
3.2 完整可复用的地址正则
综合上面分析,我最终在项目里用的粗筛正则长这样:
import re ADDRESS_RE = re.compile( r"(?<![A-Za-z0-9])" r"(" r"[13][1-9A-HJ-NP-Za-km-z]{25,34}" r"|" r"bc1[ac-hj-np-z02-9]{11,87}" r")" r"(?![A-Za-z0-9])" )逐段解释一下。
(?<![A-Za-z0-9])和(?![A-Za-z0-9])是反向断言和正向断言,翻译过来就是“前面不能是字母或数字”以及“后面不能是字母或数字”。它比\b更严格,因为\b对下划线也敏感,而这种写法只关心真正的字母数字,更贴合地址的边界特征。
中间分支一:[13][1-9A-HJ-NP-Za-km-z]{25,34},覆盖P2PKH和P2SH地址。首位1或3,后面26到35位Base58字符,总长度覆盖地址长度的常见区间。
中间分支二:bc1[ac-hj-np-z02-9]{11,87},覆盖Bech32系列地址。这里[ac-hj-np-z02-9]是Bech32字符集的正则写法:排除b、i、o、1,保留0和l。{11,87}对应Bech32地址数据部分的常见长度范围,既覆盖普通bc1地址,也覆盖bc1p的Taproot地址。
需要注意的是,这个正则是“尽量严格”的写法。如果你希望正则更简单、更健壮,可以放宽成bc1[0-9a-z]{11,87},把字符集校验交给第二段校验逻辑。两条路都行,区别在于候选地址的干净程度和正则本身的维护成本。
3.3 正则里最容易踩的5个细节
第一,re.findall和捕获组的关系。如果正则有捕获组,findall返回的是捕获组的内容,而不是整个匹配结果。上面这个正则里我用了( ... ),findall会返回括号内的完整字符串,结果刚好正确。但如果你在分支里又加了小括号,结果就会悄悄发生变化。我建议在测试阶段用findall打印一下看结果,或者直接用finditer,代码更可控。
第二,re.match和re.search的区别。match只从字符串开头匹配,search在任意位置找第一处,而真正的“找出所有地址”应该用findall或finditer。新手经常在这三个函数上绕晕。
第三,大小写处理。Bech32地址必须统一小写或统一大写,不能混写。正则里写死成bc1,就只能匹配小写地址。如果输入文本里有全大写BC1开头,需要先text = text.lower()再跑。
第四,长文本性能。re.compile一定要放到循环外面预编译,不要在大循环里反复编译同一个正则。这是Python正则性能优化的第一课。
第五,地址长度上限。BIP173规范里Bech32地址最长90位,超过的可以直接判无效。正则里{11,87}已经限制了长度,但如果你放宽长度限制,后面校验逻辑一定要补一个总长度检查,否则可能出现畸形字符串。
4. 校验逻辑:把“像地址”变成“是地址”
4.1 为什么光靠正则远远不够
正则解决的是格式匹配,它无法判断校验和是否正确。随便生成一个满足Base58字符集的26位字符串,初看和地址没区别,但它大概率校验不过。
比特币地址的Base58Check编码规则是这样的:原始数据由版本字节加20字节哈希再加4字节校验组成。校验字节是对前面部分做两次SHA256后取前4字节。也就是说,一个合法的1开头地址,它的最后4个字符不是随便来的,而是和前面内容严格绑定的。只要解码后算出的校验和与地址末尾的校验字节对不上,这个地址就是无效的。
所以做交易归集、转账前校验这类正经需求时,正则只能当候选人筛选器,最终判断必须交给校验函数。
4.2 Base58Check手工校验的Python实现
手工校验Base58Check的代码不复杂,关键点在于Base58解码时要处理好前导零。直接上代码:
import hashlib _B58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" def b58decode(s: str) -> bytes: num = 0 for char in s: num = num * 58 + _B58_ALPHABET.index(char) body = num.to_bytes((num.bit_length() + 7) // 8, "big") if num > 0 else b"" pad = 0 for char in s: if char == _B58_ALPHABET[0]: pad += 1 else: break return b"\x00" * pad + body def is_valid_legacy_address(addr: str) -> bool: try: decoded = b58decode(addr) except ValueError: return False if len(decoded) != 25: return False payload, checksum = decoded[:-4], decoded[-4:] hashed = hashlib.sha256(hashlib.sha256(payload).digest()).digest() return hashed[:4] == checksum这个函数先做Base58解码,再检查长度是否为25字节,最后比对校验和。前导零的处理容易写错:Base58编码的“1”对应解码后的0x00字节,如果地址以一连串的1开头,解码结果前面必须补相应数量的零字节,否则长度校验永远过不了,这是个很隐蔽的坑。
4.3 Bech32校验的正确姿势
Bech32校验比Base58Check复杂一些,多项式取模逻辑不展开讲,直接给可用的判断函数。这段代码是基于BIP173参考实现简化的,专门用来判断bc1开头的Bech32地址是否通过校验:
def bech32_polymod(values): generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3] chk = 1 for value in values: top = chk >> 25 chk = (chk & 0x1ffffff) << 5 ^ value for i in range(5): chk ^= generator[i] if ((top >> i) & 1) else 0 return chk def bech32_hrp_expand(hrp: str): return [ord(x) >> 5 for x in hrp] + [0] + [ord(x) & 31 for x in hrp] _BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" def is_valid_bech32_address(addr: str) -> bool: if addr.lower() != addr and addr.upper() != addr: return False addr = addr.lower() if not addr.startswith("bc1"): return False if len(addr) > 90: return False data_part = addr[3:] if len(data_part) < 6: return False try: data = [_BECH32_CHARSET.index(c) for c in data_part] except ValueError: return False return bech32_polymod(bech32_hrp_expand("bc") + data) == 1这段代码只处理Bech32,也就是SegWit v0地址。而bc1p开头的Taproot地址用的是Bech32m,校验常数不是1而是0x2bc830a3,直接套上去会判断失败。所以生产环境我更推荐安装现成库:
pip install bech32然后用库里的函数去判断,库内部会区分Bech32和Bech32m,省心很多,也避免自己维护一套容易出错的数学逻辑。
5. 完整实战流程:从文本到地址清单
5.1 把粗筛和校验拼装成完整函数
把上面的逻辑拼在一起,一个可直接复用的提取函数就完成了:
import re import hashlib ADDRESS_RE = re.compile( r"(?<![A-Za-z0-9])" r"(" r"[13][1-9A-HJ-NP-Za-km-z]{25,34}" r"|" r"bc1[ac-hj-np-z02-9]{11,87}" r")" r"(?![A-Za-z0-9])" ) def extract_addresses(text: str) -> list[str]: candidates = ADDRESS_RE.findall(text) seen = set() result = [] for addr in candidates: if addr in seen: continue if addr.startswith("bc1"): valid = is_valid_bech32_address(addr) else: valid = is_valid_legacy_address(addr) if valid: seen.add(addr) result.append(addr) return result如果文本可能包含全大写的地址,先执行text = text.lower()。这里有个取舍:全大写Base58地址转小写后,字符集没变化,校验不受影响;全大写Bech32地址转小写后也变成合法形式,所以统一先转小写是最省事的方案。
5.2 批量处理大文本时的性能注意点
如果只是处理几百KB文本,上面的函数完全够用。但要是处理几个GB的日志文件,就要注意两点。
第一,不要用findall一次性把全部候选地址装进内存。文本越大,匹配结果越多,内存压力越大。改成用finditer边扫描边处理:
def extract_addresses_stream(text: str): seen = set() for match in ADDRESS_RE.finditer(text): addr = match.group(0) if addr in seen: continue valid = is_valid_bech32_address(addr) if addr.startswith("bc1") else is_valid_legacy_address(addr) if valid: seen.add(addr) yield addrfinditer返回的是迭代器,每匹配到一个候选地址就立刻处理,不会把整个结果集堆在内存里。配合生成器,处理大文件时内存占用非常稳定。
第二,预处理要聪明。文本中如果混着HTML标签、JSON转义、肉眼可见的乱码,先做一轮定向清洗。比如用re.sub(r"<[^>]+>", " ", text)去掉标签,把"这类实体换回普通字符。不要对整段文本做太激进的操作,否则地址两边的边界也可能被破坏。
5.3 去重与落库
地址提取出来后,去重是必须做的。同一个地址可能在文本里出现多次,直接set()去重即可。但要注意,Base58Check本身不区分大小写?其实不是,Base58字符集里有大写也有小写,同一个地址的大小写形式是固定的,1A1z...和1a1z...不是同一个地址,所以去重不能盲目转小写。这里存在一个真实风险:如果文本来源混乱,同一地址可能以不同大小写形态出现,盲目用小写去重会导致同一个地址被当成两个不同的目标,后续归集会出错。
更稳妥的做法是,先把地址还原成标准形式再判断。Base58地址没有统一大小写规则,但通常保持原样;Bech32地址则统一转小写。我个人习惯是:先校验,校验通过后Bech32一律小写存储,Base58保持原样存储。
落库用SQLite或者CSV都行,关键是地址字段要加唯一索引或做去重检查。如果数据量很大,可以考虑直接存成一行一个地址的文本文件,处理速度反而比数据库更快。
6. 常见问题与排查技巧实录
6.1 高频问题速查表
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
| 匹配结果里混入大量非地址字符串 | 正则字符集没有排除0/O/I/l | 改用[1-9A-HJ-NP-Za-km-z]字符类 |
| 漏掉了bc1开头的地址 | 只写了1开头和3开头的正则分支 | 增加Bech32分支,注意字符集和长度范围 |
| bc1p地址校验失败 | Taproot地址用的是Bech32m | 使用支持Bech32m的库,或对v1地址做区分处理 |
| 全大写地址提取不到 | 正则只匹配了小写bc1 | 文本统一lower()后再处理 |
findall返回结果和预期不一致 | 正则有多个捕获组 | 改用finditer或改用非捕获组(?:...) |
| 地址中间被换行断开 | 原始文本换行导致地址断裂 | 先对文本做空白归一化,但不能把所有空格都删掉 |
| 处理大文件内存暴涨 | 用findall一次性收集结果 | 改成finditer流式处理 |
6.2 我实际踩过的几个坑
第一个坑是Base58解码的前导零。我最早写校验函数时没有处理前导零,结果所有以数字1开头的地址校验全部失败。排查了很久才发现,1在Base58里对应数字0,解码后必须补足前导的\x00字节,否则25字节长度对不上。这个细节普通教程很少提,但写校验逻辑时绕不过去。
第二个坑是Bech32字符集和Base58的混淆。Base58排除l,Bech32却保留l;Base58排除0,Bech32却保留0。我一度在写Bech32正则时把l排除掉了,导致一小部分合法地址匹配不到。后来学聪明了,正则层面直接用[0-9a-z]这种宽松字符类,把严格字符集判断完全交给校验函数。
第三个坑是re.findall的分组行为。早期我的正则是这样写的:
re.findall(r"(?<![A-Za-z0-9])([13])([1-9A-HJ-NP-Za-km-z]{25,34})(?![A-Za-z0-9])", text)结果findall返回的是每个匹配的多个组组成的元组,而不是地址全串。这个坑在正则里几乎必踩,建议统一用finditer加match.group(0),一劳永逸。
6.3 几个提升可靠性的小技巧
项目里如果对地址准确性要求很高,建议再加一层“网络探活”验证。提取出地址后,可以批量调用区块浏览器的API去查询该地址是否存在交易记录。当然,新生成的地址可能还没有任何交易,所以“没有交易记录”不等于“地址无效”,这个验证方式只能用来人工复核,不适合做硬性过滤。
另一个技巧是用假地址做测试。正则和校验函数写完,先别拿真实数据跑,自己拼几个假地址出来。比如用Base58的字符集随机生成长度为26到35的字符串,校验函数应当全部返回False;再复制一个真实地址,改动其中一个字符,校验函数也应当返回False。这两组测试过了,函数才算可靠。
最后再分享一个小技巧:正则溢出时的排查思路。如果一段文本里地址特别多,可以先单独截取一小段文本,把正则匹配到的候选地址逐条打印出来,人工确认哪些是真地址哪些是假地址,再针对问题调正则。不要一上来就优化正则的复杂度和性能,先确保候选质量是准的,性能永远是第二优先级。
我个人在实际项目里最深的体会是:地址提取这类任务,正则只是第一道工序,真正的可靠性来自校验逻辑。把两段式设计想清楚,后面无论遇到什么样的杂乱文本,都能稳稳地把地址捞出来。