
很多人第一次看到 Hacker News 上每月固定的 “Who is hiring?” 帖子时第一反应是惊喜这里居然有这么多公司在招人远程、全球、创业团队、开源社区信息密度比很多招聘网站还高。第二次打开就变成了头痛一条评论一种格式有的写了公司、职位、地点、技术栈有的只有一句Looking for a senior engineer, remote only没有搜索框没有筛选器只能一边滚动一边用肉眼找岗位。Show HN: HN Hiring – Search and Filter Who Is Hiring这个项目想解决的问题就是这一团乱麻。项目名写得很直白Search and Filter Who Is Hiring。它并不打算改变 Hacker News 的招聘生态而是告诉社区我可以把这个每月更新的招聘帖变成一个可以搜索、可以过滤的职位列表。本文不打算只停留在评价这个项目“好不好用”。我会把它当作一个真实的技术练习来拆解HN 招聘数据从哪来、字段怎么解析、搜索过滤怎么做、服务怎么封装最终给出一个可以直接运行的参考实现。读完你不仅能理解这类工具的原理还能在本地跑通一个属于自己的 HN 职位搜索服务。1. Show HN 与 HN Hiring这个项目在解决什么问题1.1 Show HN 是什么Hacker News 是硅谷技术圈非常重要的一个社区除了新闻讨论之外它还有一个让开发者展示自己作品的传统“Show HN”。当你在标题里加Show HN:前缀说明你想把刚做出来的项目、工具、产品展示给社区期待得到反馈。Show HN: HN Hiring – Search and Filter Who Is Hiring就是一个典型的 Show HN 项目。从标题能看出作者做的不是一个新的招聘平台而是给 Hacker News 上已有的招聘帖子做了一个“搜索和过滤层”。这个定位非常聪明。它没有去制造新供给而是优化存量信息的检索效率。对开发者来说HN 招聘信息是分散在评论里的半结构化文本最大的痛点不是没岗位而是没法高效筛选。1.2 HN Hiring 帖子的真实使用痛点HN 社区每个月都会有人发布固定标题的招聘帖例如Who is hiring? (July 2024)。这个帖子本身是一条 story下面的每一条评论就是一家公司的招聘信息。这些信息有几个特点信息密度高一个帖子可能有几百甚至上千条评论。格式不统一有人用公司 | 职位 | 地点有人用 Markdown 列表。没有官方搜索功能浏览器自带的查找只能逐页找关键词。岗位关键词多但无法按“技术栈 远程 全职”做组合过滤。如果只是偶尔找工作勉强能接受。但如果 Recruiter、开源维护者或自由职业者每个月都要从里面找线索效率就非常低。1.3 Search and Filter 到底难在哪很多人以为这个项目简单的点在于“拉数据 搜索”。实际上真正难的是把乱七八糟的评论解析成可搜索的字段。一条 HN 评论不是 JSON不是结构化数据而是一段带 HTML 标签的纯文本。比如pExample Inc | Backend Engineer | Remote | Full-time/p pWe build developer tools. Stack: Go, PostgreSQL, Kubernetes./p pa hrefhttps://example.com/careers relnofollowApply here/a/p要把这段文本变成companyExample Inc、titleBackend Engineer、locationRemote这样的结构化数据就需要做文本清洗、字段切分、关键词匹配。这个过程的准确性直接决定搜索过滤效果。所以这篇文章的核心判断是HN Hiring 这类工具的技术难点不在“搜索”而在“数据清洗与解析”。搜索可以用极简方案实现解析才是决定上限的地方。2. 招聘帖的数据形态为什么解析比想象中麻烦2.1 常见的职位评论格式HN 招聘帖里最经典的格式是使用分隔符区分字段。最常见的是竖线分隔公司名 | 职位 | 地点 | 是否远程 | 雇佣方式这种格式最早来自 HN 招聘帖的“约定俗成”。比如Example Inc | Senior Backend Engineer | Remote / GMT8 | Full-time这个格式对解析非常友好按|切分第一段是公司第二段是职位第三段是地点后面是其他属性。但实际情况远没有这么理想。很多人不会严格遵守这个格式。2.2 不规范的文本样本下面是一段真实感很强的招聘评论pWe are looking for a senior React developer to help build our design tool./p pLocation: Berlin, Germany. We support remote from EU time zones./p pTech stack: React, TypeScript, Node.js. Full-time only./p pApply at https://example.com/careers/p如果用简单的split(|)这段文本只会被当成一个字段完全解析不出公司名和职位名。这时候就必须依赖关键词匹配、正则表达式甚至自然语言处理。所以一个可用的 HN Hiring 工具面对的是两种输入结构化程度较高的评论用规则解析即可。非结构化的自由文本需要降级到“全文搜索 关键词匹配”。2.3 先定义输出模型在写解析代码之前最好先定义职位数据的统一结构。否则解析规则会越写越乱。一个最简的招聘信息模型可以包含字段类型说明companystring公司名称titlestring职位名称locationstring工作地点remoteboolean是否支持远程full_timeboolean是否全职urlstring申请链接或官网raw_textstring原始文本用于全文搜索这个模型既保留了结构化字段也保留了原始文本。搜索时如果结构化字段匹配失败还可以回退到全文检索。3. 数据获取用 HN API 拉取招聘帖和评论3.1 两种 API 的选型HN 官方提供 Firebase API可以按 ID 获取 story 和 comment。这个接口稳定但拿到评论后需要自己递归展开子评论。Algolia 也提供了 HN 搜索 API它的优点是通过搜索接口定位招聘帖非常方便而且items接口可以直接返回嵌套评论。对于招聘聚合场景Algolia 更适合做原型。所以我的建议是用 Algolia 找招聘帖 ID用 Algolia items 接口拉评论。开发效率更高代码也简单。生产环境再考虑官方 API 或自建数据库。3.2 获取招聘帖 IDHN 招聘帖标题通常是Who is hiring? (Month Year)。可以通过 Algolia API 搜索标题# fetch_data.py import requests ALGOLIA_API https://hn.algolia.com/api/v1 def find_hiring_story_ids(limit5): url f{ALGOLIA_API}/search params { query: Who is hiring, tags: story, hitsPerPage: limit, } resp requests.get(url, paramsparams, timeout15) resp.raise_for_status() return [hit[objectID] for hit in resp.json().get(hits, [])]这个函数会返回最近的招聘帖 story ID。tagsstory可以确保只搜索 story而不是普通评论。3.3 获取嵌套评论并展开拿到招聘帖 ID 后用 Algolia items 接口一次性拉取整棵评论树def get_job_thread_with_comments(story_id): url f{ALGOLIA_API}/items/{story_id} resp requests.get(url, timeout30) resp.raise_for_status() return resp.json() def flatten_comments(node, depth0, max_depth20): items [] if depth max_depth: return items for child in node.get(children, []): items.append( { id: child.get(id), author: child.get(author), text: child.get(text) or , created_at: child.get(created_at), depth: depth, } ) items.extend(flatten_comments(child, depth 1, max_depth)) return itemsflatten_comments的作用是把一颗多层的评论树展开成列表。大多数招聘信息都在一级评论里但也要处理“某公司回复某公司”的情况所以递归是更稳妥的做法。3.4 频率控制与边界HN API 本身是免费的但并不意味着可以无限制调用。做聚合工具时要注意控制请求频率不要用高并发去扫全站。启动时只拉最近几个月的帖子避免一次拉取太多历史数据。如果数据量大建议把拉取结果缓存到本地 SQLite而不是每次启动都重新请求。4. 数据解析从 HTML 评论到结构化职位信息4.1 移除 HTML 标签与实体HN API 返回的text字段是带 HTML 标签的富文本。比如p标签、a标签等。解析之前必须先转成纯文本。# parser.py import html import re HTML_TAG_RE re.compile(r[^]) def clean_html(text: str) - str: return html.unescape(HTML_TAG_RE.sub( , text or )).strip()html.unescape可以把amp;转成lt;转成避免解析时出现断词。4.2 按分隔符切分字段我们先用最理想的竖线格式切分SEPARATOR_RE re.compile(r\s*\|\s*) parts [p.strip() for p in SEPARATOR_RE.split(cleaned) if p.strip()]如果parts长度大于等于 3前三个字段可以近似认为是公司、职位、地点。当然这只是启发式规则不是绝对正确。4.3 用关键词池判断远程与全职远程和全职是招聘过滤的高频条件。可以用关键词池来做判定REMOTE_KEYWORDS [remote, remote-friendly, distributed] FULL_TIME_KEYWORDS [full-time, full time, fulltime, fte]只要原始文本中出现这些词就认为对应属性为真。这个方案不完美但实现成本低召回率也不错。4.4 解析结果的质量验证解析出来的职位数据不能直接拿去做搜索要先做一次质量检查。最简单的验证方式是统计空字段比例company为空的评论占比是多少。title为空的评论占比是多少。有多少条评论根本没有结构化字段。如果结构化字段缺失太多说明当前规则覆盖不够需要继续加规则。否则即使搜索接口做得再快用户也搜不到有效内容。下面是一个完整的解析器示例# parser.py import html import re from dataclasses import dataclass, field REMOTE_KEYWORDS [remote, remote-friendly, distributed] FULL_TIME_KEYWORDS [full-time, full time, fulltime, fte] SEPARATOR_RE re.compile(r\s*\|\s*) HTML_TAG_RE re.compile(r[^]) URL_RE re.compile(rhttps?://[^\s\)]) dataclass class JobPosting: company: str title: str location: str remote: bool False full_time: bool False url: str raw_text: str field(default, reprFalse) def to_dict(self): return { company: self.company, title: self.title, location: self.location, remote: self.remote, full_time: self.full_time, url: self.url, } def clean_html(text: str) - str: return html.unescape(HTML_TAG_RE.sub( , text or )).strip() def parse_comment(text: str) - JobPosting: cleaned clean_html(text) parts [p.strip() for p in SEPARATOR_RE.split(cleaned) if p.strip()] posting JobPosting(raw_textcleaned) if len(parts) 2: posting.company parts[0] posting.title parts[1] if len(parts) 3: posting.location parts[2] lower_text cleaned.lower() posting.remote any(k in lower_text for k in REMOTE_KEYWORDS) posting.full_time any(k in lower_text for k in FULL_TIME_KEYWORDS) url_match URL_RE.search(cleaned) if url_match: posting.url url_match.group(0) return posting这个解析器没有使用复杂依赖只用了 Python 标准库和 dataclass。对一个小型 HN 聚合工具来说它的可读性和维护成本都很好。5. 搜索与过滤引擎先跑通最小可用版本5.1 内存检索足够吗很多项目一上来就上 Elasticsearch其实没有必要。HN 招聘帖一个月的数据量往往只有几千条评论。几千条文本用内存列表遍历检索耗时可以忽略不计。所以最小可用版本不需要引入搜索引擎。实现一个纯 Python 的搜索器按关键词、远程、全职三个条件过滤完全可以支撑原型。5.2 实现一个简单的搜索器# engine.py from typing import List, Optional from parser import JobPosting class JobSearchEngine: def __init__(self, jobs: List[JobPosting]): self.jobs jobs def search( self, q: str , remote: Optional[bool] None, full_time: Optional[bool] None, limit: int 50, ) - List[JobPosting]: keyword q.strip().lower() results [] for job in self.jobs: if remote is not None and job.remote ! remote: continue if full_time is not None and job.full_time ! full_time: continue if keyword: haystack .join( [job.company, job.title, job.location, job.raw_text] ).lower() if keyword not in haystack: continue results.append(job) if len(results) limit: break return resultsremote和full_time采用三态设计None表示不过滤True/False表示强制匹配。这个 API 设计对后续扩展很关键因为前端传入的 checkbox 未勾选时不能粗暴地当作False。5.3 数据量变大后的升级路径如果后续要聚合历史所有招聘帖评论量可能到几十万条。这时候内存遍历就不够优雅了。推荐两条升级路径使用 SQLite FTS5 全文索引简单、零运维适合中小规模。使用 Meilisearch 或 Elasticsearch适合更大规模和高频查询。但升级的前提是搜索逻辑没有被写死在业务代码里。所以建议在engine.py里抽象一个search()方法后续改成数据库查询时调用方不需要改。6. 用 FastAPI 封装成可访问的服务6.1 项目结构建议按职责拆分文件保持简单hiring-search/ ├── fetch_data.py ├── parser.py ├── engine.py └── main.py这样数据获取、数据解析、搜索逻辑、Web 层互相独立后续替换任何一层都很容易。6.2 核心接口设计接口只需要一个就可以满足需求GET /api/jobs?qrustremotetruefull_timetrue返回 JSON{ count: 2, items: [ { company: Example Inc, title: Backend Engineer, location: Remote, remote: true, full_time: true, url: https://example.com/careers } ] }参数q是搜索关键词remote和full_time是过滤条件。接口设计越简单前端和调用方越好使用。6.3 完整 main.py# main.py from typing import Optional from fastapi import FastAPI from fastapi.responses import HTMLResponse from pydantic import BaseModel from engine import JobSearchEngine from fetch_data import find_hiring_story_ids, flatten_comments, get_job_thread_with_comments from parser import parse_comment class JobResponse(BaseModel): count: int items: list def build_jobs() - list: jobs [] for story_id in find_hiring_story_ids(limit3): payload get_job_thread_with_comments(story_id) for comment in flatten_comments(payload): if not comment[text]: continue posting parse_comment(comment[text]) if len(posting.raw_text) 30: jobs.append(posting) return jobs app FastAPI(titleHN Hiring Search) engine JobSearchEngine(build_jobs()) HTML_PAGE !DOCTYPE html html head meta charsetutf-8 titleHN Hiring Search/title /head body h1HN Hiring Search/h1 form action/api/jobs methodget input typetext nameq placeholderkeyword, e.g. rust label input typecheckbox nameremote valuetrue Remote only /label button typesubmitSearch/button /form /body /html app.get(/, response_classHTMLResponse) def index(): return HTML_PAGE app.get(/api/jobs, response_modelJobResponse) def search_jobs( q: str , remote: Optional[bool] None, full_time: Optional[bool] None, limit: int 50, ): results engine.search(qq, remoteremote, full_timefull_time, limitlimit) return { count: len(results), items: [job.to_dict() for job in results], }需要注意的是engine JobSearchEngine(build_jobs())在模块加载时会同步拉取数据这在原型阶段是能接受的但生产环境不应该这样做。生产环境应该用定时任务把数据提前写入数据库服务启动时只读缓存。6.4 一个朴素的 Web 页面上面的HTML_PAGE是一个最简单的搜索表单。虽然它提交后跳转到了 JSON 接口看起来不够美观但对验证核心流程已经足够。更完整的做法是继续加一个/search页面由服务端渲染搜索结果。这样用户可以直接在浏览器里搜索不用理解 JSON 结构。作为第一版我建议先把 API 跑通再考虑页面美化。7. 运行验证与效果检查7.1 环境准备推荐使用 Python 虚拟环境python -m venv venv source venv/bin/activate pip install requests fastapi uvicorn如果你用 Windows激活虚拟环境的命令是venv\Scripts\activate其余步骤不变。7.2 启动服务进入项目目录启动 FastAPIuvicorn main:app --reload看到类似Uvicorn running on http://127.0.0.1:8000的输出说明服务已经启动。7.3 curl 验证接口打开另一个终端用 curl 请求搜索接口curl http://127.0.0.1:8000/api/jobs?qremoteremotetrue预期返回 JSON并且count大于 0{ count: 20, items: [ { company: Example Inc, title: Backend Engineer, location: Remote, remote: true, full_time: true, url: https://example.com/careers } ] }如果count为 0优先排查解析器是否生成了有效数据。可以在build_jobs()里打印len(jobs)确认拉取和解析环节是否正常。8. 常见问题与排查方法问题现象可能原因排查方式解决方案启动后职位数量为 0拉取到的评论都是空文本手动请求一个 story ID检查返回结构确认 Algolia API 返回的字段名增加日志输出解析结果只有 raw_text评论没有使用竖线分隔符打印原始文本增加分隔符规则或保留 raw_text 供全文搜索/api/jobs 响应过慢启动时同步抓取所有评论观察启动耗时和请求数量增加缓存改为定时任务预加载数据搜索区分大小写没有统一转小写检查 search 方法对关键字和文本统一调用 lower()远程岗位过滤不准关键词池覆盖不全检查关键词匹配结果增加 “100% remote” 等变体或手动维护地点字段内存占用持续上涨每次请求都重新拉取数据查看代码中是否在请求里调用 build_jobs保证数据只加载一次使用单例或预加载服务启动时网络超时HN API 访问不稳定查看超时异常增加超时参数和重试机制设置批量缓存以上问题的共性是大多数不是搜索逻辑出错而是数据获取或解析环节不达预期。所以在排查时一定要先确认数据结构再检查解析结果最后再看接口返回。9. 工程化建议与后续方向9.1 数据更新策略HN 招聘帖每月更新一次因此聚合工具不需要做实时更新。更合理的策略是每月固定时间运行一次抓取任务。把解析结果写入 SQLite 或 PostgreSQL。Web 服务只读数据库不直接请求 HN API。这样做既能降低对 HN API 的请求压力也能保证服务启动速度和稳定性。如果担心漏数据可以在每月帖子发布后的第二天再抓取给评论一个积累时间。9.2 存储与搜索选型如果只做个人工具SQLite 是最优选择。它不需要额外服务单文件备份方便内置 FTS5 全文搜索。如果后续要做多用户产品再考虑 PostgreSQL 配合 Meilisearch。选型建议数据量级推荐方案理由几千条评论Python 内存遍历实现简单性能足够几万到几十万条SQLite FTS5零运维全文索引百万级以上Meilisearch / Elasticsearch支持复杂查询、高并发不要在项目第一天就引入重组件。先用最简单方案跑通再根据实际瓶颈升级。9.3 安全与合规这类聚合工具涉及外部数据调用安全上要注意几点只提供只读接口不要暴露任何写接口。如果部署到公网建议给管理接口加简单鉴权。使用参数化查询避免把用户输入直接拼进 SQL。控制并发请求数量避免对 HN API 造成压力。关注 HN 的 API 使用要求和数据使用条款不要用抓取数据做违反社区规则的事情。9.4 可以继续做的方向这个项目的核心思路可以继续扩展以下几个方向都值得尝试增加技术栈识别从文本中提取 Golang、Python、React 等关键词并把它作为独立搜索字段。增加薪资解析很多 HN 招聘评论会提到薪资范围可以尝试用正则提取。增加订阅提醒用户输入关键词后每月帖子更新时自动发送邮件或通知。增加浏览器插件把搜索入口直接放到 Hacker News 页面里。增加相似职位推荐当用户点开一个职位时根据技术栈和地点推荐其他职位。一个看起来很小的招聘搜索工具拆开之后其实覆盖了爬虫、数据清洗、全文检索、Web 服务、定时任务、前端交互这些完整链路。如果你正在找一个练手项目从 HN Hiring 这类工具入手比写一个“管理系统”能学到的东西多得多。