简介:这份资源面向需要银行网点数据的PHP开发者与数据采集学习者,提供全国银行列表的抓取与落地实现。数据覆盖行号、网点名称、电话、地址等字段,共123464条记录,可用于金融类系统开发、网点查询功能搭建或数据清洗练习。压缩包共3个文件,约3.97MB,包含一份PHP程序文件、一份已导出的SQL数据文件以及一份说明文档,PHP程序基于5.5.38环境,通过curl配合正则表达式抓取数据,SQL文件可直接导入MySQL使用,省去重复采集的时间成本。目前已有912人学习下载。读者可从中获得完整的采集思路、可运行的脚本代码以及现成的数据表结构,既能直接用于项目,也适合作为爬虫与正则匹配的实战参考,便于快速理解从请求到入库的完整链路。
1. 银行行号数据这件事,难点从来不在“抓”
第一次接到“获取全国银行列表”这个需求时,多数人的第一反应是写个爬虫,把行号、网点名称、电话、地址抓下来存库就完事。真动手才发现,坑根本不在抓取本身,而在数据源的选择、字段口径的统一、以及后续增量更新的稳定性。银行行号(也叫联行号、CNAPS 号)是 12 位数字编码,用于跨行支付清算,一个网点对应一个行号,全国总量在十几万到二十万条量级,且每天都有网点新增、撤并、迁址。你要做的不是一次性导出,而是维护一份能持续对齐的“银行网点主数据”。这套东西适合做支付路由、对公开户校验、发票抬头匹配、批量代付的团队,也适合需要本地化银行字典的后端和数据分析同学。下面把我实际落地过的路径拆开讲,包括数据从哪来、字段怎么清洗、增量怎么跑、哪些地方最容易翻车。
2. 数据源选型:官方口径、聚合接口与自建字典的取舍
2.1 三种主流来源的真实差异
做银行列表,绕不开三类数据源。第一类是清算机构对外公布的参与机构信息,权威性最高,字段规范,但通常只给到机构层级,网点级明细不一定全,更新节奏也偏慢。第二类是第三方数据服务商的聚合接口,按查询次数计费,字段齐全、更新快,缺点是行号与网点名称的对应关系偶尔滞后,撤并网点可能还挂着。第三类是自己维护字典,从多个来源交叉比对后落库,前期投入大,但长期最可控。
我一般会这样组合:以官方口径作为基准表,用聚合接口做增量补充和校验,最终落到自己的字典表里。判断一个来源能不能用,看四个指标——覆盖率(是否含村镇银行、农信社)、字段完整度(电话、地址是否为空)、更新频率(日更还是月更)、以及行号准确性(能否与清算报文对上)。覆盖率低于 90% 的来源,直接放弃做主表。
2.2 表结构设计与字段口径统一
字段口径不统一是后期最大的返工来源。同一个“地址”,有的来源给的是注册地址,有的是营业地址;电话有的是总机,有的是对公柜台。落库前必须定死语义。下面是我常用的建表语句,字段注释写清楚口径,后面接任何来源都往这个结构上映射。
CREATE TABLE bank_branch ( bank_code VARCHAR(20) NOT NULL COMMENT '银行大类编码,如 ICBC', bank_name VARCHAR(64) NOT NULL COMMENT '银行名称,如中国工商银行', branch_no VARCHAR(12) NOT NULL COMMENT '联行号/行号,12位数字,主键', branch_name VARCHAR(128) NOT NULL COMMENT '网点全称,含支行/分理处层级', province VARCHAR(32) DEFAULT NULL COMMENT '省/直辖市', city VARCHAR(32) DEFAULT NULL COMMENT '地级市', district VARCHAR(32) DEFAULT NULL COMMENT '区县', address VARCHAR(256) DEFAULT NULL COMMENT '营业地址,非注册地址', phone VARCHAR(64) DEFAULT NULL COMMENT '对公联系电话,多个用逗号分隔', status TINYINT NOT NULL DEFAULT 1 COMMENT '1正常 0撤并 2待核实', source VARCHAR(32) NOT NULL COMMENT '数据来源标识', updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (branch_no), KEY idx_bank_city (bank_code, city), KEY idx_name (branch_name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='银行网点主数据';branch_no做主键是刻意的,行号天然唯一,用它做幂等写入最省事。status字段别省,撤并网点不能物理删除,否则历史订单对不上。source记录来源,方便出问题时回溯是哪一批数据带进来的脏值。address明确写“营业地址”,避免不同来源混入注册地址导致同一网点出现两条记录。
2.3 采集与清洗的最小可跑流程
不管数据来自接口还是文件,统一走“拉取 → 解析 → 映射 → 校验 → 入库”五步。下面是一个 Python 清洗脚本的骨架,重点看字段映射和校验逻辑,而不是具体某个来源的解析细节。
import re import pymysql # 行号必须是12位纯数字,这是硬校验,不满足直接丢弃 BRANCH_NO_RE = re.compile(r'^\d{12}$') def normalize_phone(raw): """把各种分隔符统一成逗号,去掉分机号后缀""" if not raw: return None parts = re.split(r'[;;,,、/\s]+', raw.strip()) cleaned = [] for p in parts: p = re.sub(r'[-—]?\d{1,5}$', '', p) if p.count('-') >= 2 else p p = p.strip() if p: cleaned.append(p) return ','.join(cleaned) if cleaned else None def map_record(src): """把来源字段映射到主数据结构,来源不同只改这里""" branch_no = str(src.get('cnaps_code', '')).strip() if not BRANCH_NO_RE.match(branch_no): return None # 行号非法,丢弃并记日志 return { 'bank_code': src.get('bank_type', '').strip().upper(), 'bank_name': src.get('bank_name', '').strip(), 'branch_no': branch_no, 'branch_name': src.get('branch_name', '').strip(), 'province': src.get('province'), 'city': src.get('city'), 'district': src.get('district'), 'address': src.get('address'), 'phone': normalize_phone(src.get('phone')), 'status': 1, 'source': src.get('_source', 'unknown'), } def upsert(conn, rec): """按行号幂等写入,存在则更新,不存在则插入""" sql = """INSERT INTO bank_branch (bank_code,bank_name,branch_no,branch_name,province,city,district,address,phone,status,source) VALUES (%(bank_code)s,%(bank_name)s,%(branch_no)s,%(branch_name)s,%(province)s, %(city)s,%(district)s,%(address)s,%(phone)s,%(status)s,%(source)s) ON DUPLICATE KEY UPDATE branch_name=VALUES(branch_name), address=VALUES(address), phone=VALUES(phone), status=VALUES(status), source=VALUES(source)""" with conn.cursor() as cur: cur.execute(sql, rec)normalize_phone里那个分机号处理逻辑是血泪经验:很多来源的电话字段带“-8021”这种分机,直接入库后前端展示会串味,所以对含两个以上连字符的号码做尾部截断。map_record把来源差异全部收敛在一个函数里,换数据源只改这一处,不动入库逻辑。upsert用ON DUPLICATE KEY UPDATE保证重复跑不会产生脏数据,这是增量更新的基础。
3. 增量更新与行号校验:让字典长期可用
3.1 增量比对策略与调度
一次性全量导入只是起点,真正决定这套字典能不能长期用的是增量机制。我的做法是每天拉一次全量快照,与库内做 diff,而不是依赖来源的增量接口——增量接口的“变更”定义各家不一样,有的只给新增不给撤并,反而更难对齐。diff 逻辑很简单:来源里有、库里没有的,插入;两边都有但关键字段(网点名称、地址、电话)不一致的,更新并记录变更日志;库里有、来源里没有的,标记为待核实,连续 N 天未出现再置为撤并。
def diff_and_apply(conn, incoming): """incoming: {branch_no: record} 来源全量快照""" with conn.cursor(pymysql.cursors.DictCursor) as cur: cur.execute("SELECT branch_no, branch_name, address, phone FROM bank_branch WHERE status=1") local = {r['branch_no']: r for r in cur.fetchall()} to_insert = [v for k, v in incoming.items() if k not in local] to_update = [] for k, v in incoming.items(): if k in local: old = local[k] if (old['branch_name'] != v['branch_name'] or old['address'] != v['address'] or old['phone'] != v['phone']): to_update.append(v) missing = [k for k in local if k not in incoming] for rec in to_insert: upsert(conn, rec) for rec in to_update: upsert(conn, rec) if missing: with conn.cursor() as cur: cur.executemany( "UPDATE bank_branch SET status=2 WHERE branch_no=%s", [(m,) for m in missing]) conn.commit() return len(to_insert), len(to_update), len(missing)调度上,全量快照建议放在凌晨低峰,单次十几万条 diff 在普通 MySQL 上几分钟能跑完。missing不直接删,先置status=2,给人工或二次来源确认留窗口,这是后悔药。
3.2 行号校验的三种手段
行号错了,支付路由就会打到错误网点,后果比数据缺失严重得多。校验分三层:格式校验(12 位纯数字,前几位对应银行大类)、来源交叉校验(两个独立来源同一行号指向同一网点名称才算可信)、以及业务校验(拿真实清算报文里的行号反查字典,命中率低于阈值就告警)。格式校验在入库时做,交叉校验在合并阶段做,业务校验放在上线后做监控。
| 校验层 | 触发时机 | 判定规则 | 失败处理 |
|---|---|---|---|
| 格式校验 | 入库前 | 12 位数字,前缀匹配银行大类 | 丢弃并记日志 |
| 交叉校验 | 多源合并 | 两源网点名称相似度 ≥ 0.9 | 标记待核实 |
| 业务校验 | 上线后 | 报文行号命中率 ≥ 99% | 触发告警排查 |
3.3 电话与地址字段的清洗边界
电话和地址是最容易“看起来对、用起来错”的字段。地址不要做过度标准化,把“XX路 88 号”强行改成“XX路88号”反而可能和来源对不上,保留原始形态、只做去空格和全半角统一即可。电话字段要区分对公和对私,很多来源混在一起,我的处理是只保留看起来像固话或对公手机的号码,明显是个人手机号的(比如来源标注为联系人手机)单独存一列,不混进主电话字段。这一步没有通用规则,得按来源逐个看样本,别指望一个正则打天下。
4. 避坑与排查:那些让字典“看着全、用着错”的细节
4.1 坑一:行号前导零被吞
现象:导入后部分行号变成 11 位,支付路由报“行号不存在”。原因:来源是 Excel 或 CSV,行号列被当成数字读取,前导零丢失。解决:读取时强制按字符串处理,pandas 用dtype={'cnaps_code': str},CSV 用csv.reader而非DictReader的自动类型推断,入库前再过一遍 12 位正则。
4.2 坑二:同一网点多条记录
现象:按网点名称查询返回两三条,行号却不同。原因:来源里同时存在旧行号和迁址后的新行号,或不同来源对同一网点给了不同编码。解决:以行号为主键天然去重,但要在合并阶段按“网点名称 + 地址相似度”做聚类,把疑似同一网点的记录标出来人工确认,别自动合并。
4.3 坑三:撤并网点未及时下线
现象:代付打款到已撤并网点,被退回。原因:来源更新滞后,或增量逻辑只处理新增不处理消失。解决:坚持全量 diff,missing置status=2并设观察期,同时接入业务侧失败报文反查,把真实失败的行号优先置为撤并。
4.4 坑四:电话字段混入客服热线
现象:大量网点电话都是同一个 95 开头号码。原因:来源把总行客服电话填到了每个网点。解决:统计电话字段的重复度,出现频次异常高的号码列入黑名单,清洗时置空而不是保留,避免误导用户。
4.5 坑五:字符集导致网点名称乱码
现象:部分村镇银行名称显示为问号或方块。原因:来源文件是 GBK,入库连接是 utf8mb4,中间没转码。解决:读取时显式指定编码,入库连接串加charset='utf8mb4',建表也用 utf8mb4,三处必须一致,缺一处就翻车。
5. 把字典用起来:查询接口与命中率验证的一个小技巧
字典建好只是半成品,真正体现价值的是查询侧。我一般会封一个按行号、按名称、按城市三个维度的查询接口,其中按名称查询要做模糊匹配和拼音首字母支持,因为用户输入“工行北京海淀”这种口语化关键词是常态。下面这个查询函数把三个维度揉在一起,优先精确命中行号,其次名称前缀,最后城市过滤,返回结果按匹配度排序。
def search_branch(conn, keyword=None, branch_no=None, city=None, limit=20): """三维度查询:行号精确 > 名称模糊 > 城市过滤""" if branch_no: with conn.cursor(pymysql.cursors.DictCursor) as cur: cur.execute("SELECT * FROM bank_branch WHERE branch_no=%s AND status=1", (branch_no,)) return cur.fetchall() sql = "SELECT * FROM bank_branch WHERE status=1" params = [] if keyword: sql += " AND (branch_name LIKE %s OR bank_name LIKE %s)" params += [f'%{keyword}%', f'%{keyword}%'] if city: sql += " AND city=%s" params.append(city) # 名称越短通常越接近用户想找的网点,用它做粗排序 sql += " ORDER BY CHAR_LENGTH(branch_name) ASC LIMIT %s" params.append(limit) with conn.cursor(pymysql.cursors.DictCursor) as cur: cur.execute(sql, params) return cur.fetchall()ORDER BY CHAR_LENGTH(branch_name)这个排序技巧不完美但实用:用户搜“海淀支行”,短名称的网点大概率就是目标,长名称的往往是“海淀支行营业部”这类下级。真要做得更准,可以加一列拼音首字母,用LIKE 'hd%'兜底。
验证字典质量,我有个习惯:每周抽 200 条真实业务报文里的行号,反查字典,统计命中率和字段完整率,把未命中的行号单独存一张表,作为下周数据源比对的输入。这个动作坚持三个月,字典的准确率会从“能用”变成“敢用”。别指望一次导入就一劳永逸,银行网点数据是活的,维护它的过程本身就是这套方案的一部分。希望帮到你。
本文还有配套的精品资源,点击获取