项目:XSearch | 版本:v1.2 | 生成日期:2026-10-06 | 依据:源码分析
已有各篇讲"系统有什么":《07-模块详解》给每个模块列了函数清单与机制概要,本篇做的是另一件事——挑三个最有代表性的文件,贴真实代码,逐段讲它在干什么、为什么这么写、以及这段代码能教给后来者什么。选文件的判据是"一个文件承载一条完整的主干叙事":
XSearch/crawler/spiders/news_rss.py(163 行)——写入侧的起点,一个代表性 spider 的完整解剖,顺带讲清它头顶的中间件链是怎么配合的;XSearch/search/api_views.py 的 api_search(约 150 行函数体)——读取侧最复杂的链路,ES 查询构造、高亮、分面、三级纠错全在这一段;XSearch/search/crawl_manager.py 的 CrawlManager.start()(约 60 行)——Web 输入通向命令执行的唯一闸门,安全校验密度最高的函数。走读的每一段末尾都有"这段代码教会我们什么"小结;引文均摘自当前工作区源码(路径相对 XSearch/ 仓库根),行号以源码实测为准,略有出入以文件内容为准。建议打开源码对照阅读。
"""
新闻 RSS 聚合爬虫:抓官方 RSS 源写入 ES "quotes" 索引(可搜索)。
RSS 是网站官方分发格式,抓取合规且无需对抗反爬。内置 3 个源(2026-09-27 实测可用):
news_chinanews 中国新闻网滚动 https://www.chinanews.com/rss/scroll-news.xml
(feed 无摘要,自动跟进文章页抓 div.left_zw 正文)
news_ithome IT之家科技新闻 https://www.ithome.com/rss/ (description 为正文开头)
news_solidot Solidot 科技 https://www.solidot.org/index.rss
已停用:news_people(人民网)。2026-09-27 实测其 /rss/*.xml 全部 feed 的 101 个
pubDate 冻结在 2025-06-03~06-05,即站方已停止更新 RSS;再采集只会用 md5(url)
反复覆盖同一批 16 个月前的旧文档,并让「新闻列表」出现"最新新闻是去年"的观感。
入库的既有 news_people 文档保留,可按需 /api/admin/db/purge/ 清理。
……
"""
# RSS 源配置:key 即写入 ES 的 source 字段
FEEDS = {
"news_chinanews": {
"label": "中国新闻网",
"url": "https://www.chinanews.com/rss/scroll-news.xml",
"follow_links": True, # feed 无摘要,跟进文章页抓正文
},
"news_ithome": {
"label": "IT之家",
"url": "https://www.ithome.com/rss/",
"follow_links": False,
},
"news_solidot": {
"label": "Solidot",
"url": "https://www.solidot.org/index.rss",
"follow_links": False,
},
}解读:这个文件的第一次设计表态发生在任何人写 class 之前——把"数据源"从代码逻辑里剥出来,变成一个纯字典。FEEDS 的每个条目只有四个字段(label/url/follow_links/以及隐式的 key),key 同时充当写入 ES 的 source 字段值,这意味着"加一个源"在很多情况下只需要加一个字典条目(《11-扩展开发指南》SOP-6 整节就是围绕这个字典展开的)。follow_links 这个布尔键是配置化思想的点睛:中新网 feed 无摘要、需要跟进文章页抓正文,其他两源不需要——一个开关让同一条解析流程服务两种源形态,而不是分出两个爬虫。
文件头注释则是这个仓库注释文化的标本:它不只说"是什么",还记录了一次完整的停用决策——人民网源为什么停(101 个 pubDate 冻结在 16 个月前)、不停会怎样("最新新闻是去年"的观感)、存量数据怎么办(保留可检索、可按需清理)。这段注释与 search/api_views.py 的 NEWS_SOURCES 常量注释、测试 test_dead_people_feed_removed_everywhere 三方呼应,构成一个跨文件的决策记录。
这段代码教会我们什么:把易变的事实(数据源清单)从逻辑中分离成数据,是爬虫可维护性的第一杠杆;注释要写到"决策与后果"的层面,停用一个源的理由比启用十个源的理由更值得写下来。
def strip_html(text):
"""去掉 script/style 块、HTML 标签和实体,得到纯文本。
人民网等站点的视频新闻会在 description 里嵌 <script> 播放器代码
(showPlayer(...)),必须整块删除,否则 JS 代码会混进正文。
"""
if not text:
return ""
# 成对的 script/style/noscript 块连内容一起删
text = re.sub(r"<(script|style|noscript)\b[^>]*>.*?</\1\s*>", "", text,
flags=re.S | re.I)
# 未闭合/自闭合的残留开标签也删掉
text = re.sub(r"<(script|style|noscript)\b[^>]*/?>", "", text, flags=re.I)
return unescape(re.sub(r"<[^>]+>", "", text)).strip()
def parse_date(text):
"""兼容两种 feed 日期格式:RFC822(IT之家/Solidot)与 YYYY-MM-DD HH:MM:SS。"""
text = (text or "").strip()
if not text:
return None
try:
return parsedate_to_datetime(text)
except (TypeError, ValueError):
pass
for fmt in ("%Y-%m-%d %H:%M", "%Y-%m-%d"):
try:
return datetime.strptime(text, fmt)
except ValueError:
continue
return None解读:这两个函数是整条写入链路上"外部数据 → 可信数据"的转换边界。strip_html 的三段式处理有明确的攻击模型:第一段删成对的 script/style/noscript 块(防播放器 JS 混入正文,注释给出了真实出处);第二段删未闭合/自闭合的残留开标签——现实世界的 HTML 不会总是良构的,只处理成对标签会把半个 script 标签漏进正文;第三段删全部标签并反转义实体,产出"近乎纯文本"。这个函数在安全链里的位置是第一环:写入侧先清洗,读侧前端 renderHighlight 再转义(《08-安全与权限设计》第六节的"三处防线"),两层各自独立成立。
parse_date 的写法示范了"脏数据协议":三种格式逐一尝试(RFC822、带时分、仅日期),全部失败返回 None 而不是抛异常——调用方与下游管道都按"可能没有日期"设计(_to_int/_to_float 同款哲学)。None 会一路存进 ES 的 create_date(Date 字段允许 null),查询侧靠 missing: "_last" 排序兜底。
这段代码教会我们什么:外部输入的清洗函数应该写成纯函数(文本进、文本出、不抛异常),这既是安全边界也是可测试性的前提——search/tests.py 的 NewsRssStripHtmlTests 三个用例零 mock 就是直接收益;"解析失败返回 None"比"解析失败抛异常"对数据管道友好得多。
async def start(self):
for key in self.feed_keys:
yield scrapy.Request(
FEEDS[key]["url"],
callback=self.parse_feed,
cb_kwargs={"source_key": key},
headers={"Accept": "application/rss+xml, application/xml, text/xml, */*"},
)
def parse_feed(self, response, source_key):
feed = FEEDS[source_key]
items = response.xpath("//channel/item")
self.logger.info("[%s] feed 解析出 %d 条", feed["label"], len(items))
for item in items:
title = (item.xpath("./title/text()").get() or "").strip()
link = (item.xpath("./link/text()").get() or "").strip()
description = item.xpath("./description/text()").get() or ""
pub_date = item.xpath("./pubDate/text()").get()
if not title or not link:
continue
if feed["follow_links"] and CNS_ARTICLE_RE.search(link):
yield scrapy.Request(
link,
callback=self.parse_chinanews_article,
cb_kwargs={"source_key": source_key, "rss_title": title,
"pub_date": pub_date},
)
else:
content = strip_html(description) or title # 无摘要退化为标题,保证可入库
yield self._news_item(source_key, title, link, content, pub_date)解读:async def start() 是 Scrapy 2.13+ 的新入口(老写法是 start_requests),以 yield 请求的方式声明初始队列——三个 feed 请求各带 cb_kwargs 把来源 key 传进回调,避免回调内部再去反查"这条响应来自哪个源"。parse_feed 的骨架是"逐条抽取 → 防御性校验 → 分流":title 与 link 任一为空直接 continue(feed 里混广告条目是常态);分流条件是 feed["follow_links"] and CNS_ARTICLE_RE.search(link),正则 chinanews\.com\.cn/\w+/\d{4}/[\d-]+/\d+\.shtml 的职责写在模块常量旁的注释里——区分真实文章链接与滚动列表页 news1.html,防止跟进请求在列表页里打转。注意中新网分支把 feed 里已知的 title/pub_date 通过 cb_kwargs 随行,文章页解析失败时可以回退使用。
self.logger.info("[%s] feed 解析出 %d 条", ...) 这一行看似平淡,实则是排障协议的一部分:每个源一条计数日志,《12-FAQ与故障排查》C1 的分诊第一步就是"看日志每源'feed 解析出 N 条'是否为 0"——日志语句是故障决策树的前置投资。
这段代码教会我们什么:爬虫解析层的"分流"应该由配置与可校验的正则共同驱动,而不是 if-else 堆叠 URL 判断;回调间传上下文用 cb_kwargs 显式随行,不依赖隐式状态;每层解析都要留计数日志,empty 状态的告警(item_scraped_count == 0)才有据可查。
class BrowserFingerprintHeadersMiddleware(object):
"""对白名单域名替换成成套的真实浏览器指纹头,其余域名保留自报身份的 USER_AGENT。
……
"""
def process_request(self, request):
host = (urlparse(request.url).hostname or "").lower()
if not self._matches(host):
return None
# 直接替换(而非 setdefault):内置 UserAgentMiddleware(优先级 500)已先写入
# USER_AGENT,setdefault 不会覆盖它,真实指纹头就不生效(曾导致 B站 接口 412)
try:
headers = get_headers()
except Exception as e:
logger.warning("指纹生成失败(%s),使用上一次/内置头", e)
return None
for k, v in headers.items():
request.headers[k] = v
return None解读:news_rss 发出的所有请求都会先经过 crawler/middlewares.py 的三个下载中间件(优先级 543/544/545),但有趣的是——对这个爬虫而言三个中间件全部空转:指纹中间件的白名单只有 douyin 域名,news_rss 的域名不命中(_matches 按点边界精确匹配,return None 放行);代理中间件的池默认为空(itertools.cycle([]) 不存在,self._pool 为 None);Playwright 降级中间件只在 process_exception 时介入。这正是白名单设计的意义:默认路径零干预,请求带着自报身份的 XSearchBot/1.0 诚实出发。
上引片段是"伪装"那条分支的核心。三处细节值得逐个指出:其一,request.headers[k] = v 是整体替换而非 setdefault,注释完整记录了事故(内置 UA 中间件先写入、setdefault 盖不掉、目标站返回 412)——这是理解 Scrapy 中间件优先级的活教材;其二,get_headers() 包在 try 里,指纹库失败时放弃伪装但继续用原请求头,绝不让装饰性功能炸掉采集主流程;其三,_matches 的点边界匹配(host == h or host.endswith("." + h))把 evil-douyin.com 挡在白名单外,与后端图片代理的 SSRF 防护是同一套匹配语义。
这段代码教会我们什么:中间件是"横切关注点"的正确容器(合规、伪装、代理都不该污染 spider 代码——news_rss.py 里确实找不到一行指纹/代理逻辑);异常路径上保持礼貌约束(PlaywrightFallbackMiddleware 降级时刻意保留 download_slot)说明"合规"必须写到异常分支里才算数。
@staticmethod
def _news_item(source_key, title, url, content, pub_date):
feed = FEEDS[source_key]
return {
"url_object_id": hashlib.md5(url.encode("utf-8")).hexdigest(),
"title": title,
"content": content[:MAX_CONTENT_CHARS],
"author": feed["label"],
"tags": ["新闻", feed["label"]],
"url": url,
"front_image_url": "",
"source": source_key,
"create_date": parse_date(pub_date),
}解读:spider 的唯一出口是一个纯 dict(不是 scrapy.Item 类),字段集与《15-接口全量参考》第六节的写入侧契约严格对齐。四个键值得逐个说:url_object_id = md5(url) 是全系统幂等性的锚点(ADR-05,重复采集自动覆盖更新);content[:MAX_CONTENT_CHARS] 的 5000 字上限防超长文章撑大 ES 文档;tags 携带 ["新闻", 来源中文名],下游 multi_match 里 tags 字段权重 ^2,这个数组直接参与中文检索;create_date 交给 parse_date 处理、失败为 None。管道 index_item() 对这份 dict 的态度是"宽容但守住底线"——缺键清洗为 None、content 空则整条丢弃。spider 与管道之间因此形成一份低成本契约:新增爬虫只要产出这个形状的 dict,入库、检索、分面、幂等全部自动成立。
这段代码教会我们什么:爬虫的输出契约越简单(一个 dict、四个必填键),生态越容易扩展;把 id 生成放在 spider 侧(谁最了解"什么使这条内容唯一"),把清洗放在管道侧(横切规则集中一处),职责切分才干净。
def api_search(request):
query = request.GET.get("q", "").strip()
source = request.GET.get("source", "").strip() # 可选:按来源过滤
try:
page = max(int(request.GET.get("p", "1")), 1)
except ValueError:
page = 1
sort_param = request.GET.get("sort", "relevance") # relevance/time/hot
days = request.GET.get("days", "").strip() # 近 N 天过滤(7/30/90)
if not query:
return JsonResponse({"total": 0, "page": 1, "page_nums": 0, "results": []})
_record_search_keyword(query)解读:入口六行确立了全部输入契约:q 去空白、p 非法回退 1、sort/days 先收后校验(校验逻辑在 _SORTS/_DAY_VALUES 常量的 dict.get/in 判断里完成——用常量字典做白名单,比 if-else 链更难写错)。第一道短路是"空词直接返回空结果不查 ES":既省一次注定空转的 ES 查询,也被测试 ApiSearchTests.test_empty_query_skips_es 钉死为契约("空关键词不查 ES"被《15-接口全量参考》第八节列为三个不可变承诺之一)。_record_search_keyword 把搜索词 zincrby 进 Redis 热搜词 zset 并裁剪前 100,全程 try-except——搜索主路径上不允许出现 Redis 故障。
这段代码教会我们什么:API 入口的参数处理要"先夹取、再白名单、后短路";把"哪些输入合法"编码为模块级常量,校验就变成了查表而非逻辑;高频旁路功能(热搜词统计)必须与主路径故障隔离。
# ---- ES 库内检索 ----
# 活跃维度的筛选(来源/时间)放 post_filter:分面聚合统计不受自身筛选影响,
# 每个分面的计数始终是"其他筛选条件下"的分布,供用户直接切换
post_filters = []
if source:
post_filters.append({"term": {"source": source}})
if days in _DAY_VALUES:
post_filters.append({"range": {"create_date": {"gte": "now-{}d".format(days)}}})
sort = _SORTS.get(sort_param)
def _run_search(match_text, page_num, fuzzy=False):
mm = {"query": match_text,
"fields": ["title^3", "tags^2", "author", "content"],
# 超过 2 个词元的查询要求 80% 词元命中,防止低质命中刷屏
"minimum_should_match": "2<80%"}
if fuzzy:
# 模糊重搜:编辑距离容错(如"猪申克"→"肖申克"),
# 并放宽词元命中率让部分错词不拖垮整个查询
mm["fuzziness"] = "AUTO"
mm["minimum_should_match"] = "2<50%"
body = {
"index": INDEX,
"query": {"bool": {
"must": {"multi_match": mm},
"must_not": [{"terms": {"source": list(SEARCH_EXCLUDED_SOURCES)}}],
}},
"from_": (page_num - 1) * PAGE_SIZE,
"size": PAGE_SIZE,
"highlight": _HIGHLIGHT,
"aggs": _FACET_AGGS,
}
if post_filters:
body["post_filter"] = {"bool": {"must": post_filters}}
if sort:
body["sort"] = sort
return client.search(**body)解读:这是全项目最能体现"懂 ES"的一段。三层的分工语义:must 的 multi_match 决定相关性与召回(字段权重 title 三倍、tags 两倍;minimum_should_match: "2<80%" 的意思是"超过 2 个词元的查询要求 80% 词元命中",拦住"十个词命中一个"的垃圾结果);must_not 把 douyin_hot/aihot_hot 两个榜单来源永久挡在搜索结果外(ADR-07,榜单条目没有发布时间语义,混进"最新"排序会长期霸榜);source/days 的筛选放 post_filter 而不是 query 层——这是最容易被写错的一处,注释把原因讲透了:分面聚合(aggs)统计的是 post_filter 之前的结果集,所以每个分面的计数始终是"其他筛选条件下"的分布,用户才能看到"切到这个来源会有多少条"再决定要不要点。若把筛选写进 must,切了来源之后其他来源的分面计数会全部归零,分面导航瞬间变成废纸。
内嵌函数 _run_search(match_text, page_num, fuzzy=False) 的存在是为纠错链服务的:主查询、纠错词重搜、模糊重搜共用同一个查询骨架,只有 multi_match 的两个参数不同(fuzzy 分支加 fuzziness: "AUTO" 并把词元命中率放宽到 2<50%)。用闭包捕获 post_filters/sort,避免了参数在整个函数里反复传递。
这段代码教会我们什么:ES bool 查询的 must/must_not/post_filter 不是三种写法而是三种语义(打分召回 / 无条件排除 / 不影响聚合的过滤);分面导航的正确性完全取决于筛选放哪一层;把"同一查询的多种变体"收敛为一个带开关的构造函数,纠错链才不会复制出三份漂移的 DSL。
<span class="kw"> 的旅程_HIGHLIGHT = {"pre_tags": ['<span class="kw">'], "post_tags": ["</span>"],
"fields": {"title": {}, "content": {}}}
def _extract(resp):
results = []
for hit in resp["hits"]["hits"]:
src = hit["_source"]
hl = hit.get("highlight") or {}
results.append({
# ES 文档 _id:详情页按稳定 id 取文,不再用"标题再搜一遍取第一条"
"id": hit.get("_id", ""),
"title": "".join(hl["title"]) if hl.get("title") else src.get("title", ""),
"content": "".join(hl["content"]) if hl.get("content") else (src.get("content") or "")[:200],
"url": src.get("url", ""),
"author": src.get("author", ""),
"source": src.get("source", ""),
"rating": src.get("rating"),
"rank": src.get("rank"),
"front_image_url": src.get("front_image_url", ""),
"create_date": _display_date(src.get("create_date")),
"praise_nums": src.get("praise_nums"),
"view_nums": src.get("view_nums"),
"reply_nums": src.get("reply_nums"),
"danmaku_nums": src.get("danmaku_nums"),
})
aggs = resp.get("aggregations") or {}
facets = {
"sources": [{"key": b["key"], "count": b["doc_count"]}
for b in (aggs.get("facet_sources") or {}).get("buckets", [])],
"days": [{"key": b["key"], "count": b["doc_count"]}
for b in (aggs.get("facet_days") or {}).get("buckets", [])],
}
return resp["hits"]["total"]["value"], results, facets解读:_HIGHLIGHT 是模块级常量:高亮只做 title/content 两字段、自定义标签 <span class="kw">。这个标签选择不是任意的——它是前端 renderHighlight()(frontend/src/api.ts)的安全协议的对端:ES 返回的高亮片段是唯一携带 HTML 的响应内容,前端先整体 HTML 转义、再仅把 <span class="kw"> 还原为 <mark class="kw">。若后端用默认的 <em> 标签或前端直接 v-html,存储型 XSS 的通道就开了。后端选一个可枚举、可精确还原的标签形态,前端的安全还原才可行。
_extract 的抽取逻辑有两处防御细节:高亮缺失时回退原字段(hl.get("title") 为空就用 src 原值),content 在无高亮时截 200 字(列表场景的兜底);id 键取自 hit.get("_id"),注释点明这是对旧实现的替代——旧 /api/ai/item 拿标题再搜一遍取第一条,"标题相近或撞车时会把读者带到另一篇文章上,资讯站的一次错链就是一次信任崩塌,而且代码里根本无法察觉"(api_doc docstring 原话)。create_date 一律过 _display_date() 换算站点时区并容忍脏数据(解析失败退回原串前 10 位,"读接口不能因为一条脏数据整页 500")。聚 合桶的抽取用 (aggs.get(...) or {}).get("buckets", []) 的双层兜底,聚合形状异常不炸响应。
这段代码教会我们什么:后端与前端之间的"HTML 协议"要设计成双方都好防御的形状(可枚举标签 + 整体转义后精确还原);列表抽取函数要对每一层 ES 响应形状做兜底——聚合缺桶、高亮缺失、日期脏值都不该把一次搜索变成 500;_id 直通详情页是消除"标题猜文"这类隐蔽错链的根治方案。
try:
resp = _run_search(query, page)
except Exception as e:
return _es_error(e)
total, results, facets = _extract(resp)
# 容错纠错(两级):0 结果时先用短语建议器找相近词重搜;
# 仍无结果则用编辑距离模糊匹配重搜(fuzziness AUTO)
did_you_mean = None
corrected = None
fuzzy_match = False
if total == 0 and page == 1:
did_you_mean = _did_you_mean(query)
if did_you_mean and did_you_mean != query:
try:
total2, results2, facets2 = _extract(_run_search(did_you_mean, 1))
if results2:
corrected, total, results, facets = did_you_mean, total2, results2, facets2
except Exception:
pass
if not results:
try:
total2, results2, facets2 = _extract(_run_search(query, 1, fuzzy=True))
if results2:
fuzzy_match, total, results, facets = True, total2, results2, facets2
except Exception:
pass
# 分页 bug 修复:ES 的 total 偶尔会高于实际命中数(估算偏差),
# 导致出现空页。如果当前页为空且不是第 1 页,自动回退到最后一页。
if not results and page > 1:
for fallback_page in range(page - 1, 0, -1):
try:
resp2 = _run_search(query, fallback_page)
except Exception:
continue
hits2 = resp2["hits"]["hits"]
if hits2:
_, results, _ = _extract(resp2)
page = fallback_page
total = (page - 1) * PAGE_SIZE + len(results)
break
page_nums = math.ceil(total / PAGE_SIZE) if total else 0
if page > 1 and len(results) < PAGE_SIZE:
page_nums = page解读:纠错链的三个设计决定值得念叨。触发条件收敛:只在 total == 0 and page == 1 时介入——用户翻到第 3 页没结果与搜错词无关,纠错此时是骚扰;等级递进:先 phrase suggester("您是不是要找",改词重搜置 corrected),再 fuzziness AUTO(容忍错字的模糊重搜置 fuzzy),两级都失败才从 Redis 取热搜词 Top6 作 suggestions 引导——从"帮你改词"到"帮你换方向",帮助的侵入性逐级增强。失败静默:每个补救步骤各自 try-except,纠错自身故障绝不影响主响应(宁可返回干巴巴的空结果,不让补救逻辑制造 500)。前端对 corrected/fuzzy 的呈现也有分工:黄色提示"已为你显示「xxx」的结果"与蓝色提示"已显示相近结果"(《02-页面设计》4.2)。
分页修正处理的是 ES 的一个真实怪癖:total 偶有估算偏差,第 5 页可能实际是空的。修正是双向的——当前页空且 p>1 时向前逐页试探到最后一个有数据的页(total 按落点页反推),当前页不满 10 条且非第 1 页时把 page_nums 收敛为当前页;前端 SearchView.runSearch() 镜像了同一套修正(《10-性能设计与优化》2.3 承认这是"已知的可接受慢路径")。did_you_mean 的建议词与原词不同才重搜这个细节(did_you_mean != query)防住了建议器返回原词造成的无意义重查。
这段代码教会我们什么:搜索体验的下半场在"0 结果"分支里——降级链要有清晰的等级(改词 → 模糊 → 引导)、每级都要可标记(corrected/fuzzy/suggestions 三字段让前端知情)、每级都要独立失败;分页修正要前后端同心(服务端修结果,前端修页码条),单侧修都会露馅。
def start(self, spider="douyin_hot", pages=2, js=False, resume_job=None):
"""启动爬虫任务。
:param resume_job: 要恢复的任务 ID(JOBDIR 名称),None 表示新任务。
恢复时使用同一个 JOBDIR,Scrapy 会跳过已爬取的 URL。
"""
spec = SPIDERS.get(spider)
if spec is None:
return {"started": False, "reason": "未知爬虫: {}".format(spider)}
with self._lock:
# 上一轮可能已经结束但还没被收(盯梢线程尚未跑完 / 后端重启),
# 先收割一次,避免旧任务永久停在 running 且新任务把它的日志算进来
self._check_and_update_current()
if self._proc is not None and self._proc.poll() is None:
return {"started": False, "reason": "已有采集任务在运行",
"pid": self._proc.pid, "started_at": self._started_at}
if not os.path.isdir(SPIDER_DIR):
return {"started": False, "reason": "找不到 XSearch 项目目录"}解读:start() 的第一行不是干活而是查表:SPIDERS.get(spider) 查不到就拒绝。这个字典(5 个键,key 是前端可传的爬虫名、value 里 scrapy_name 才是真正的 scrapy 爬虫名)是 Web 输入到命令执行的唯一通道——前端传来的字符串永远不直接进命令行,只作为查表键。这意味着无论请求带什么 payload,subprocess 里出现的爬虫名只可能是字典里那五个字面量之一,任意命令执行在结构上不可能(《08-安全与权限设计》第三节把它列为"命令执行"危险的闸门)。
第二个动作是先收割再互斥:进锁后先调 _check_and_update_current()(把可能已结束但盯梢线程还没收的上一轮任务收掉),再看 self._proc.poll() 判断是否真有活任务。注释解释了为什么顺序不能反——不先收割,旧任务会永久停在 running 且新任务把它的日志算进来。_lock 用 RLock(可重入锁)是因为锁内还要调 _record_history()(它自己也加锁)。
这段代码教会我们什么:把用户输入当"查表键"而不是"命令片段",命令注入这一整类问题就消失在结构里;互斥区的第一个动作是"处理上一轮的遗产"(收割半途状态),并发正确性的难点从来不在锁本身而在状态迁移的时序。
# JOBDIR:持久化爬虫状态(已爬取 URL、请求队列),支持中断恢复
if resume_job:
# 只接受 jobs/ 的直接子目录名:拒绝绝对路径与 "../" 穿越,
# 否则等于让调用方把 JOBDIR(爬虫可写状态的地方)到任意目录
name = resume_job.strip().rstrip("/")
if (name != os.path.basename(name) or name in (".", "..")
or not JOB_NAME_RE.fullmatch(name)):
logger.warning("拒绝非法 resume_job 参数: %r", resume_job)
return {"started": False, "reason": "非法的任务名称"}
job_dir = os.path.join(JOBS_DIR, name)
if not os.path.isdir(job_dir):
return {"started": False, "reason": "找不到要恢复的任务: {}".format(name)}
else:
job_dir = os.path.join(JOBS_DIR, "{}_{}".format(spider, int(time.time())))解读:resume_job 是"恢复中断任务"功能要传的 JOBDIR 目录名,它最终会被拼进 -s JOBDIR=<jobs_dir>/<name> 的命令行——而 JOBDIR 是爬虫可写状态的地方(Scrapy 会往里写请求队列与指纹文件)。若不设防,攻击者(已登录管理员,或被 XSS 劫持的管理员会话)可以传 ../../etc 之类让爬虫把可写状态泼到任意目录。防线是三重的,缺一不可:
name != os.path.basename(name):先规范化(去空白、去尾部斜杠),再确认"传来的串就是它自己的 basename"——绝对路径(/etc/passwd 的 basename 是 passwd,与原串不等)与含分隔符的相对路径(a/b)在此拦截;name in (".", ".."):两个特殊目录名显式排除(它们能通过第一重校验:basename 等于自身);JOB_NAME_RE.fullmatch(name):字符集白名单 [A-Za-z0-9][A-Za-z0-9._-]{0,79},fullmatch 保证整串匹配(search 只会命中子串,foo/../../x 里的 x 单独看是合法的,fullmatch 让含分隔符的整串无法通过),长度上限 80 防异常输入;os.path.isdir(job_dir) 确认目录真实存在于 jobs/ 下——格式对但不存在照样拒绝("找不到要恢复的任务")。测试 ResumeJobValidationTests 用 7 组恶意输入(绝对路径、../ 穿越、.、..、含斜杠、特殊字符、合法但不存在)固化了全部边界。注释里"否则等于让调用方把 JOBDIR 指到任意目录"这句话值得整段读——它说明了为什么一个"看起来只是方便恢复任务"的参数值得四重校验:参数的危险等级取决于它最终流向哪里(可写的文件系统路径),而不是它看起来像什么。
这段代码教会我们什么:路径类输入的防御要形成"规范化 → basename 一致性 → 特殊名排除 → 字符集 fullmatch → 存在性校验"的纵深,任何一重单独拿出来都有绕过姿势;把"这个参数最终会被谁写"想清楚,校验的严格程度就不再是主观问题。
cmd = [sys.executable, "-m", "scrapy", "crawl", spec["scrapy_name"]]
cmd += spec.get("extra_args", [])
# 添加 JOBDIR 支持中断恢复
cmd += ["-s", "JOBDIR={}".format(job_dir)]
# 打开日志文件传给子进程,子进程继承 fd 后父进程立即关闭自己的句柄,
# 避免依赖"下一次 start 时关闭"导致的句柄泄漏
log_file = open(self._log_path, "w")
self._proc = subprocess.Popen(cmd, cwd=SPIDER_DIR,
stdout=log_file, stderr=subprocess.STDOUT)
log_file.close()
self._started_at = datetime.now().isoformat(timespec="seconds")
self._current_job_id = "job_{}".format(int(time.time() * 1000))
self._record_history(self._current_job_id, spider, "running",
pid=self._proc.pid, log_path=self._log_path,
job_dir=os.path.basename(job_dir),
resumed=bool(resume_job))
# 盯梢线程:进程退出就立刻回写终态并按需告警。
# 放在 _current_job_id 赋值之后启动,否则 expected_job_id 对不上会被自己忽略。
threading.Thread(target=self._watch_exit,
args=(self._proc, self._current_job_id),
daemon=True, name="xsearch-crawl-watch").start()
return {"started": True, "pid": self._proc.pid,
"started_at": self._started_at, "spider": spider, "label": spec["label"],
"job_dir": os.path.basename(job_dir), "resumed": bool(resume_job)}解读:命令构造的三段各有讲究。[sys.executable, "-m", "scrapy", ...] 是列表形式且没有 shell=True——不经过 shell 意味着任何注入拼接都无从谈起;cwd=SPIDER_DIR 把子进程钉在项目根(scrapy.cfg 所在地);-s JOBDIR=... 以 Scrapy 设置项的方式传入而非拼接进命令串。日志句柄的时序是容易踩的坑:open 后传给 Popen、子进程继承 fd、父进程立即 close()——注释点破了反面案例(留到"下一次 start 时关闭"会导致句柄泄漏)。
盯梢线程 _watch_exit 是整个任务管理器的可靠性支点:它阻塞在 proc.wait() 上(不轮询、不 sleep),子进程一退出就回写终态——returncode != 0 记 failed、item_scraped_count == 0 记 empty、否则 completed,失败与空跑触发 30 分钟冷却的告警邮件。模块头注释记录了它的动机:"轮询一停,历史里就会永久挂着 running"(旧实现依赖前端轮询回写状态)。启动时序上还有一个微妙的注释:线程放在 _current_job_id 赋值之后启动,否则盯梢线程携带的 expected_job_id 与 self._current_job_id 对不上,回写会被 _check_and_update_current 的防误收逻辑忽略——线程启动顺序与"防收错人"的守卫条件相互咬合,这类时序耦合必须靠注释留给后人。
这段代码教会我们什么:子进程管理的完整清单是"列表命令 + 显式 cwd + 句柄即时回收 + 启动时序注释 + 守护线程收尸";状态回写不依赖消费者(盯梢线程是生产者侧责任),这是"轮询一停历史就挂 running"那类 bug 的根治法。
把三段走读并排放,能提炼出这个代码库反复出现的六个工程习惯。习惯一,注释写"坏过的形状":412 的成因、EBUSY 的日期、"轮询一停历史就挂 running"——每段关键代码旁的注释都是一份事故档案,读注释先于读实现是这个仓库正确的打开方式。习惯二,白名单优先于校验:爬虫名查表、resume_job 正则、source 常量、_SORTS 字典——"合法值枚举在常量里,逻辑退化为查表"的模式出现了至少五次。习惯三,外部数据入口必有清洗边界:strip_html、parse_date、_to_int/_to_float、_display_date 全部是"脏进净出、失败不抛"的纯函数。习惯四,故障降级分层:指纹库失败降级模板、Redis 挂掉丢缓存、纠错失败退空结果——主路径永远比旁路功能优先。习惯五,时序耦合必须注释:盯梢线程的启动顺序、先收割后互斥、日志句柄的关闭时机,凡是"换个顺序就坏"的代码都留了话。习惯六,契约最小化:spider 只欠一个 dict 四个必填键、前端只欠 api.ts 的 interface、搜索响应只欠 _id 直通——契约越小,两侧越自由。
三个文件合计约 500 行,却覆盖了写入、检索、任务管理三条主干。若要继续深读,按本篇的走读方式顺延的自然候选是 crawler/spiders/douyin_hot.py(文本解析代替 DOM 选择器的抗改版策略)与 crawler/pipelines.py(mapping 与幂等 upsert 的锚点)——它们的机制概要已在《07-模块详解》2.4/2.5 节,留待读者按需对照。