Python技术迷

一行代码实现地址信息解析

昨天晚上十一点多吧,我在楼下便利店排队买烟,前面那哥们儿跟老板吵起来了,说外卖老是送错楼,说他写得很清楚“xx小区3号楼2单元1202”,结果骑手一看地址就懵……我当时脑子里第一反应不是“外卖好惨”,而是“卧槽你这地址解析肯定是写崩了”,你们懂吧,就是那种把“省市区+街道+门牌+房号+收件人电话”揉成一坨的文本,系统要把它拆开,拆不出来就各种事故:分仓分错、运费算错、风控误判、客服背锅。

然后我回去就顺手写了个小玩意儿,核心目标就一个:别整复杂,先把最常见的中文地址拆出 省/市/区县,剩下的全当 detail。重点是“能跑、可解释、好补丁”,别一上来就上模型,线上一出问题你连 debug 都不知道从哪下手……嗯我知道你们有人已经想吐槽了,“东哥你这不是传统 rule-based 嘛”,对对对,就是它,稳定,真香。

先给你们看那个“一行代码”的效果哈,就这种:

parsed = AddressParser().parse("张三 13800138000 广东省深圳市南山区粤海街道科苑路15号A座1202")

一行,直接给你 dict,能落库能打日志能做风控规则,先把业务跑起来再说。下面是完整实现,我写得比较“糙”,但是线上好改,逻辑也直白。

import re
from dataclasses import dataclass
from typing import Dict, List, Optional, Tuple

@dataclass
classParseResult:
    province: str = ""
    city: str = ""
    district: str = ""
    detail: str = ""
    name: str = ""
    phone: str = ""

classAddressParser:
"""
    一个偏工程的中文地址解析器:
    - 先抽 phone
    - 再尝试匹配 省/市/区(用“最长匹配”防止短词误命中)
    - 剩余文本作为 detail
    - name 这里给个很保守的猜测:地址开头的2~4个中文(可按你业务改)
    """


# 你别笑,先用小字典跑起来,后面可以从行政区划表扩充
    PROVINCES: List[str] = [
"北京市", "上海市", "天津市", "重庆市",
"广东省", "浙江省", "江苏省", "山东省", "福建省",
"四川省", "湖北省", "湖南省", "河北省", "河南省",
    ]

# 演示用:少量城市/区,实际你可以用全量表(甚至做成配置/热更新)
    CITIES: List[str] = [
"深圳市", "广州市", "杭州市", "南京市", "成都市", "武汉市", "长沙市", "郑州市",
    ]

    DISTRICTS: List[str] = [
"南山区", "福田区", "宝安区", "罗湖区",
"天河区", "越秀区",
"西湖区", "滨江区",
"武侯区", "高新区",
    ]

    PHONE_RE = re.compile(r"(?:\+?86[-\s]?)?(1[3-9]\d{9})")

defparse(self, text: str) -> Dict[str, str]:
        r = self._parse(text)
return {
"province": r.province,
"city": r.city,
"district": r.district,
"detail": r.detail,
"name": r.name,
"phone": r.phone,
        }

def_parse(self, text: str) -> ParseResult:
        t = self._normalize(text)
        res = ParseResult()

# 1) phone
        m = self.PHONE_RE.search(t)
if m:
            res.phone = m.group(1)
            t = (t[:m.start()] + " " + t[m.end():]).strip()

# 2) name(很保守,避免把“广东省”当名字…)
#    规则:开头连续2~4个中文,且后面不是 省/市/区
        nm = re.match(r"^([\u4e00-\u9fa5]{2,4})(?!省|市|区|县)", t)
if nm:
            res.name = nm.group(1)
            t = t[nm.end():].strip()

# 3) 省/市/区:最长匹配(避免“南山”误吃“南山区”)
        res.province, t = self._pick_longest(t, self.PROVINCES)
        res.city, t = self._pick_longest(t, self.CITIES)
        res.district, t = self._pick_longest(t, self.DISTRICTS)

