
最近在帮朋友整理一批链上转账记录遇到了一个特别典型的活儿从一堆混乱文本里把比特币地址精确地捞出来。源数据有网页抓来的、有聊天记录导出的、还有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系列。它们的开头字符不同、编码方式不同、用途也不同。地址类型开头字符常见长度编码方式典型用途P2PKH126-35位Base58Check最早的收款地址现在仍在大量使用P2SH326-35位Base58Check多签地址、隔离见证兼容地址Bech32bc114-90位Bech32原生SegWit地址费率更低Bech32mbc1p62位左右Bech32mTaproot地址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到Za-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| rbc1[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| rbc1[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)去掉标签把quot;这类实体换回普通字符。不要对整段文本做太激进的操作否则地址两边的边界也可能被破坏。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排除lBech32却保留lBase58排除0Bech32却保留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。这两组测试过了函数才算可靠。最后再分享一个小技巧正则溢出时的排查思路。如果一段文本里地址特别多可以先单独截取一小段文本把正则匹配到的候选地址逐条打印出来人工确认哪些是真地址哪些是假地址再针对问题调正则。不要一上来就优化正则的复杂度和性能先确保候选质量是准的性能永远是第二优先级。我个人在实际项目里最深的体会是地址提取这类任务正则只是第一道工序真正的可靠性来自校验逻辑。把两段式设计想清楚后面无论遇到什么样的杂乱文本都能稳稳地把地址捞出来。