简介一份面向需要核验东莞市不动产登记信息的个人、法务或房产事务从业者的查询结果凭证PDF聚焦权利人本人住房证明的查询场景帮助读者理解官方查询结果包含哪些字段、如何解读产权状态及使用限制。压缩包内仅1个PDF文件大小约256KB内容为2024年5月9日出具的东莞市不动产登记情况查询结果列表包含查询编号、校验码、权利人信息及登记列表等栏目并以说明文字交代结果效力。现有36人学习或下载。读者可从中了解查询结果仅反映查询时点信息、核验二维码有效期3个月、合同按揭备案职能自2022年7月1日起划转至市住建局住房事务中心以及“已备案”通常指已办理商品房买卖合同备案或预告登记但尚未办证等要点也可参考查询证明妥善保管、正当使用及异议核实的处理思路适合作为不动产查询材料样例与字段释义参考。1. 一份“全是横杠”的住房证明权利人查询结果到底证明了什么一份住房证明权利人查询打印出来最抓眼球的往往不是数据而是那一片横杠。不动产坐落、权属证书号、面积、权利类型、权利性质、用途、登记日期、产权状态、备注十来列里九列是“-”。前阵子有位同学把这份 PDF 丢给我第一句话就是“是不是解析器抽坏了”。恰恰相反这份不动产登记查询结果的价值就藏在这些横杠里它是一份时点快照“-”代表该字段无查询结果而不是字段缺失。把这两件事混为一谈后面的入库、核验、到期提醒会一错到底。这篇就把这份查询结果证明当成一份可解析、可校验、可归档的文档资源来拆讲清字段语义、抽取脚本、校验边界和五类翻车现场适合要做证明归档、材料核验、台账管理的从业者照着复现。2. 拆字段12 列表头与 5 条说明构成的语义地图这份 PDF 的排版不像表格文件那样规整它是一张 A4 纸上的“标题 头部键值对 一句主体说明 表体 五条脚注 盖章位 查询日期”的组合。想稳定抽取先把版面切成块再逐块定锚点最后才谈正则。下面按从上到下的顺序把每一块的语义讲透。2.1 头部三件套编号、校验码、权利人段的切分锚点头部第一行是文档标题明确写了“不动产登记情况查询结果列表权利人查询”这一行决定了后续所有字段的解释口径查询方式是权利人查询不是利害关系人查询也不是司法查询。第二行是编号第三行是校验码两者中间用大量空格分隔肉眼看着像同一行实际抽文本时经常粘在一起。第四行是一句主体说明格式固定为“以下为权利人某某证件号码某某在某市的全部不动产登记列表”这句话里同时承载了权利人姓名、证件号码、查询范围三个信息。从手里这份样本看编号是一串 15 位数字形态上可以拆成“2 位业务前缀 8 位日期 5 位流水”其中 8 位日期与文末查询日期的年月日完全对齐校验码是 6 位大写十六进制风格的字符。这里要强调一句这是对样本形态的观察不是对官方编码规则的断言。落地时把编号当字符串存别做数值解析更别去掉前导零否则 00 开头的前缀会被吞掉后面拿编号做唯一键就会撞车。主体说明那句是整份文档里最容易抽错的地方。姓名和证件号码之间用中文逗号分隔证件号码后面紧跟“在某市的全部不动产登记列表”中间没有明显分隔符。血泪经验是不要用“ ”这种带空格的模式去匹配PDF 抽出来的文本里空格数量完全看排版引擎的心情用中文标点做锚点更稳。表 1 把头部字段的形态和可空性列清楚后面写正则时直接照着这张表来。字段样本形态是否可空说明文档标题固定中文串否用于判定文档类型防止把别的证明混进来编号15 位纯数字字符串否业务主键之一禁止数值化校验码6 位大写字母数字否用于人工核验格式可校验、真伪不可本地校验权利人姓名中文字符串否属于个人敏感信息落库前必须脱敏证件号码15 或 18 位否同上建议只存哈希和掩码查询范围行政区划名称否决定了“全部”二字的边界查询日期YYYY-MM-DD HH:MM:SS否快照时点全篇最重要的时间锚2.2 表体 12 列的取值域哪些空是“没查到”哪些空是“抽错了”表体一共 12 列序号、权利人、证件号码、不动产坐落、权属证书号、面积、权利类型、权利性质、用途、登记日期、产权状态、备注。这 12 列不能一视同仁按取值域可以分成三类。第一类是恒有值列序号、权利人、证件号码。只要系统出了一行这三列就一定有内容。如果抽出来序号有值而权利人列是“-”那基本可以判定是抽取错位而不是业务上没查到。第二类是业务字段列坐落、证书号、面积、权利类型、权利性质、用途、登记日期。这些列出现“-”是正常业务语义表示该条记录在这些维度上没有登记信息。面积列还要注意单位样本里因为无结果所以是横杠真实有结果时通常是数字加“平方米”抽取时要单独把数值和单位拆开别直接塞进浮点字段。第三类是状态列产权状态和备注。产权状态是全篇语义最重的一列脚注里专门给了释义显示“已备案”表示该房已办理商品房买卖合同备案或预告登记但未办理购房人名下的产权登记也就是俗称的未办证。备注列则常常是空串而不是横杠这两种空在抽取时要归一否则同一个“无内容”会产出两种表示。最关键的一点这份样本是一行“占位空行”。序号是 1其余业务列全是“-”。它表达的意思是“这个人在查询范围内存在登记档案条目但当前没有可展示的不动产登记结果”而不是“表体为空”。所以判定结果状态至少要分三态表体 0 行是无记录1 行且业务列全横杠是占位空行业务列有实际值是有效记录。三态混淆是后面所有 bug 的源头。2.3 说明区 5 条有效期、职能划转与“-”的官方释义脚注区编号 1 到 5每条都影响解析和业务判断别当免责声明直接跳过。第 1 条讲查询结果是系统信息若与原始登记凭证或权利人持有的书面资料不一致要联系登记机构核实并以重新出具的查询结果为准。这条的工程含义是这份 PDF 不是唯一事实源台账里要留“是否已人工复核”的字段。第 2 条讲查询结果只反映查询时点的信息核验二维码有效期为 3 个月。这里藏着两个不同的时间概念后面第 4 章会专门拆一是快照时点永久有效二是二维码核验窗口3 个月。很多人把这两个混成一个“证明有效期”结果过期就慌。第 3 条讲合同按揭备案职能已于 2022 年 7 月 1 日划转文档里呈现的备案信息是划转之前的历史登记最新情况要去另一个部门查。这条直接决定了时间断层的处理方式拿 2022 年 7 月 1 日之后的时间点去这份文档里找备案信息找不到是正常的不是数据丢了。第 4 条讲申请人对查询证明负有妥善保管、正当使用的义务。这条是合规约束落到系统里就是最小留存原则证件号码哈希化、姓名掩码化、原件路径做权限控制。第 5 条最短但最要命“-”表示无查询结果。这句话把空值语义钉死了任何把它当 NULL、当空串、当解析失败的处理方式都是错的。3. 抽成结构化记录pdfplumber 加正则的落地脚本版面语义清楚了接下来落到代码。目标很明确输入一个 PDF输出一条 JSON包含头部字段、表体行、结果状态三部分同时把敏感字段脱敏后再落库。3.1 环境准备与 PDF 结构预检依赖不多两个就够。如果跑在离线环境提前把 wheel 包下好拷进去别在生产机上现装。pip install pdfplumber pypdf装完之后先别急着写抽取逻辑做一次结构预检。很多翻车是因为拿到的 PDF 是扫描件页面里根本没有文字层extract_text()返回空串正则一路匹配不上最后报的是“编号缺失”实际根因是图片。预检脚本就三行判断每页有没有可用文字import pdfplumber def preflight(path): with pdfplumber.open(path) as pdf: for i, page in enumerate(pdf.pages): text page.extract_text() or # 阈值按版面密度调一页证明正文通常 800 字符以上 print(i, len(text), len(page.images))extract_text()的返回长度是判断文字层是否存在的最直接指标page.images的数量用来辅助判断是不是整页扫描图。一般一页这种证明正文在 800 到 1500 字符之间低于 200 就当作扫描件走 OCR 分支别硬套正则。3.2 头部键值对抽取锚点正则与容错头部四个字段用四条正则锚点全部选中文冒号和中文逗号避免被空格数量影响。写法如下import re # 锚点全部使用中文标点容忍任意空白 RE_NO re.compile(r编\s*号\s*[:]\s*([0-9]{10,20})) RE_CHECK re.compile(r校\s*验\s*码\s*[:]\s*([0-9A-Z]{4,12})) RE_HOLDER re.compile( r以下为权利人\s*[:]\s*([^\s,])\s*[,]\s* r证件号码\s*[:]\s*([0-9Xx]{15,18}) ) RE_QTIME re.compile(r查询日期\s*[:]\s*(\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2})) def parse_head(text): out {} for key, pat in ( (cert_no, RE_NO), (check_code, RE_CHECK), (holder, RE_HOLDER), (query_time, RE_QTIME), ): m pat.search(text) # 匹配不到就留 None交给上层判定不要抛异常中断整批 out[key] m.groups() if key holder else (m.group(1) if m else None) return out三个参数细节值得说。第一\s*夹在字段名的每个汉字之间是因为 PDF 抽出的文本有时会在汉字中间插空格不加这个容错编号那一行偶尔会漏。第二权利人正则用[^\s,]而不是\S因为姓名后面紧跟中文逗号\S会把逗号一起吃进去。第三匹配失败返回 None 而不是抛异常批量处理时单份文件格式异常不应该拖垮整批任务失败原因记到日志里人工复核更划算。3.3 表体抽取给 pdfplumber 画对竖线表体是整份文档最难抽的部分。这类证明的表格边框通常只有横线没有竖线或者竖线是浅灰色导致识别不到。默认参数下extract_tables()经常把 12 列糊成两三列。我一般用文字位置推竖线def parse_table(page): settings { vertical_strategy: text, # 用文字块的左边界推竖线 horizontal_strategy: text, # 同理推横线 min_words_vertical: 2, # 一列至少出现 2 个词才成列 min_words_horizontal: 1, intersection_tolerance: 5, # 交叉点容差排版松时可放大 } tables page.extract_tables(table_settingssettings) or [] rows [] for tb in tables: for r in tb: # 去掉全空行保留含“-”的占位行 if r and any((c or ).strip() for c in r): rows.append([(c or ).strip() for c in r]) return rowsmin_words_vertical这个参数是成败关键。设成 1 会把页眉页脚的单字也当成一列设成 3 以上又会在备注列这种稀疏列上丢掉整列2 是这类文档比较稳的折中。intersection_tolerance控制横竖线交叉点的吸附距离如果抽出来列数是对的但内容整体右移一列把这个值从 5 往 10 调通常能救回来。抽完之后一定要做列数校验不等于 12 就说明版面识别失败此时不要硬映射字段直接把原文整页存下来走人工。硬映射的后果比抽不出来严重得多因为错位的数据看起来是“正常数据”下游根本发现不了。3.4 三态判定与脱敏落库行抽出来了接下来做状态判定和脱敏。判定逻辑很短但每一条都对应一个业务含义import hashlib DASH {-, —, , –, , None} BIZ_COLS (3, 4, 5, 6, 7, 8, 9) # 坐落、证书号、面积、权利类型、权利性质、用途、登记日期 def judge_state(rows): body [r for r in rows if len(r) 12 and r[0].isdigit()] if not body: return NO_RECORD, 0 hit any(any(c not in DASH for c in r[i] for i in BIZ_COLS) for r in body) return (HAS_RECORD if hit else PLACEHOLDER_ONLY), len(body) def mask_id(id_no): # 保留前 3 后 2其余打码哈希用于跨文档比对 h hashlib.sha256(id_no.encode(utf-8)).hexdigest() return id_no[:3] * * (len(id_no) - 5) id_no[-2:], hBIZ_COLS只取业务字段列故意排除序号、权利人、证件号码、产权状态和备注。原因是前三列在占位行里也有值状态列和备注列的空值形态不统一混进来会让三态判定失真。judge_state返回的PLACEHOLDER_ONLY就是第 2 章说的占位空行这个状态必须单独存在不能折叠进NO_RECORD。脱敏这块姓名和证件号码都不存明文。掩码用于人眼核对哈希用于跨文档判同——同一个人在不同日期开的证明证件号码哈希是一致的可以做关联分析而不泄露原值。原件路径单独存并且只给有权限的账号读。4. 校验与快照编号规则、二维码有效期与时点语义字段抽出来只是第一步能不能用取决于校验做得够不够。这一章讲三件事编号能校到什么程度、二维码有效期到底约束什么、台账怎么设计才不留后悔药。4.1 编号与校验码能校什么、不能校什么编号的本地校验只能做到格式和一致性两层。格式层校验长度和纯数字一致性层校验编号中段的 8 位日期是否与查询日期对齐。样本里两者是一致的这个校验能拦住大部分“抽错位”和“拿错文件”的情况但拦不住伪造。def check_cert_no(cert_no, query_time): if not cert_no or not cert_no.isdigit() or len(cert_no) ! 15: return FORMAT_FAIL mid cert_no[2:10] # 观察到的日期段位置 if mid ! query_time[:10].replace(-, ): return DATE_MISMATCH return OK校验码只做格式校验4 到 12 位大写字母数字。必须说清楚本地没有任何办法验证校验码的真伪它的核验依赖出具方系统。所以台账里要区分“格式通过”和“已核验”两个状态前者是脚本给的后者是人工或对接接口给的别混成一个字段。4.2 二维码 3 个月有效期与证明本身的区别这是踩坑最密集的地方直接用表格拆开。概念依据时长到期后的实际影响查询时点快照脚注 2 前半句永久无影响它记录的就是那个时刻的状态二维码核验窗口脚注 2 后半句3 个月扫码核验通道失效需重新开具才能再核备案信息时效脚注 32022-07-01 为界之后的备案信息不在本文件覆盖范围内人工复核状态脚注 1无期限与原始凭证不一致时需要重新核实落到系统里就是两个时间字段query_time存快照时点qr_expire_at存query_time加三个月。加三个月这里有个小陷阱直接用 90 天会漂用日期库做自然月加法更准from datetime import datetime from dateutil.relativedelta import relativedelta def qr_expire(query_time_str): t datetime.strptime(query_time_str, %Y-%m-%d %H:%M:%S) return (t relativedelta(months3)).strftime(%Y-%m-%d %H:%M:%S)relativedelta(months3)处理了月末日期的情况2 月 29 日加三个月不会变成 5 月 29 日之外的怪值。别用timedelta(days90)图省事跨月份的时候差一天到期提醒就会提前或延后触发。4.3 快照台账用 file_sha256 锁住原件台账表设计成“一份文件一行”主键用编号、查询时间、文件哈希三者组合。文件哈希是防替换的关键原件被换过哪怕一个字节哈希就变能立刻发现。CREATE TABLE IF NOT EXISTS house_cert_snapshot ( id INTEGER PRIMARY KEY AUTOINCREMENT, cert_no TEXT NOT NULL, check_code TEXT, holder_mask TEXT NOT NULL, holder_hash TEXT NOT NULL, query_time TEXT NOT NULL, qr_expire_at TEXT NOT NULL, result_state TEXT NOT NULL, row_count INTEGER DEFAULT 0, file_sha256 TEXT NOT NULL, source_path TEXT, reviewed INTEGER DEFAULT 0, created_at TEXT DEFAULT (datetime(now,localtime)), UNIQUE(cert_no, query_time, file_sha256) );字段说明几个。holder_mask和holder_hash一对前者给人看后者给程序比对。reviewed对应脚注 1 的人工复核默认 0人工确认后置 1。result_state存三态字符串而不是布尔是为了让下游查询能直接区分占位空行和无记录。UNIQUE约束里带上file_sha256同一份证明重复导入不会产生脏行但重新开具的同编号不同文件能正常进来这个设计比用文件名做主键稳得多。5. 避坑排查五类翻车现场与定位手法写抽取脚本的前两周最顺第三周开始出问题而且问题往往不是脚本报错是数据看起来正常但结论是错的。下面五条都是实际会撞上的。5.1 把“-”写成 NULL下游语义反转现象台账里明明有记录下游按“坐落 IS NULL”查出来的却是全部记录业务方以为系统把所有人的房子都查到了。原因入库时统一做了空值归一把横杠、空串、None 全转成 NULL脚注 5 的语义被抹掉了。解决保留原始字符串另加一列is_dash标记。判定逻辑只在result_state上做不在字段空值上做。改完之后横杠和 NULL 各走各的路。5.2 表格抽取把占位行过滤掉现象某批文件全部被判成无记录但人工打开 PDF 明明能看到一行序号 1。原因清洗阶段写了“删除全空行”而占位行除了序号以外全是横杠被当成空行删掉了于是从“占位空行”变成“无记录”结论直接反了。解决清洗规则改成“删除全列为空的物理空行”保留含横杠的行。这个 bug 特别隐蔽因为两种状态的最终输出都是“没有不动产”但业务含义完全不同一个代表要复核一个代表可以放心。5.3 把 2022-07-01 前后的备案信息混着用现象拿 2023 年开具的证明去查某套房的合同备案查不到业务方认定该房未备案。原因脚注 3 明确写了备案职能在 2022-07-01 划转这份文件只覆盖划转之前的历史登记之后的情况要去另一个口子查。解决在查询条件里加时间分流。查询时间早于 2022-07-01 的走本文件晚于的本文件只作历史参考最新状态需要另行取数。台账里加一列scope_note把这条边界写死在数据里比写在文档里管用。5.4 二维码过期就当成证明失效现象三个月一到定时任务把整批记录标成失效业务方要求全部重新开具白跑一趟。原因把核验窗口和证明效力混成了一个概念。解决拆成两个状态字段qr_status只管扫码通道record_status管归档状态。过期只影响前者后者只有在人工复核发现不一致时才变更。到期提醒的文案也要跟着改提示“核验通道已过期如需再次核验请重新开具”而不是“证明已失效”。5.5 文件名当主键同名覆盖现象同一个人一年内开了两次证明文件名格式一样第二次导入直接覆盖了第一次。原因文件名里的日期只到天同一天多次开具就撞了用文件名当唯一键天然会覆盖。解决主键换成编号加查询时间加文件哈希。文件名只当索引用进库前先算sha256已存在就跳过不存在就新增。这套下来同一天开五次也不会丢记录。6. 批量核验与到期提醒把单份证明管成台账单份 PDF 抽通了不算完真正省时间的是批量跑起来之后的可运维性。这一章给两个具体做法和一个兜底顺序。批量扫描目录增量入库主流程就是把前几章的函数串起来import os, json, hashlib def sha256_of(path, buf1 20): h hashlib.sha256() with open(path, rb) as f: for chunk in iter(lambda: f.read(buf), b): h.update(chunk) return h.hexdigest() def batch_ingest(root, conn, seen): for dirpath, _, files in os.walk(root): for name in files: if not name.lower().endswith(.pdf): continue path os.path.join(dirpath, name) digest sha256_of(path) if digest in seen: # 哈希已入库直接跳过 continue with pdfplumber.open(path) as pdf: text \n.join((p.extract_text() or ) for p in pdf.pages) rows parse_table(pdf.pages[0]) head parse_head(text) state, cnt judge_state(rows) # 头部缺失视为不可用记录后跳过而不是入库脏数据 if not head[cert_no] or not head[query_time]: print(SKIP, path, head-missing) continue print(OK, head[cert_no], state, cnt)seen是一个哈希集合启动时从库里一次性捞出来避免每份文件都查一次数据库。buf取 1MB 是文件读写的常见折中再大对单份小 PDF 没意义。头部缺失直接跳过而不是补默认值是因为补默认值会把“解析失败”伪装成“正常入库”后面排查成本极高。到期提醒用一条 SQL 就够把未来 30 天内核验通道过期的记录捞出来SELECT cert_no, holder_mask, query_time, qr_expire_at, CAST(julianday(qr_expire_at) - julianday(now, localtime) AS INTEGER) AS days_left FROM house_cert_snapshot WHERE qr_status VALID AND julianday(qr_expire_at) - julianday(now, localtime) 30 ORDER BY days_left ASC;julianday是 SQLite 内置的日期转儒略日函数两个相减得到天数差localtime保证和自己的时区对齐不然跨时区跑会偏一天。days_left为负数表示已过期提醒文案按正负分两套。抽不全的时候兜底顺序我一般固定走这四步按成本从低到高排顺序手段适用情形成本1调vertical_strategy为 lines表格有完整边框低2用文字位置推列加调容差只有横线或竖线浅灰中3按固定字符宽度切行列宽稳定、字体等宽中4转图片走 OCR 后人工校对无文字层高顺序别乱。见过不少团队一上来就上 OCR明明调一个intersection_tolerance就能解决白搭进去几周的模型调优时间。这套东西跑顺之后我最大的教训是所有“看起来是空的”字段都得先问一句它到底是业务上的空还是解析上的空。当初为了省事在清洗函数里统一把横杠扔进 None结果一份占位空行的证明被误判成“查无此人”业务方拿着错误结论去催材料回头查了半天才发现是正则里少写了一个中文逗号。从那以后我每次写这类抽取脚本都会先拿一份“全是横杠”的样本跑一遍确认三态判定输出的是占位空行而不是无记录才敢放开跑批量。这份脱敏样例连同字段字典、三态判定脚本和建表语句一起放在下载包里拿去改改锚点就能接自己的材料。希望帮到你。本文还有配套的精品资源点击获取