公司动态

基于SQLite FTS5的轻量域名搜索引擎构建指南

📅 2026/8/31 17:51:38
基于SQLite FTS5的轻量域名搜索引擎构建指南
在独立开发、副业项目、品牌起名和域名观察场景里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 从成本和性能角度看都比较合理。不要开太多后台 workerSQLite 不适合高频并发写但搜索场景主要是只读查询影响不大。真正需要注意的是备份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, encodingutf-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_modeWAL;) conn.execute(PRAGMA synchronousNORMAL;) 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 synchronousNORMAL在大多数场景下兼顾性能和一致性批量导入后如有必要再做一次完整备份。4.3 从子域名提取注册域名如果榜单里包含子域名需要决定是否只保留注册域名。这里不能简单把www.example.com拆成example.com因为多级后缀很多例如com.cn、co.uk。推荐做法是使用tldextractimport 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 为什么选 FTS5FTS5 是 SQLite 自带的全文检索扩展支持 BM25 排序、前缀查询和外置内容表。对纯 ASCII 的域名域名搜索体验接近“搜索框 自动补全”比LIKE %python%快得多也不会每次查询都全表扫描。50 万行索引在普通 VPS 上构建时间通常可以接受个人项目不需要为它单独部署服务。5.2 建表与同步策略用两个表主表domains和 FTS 虚拟表domains_fts再用触发器保持同步。CREATE VIRTUAL TABLE domains_fts USING fts5( domain, tld, contentdomains, content_rowidid, 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;contentdomains表示 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