# 4) detail:把一些多余分隔符收一收
        res.detail = self._cleanup_detail(t)

return res

def_pick_longest(self, text: str, vocab: List[str]) -> Tuple[str, str]:
        hit = ""
        pos = -1
for w in vocab:
            p = text.find(w)
if p != -1:
# 谁更长用谁;长度一样取更靠前的
if len(w) > len(hit) or (len(w) == len(hit) and (pos == -1or p < pos)):
                    hit, pos = w, p

ifnot hit:
return"", text

# 命中词前后可能有姓名/空格/符号,通常我们把命中词之前也留着(有时是“收件人”)
# 但工程上更常见:命中词之前是垃圾,就切掉它
# 这里做个折中:只切掉 hit 之前的非中文/空白
        before = text[:pos].strip()
        after = text[pos + len(hit):].strip()

# 如果 before 里全是符号/数字/空格,丢掉;否则保留到 detail 里(比如“XX小区”)
if before and re.fullmatch(r"[\d\W_]+", before):
            before = ""

        rest = (before + " " + after).strip()
return hit, rest

def_normalize(self, s: str) -> str:
# 常见分隔符统一空格,避免“,” “;” “|” 搞乱
        s = s.strip()
        s = re.sub(r"[,,;;|/]+", " ", s)
        s = re.sub(r"\s+", " ", s)
return s

def_cleanup_detail(self, s: str) -> str:
        s = s.strip()
        s = re.sub(r"\s+", " ", s)
# 有些人会写“广东深圳南山”,省市不带“省/市”,这里先不强行猜,避免误判
return s

你看哈,这玩意儿很“土”,但真能救命。为啥我喜欢它呢,因为你一旦遇到线上投诉,比如“同一个小区为啥分到两个仓”,你打日志一看 province/city/district/detail 就知道是哪一步切错了,是字典不全?还是输入里有脏符号?还是用户写了“深圳南山”没带“市/区”?都能定位,不像黑盒模型那种,错了你只能跪着调参。

当然了,现实地址比这恶心多了……比如“广东深圳南山科技园xx栋”这种不带“市/区”,再比如“收件人-张三 电话-xxx 地址-xxx”,还有把公司名写前面“XX科技有限公司 广东省深圳市…”这种。我的做法通常是:先别贪,把 能确定的行政区划 拿到就行,剩下 detail 给下游(比如地图 geocode 或者人工客服)处理,别在解析阶段就强行“猜”,猜错的成本比空着还大,真的。

你们要是想再往前走一步,我建议加个“小技巧”,就是把词表做成 可热更新 的,比如从数据库或配置中心拉一份行政区划表,或者按你业务覆盖的城市先铺齐;然后 _pick_longest 这里可以换成 Trie(前缀树)做线性扫描,性能会更稳。现在这个 find 方式在词表小的时候问题不大,词表大了会慢一点点,不过也不是不能用,看 QPS。

随手给个小测试,你拿去跑一下就知道输出长啥样:

if __name__ == "__main__":
    p = AddressParser()
    cases = [
"张三 13800138000 广东省深圳市南山区粤海街道科苑路15号A座1202",
"李四, 18612345678, 深圳市 南山区 科技园科兴科学园B3-1201",
"王五 020-xxxxxxx 广东省广州市天河区体育西路109号",
"XX科技有限公司 张六 13900001111 浙江省杭州市西湖区文三路XXX号",
    ]
for c in cases:
        print(c)
        print(p.parse(c))
        print("-" * 30)

我现在困得要死……但最后再啰嗦一句啊:别被“一行代码”这四个字带跑偏了,一行只是入口爽,背后还是一堆脏活累活,比如词表维护、异常输入、日志监控、回归用例。你真上了生产,建议把“解析失败率”“省市区为空占比”“top10异常样本”都打出来,不然外卖又送错楼,你还以为是骑手的问题……行了我去把阳台窗户关一下,外面风太大了,回头再聊。