简介:面向个人站长、运维人员与开发者的易捷域名查询系统v1.0,是一款基于PHP构建的轻量级域名查询工具。它聚焦实时域名注册状态检查、到期信息查看、多个常见顶级后缀的可用性判断,并可通过简单部署融入现有网站,满足日常域名选配、信息查验与资产核验需求。压缩包内含1个PHP源码文件,整包仅2KB,无复杂依赖,适合直接上传至服务器或本地环境运行调试。目前已有170人学习下载,适用于需要快速搭建查询入口的中小站点,也可作为二次开发的基础脚本。借助该份源码,用户可省去手动执行whois命令的繁琐流程,在浏览器界面中完成域名可用性判断与基本信息检索,同时便于学习PHP网络请求、数据解析及简单结果页输出的实现思路。这份轻量实现尤其适合初学者理解域名查询工具的基本工作原理,后续可自行扩展批量查询、智能推荐等进阶功能。
1. 易捷域名查询系统v1.0能省下什么:一批历史域名的记录盘点
接手一批历史域名时,最耗时的不是改解析,而是弄不清每个域名当前的真实状态。易捷域名查询系统v1.0(ej99domainv1.0)解决的就是主机域名批量盘点这个场景:一次喂进去几百个域名,统一查A、CNAME、MX、TXT、NS这些常用记录类型,输出可读的CSV,同时把NXDOMAIN、超时这类异常单独标记出来。它适合三类人:刚接手旧资产需要理清解析关系的运维,做SPF/DMARC遗留记录清理的安全同学,以及每天要巡检大量域名的交付团队。下面我按自己拆包复现的顺序,从请求构造、批量并发、结果落盘讲到实际使用时的翻车点。
2. 请求构造与响应解析:dnspython 的核心用法
2.1 为什么不用socket.gethostbyname:选型理由
很多第一次写域名查询脚本的人会直接用Python自带的socket,因为代码短,三五行就能拿到IP。但亲自批量查过一轮就会发现它有几个硬伤:socket.gethostbyname只能拿A记录,而且只返回解析到的第一个IP;CNAME、MX、TXT这些记录类型完全拿不到;更麻烦的是它对超时和域名不存在的处理非常模糊,抛出的异常并不区分是网络超时、没有记录还是域名本身不存在,结果落到报告里全是“解析失败”,根本没法判断是域名问题还是网络问题。
易捷域名查询系统v1.0的核心依赖选的是dnspython库,原因有三:记录类型覆盖完整,A、AAAA、CNAME、MX、TXT、NS都能查;可以显式指定nameservers,绕开系统默认DNS的缓存污染;异常对象细分了NoAnswer、NXDOMAIN、Timeout三类,能直接把状态写进结果。安装只需要一行命令:
pip install dnspython idnaidna库是因为后面批量处理时会遇到中文域名,没有它会在编码阶段直接报错。dnspython安装完成后,建议先在命令行里跑一下python -c "import dns.resolver; print(dns.__version__)"确认版本,v1.0脚本基于dnspython 2.x的API写的,如果你机器上还是1.x的老版本,部分函数名会不兼容。
2.2 查A/AAAA/CNAME:核心查询函数
整个工具包的查询主干是这样的,单条记录查询的代码我直接贴出来看:
import dns.resolver def query_dns(domain, rtype="A"): """查询单个域名的指定记录类型,返回可落盘的结构化结果""" resolver = dns.resolver.Resolver(configure=True) resolver.timeout = 2.0 resolver.lifetime = 4.0 result = { "domain": domain, "rtype": rtype, "records": [], "ttl": None, "status": "OK", } try: answers = resolver.resolve(domain, rtype, raise_on_no_answer=True) for rdata in answers: result["records"].append(rdata.to_text()) result["ttl"] = int(answers.rrset.ttl) if answers.rrset else None except dns.resolver.NoAnswer: result["status"] = "NO_ANSWER" except dns.resolver.NXDOMAIN: result["status"] = "NXDOMAIN" except dns.exception.Timeout: result["status"] = "TIMEOUT" return result这段代码的逻辑很容易跟:先创建Resolver对象,然后按rtype发起resolve,成功就把每条答案转成文本存进records,同时记录这条记录集合的TTL;NoAnswer代表域名存在但没有该类型记录,NXDOMAIN代表域名本身不存在,Timeout代表网络层失败。三种状态最后都会落到结果里,不会因为一个域的异常中断整个批量的跑批。
几个关键参数按字面理解就可以:timeout是每次网络读写的单次等待上限,lifetime是解析整个查询(包括可能的多次重试)的总时长上限,configure=True表示读取系统/etc/resolv.conf配置。我习惯把lifetime设成timeout的两倍,避免单次查询在极端网络情况下反复重试拖慢整个批处理。
2.3 查MX/TXT/NS:交换与文本记录的解析差异
A记录只是最简单的场景,域名盘点真正要处理的是MX、TXT、NS这类结构化数据。它们的rdata结构不同,如果统一用to_text()拿字符串,后续做判断还得自己解析,不如直接在查询函数里分开处理:
import dns.resolver def query_mx(domain): resolver = dns.resolver.Resolver() resolver.timeout = 2.0 resolver.lifetime = 4.0 try: answers = resolver.resolve(domain, "MX") records = [] for rdata in answers: # MX记录包含优先级和交换主机两部分 records.append(f"{rdata.preference} {rdata.exchange}") return records except dns.resolver.NoAnswer: return [] except dns.resolver.NXDOMAIN: return ["NXDOMAIN"] def query_txt(domain): resolver = dns.resolver.Resolver() resolver.timeout = 2.0 resolver.lifetime = 4.0 try: answers = resolver.resolve(domain, "TXT") records = [] for rdata in answers: # TXT的strings是bytes列表,按RFC存储时为多段字符串 txt_value = b"".join(rdata.strings).decode("utf-8", errors="replace") records.append(txt_value) return records except dns.resolver.NoAnswer: return [] except dns.resolver.NXDOMAIN: return ["NXDOMAIN"]MX记录用preference和exchange分别取优先级和邮件交换主机,拼成“10 mail.example.com”这种格式,和DNS解析软件输出的格式一致,看报告的人不需要额外理解。TXT记录需要注意rdata.strings是一个bytes列表,某些SPF记录会拆成多段字符串存储,直接rdata.strings[0]只能取到第一段,必须用b"".join()拼起来再解码。v1.0把这两个函数单独提取出来,而不是走通用查询函数,就是为了让报告中MX和TXT的字段可直接阅读。
NS记录的处理和MX很像,用rdata.target取目标NS服务器。有一个容易忽略的点:如果域名本身是DNSSEC签名的,查询响应里会多出RRSIG记录,普通查询函数拿到的是验证后的数据,一般不需要额外处理;但如果你的盘点脚本需要输出“该域名是否启用DNSSEC”,那就得单独查DNSKEY或DS记录,当前版本没有做这层,我是在后续扩展里补上的。
2.4 判断权威答案:AA标志与不可信缓存
批量盘点时有个容易被忽略的细节:如果你用机器上默认的resolver去查,拿到的不一定是权威答案,可能是各级DNS缓存里的旧值。部分场景比如域名刚刚过户、DNS刚切换,缓存里的旧记录会直接误导盘点结论。
想区分这次查询是不是权威返回,不能只依赖answers是否为空,要看响应里的AA(Authoritative Answer)标志位。dnspython对高层的resolve()封装没有直接暴露整个报文,这时可以用底层API手写查询:
import dns.message import dns.query import dns.flags def query_with_aa(domain, rtype="A", server="223.5.5.5"): # 构造查询报文,rd=1表示允许递归,但我们保留AA标志的读取 q = dns.message.make_query(domain, rtype) r = dns.query.udp(q, server, timeout=2.0) aa = bool(r.flags & dns.flags.AA) records = [item.to_text() for item in r.answer] if r.answer else [] return {"aa": aa, "records": records}注意这里的AA标志含义:向公共递归DNS查询时,响应里的AA通常是0,因为数据来自缓存而非权威区;向某个域名的权威NS直接查询时,AA为1。v1.0中的实际用法是:当某条记录的查询结果异常或是和预期不符,脚本会用这个底层方法对域名列表里的重点域做二次核验。你不需要对每个域名都查AA,那会显著增加耗时,通常只对状态为OK但记录值里出现“IP不在已知网段”的域名做复核。
提示:查询公共DNS时出现AAA标志为0并不表示回答错误,只能说明该响应来自缓存。判断线上解析是否生效,最终应当向权威NS发起查询。
3. 从单条到千级:批量查询的并发控制与结果落盘
3.1 批量输入文件与IDNA编码
单条查询能用之后,整个v1.0工具包的下一步价值在批量。输入文件我通常要求按每行一个域名准备,允许空行和井号注释,但会做两件额外的预处理:小写化和国际化域名转码。很多域名注册商后台导出的资产表里可能有“中文.公司”这种形式,直接用ASCII编码的DNS报文是发不出去的,需要先转成punycode。代码里用idna库实现:
from pathlib import Path import idna def load_domains(path: str) -> list[str]: raw = Path(path).read_text(encoding="utf-8").splitlines() domains = [] seen = set() for line in raw: line = line.strip().lower() if not line or line.startswith("#"): continue try: # 国际化域名转punycode,普通域名原样返回 ascii_domain = idna.encode(line).decode("ascii") except idna.IDNAError: print(f"跳过非法域名: {line}") continue if ascii_domain not in seen: seen.add(ascii_domain) domains.append(ascii_domain) return domainsidna编码这步不会影响普通英文域名,它只在输入包含中文时才动手。去重用set判断,比直接用list去重快很多,几百上千的量级感觉不出来,但如果有人把几万个域名塞进来,这个差异就能在导入时间上体现出来。编码失败就直接跳过并打印,避免一个乱行导致整个批次崩溃。
3.2 线程池并发与QPS限速
批量查询如果串行跑,每个域名查5种记录类型,平均一个域名要消耗几百毫秒,几百个域名下来就是几分钟到十几分钟。v1.0用concurrent.futures.ThreadPoolExecutor并发处理,同时引入信号量做限速,防止同时发出的UDP查询太多导致本机socket缓冲溢出,或是被解析服务器临时限流:
from concurrent.futures import ThreadPoolExecutor import threading import time semaphore = threading.Semaphore(8) # 同时最多8个查询在途 def throttled_query(domain): with semaphore: # 这里调用的是前面定义的单条查询函数 result = query_dns(domain, "A") time.sleep(0.02) # 最低消耗预留,平摊瞬时请求 return result def batch_query(domains, rtype="A", max_workers=16): results = [] with ThreadPoolExecutor(max_workers=max_workers) as executor: # 用map保持结果顺序与输入一致 for res in executor.map(lambda d: throttled_query(d, rtype), domains): results.append(res) return results线程数设16、信号量设8,是我在普通云主机上试过比较稳的组合。线程开太大,比如一次开64个线程,网络波动时会出现大量Timeout,而且本地DNS递归超时重试会让UDP并发数成倍增长。time.sleep(0.02)这行不是玄学,它让每个线程在query完成后强制间隔20毫秒,把每秒发出的解析请求控制在合理范围内。如果你对接的是公共DNS频繁调用会被限流,保留这个间隔能明显降低返回SERVFAIL的概率。
3.3 CSV落盘:字段设计与中文乱码
结果落盘是整个工具包最能体现“能直接给老板看”的部分。我设计的是五列结构:查询时间、域名、记录类型、记录值、TTL。查询时间放在最前,方便按批次筛选;TTL单独列出来,因为工具包里已经做了timeout/lifetime的重试,TTL可以辅助判断缓存时长:
import csv from datetime import datetime def query_dns_with_time(domain, rtype): result = query_dns(domain, rtype) result["query_time"] = datetime.now().isoformat(timespec="seconds") return result def write_csv(results, out_path): fieldnames = ["query_time", "domain", "rtype", "record", "ttl"] with open(out_path, "w", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter(f, fieldnames=fieldnames) writer.writeheader() for item in results: for record in item["records"]: writer.writerow({ "query_time": item.get("query_time", ""), "domain": item["domain"], "rtype": item["rtype"], "record": record, "ttl": item["ttl"] or "", })ttl的写法有个细节:在dnspython 2.x里,answers.rrset.ttl本身是int,但answers.rrset在NoAnswer时是None,直接取ttl会抛AttributeError。我在单条查询函数里做了int(answers.rrset.ttl) if answers.rrset else None的保护,这里在落盘时再对None补一个空字符串,CSV里就不会出现“None”这种不专业的内容。
编码必须写utf-8-sig而不是utf-8。Windows上的Excel打开纯utf-8的CSV时,中文列经常直接变成乱码,utf-8-sig会写入BOM头,Excel、Numbers都能正确识别。如果你在Linux上用cat查看文件,BOM头虽然会让第一行开头多一个不可见字符,但用pandas或Python读CSV不会受影响。
3.4 断点续跑:从上次结果恢复任务
批量跑几百个域名时,最怕跑到一半网络抖动,重跑又得全部再查一遍。v1.0的做法是从上次生成的CSV里读取“已完成”的域名和记录类型组合,本次批次直接跳过。这是用空间换时间,比每次删旧文件重跑靠谱得多:
def load_done_pairs(csv_path: Path) -> set: done = set() if not csv_path.exists(): return done with open(csv_path, encoding="utf-8-sig") as f: reader = csv.DictReader(f) for row in reader: # 结果文件里同一域名可能有多行不同记录类型 done.add((row["domain"], row["rtype"])) return done然后在批量主循环里,每处理一个域名每种记录类型之前,先检查(domain, rtype)是否已经在done集合里,是就直接跳过。这个逻辑简单到基本不会引入新bug,但它能让你第二次跑批的时候省掉大半时间。配合输入的原始域名文件,断点续跑做到的是“新增多少查多少”,不是“从头再来”。
注意:断点续跑依赖CSV里记录类型的完整性,如果你改了查询逻辑或新增了记录类型,旧结果文件里不会包含新类型的已完成状态,这些域名会被重新查询一遍,这是预期行为。
4. 避坑与排查:DNS查询最容易翻车的四个场景
4.1 CNAME与其他记录共存时的NoAnswer误判
现象:有一个域名,查A记录正常,查MX或TXT直接返回NO_ANSWER,但注册商后台明明配置了TXT记录。排查时发现该域名已经配置了CNAME。
原因:RFC 1034规定,如果域名存在CNAME记录,那么该节点不能有其他数据记录。查询MX或TXT时权威服务器不会返回其他记录,而是返回NoAnswer,dnspython把它当成“域名存在但没有该类型记录”处理,盘点上就会误判成“未配置MX/TXT”。
解决:批量查询的数据流里增加CNAME预判。先查CNAME,如果存在,就先跳过MX/TXT/NS这几种记录类型的查询,在结果里单独标“CNAME_ONLY”,并利用CNAME的别名继续查询A记录。具体实现就是在原来查询函数外层加一层判断:
def query_all(domain): result = [] try: # 先看是否有CNAME cname_result = query_dns(domain, "CNAME") if cname_result["status"] == "OK" and cname_result["records"]: result.append({"domain": domain, "rtype": "CNAME", "record": cname_result["records"][0], "status": "CNAME_ONLY"}) else: for rtype in ["A", "MX", "TXT", "NS"]: result.append(query_dns(domain, rtype)) except Exception as e: result.append({"domain": domain, "rtype": "ERROR", "record": str(e), "status": "EXCEPTION"}) return result这个先后顺序不只是为了准确性,也减少了一次无效查询:CNAME存在的域名查其他类型几乎必然没有结果,提前跳过能省不少时间。
4.2 本地与运营商DNS缓存造成的查询误判
现象:域名刚在注册商后台改了DNS服务器,易捷域名系统查询出来的还是旧IP;在服务器上curl域名也已经生效,但脚本结果迟迟不更新。查了好久才发现是本地resolver缓存了旧记录。
原因:系统默认resolver会缓存DNS响应,批量脚本如果统一用Resolver(configure=True),默认读的是/etc/resolv.conf里的DNS服务器,可能是内网DNS或运营商DNS,这些层面的缓存并不随域名的权威变更而失效。TTL为86400的记录,可能要等一天才能看到变化。
解决:给批量查询单独构造Resolver,指定公共DNS的IP,并且每次查询前在其他几个候选DNS之间轮询,避免某个缓存一直固定不变:
import dns.resolver CANDIDATE_DNS = ["223.5.5.5", "114.114.114.114", "8.8.8.8"] dns_index = 0 def get_resolver(): global dns_index r = dns.resolver.Resolver() r.nameservers = [CANDIDATE_DNS[dns_index % len(CANDIDATE_DNS)]] r.timeout = 2.0 r.lifetime = 4.0 dns_index += 1 return r这里的坑是不要把所有查询都固定到同一个公共DNS,否则你从一个缓存跳到另一个缓存,问题没解决只是换了一个“垂钓点”。轮询能让每次批量结果里掺杂不同DNS的视角,一旦发现同一域名在不同批次里结果不同,反而能快速定位到缓存问题。
4.3 超时阈值设置导致的批量TIMEOUT
现象:本地单条查询好好的,批量跑到150个域名后开始集中出现TIMEOUT,甚至整批结果里三分之一都是超时状态。
原因:单条查询时网络空闲,其他程序没有抢资源;批量并发16个线程后,UDP请求、线程切换以及公共DNS服务端的限流共同导致响应时间被拉长,而timeout还是按单条场景设置的1秒,自然会成片超时。部分云厂商的DNS还对并发做了限制,超过阈值直接丢弃查询。
解决:把timeout和lifetime放到配置项中统一管理,批量场景的经验值是timeout不低于2秒、lifetime不低于4秒,并且对TIMEOUT状态做一次重试,重试时换一个备用DNS:
def query_dns_with_retry(domain, rtype, retries=1): for attempt in range(retries + 1): result = query_dns(domain, rtype) if result["status"] != "TIMEOUT": return result # 超时后等待短暂时间,换DNS再来一次 time.sleep(0.1 * (attempt + 1)) return result重试次数设置为1即可,重试2次以上在批量场景里对整体耗时的拖累非常明显,收效却不高。如果重试后仍超时,就保持TIMEOUT状态落盘,不要把它当成失败丢弃,这样可以在报告里追踪到底是网络问题还是域名解析问题。
4.4 Windows下CSV乱码与TTL取值错误
现象:在Windows上双击打开生成的CSV,中文全部乱码;同时报告的TTL列有时候是数字,有时候是“None”,脚本里明明做了判空。
原因:乱码是编码问题,前面提到过必须用utf-8-sig。TTL显示None的场景有两种可能:一是NoAnswer状态没有rrset,二是异常分支里没有把ttl初始化。前者是逻辑问题,后者是封装的坑——我在第一次写v1.0时,只在成功分支赋值了result["ttl"],异常分支忘了初始化,导致NoAnswer的结果字典里根本没有ttl这个键,后面落盘时取item["ttl"]直接报KeyError。
解决:在执行时强制让结果字典在初始化时就带好所有键,包括ttl: None。CSV读回时,用row.get("ttl", "")而不是row["ttl"],更稳妥:
result = { "domain": domain, "rtype": rtype, "records": [], "ttl": None, "status": "OK", }做一次全字段初始化成本很低,却能让所有下游代码省去一堆判空。
5. 进阶:把查询结果接进定时变化巡检
v1.0本身做的是单次盘点,但如果你手里的域名是持续变动的资产,把结果保存下来做变化巡检会有更大的价值。我一般会在批量查询跑完后,把本次CSV和上一次CSV做一次diff,只列出“记录值发生变化”和“状态发生变化”的域名,然后输出一份简短的变更摘要。这样不用每天对着几百行的CSV逐条看。
def diff_results(old_csv, new_csv): def load_map(path): mapping = {} with open(path, encoding="utf-8-sig") as f: for row in csv.DictReader(f): key = (row["domain"], row["rtype"]) mapping.setdefault(key, set()).add(row["record"]) return mapping old_map = load_map(old_csv) new_map = load_map(new_csv) changes = [] for key in old_map.keys() & new_map.keys(): if old_map[key] != new_map[key]: changes.append({ "domain": key[0], "rtype": key[1], "old": sorted(old_map[key]), "new": sorted(new_map[key]), }) # 新增域名和删除域名单独归一类 added = new_map.keys() - old_map.keys() removed = old_map.keys() - new_map.keys() return changes, added, removed接入定时任务时,只需要把易捷域名查询系统的命令行入口加上“--output 日期.csv”参数,再用shell脚本串联新旧文件。每天执行一次,把diff结果推送给自己,基本能做到域名解析变更的分钟级感知。
变化巡检还有一个实践技巧:不要只看记录值变化,TTL突变本身就是信号。某个域名的TTL从3600变成了60,通常意味着DNS管理员在为变更做准备,接下来的48小时内很可能发生解析切换。我通常会在diff里额外比较TTL,优先关注TTL变小且记录值变化的域名。
从那以后,我每次做批量查询都把流程固定成:先预判CNAME、再轮询公共DNS、超时重试一次、最后用utf-8-sig落盘。这套v1.0工具包我反复用过多次,踩过的坑基本都写在上面了,希望帮到你。
本文还有配套的精品资源,点击获取