“Who is Hiring”这个话题,在 Hacker News 上已经持续活跃了十几年。每个月第一个工作日,YC 官方账号 whoishiring 会准时发布一条月度招聘帖,帖子下面挂着成百上千条匿名的招聘评论。这些评论不是 HR 整理的 JD,很多是创始工程师亲自写的,里面常常藏着技术栈细节、团队现状、远程政策,甚至连签证支持情况都会写清楚。
但问题也出在这里:这条帖子本质上是一个巨大的嵌套评论树。你在这个帖子里找工作,只能靠一条条往下滚动,肉眼扫关键词。即使你平时很擅长用浏览器查找功能,遇到评论树展开和分页加载,也会变得非常吃力。真实的求职体验是:翻了一个多小时,眼睛都花了,却还是担心错过了适合自己的岗位。
所以,当看到“HN Hiring – Search and Filter Who Is Hiring”这类 Show HN 项目时,我第一反应是:这确实击中了真实痛点。它做的不是创造一个招聘平台,而是把 HN 上已经公开、却难以阅读的数据,变成可以搜索和过滤的结构化信息。这篇文章就以这个需求为切入点,带大家从原理到代码实现,完整做一个 HN Hiring 搜索过滤工具。
读完你会理解几件事:
- Hacker News API 的数据形态,尤其是嵌套评论树如何展平。
- 为什么“Who is Hiring”这类自由文本数据,不能只靠关键词搜索。
- 如何用 Python 从零实现一个可用的命令行招聘搜索工具,包括抓取、缓存、字段提取和过滤。
如果你正在找工作,或者每个月都有浏览 HN 招聘帖的习惯,这篇文章提供的脚本能直接帮你把筛选时间从半小时压缩到几分钟。
1. 这篇文章真正要解决的问题
1.1 为什么 HN 招聘帖值得做成工具
HN 的“Who is Hiring”帖子有几个商业招聘平台很难复制的特点。
第一,信息密度高。发帖人通常是工程师或技术负责人,会直接写清楚技术栈、团队当前阶段、工程挑战,这些信息在传统招聘网站往往被美化成很模糊的描述。
第二,社区评分带来天然筛选。HN 的投票机制会把靠谱公司的评论推到更靠前的位置,公司想要好口碑,就得写出真实、有吸引力的职位描述。
第三,数据完全公开。HN API 虽然不像一些开放平台那么完善,但足以抓取帖子、评论和用户信息。
但这些优点都被同一个问题掩盖:数据的读取成本太高。一个月度招聘帖通常有几百条顶层评论,每条评论还有多层子评论。评论之间有的是一家公司不同岗位的补充,有的是求职者追问,有的是公司回复。面对这样一片“评论森林”,人工浏览的效率极低。
1.2 HN Hiring 工具到底解决了什么
HN Hiring 类工具本质上做的是三件事:
- 把树形评论展平为一行一条的扁平记录。
- 从自由文本里尽量抽取结构化字段,例如公司名、地点、远程类型、签证支持。
- 提供搜索和过滤入口,让用户按关键词、地区、远程等条件快速定位。
它并不创造新数据,它处理的是已经存在但难以使用的数据。这就像一个二手资料整理员,做的不是生产内容,而是降低读取成本。对开发者来说,理解这个过程,比理解具体某一天某家公司招什么人更有长期价值。
1.3 适合谁阅读这篇文章
如果你是后端或爬虫方向的开发者,这篇文章是一次很好的 HN API 实战;如果你正在求职,文章最后的代码可以直接用来快速筛选官方帖;如果你只是好奇“Show HN”项目背后的工程思路,也能通过这篇文章看到一个看似简单的工具,实际上涉及请求控制、数据清洗、缓存设计等一堆细节。
2. “Who is Hiring”帖子的数据结构与使用场景
2.1 HN API 中最关键的两个对象
Hacker News 的数据模型并不复杂,最核心的对象是 Story 和 Comment。
- Story:帖子本身,也就是根节点。
- Comment:帖子下面的评论,每个评论可以有自己的子评论。
“Who is Hiring”月度帖就是一个 Story。这个 Story 的kids字段会指向第一批顶层评论,每一条评论都可以继续挂子评论,子评论又可以继续嵌套。理论上,评论树可以无限深,但实际中 HN 官方 API 会限制深度,大部分招聘帖的评论深度在 2 到 5 层之间。
JSON 结构如下:
{ "id": 37504201, "by": "cto-example", "type": "comment", "text": "Company: Example AI<br>Remote: Yes<br>Stack: Python, Go, K8s<br>Contact: hr@example.ai", "parent": 37501800, "kids": [37504300, 37504400], "time": 1719878400, "deleted": false }这里面最容易忽略的是text字段。它不只是纯文本,而是一段 HTML。常见的情况是<p>标签表示换行,<a href="...">表示链接。所以做文本清洗时,必须先去掉 HTML 标签,再用 HTML 实体解析处理&、<这类字符。
2.2 招聘评论里通常有哪些高频字段
HN 招聘帖没有强制模板,但绝大多数评论会习惯性地在开头几行包含这些字段:
| 字段 | 常见写法 | 说明 |
|---|---|---|
| Company | Company: Example AI | 公司名 |
| Location | Location: Berlin, Germany | 办公地址 |
| Remote | Remote: Yes/Onsite/Hybrid | 远程政策 |
| Visa | Visa: Yes/Sponsorship available | 是否支持签证 |
| Stack | Stack: Python, Go, Rust | 技术栈 |
| Contact | Email: hr@example.ai | 联系方式 |
| Compensation | Comp: $120k-$150k | 薪资范围 |
这就是为什么一个 HN Hiring 工具不需要先上 NLP 模型。先用正则把最常见字段抽出来,配合关键词搜索,已经能覆盖大部分用户需求。真正的难点在于格式不统一,所以代码必须足够“宽容”,抽不出来的也不要报错,把原始文本留给用户去看。
2.3 典型使用场景
- 应届生找第一份工作:按“intern”“remote”过滤,快速找出愿意招实习生且支持远程的公司。
- 有经验的开发者跳槽:按技术栈“Go”加地点“Berlin”过滤。
- 远程岗位爱好者:只看
Remote: Yes的评论。 - 猎头做市场分析:抓取整月数据,统计今年远程岗位比例,或者分析某个地区对特定语言的需求变化。
分析场景对结构化程度要求更高,这也是为什么我们最好把结果导出为 CSV 或 JSON,而不是只打印到终端。
3. 环境准备与前置条件
本文代码使用 Python 3.10 以上版本。不依赖框架,只需要几个非常常用的第三方库。
你需要准备:
- Python 3.10 或更高版本。
requests:用于调用 HN API。rich(可选):增强终端表格输出。pandas(可选):用于导出 CSV 和分析统计。
创建虚拟环境并安装依赖:
python -m venv .venv source .venv/bin/activate pip install requests rich pandasHN API 不需要注册,也不需要 API Key。它的接口风格非常简单,直接请求 HTTPS 地址返回 JSON。但要注意,虽然不需要鉴权,HN 官方对同一 IP 的访问频率有隐性限制。短时间大量请求会触发限流,返回 429 或超时。所以代码里必须做好频率控制和重试机制。
下面列出正式抓取前需要确认的事项:
| 检查项 | 建议 |
|---|---|
| Python 版本 | 3.10 以上 |
| 网络连通性 | 能访问hacker-news.firebaseio.com和hn.algolia.com |
| 请求频率 | 评论遍历请求间隔建议至少 0.05 秒 |
| 缓存目录 | 准备一个临时目录存放 JSON 缓存文件 |
4. 核心流程拆解:从 HN API 到结构化招聘数据
整个 HN Hiring 工具的流程可以分成五步,每一步都可以独立测试。
4.1 找到当月的“Who is Hiring”帖子
HN 官方提供了 Algolia Search API,很适合做这个任务。我们可以直接搜索标题中包含 “Who is hiring?” 的 Story,并且按照发布时间排序,取最新一条。
关键点在于:搜索接口返回的结果可能包含很多类似的帖子,比如有人会问“Who is hiring remote-only?”这样的题目。为了避免误抓,我们要加一个硬性过滤条件:发帖人必须是whoishiring账号。
4.2 从根 Story 出发,递归抓取评论树
拿到 Story id 后,调用单个条目接口https://hacker-news.firebaseio.com/v0/item/{id}.json获取详情。这个接口的返回结构里包含kids数组,也就是子评论的 id 列表。
递归抓取时要注意几点:
- 设置最大深度,防止异常的超深评论树拖垮程序。
- 每个请求之间加
time.sleep(),避免触发限流。 - 判断对象是否为
deleted状态,已删除的评论也应该记录下来,避免后续分析时空缺。
4.3 把嵌套评论树展平为扁平记录
原始评论树是嵌套的,不适合直接做条件过滤。展平操作非常直接:
- 每条评论生成一行。
- 记录
comment_id、parent_id、author、text、time、depth。 - 不管嵌套多深,最终都塞进一个列表中。
展平后的数据可以写入 JSON 文件,也可以直接加载到 pandas DataFrame。这就是后续所有搜索和过滤的数据源。
4.4 规则抽取字段与关键词过滤
字段抽取是规则驱动的。正则表达式从评论正文中提取 Company、Remote、Location、Visa 等信息。如果抽取失败,不要丢掉原始文本。设计原则是:字段抽取只做辅助,不做唯一判断依据。
搜索过滤时可以结合两个维度:
- 结构化字段:比如
remote == "yes"。 - 全文关键词:比如
"golang" in text。
两者同时满足才能算一条高置信度结果。
4.5 缓存与输出
抓取动作成本高,所以要缓存。每次程序启动先检查本地是否有缓存文件,如果缓存文件里已经有当天或当月的数据,就不需要重新请求 API。这样可以避免重复抓取,也降低被限流的风险。
最终输出建议同时支持两种格式:
- 终端摘要:方便快速浏览。
- JSON/CSV 文件:方便后续处理。
5. 完整示例代码实现
5.1 第一步:找到当月“Who is Hiring”帖子
# fetch_post.py import requests API_SEARCH = "https://hn.algolia.com/api/v1/search" API_ITEM = "https://hacker-news.firebaseio.com/v0/item/{item_id}.json" def find_latest_who_is_hiring_post(): params = { "query": "Ask HN: Who is hiring?", "tags": "story", "restrictSearchableAttributes": "title", "numericFilters": "points>50", "hitsPerPage": 10, } resp = requests.get(API_SEARCH, params=params, timeout=15) resp.raise_for_status() hits = resp.json().get("hits", []) for hit in hits: title = hit.get("title", "") if hit.get("author") == "whoishiring" and "who is hiring" in title.lower(): return { "story_id": hit.get("objectID"), "title": title, "url": hit.get("url"), "created_at": hit.get("created_at"), } return None if __name__ == "__main__": post = find_latest_who_is_hiring_post() print(post)这段代码先通过 Algolia 搜索接口,按标题关键词“Ask HN: Who is hiring?”搜索,再用author == "whoishiring"做硬过滤,确保不会抓到用户转发的类似主题帖。
numericFilters里的points>50是一个额外条件,目的是排除点赞很少的冷门帖子。如果不需要,可以去掉它再测试。
5.2 第二步:递归抓取评论并展平
# fetch_comments.py import requests import time import json API_ITEM = "https://hacker-news.firebaseio.com/v0/item/{item_id}.json" def fetch_item(item_id, max_retries=3): for attempt in range(max_retries): try: resp = requests.get(API_ITEM.format(item_id=item_id), timeout=10) resp.raise_for_status() return resp.json() except requests.RequestException as exc: if attempt == max_retries - 1: raise time.sleep(2 * (attempt + 1)) return None def flatten_comments(item_id, depth=0, rows=None, max_depth=10): if rows is None: rows = [] if depth > max_depth: return rows item = fetch_item(item_id) if item is None or item.get("type") != "comment": return rows text = item.get("text", "") rows.append({ "comment_id": item_id, "parent_id": item.get("parent"), "author": item.get("by"), "text": text, "time": item.get("time"), "depth": depth, }) for kid in item.get("kids", []): flatten_comments(kid, depth + 1, rows, max_depth) time.sleep(0.05) # 控制请求频率 return rows if __name__ == "__main__": story = requests.get( API_ITEM.format(item_id=37500000), timeout=10 ).json() print("story_title:", story.get("title")) rows = [] for kid in story.get("kids", []): rows.extend(flatten_comments(kid, max_depth=8)) print("total comments:", len(rows)) with open("comments.json", "w", encoding="utf-8") as fp: json.dump(rows, fp, ensure_ascii=False, indent=2)这段代码的核心是flatten_comments,它做了深度优先遍历。每次从 item 接口拿到的评论对象,如果还有kids,就继续递归遍历。为了不让递归失控,max_depth设置为 8,已经足够覆盖实际招聘帖的所有情况。
值得注意的是fetch_item里加了重试逻辑。网络波动时,第一次请求可能超时,稍等两秒再试一次往往就能成功。
5.3 第三步:对评论文本做字段提取
# parse_job.py import re import html FIELD_PATTERNS = { "remote": re.compile(r"(?i)\b(remote|hybrid|onsite)\b"), "visa": re.compile(r"(?i)\b(visa|sponsorship|sponsor)\b"), "location": re.compile(r"(?i)(location|based in|office)[::]\s*([^\n|]+)"), } def clean_html(raw_text): if not raw_text: return "" text = re.sub(r"<[^>]+>", " ", raw_text) return html.unescape(text) def parse_job_field(text): cleaned = clean_html(text) result = { "remote": None, "visa": False, "location": None, } remote_match = FIELD_PATTERNS["remote"].search(cleaned) if remote_match: result["remote"] = remote_match.group(0).lower() if FIELD_PATTERNS["visa"].search(cleaned): result["visa"] = True location_match = FIELD_PATTERNS["location"].search(cleaned) if location_match: result["location"] = location_match.group(2).strip() return result if __name__ == "__main__": sample = "Location: Berlin, Germany<br>Remote: Yes<br>Visa: No<br>Stack: Python" print(parse_job_field(sample))这个模块是“尽力而为”的字段抽取器。它的返回值可以作为标签,但不能作为最终决策依据。比如有的评论写“No remote”,正则仍会抽取到remote这个词,所以需要结合上下文判断,或依赖用户主动搜索确认。
5.4 第四步:组合成完整的搜索过滤入口
# hn_hiring_tool.py import json import argparse from pathlib import Path def load_comments(path="comments.json"): with open(path, "r", encoding="utf-8") as fp: return json.load(fp) def filter_comments(rows, keyword=None, remote_only=False, location=None): result = [] for row in rows: text = row["text"] if keyword and keyword.lower() not in text.lower(): continue if remote_only and "remote" not in text.lower(): continue if location and location.lower() not in text.lower(): continue result.append(row) return result def main(): parser = argparse.ArgumentParser(description="HN Hiring search tool") parser.add_argument("--keyword", "-k", help="关键词,例如 python") parser.add_argument("--remote", action="store_true", help="只看远程岗位") parser.add_argument("--location", help="地理位置关键词,例如 Berlin") parser.add_argument("--input", default="comments.json", help="输入 JSON 路径") parser.add_argument("--limit", type=int, default=30, help="最多输出多少条") args = parser.parse_args() rows = load_comments(args.input) matches = filter_comments( rows, keyword=args.keyword, remote_only=args.remote, location=args.location, ) for row in matches[: args.limit]: print(f"#{row['comment_id']} by {row['author']} [depth={row['depth']}]") preview = " ".join(row["text"].replace("\n", " ").split())[:140] print(preview) print("-" * 80) if __name__ == "__main__": main()命令行入口把整个流程串起来了。先运行fetch_post.py找到帖子,再运行fetch_comments.py拉取全部评论,最后通过hn_hiring_tool.py -k "golang" --remote这样的命令筛选目标岗位。
python hn_hiring_tool.py -k "golang" --remote --location "Berlin" --limit 106. 运行结果与效果验证
6.1 第一步预期输出
运行python fetch_post.py,预期输出类似:
{ "story_id": "41000001", "title": "Ask HN: Who is hiring? (June 2025)", "url": "https://news.ycombinator.com/item?id=41000001", "created_at": "2025-06-02T06:00:00.000Z" }如果输出为None,说明搜索条件没有命中,通常是numericFilters或query组合有问题,按第 7 章排查。
6.2 第二步预期输出
运行python fetch_comments.py,控制台会输出类似:
story_title: Ask HN: Who is hiring? (June 2025) total comments: 684comments.json文件会出现在当前目录。判断成功有两个标准:
total comments数量在几百到一千左右,符合实际招聘帖规模。comments.json能正常通过json.load()加载。
如果数量明显偏少,比如只有几十条,说明评论树抓取在某个层级中断了。优先检查max_depth是否太小,或者请求是否被限流。
6.3 第三步预期输出
运行:
python hn_hiring_tool.py -k "golang" --remote --limit 10预期输出类似:
#41012345 by startup-cto [depth=1] Company: Example AI Remote: Yes Stack: Python, Go, K8s Contact: hr@example.ai ...每条记录都保留comment_id,方便用户点击链接回到原始评论确认真实信息。
7. 常见问题与排查思路
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 搜索接口返回空 hits | query 或 tags 组合不匹配 | 去掉numericFilters后再测试 | 放宽条件,或改用按日期拉取最新帖子 |
| item 接口超时 | 请求频率过高 | 查看日志中的连续请求间隔 | 增加time.sleep和指数退避重试 |
| 评论树只抓到一半 | max_depth设置过小 | 检查评论最大深度 | 调大max_depth,或改用迭代栈遍历 |
| 正文带 HTML 标签 | item 的text字段本身是 HTML | 打印原始字段检查 | 在提取前用clean_html处理 |
| 过滤结果包含无关评论 | 关键词匹配的是整段正文 | 打印匹配段上下文 | 增加字段抽取,按 Company/Stack 过滤 |
| 重复抓取被限流 | 未做本地缓存 | 查看请求次数 | 启动时检查本地缓存文件,命中则跳过 |
上面表格里最常见的坑是文件编码问题。如果在 Windows 下用默认编码打开comments.json,会出现中文乱码。写入文件时一定要指定encoding="utf-8",读取时同样指定。
8. 最佳实践与工程建议
8.1 缓存优先,请求其次
HN API 不是用来做全量爬虫的。一个招聘帖几百条评论,每次抓取都会触发几百次请求。如果每个月都要跑一次这个工具,最好把结果缓存到本地 JSON 或 SQLite。下一次运行先读缓存,只有当帖子发布时间变化时才重新抓取。
缓存键可以设计得简单一点:直接用年月作为文件名,例如who-is-hiring-2025-06.json。这样既方便管理历史数据,又方便做月度趋势分析。
8.2 结构化字段只做辅助,不做过强断言
正则抽取的字段质量波动非常大。比如有的评论写“No remote”,有的写“Remote: yes”,有的干脆写“US only, no remote”。这些情况都会干扰remote字段的判断。
更稳妥的做法是提供两个视图:结构化标签视图和原始全文视图。用户搜索时,两个视图同时参与匹配,但最终是否点击查看,还是要回到原始评论。
8.3 请求要做频率控制和重试
生产级实现建议用队列加迭代的方式替代递归。把待抓取的 comment_id 放进队列,用一个小线程池控制并发数,例如同时最多 3 个请求。全局限流可以控制在每秒 3 到 5 个请求,这是 HN 官方社区普遍认可的安全范围。
重试策略使用指数退避,第一次失败等 1 秒,第二次等 2 秒,第三次等 4 秒,最多重试 3 次。如果三次都失败,记录下来,不要中断整个流程。
8.4 输出格式要便于下游处理
除了打印摘要,建议把结果同时输出为 JSON 和 CSV。CSV 可以直接用 Excel 打开,JSON 可以继续给其他脚本处理。如果后续要做数据分析,可以读 CSV 统计远程岗位比例、地点分布、技术栈频率。
如果你的目标是做更复杂的检索,把数据存进 SQLite 是更合理的选择。每条评论一行,加一个fts全文索引,查询会快很多。
8.5 保留原文链接
工具展示结果时,务必让每条记录都能点击回到 HN 原始评论。原始链接格式固定为:
https://news.ycombinator.com/item?id={comment_id}这样做一方面方便用户确认真实信息,另一方面也能避免因文本截断导致误解。HN 上有些招聘评论会有很长的尾注和更新历史,只看抓取片断容易遗漏重要信息。
8.6 处理好被删除的评论
HN 允许用户删除评论。抓取时如果遇到deleted字段为true的评论,文本内容通常是不可见的,但评论 id 依然存在。为了数据分析的完整性,建议保留这条记录,文本字段标记为[deleted],而不是直接跳过。
9. 总结与后续学习方向
“Who is Hiring”帖子是一座数据丰富但结构混乱的金矿。HN Hiring 这类工具选择的思路不是改造数据源,而是用一个轻量解析层把评论树变成可检索的列表。本文从 HN API 的数据模型切入,实现了帖子发现、评论展平、字段抽取和关键词过滤四步流程。
对大多数开发者来说,这套命令行脚本已经能帮你把每个月花在“翻评论”上的时间缩短到几分钟。你只需要在每个月帖子发布后运行一次抓取脚本,之后随时可以本地搜索。
下一步可以深入的方向有三个:
- 引入 SQLite 加全文索引,把评论数据变成真正可交互的查询数据库。
- 用向量检索替换关键词匹配,让“找 Rust 远程岗位”这样的问题能匹配到表述不同但语义相近的评论。
- 把命令行工具封装成一个简单 Web 服务,前端提供筛选面板,后端按月缓存数据。
无论选择哪个方向,核心原则都保持不变:处理自由文本时保留原始信息,做过滤时保持低噪音,请求外部接口时尊重限流和缓存。把这些基础做扎实,工具才能真正在求职或统计场景里发挥作用。