项目:XSearch | 版本:v1.0 | 生成日期:2026-10-05 | 依据:源码分析
XSearch 的存储布局有一个重要特征:业务文档的唯一权威存储是 Elasticsearch 的 quotes 索引,Django ORM 层面没有任何业务模型。XSearch/search/models.py 的全部内容是一段迁移说明:"索引 mapping 统一由爬虫侧维护(crawler/pipelines.py 的 QuoteDocument,含 suggest 补全字段),搜索/建议接口直接使用原生 ES 客户端";search/admin.py 也没有注册任何模型。SQLite(XSearch/db.sqlite3)只承担 Django 会话等框架数据;采集运行时状态(任务历史、定时任务配置)持久化为两个 JSON 文件(crawl_history.json / schedules.json);辅助性状态(热搜词、各类缓存)在 Redis。这一章分别讲清 ES mapping(主模型)、各爬虫产出的 item 字段契约(写入侧约定)、JSON 状态文件结构与 Redis 键空间(辅助模型),以及文档的生命周期。
索引名 quotes(历史遗留命名,crawler/settings.py 的 ES_INDEX = os.getenv("ES_INDEX", "quotes"),可经 ES_INDEX 环境变量覆盖;Django 侧 xsearch/settings.py 读取同名变量保持一致)。mapping 由 crawler/pipelines.py 的 QuoteDocument 类(elasticsearch-dsl 的 Document)声明,EsArticlePipeline.open_spider() 在每次爬虫启动时探测 ES 并调用 QuoteDocument.init()——索引不存在时才按当前 mapping 创建,已存在则不动。
中文分词是这套 mapping 的灵魂:文本字段建索引用 ik_max_word(细粒度切分,最大化索引词元),检索用 ik_smart + 自定义停用词过滤器(ik_search_analyzer,停用词表 CN_STOPWORDS 收录"的/了/是/在"等约 50 个高频虚词)。源码注释解释了检索侧停用词的动机:"IK 会把'的/了/是'切成独立词元,OR 语义下命中几乎所有中文文档,造成'搜肖申克的救赎、出来沉默的羔羊'的噪音"。这也是为什么 ES 必须使用 deploy/es-ik.Dockerfile 定义的带 IK 插件镜像(8.15.3 + analysis-ik),stock 镜像第一次建索引就会失败。
| 字段 | ES 类型 | 分词/索引属性 | 写入来源(爬虫 item 字段) | 说明 |
|---|---|---|---|---|
suggest | Completion | 补全字段 | input: [title, author] | 搜索框补全(/api/suggest/),fuzziness 2 |
title | Text | index: ik_max_word;search: ik_smart+停用词 | title(缺省回退 author 或 content 前 40 字) | multi_match 权重 ^3 |
content | Text | 同上 | content/text(上限 5000 字符,回退标题) | multi_match 权重 1;列表接口截断 150 字 |
tags | Text | 同上 | tags(数组,如 ["新闻","IT之家"]) | multi_match 权重 ^2 |
author | Keyword | 不分词 | author(源站名/信源名) | 多_match 中的精确匹配字段 |
url | Keyword | 不分词 | url | 回源站链接 |
url_object_id | Keyword | 不分词 | url_object_id | 同时作为 ES _id:md5(url) 或站点原生 id |
front_image_url | Keyword | 不分词 | front_image_url(当前各源均为空串) | 预留封面图字段 |
rating | Float | — | rating(AIHOT 评分 0–100) | 前端渲染为"AI 评分"标签 |
praise_nums | Integer | — | praise_nums | 点赞数 |
view_nums | Integer | — | view_nums(抖音热度值/AIHOT 信源数复用) | 播放/热度;sort=hot 的排序键 |
reply_nums | Integer | — | reply_nums | 评论数 |
danmaku_nums | Integer | — | danmaku_nums | 弹幕数 |
coin_nums | Integer | — | coin_nums | 投币数(B站遗留学段) |
favorite_nums | Integer | — | favorite_nums | 收藏数 |
share_nums | Integer | — | share_nums | 分享数 |
duration | Integer | — | duration | 时长(秒,视频类遗留学段) |
rank | Integer | — | rank(榜单排名) | 榜单接口 sort:[{"rank":"asc"}] 的排序键 |
source | Keyword | 不分词 | source(爬虫名衍生的来源 key) | 全系统最重要的过滤/聚合维度 |
crawled_at | Date | — | utc_now()(管道写入时刻,aware UTC) | 数据管理列表的排序键 |
create_date | Date | — | 各源发布时间(ISO/RFC822 解析) | 搜索排序、时间分面、要闻窗口的核心时间键 |
source 字段的全集(由五个爬虫写入):news_chinanews、news_ithome、news_solidot(news_rss 与 news_backfill 共用前两者);aihot_news、aihot_daily、aihot_hot;douyin_hot。另有历史来源 news_people(人民网,已停用采集,存量文档仍在库内可检索)。search/api_views.py 以常量维护了这些分组:NEWS_SOURCES(三新闻源)、AI_SOURCES(三 AI 源)、RANKING_SOURCES(两榜单源,搜索与要闻接口排除)。
_id 与去重策略index_item() 中 doc.meta.id = item.get("url_object_id") or _md5(url + content[:200])。各爬虫的取值:
| 爬虫 | _id 构造 | 幂等语义 |
|---|---|---|
| news_rss / news_backfill | md5(url) | 同一篇文章重复采集覆盖更新(backfill 的纯标题条目会被后续 news_rss 的全文版本覆盖补全) |
| aihot_news | aihot_{AIHOT id} / aihot_daily_{date} | 7 天窗口滚动重抓自动覆盖;日报按日期稳定 |
| aihot_hot | aihot_{publicId} | 榜内事件覆盖更新;跌出榜单的文档由 _prune_stale() 在 spider_closed 时 delete_by_query 删除 |
| douyin_hot | douyin_hot_{rank} | 每轮覆盖同 rank 槽位(榜单是"当前状态") |
Django 读接口并不回传原始 _source,而是经 _list_item()(api_views.py)做统一映射与裁剪:id 取 hit._id;content 在列表上下文截断 150 字、AI 详情/榜单全文场景保留至 5000 字;create_date 经 _display_date() 把 ES 的 UTC 日期换算成站点时区(Asia/Shanghai)的 YYYY-MM-DD,解析失败退回原串前 10 位("读接口不能因为一条脏数据整页 500")。
| 模型 | 位置 | 状态 | 说明 |
|---|---|---|---|
| (无业务模型) | search/models.py | 迁移说明文件 | mapping 归 crawler/pipelines.py 管 |
| Django 内置 User/Session 等 | django.contrib | 默认启用 | 仅供 /admin/ 与会话;站点自身的管理员不走它(见第四章) |
因此本项目没有业务相关的 Django migration,migrate 只建框架表。管理员账号是 settings 级配置(ADMIN_USERNAME/ADMIN_PASSWORD,环境变量或 local_settings.py),不是数据库记录——登录态完全由进程内 token 字典表达,与 ORM 无关。
读写方:search/crawl_manager.py 的 CrawlManager(内存列表 _history,落盘上限 MAX_HISTORY = 200 条,_record_history() 按 job_id 原位更新)。记录结构:
| 字段 | 类型 | 说明 |
|---|---|---|
job_id | str | job_{毫秒时间戳},任务唯一标识 |
spider | str | 爬虫 key(白名单之一) |
status | str | running / completed / failed / empty / interrupted 五态 |
started_at / ended_at | str | ISO 秒级时间戳 |
pid | int | 子进程 PID(启动收尾时探测存活) |
log_path | str | 日志绝对路径(status 接口回显) |
job_dir | str | JOBDIR 目录名(恢复用) |
resumed | bool | 是否为恢复任务 |
returncode | int | 退出码(终态记录) |
items | int | 从日志解析的 item_scraped_count |
状态机:running → completed(returncode=0 且 items>0)/ failed(returncode≠0)/ empty(returncode=0 且 items=0);重启时遗留的 running 由 _reconcile_stale_runs() 按 pid 存活探测改判 interrupted。
读写方:ScheduleManager({"jobs": {...}, "history": [...]} 两键结构,history 裁剪最近 20 条)。jobs 以 job_id 为键,值为:
| 字段 | 类型 | 说明 |
|---|---|---|
spider | str | 爬虫 key |
pages / js | int/bool | 透传参数(pages 1–20) |
cron | str | 五段 cron("分 时 日 月 周"),添加/更新时经 _validate_cron 三级校验(段数→字符集正则→CronTrigger.from_crontab 语义) |
enabled | bool | 启停开关(toggle 接口改写) |
created_at | str | 创建时间 |
label | str | 爬虫中文标签(展示用) |
last_fire / last_status / last_reason | str | 上次触发时刻/结果(started/skipped/misfired/error),解决"从没跑过看不出来"的可观测性问题 |
两个文件的读写都走 _write_json_atomic(先写 .tmp + fsync,再原地覆盖主文件——规避单文件 bind mount 上 os.replace 的 EBUSY)与 _load_json_with_recovery(主文件损坏回退 .tmp,再不行备份 .corrupt-<ts> 后抛出,由调用方降级 + 告警)。
| 键 | 类型 | 写入方 | 用途 | TTL |
|---|---|---|---|---|
search_keywords_set | ZSet(score=次数) | api_search 每次搜索 zincrby | 热搜词统计(stats 读 Top8、空结果/首页读 Top6 引导),每次写入后 zremrangebyrank 裁剪到前 100 | 无(常驻) |
headlines:{hours} | String(JSON) | api_headlines | 首页要闻缓存 | 120 秒 |
rankings_news:{source}:p{page} | String(JSON) | api_rankings 新闻分支 | 新闻列表缓存;数据管理清理来源时按 scan_iter 精确删除 | 60 秒 |
sitemap:{base} | String(XML) | sitemap_xml | 站点地图缓存 | 3600 秒 |
Redis 连接配置了 3 秒连接/读超时,所有访问包在 try-except 里——"Redis 不可用只是少了缓存,不影响出结果"。
需要说明的是,这是一张逻辑 ER 图:ES 文档与 Redis 键之间没有外键约束,"关系"只体现在查询语义上(如 SPIDER_RUN.items 是写入 QUOTES 的条数、SEARCH_KEYWORD 与 QUOTES 的命中关系在搜索时才发生)。项目刻意放弃了关系型外键,换取爬虫写入端的完全无状态。
文档生命周期从爬虫管道开始:index_item() 校验 content 非空 → 构造 QuoteDocument → _id 赋值 → doc.save() upsert。生命周期终点有四种:① 新闻/资讯类文档长存(靠 create_date 随 7/30/90 天时间分面自然"沉底",不删除);② 榜单类(aihot_hot/douyin_hot)是覆盖语义的当前状态,前者还主动删除跌榜条目;③ 管理员经 /api/admin/db/doc/<id>/ DELETE 单删,或 /api/admin/db/purge/ 按来源批量清理(先 indices.refresh 再 delete_by_query,删完校验残余并补删一轮,最后清理相关 Redis 缓存);④ 存量停用来源(news_people)由管理员决定是否清理。
采集运行记录的生命周期更短:running 起于 start(),通常在子进程退出后数秒内被盯梢线程终结;异常路径(后端重启)由下次启动的 _reconcile_stale_runs() 收尾为 interrupted;整个列表滚手上限 200 条,老记录被挤出。定时任务的生命周期由管理员控制:add → (update/toggle)* → remove;文件损坏时降级为空配置并发告警,绝不静默重置。
数据模型中最容易被忽视的约定是时区。utc_now() 的注释完整记录了事故与修复:naive 的 datetime.now() 会被 ES 当作 UTC 解释,容器里 TZ=Asia/Shanghai 时每条文档的时间都偏移 8 小时,导致"近 7 天"分面与时间排序全部错位。修复方案是写入统一 aware UTC(管道侧 utc_now()、douyin 爬虫 datetime.now(timezone.utc)),展示侧 _display_date() 按站点时区换算。数值字段同理:_to_int/_to_float 把脏值统一清洗为 None 而非让写入失败;strip_html()(news_rss)删除 script/style 块防播放器代码混入正文;正文 5000 字符上限防超长文章撑大文档。search/tests.py 的 TzAwareWriteTests、DateDisplayTests 把这些约定固化成了回归测试。