☰
基于SQLite FTS5的轻量域名搜索引擎构建指南
2026/10/15 1:31:20 网站建设 项目流程

在独立开发、副业项目、品牌起名和域名观察场景里,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 和内存感受到压力。

真正耗费成本的是三件事:

  1. 数据获取的合法性与稳定性。
  2. 清洗和去重逻辑。
  3. 后续的 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.dbSQLite 数据库文件
scripts离线下载、清洗、建索引脚本
appFastAPI 接口服务
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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询