在独立开发、副业项目、品牌起名和域名观察场景里,50 万级域名数据并不少见。常见做法是下载一份公开域名榜单,再用脚本一行行过滤,可是当你想按“包含 python 的 .com 域名、长度不超过 8、并且在热门榜里出现过”这样的组合条件检索时,手工处理效率就很低。这篇文章以 10 美元级成本和“一个周末完成原型”的目标为背景,拆解一个面向 maker 的 50 万域名轻量搜索引擎:数据源怎么选、SQLite FTS5 怎么建索引、FastAPI 怎么提供查询接口、前端怎么接起来,以及构建过程中最容易遇到的domain forbidden、non-existent domain这类错误如何排查。读完以后,可以用同样思路搭建域名查询工具,也可以把里面的数据清洗、索引和查询方法迁移到其他元数据检索场景。
1. 域名搜索引擎到底在搜什么
1.1 域名搜索和网页搜索是两种产品
普通搜索引擎抓取页面正文,做分词、相关性分析和海量索引。域名搜索面对的是结构化记录:每个域名是一个字符串,附带长度、后缀、排名、注册状态等字段。用户输入python时,想要的是域名主体里包含python的候选列表,而不是某个页面是否讨论了 Python。评价一个域名搜索引擎,主要看它能否快速完成“关键词 + 后缀 + 长度 + 其他维度”的组合过滤,以及结果排序是否符合使用习惯。
域名搜索的查询条件通常非常明确:
- 关键词:域名主体包含哪些字符序列。
- 后缀:只想要
.com、.io、.ai,还是任意后缀。 - 长度:域名主体在哪个长度区间。
- 热度:该域名在公开榜单中的相对排名。
- 字符特征:是否便于拼读、是否由纯单词构成、是否包含连字符等。
这些条件更适合用结构化查询来实现,而不是语义搜索。50 万行规模下,SQLite 的 B-tree 索引配合 FTS5 全文索引已经能覆盖绝大多数组合查询。真正需要引入 Elasticsearch 等分布式检索引擎的场景,通常是数据量到千万级甚至亿级,或者查询模式复杂到无法用简单索引表达。对一个 maker 工具原型来说,先跑通最小闭环比一开始追求架构更重要。
1.2 maker 的典型使用场景
这里的 maker 指独立开发者、内容创作者、小团队和做 side project 的人。他们在域名场景里的典型诉求包括:
- 给新项目起名:输入
cloud、note、sync等关键词,快速看到一批候选域名。 - 品牌保护:观察与自己品牌相近的域名是否出现在热门榜单里,判断是否需要提前关注。
- 批量筛选:按后缀和长度筛出短域名、可读域名,再交给 WHOIS 或注册商接口做二次验证。
- 灵感挖掘:用前缀、后缀和随机组合发现一些平时想不到的域名。
这些场景有一个共性:不需要立刻拿到域名的注册状态。公开榜单本身是“曾出现过的域名集合”,不等于“可注册域名”。搜索系统承担的是第一轮筛选,把候选集从 50 万缩到几千甚至几十条,后续再通过 WHOIS 或注册商接口判断域名是否可用。
1.3 为什么 50 万数据量不需要重架构
50 万个域名保存为文本,每行大约 30 到 60 字节,加上排名和附加字段,原始数据也只有几十 MB 到一百多 MB。SQLite 单文件可以完整容纳,加上 FTS5 索引后,数据库文件通常在几百 MB 级别,具体大小取决于附加字段数量和是否保留外部内容表。这个量级不会让现代 CPU 和内存感受到压力。
真正耗费成本的是三件事:
- 数据获取的合法性与稳定性。
- 清洗和去重逻辑。
- 后续的 WHOIS 查询或注册状态更新。
这三件事和搜索引擎本身一样重要。如果在一开始就把注意力放在消息队列、分布式检索、多副本部署上,反而会偏离“低成本快速验证”的目标。先用一个 SQLite 文件把整个流程跑通,等出现实际的性能瓶颈,再考虑更换数据存储和检索引擎。
| 对比项 | 网页搜索引擎 | 域名搜索引擎 |
|---|---|---|
| 数据对象 | 网页正文与链接 | 域名及少量结构化字段 |
| 数据量级 | 数十亿级别 | 十万到百万级别 |
| 查询模型 | 语义相关、排序复杂 | 关键词 + 规则过滤 |
| 典型延迟要求 | 几百毫秒 | 几十毫秒到几百毫秒 |
| 基础设施 | 分布式抓取和索引 | 单机 SQLite 即可承载 |
2. 数据源选型与 10 美元成本分配
2.1 公开域名榜单怎么选
构建域名搜索系统的第一步是拿到足够广的域名集合。常见数据源包括:
- CERT/CC 的 Top 1 Million 列表,文件名为
top-1m.csv,字段通常是排名和域名。 - Tranco 榜单,每天生成一份,排序稳定性较好。
- Cisco Umbrella Popularity List,按 DNS 请求量排序。
- Common Crawl 的部分站点列表,数据量大但更新周期长。
- 自己分析证书透明日志或 DNS 流量,灵活但成本高。
选型时要确认四点:更新频率、数据格式、下载方式、使用许可。不同榜单的排序口径不同,有的按访问量,有的按域名总量,有的包含子域名。如果只是做 maker 工具,优先选更新频率高、格式简单、没有严格禁止二次分发的榜单。使用前一定要阅读数据许可条款,不要把“可以下载”等同于“可以任意对外分发”。
不要试图用脚本直接抓网页上展示的热门域名表格。很多榜单页面对程序访问有限制,接口可能返回{"code":1004,"error":"domain forbidden"}之类的错误。这类限制是数据所有者的访问控制策略,不是搜索系统本身的故障。正确做法是找官方下载链接,或者申请 API 权限后在允许的频率内访问。
注意:公开榜单里的列可能是域名,也可能是完整主机名。例如
www.example.com。如果目标是给起名和候选筛选使用,通常建议提取注册域名example.com,避免同一个主域出现几十次。
2.2 榜单数据的常见坑
不同榜单格式差异很大,清洗时需要特别注意:
- 有的榜单包含子域名,直接用会导致
example.com和www.example.com同时出现,搜索结果被污染。 - 有的榜单会在域名末尾保留一个点,例如
example.com.,需要做归一化。 - 有的榜单会混入带端口、协议、路径的脏数据,要用正则或解析函数过滤。
- 不同榜单的更新日期不同,不能假定两份数据可以直接合并,最好使用
source和date字段标记来源。 - 榜单排名只能反映“该榜单当时的观测结果”,并不代表域名的真实访问量。
2.3 10 美元级成本如何分配
“10 美元”能跑起来的关键,是把核心能力放在普通 VPS 和 SQLite 上,而不是购买托管搜索服务。按常见市场行情参考:
| 项目 | 常见价格区间 | 说明 |
|---|---|---|
| 1 核 1G 内存 VPS | 约 5-10 美元/月 | 跑 FastAPI 和 SQLite 足够 |
| 域名 | 约 10 美元/年 | 如果只做内网工具,可以省略 |
| 对象存储备份 | 接近 0 美元 | 只存几个 SQLite 备份文件 |
| 托管搜索服务 | 按量计费 | 个人工具阶段没有必要开 |
如果项目只给个人或小团队使用,一台 1 核 1G 的 VPS 从成本和性能角度看都比较合理。不要开太多后台 worker,SQLite 不适合高频并发写,但搜索场景主要是只读查询,影响不大。真正需要注意的是备份:SQLite 是单文件,备份成本很低,但很多临时项目恰恰没有做备份。
3. 环境准备与项目结构
3.1 运行环境要求
下面示例使用 Python 3.10 以上版本,SQLite 需要开启 FTS5。Python 自带的sqlite3标准库通常已经编译了 FTS5,但不同环境差异较大,建议先做检查。
python3 --version sqlite3 --version python3 -c "import sqlite3; conn = sqlite3.connect(':memory:'); print(conn.execute('select sqlite_version()').fetchone()[0])"确认 FTS5 是否可用:
python3 << 'EOF' import sqlite3 conn = sqlite3.connect(':memory:') print(conn.execute("select sqlite_compileoption_used('ENABLE_FTS5')").fetchone()[0]) EOF如果输出结果是 0 或空,说明当前 Python 编译的 SQLite 不包含 FTS5,需要换一个 Python 发行版或重新编译 SQLite。对于国内开发者,使用系统包管理器安装的 Python 通常也能满足,但落地前要先确认。
3.2 依赖安装
快速原型只需要两个 Python 包:FastAPI 和 Uvicorn。请求第三方数据源可以使用标准库urllib,也可以加requests。不要一开始引入 pandas 和整套数据处理框架,清洗 50 万行用标准库csv和re就够了。
pip install fastapi uvicorn requests如果需要从子域名中提取注册域名,可以加tldextract,但要注意它需要后缀表数据,离线环境要先做好缓存。
3.3 项目目录结构
建议把离线数据处理和在线 API 服务分开,目录结构如下:
domain-hunt/ ├── data/ │ ├── raw/ │ │ └── top-1m.csv │ └── domains.db ├── scripts/ │ ├── download_top_million.py │ ├── clean_and_load.py │ └── build_fts.py ├── app/ │ ├── __init__.py │ ├── main.py │ └── search.py ├── static/ │ └── index.html └── requirements.txt| 目录/文件 | 作用 |
|---|---|
data/raw | 存放下载的原始榜单 |
data/domains.db | SQLite 数据库文件 |
scripts | 离线下载、清洗、建索引脚本 |
app | FastAPI 接口服务 |
static | 前端静态文件 |
这样的分层在后面调试时比较清晰:数据问题到scripts里找,接口问题到app里找,页面问题到static里找。
4. 数据清洗与入库
4.1 下载榜单并解析
假设已经拿到一份top-1m.csv,格式为rank,domain。下载和解析应该放在离线脚本中,不要放进 API 服务,避免某个外部数据源抖动影响在线查询。
下面是一个最小解析示例:
import csv import re from urllib.parse import urlparse def clean_domain(raw: str) -> str | None: raw = raw.strip().lower() if not raw: return None # 去掉协议、路径和端口 if "://" in raw: raw = urlparse(raw).netloc or raw raw = raw.rstrip(".") # 只保留域名基本格式 match = re.fullmatch(r"(?:[a-z0-9_-]+\.)+[a-z]{2,}", raw) if not match: return None return raw with open("data/raw/top-1m.csv", newline="", encoding="utf-8") as f: reader = csv.reader(f) seen = set() cleaned = [] for row in reader: if len(row) < 2: continue rank = row[0].strip() domain = clean_domain(row[1]) if not domain or domain in seen: continue seen.add(domain) cleaned.append((rank, domain))这段代码先做字符串归一再过滤格式。urlparse只在遇到协议时使用,避免正常域名被误解析。strip()和rstrip(".")用于处理空白和末尾点。clean_domain返回None的行会被直接丢弃。
4.2 写入 SQLite
写库时不要一次插入一行,应该累积成事务,用executemany批量提交。50 万行数据如果逐条执行INSERT,会非常慢,而且容易出现磁盘同步压力。
import sqlite3 conn = sqlite3.connect("data/domains.db") conn.execute("PRAGMA journal_mode=WAL;") conn.execute("PRAGMA synchronous=NORMAL;") conn.execute(""" CREATE TABLE IF NOT EXISTS domains ( id INTEGER PRIMARY KEY, rank INTEGER, domain TEXT NOT NULL UNIQUE, tld TEXT, domain_len INTEGER ); """) rows = [] for rank, domain in cleaned: tld = domain.split(".")[-1] rows.append((int(rank), domain, tld, len(domain))) conn.executemany( "INSERT OR IGNORE INTO domains(rank, domain, tld, domain_len) " "VALUES (?, ?, ?, ?)", rows, ) conn.commit()关键点:
INSERT OR IGNORE用于去重,如果榜单里同一个域名出现两次,第二次会被忽略。domain_len提前算好,避免每次查询时调用length()函数。- 开启 WAL 模式后,读和写可以并行,在线搜索接口访问时不容易出现
database is locked。 PRAGMA synchronous=NORMAL在大多数场景下兼顾性能和一致性,批量导入后如有必要再做一次完整备份。
4.3 从子域名提取注册域名
如果榜单里包含子域名,需要决定是否只保留注册域名。这里不能简单把www.example.com拆成example.com,因为多级后缀很多,例如com.cn、co.uk。推荐做法是使用tldextract:
import tldextract def to_registrable_domain(raw: str) -> str: ext = tldextract.extract(raw) if ext.suffix: return f"{ext.domain}.{ext.suffix}" return raw代价是引入额外依赖,而且tldextract需要下载后缀表,离线环境要先缓存。如果只针对少数常见后缀,也可以维护一个白名单后缀列表,做简单的后缀匹配。选择哪种方案取决于榜单来源和产品目标。
4.4 增量更新与全量重建
域名榜单会持续变化。个人工具可以先采用“全量下载,重建库”的简单策略:
- 每周下载一次最新榜单。
- 清洗后导入一个新 SQLite 文件。
- 索引构建完成后,用新文件替换旧文件。
这种方式逻辑简单,回滚容易,不会出现半新半旧的数据状态。如果后续需要每天更新,可以增加updated_at字段,只导入新增和变化的域名,但会引入更多边界情况。对 50 万数据量来说,全量重建的成本不高,优先使用全量方案。
5. 用 SQLite FTS5 建立可搜索索引
5.1 为什么选 FTS5
FTS5 是 SQLite 自带的全文检索扩展,支持 BM25 排序、前缀查询和外置内容表。对纯 ASCII 的域名域名,搜索体验接近“搜索框 + 自动补全”,比LIKE '%python%'快得多,也不会每次查询都全表扫描。50 万行索引在普通 VPS 上构建时间通常可以接受,个人项目不需要为它单独部署服务。
5.2 建表与同步策略
用两个表:主表domains和 FTS 虚拟表domains_fts,再用触发器保持同步。
CREATE VIRTUAL TABLE domains_fts USING fts5( domain, tld, content=domains, content_rowid=id, tokenize = "ascii" ); CREATE TRIGGER domains_ai AFTER INSERT ON domains BEGIN INSERT INTO domains_fts(rowid, domain, tld) VALUES (new.id, new.domain, new.tld); END; CREATE TRIGGER domains_ad AFTER DELETE ON domains BEGIN INSERT INTO domains_fts(domains_fts, rowid, domain, tld) VALUES ('delete', old.id, old.domain, old.tld); END; CREATE TRIGGER domains_au AFTER UPDATE ON domains BEGIN INSERT INTO domains_fts(domains_fts, rowid, domain, tld) VALUES ('delete', old.id, old.domain, old.tld); INSERT INTO domains_fts(rowid, domain, tld) VALUES (new.id, new.domain, new.tld); END;content=domains表示 FTS 表只保存索引,实际内容回主表读取,可以节省磁盘空间。触发器的作用是让主表和索引保持一致。如果采用“全量重建”策略,也可以不使用触发器,而是在导入完成后直接执行:
INSERT INTO domains_fts(domains_fts) VALUES('rebuild');这种方式更可控,适合离线脚本,不会因为一次失败的中间操作导致线上数据不一致。
5.3 FTS5 查询语法
FTS5 的典型匹配写法:
SELECT rowid, bm25(domains_fts) FROM domains_fts WHERE domains_fts MATCH 'python*' ORDER BY bm25(domains_fts) LIMIT 20;python*表示前缀匹配,能命中python、pythons、pythonic等。要注意.、-、_在 FTS