- 想确认公共 API 和内部 API 契约的开发者
- 准备新增、调整或核对 HTTP 路由与返回结构的读者
- 不重复讲目录和服务职责边界,那部分见 project-structure.md 与 services-overview.md
- 不展开所有环境变量,那部分见 config-reference.md
- backend/main.py
- crawler/main.py
- HeyBlog_Model_API/app.py
- search/main.py
- persistence_api/main.py
- frontend/src/lib/api.ts
这份文档基于当前仓库源码整理 HeyBlog 已实现的 HTTP API,重点说明:
- 哪些是前端或外部调用方应该依赖的公共 API
- 哪些是拆分服务之间使用的内部 API
- 每个接口的请求参数、返回结构和职责边界
- 与接口直接相关的服务调用关系
当前代码实现对应的服务分层如下:
frontend:public discovery surface + protected admin surfacebackend:统一对外 API 聚合层crawler:爬虫执行与运行时控制model-api:供 crawler 调用的 URL 二分类内部服务search:搜索索引与查询persistence-api:持久化读写接口persistence-db:PostgreSQL 数据库
配套文档:
- project-structure.md
- services-overview.md
- service-architecture.md
- config-reference.md
- developer-workflows.md
默认端口来自 docker-compose.yml:
frontend:3000backend:8000crawler:8010search:8020persistence-api:8030model-api:8040persistence-db:5432
Public API 由 backend 服务统一暴露,供 public 浏览、图谱与用户 seed 提交流程使用:
GET /GET /internal/healthGET /api/statusPOST /api/auth/registerPOST /api/auth/loginGET /api/auth/mePOST /api/auth/logoutPOST /api/auth/email/verify/requestPOST /api/auth/email/verify/confirmPOST /api/auth/password/forgotPOST /api/auth/password/resetGET /api/me/label-selectionsGET /api/blogs/catalogPOST /api/recommendations/random-blog-batchesPOST /api/recommendation-eventsGET /api/blogs/{blog_id}/statsPOST /api/blogs/user-seedsPOST /api/blogs/{blog_id}/user-labelsGET /api/blogs/lookupGET /api/blogs/{blog_id}GET /api/icons/proxyGET /api/graph/views/coreGET /api/graph/nodes/{blog_id}/neighborsGET /api/graph/snapshots/latestGET /api/graph/snapshots/{version}GET /api/statsGET /api/filter-stats
源码位置: backend/main.py
补充说明:
- 浏览器实际访问的是
frontend服务。 - frontend/server.py 会把
/api/*代理到backend。 - 因此“公共 API 由 backend 提供”与“浏览器经 frontend 访问 API”这两件事同时成立。
- blog 规则重扫接口同样由
frontend -> backend访问,但实际归并动作发生在 persistence 层。
Admin API 同样由 backend 暴露,但统一位于 /api/admin/* 下,并要求 Authorization: Bearer <token>。该 token 可以是 legacy HEYBLOG_ADMIN_TOKEN,也可以是已登录、已验证邮箱且 role=admin 的用户 session token:
GET /api/admin/runtime/statusGET /api/admin/runtime/currentPOST /api/admin/runtime/startPOST /api/admin/runtime/stopPOST /api/admin/runtime/run-batchPOST /api/admin/crawl/bootstrapPOST /api/admin/crawl/runPOST /api/admin/blogs/requeue-failedPOST /api/admin/database/resetGET /api/admin/blog-labeling/candidatesGET /api/admin/blog-labeling/countsGET /api/admin/blog-labeling/tagsPOST /api/admin/blog-labeling/tagsGET /api/admin/blog-labeling/parquet-statusPOST /api/admin/blog-labeling/parquet-syncPOST /api/admin/blog-labeling/parquet-rebuildGET /api/admin/blog-labeling/parquet-exportPOST /api/admin/blog-labeling/title-previewPUT /api/admin/blog-labeling/labels/{blog_id}GET /api/admin/recommendation-statsGET /api/admin/hourly-statsGET /api/admin/usersPATCH /api/admin/users/{user_id}/role
补充脚本:
scripts/migrate_blog_label_assignment_ids.py:兼容性迁移脚本,把历史blog_label_assignments.blog_id对齐到稳定的 URL subject id。默认 dry-run,--apply才会写库。- 容器内运行前需要重新构建
persistence-api镜像,因为脚本目录是随镜像一起复制进去的。
- 容器内运行前需要重新构建
scripts/import_legacy_label_counts.py:把早期url,title,labelCSV 直接导入当前 URL-keyedblog_labels。默认 dry-run;传--apply --clear-existing会先清空blog_labels,再按 normalized URL 聚合写入label_id计数字典。传--titles-only --apply时只按 CSV 快速回填已存在blog_labels.title,不创建新行,也不改变label_id计数。旧标签others会映射为当前other。
认证语义:
- Admin API 接受 legacy
HEYBLOG_ADMIN_TOKEN,也接受已登录、已验证邮箱且role=admin的用户 session token。 - 未提供 token:
401 admin_auth_required - token 不合法:
403 admin_auth_invalid - token 属于普通用户或未验证 admin 候选账号:
403 admin_auth_forbidden - 未配置
HEYBLOG_ADMIN_TOKEN且未开启HEYBLOG_ADMIN_DEV_BYPASS=true,同时请求也不是合法 admin 用户 session:503 admin_auth_not_configured
拆分架构下,backend 通过 HTTP 调用三个内部服务:
crawler内部接口:执行抓取、控制运行时search内部接口:查询索引、重建索引persistence-api内部接口:读写 blog、edge、log 以及聚合统计
这些接口都以 /internal/* 命名,原则上不应作为前端直接依赖的长期协议。
用途:返回后端服务的基础入口信息。
响应示例:
{
"name": "HeyBlog Backend",
"status": "/api/status",
"panel": "served-by-frontend"
}用途:后端聚合健康检查。该接口会主动探测:
- persistence 的
stats() - crawler 的
runtime_status() - search 的
search("")
只要任一上游服务异常,后端会返回 503.
成功响应:
{
"status": "ok"
}用途:返回适合操作面板使用的简化状态。
返回字段:
is_running: 是否处于starting/running/stoppingpending_tasks: 等待中的博客数processing_tasks: 处理中博客数finished_tasks: 已完成博客数failed_tasks: 失败博客数total_blogs: blog 总数total_edges: edge 总数
数据来源:
- 统计字段来自
persistence-api /internal/stats is_running来自crawler /internal/runtime/status
用途:返回完整统计信息。
返回字段:
total_blogstotal_edgesaverage_friend_linksstatus_countspending_tasksprocessing_tasksfailed_tasksfinished_tasks
字段语义:
average_friend_links: blog 的平均友链发现数status_counts: 按crawl_status分组后的原始计数raw_discovered_urls:raw_discovered_urls表中的原始发现 URL 总数;crawler 会用它执行HEYBLOG_RAW_DISCOVERED_URL_LIMIT启动保护,配置为-1时不限制
用途:返回基于配置化 URL 过滤链的统计结果,用于展示规则过滤漏斗、RSS/模型成功判定分流,以及最终入库博客数量。
返回结构:
by_filter_reason: 兼容字段。按过滤链顺序排列的对象;每个 value 表示执行完该过滤器后仍然剩余多少 URL,末尾包含success与blogsrule_drops: 每个rule:*规则实际拦截的 URL 数量success_sources:successURL 的判定来源计数rss: 通过 RSS/Atom feed discovery 判定为博客model: 通过模型共识判定为博客unknown: 缺少来源字段的成功 URL,通常只会出现在旧数据或手工导入数据中
funnel: 面向前端可视化的关键漏斗节点raw: 进入过滤链的标准化 URL 总数after_rules: 通过全部确定性规则后的候选 URL 数model_rejected: 被模型共识拒绝的 URL 数success: 成功判定为博客 URL 的总数blogs: 实际入库博客总数
统计语义:
- 顺序由
runtime_resources/filter_chain.toml中启用的过滤器顺序决定 - 统计基础来自持久化表
raw_discovered_urls rule:duplicate_url是过滤链前置状态:同一个normalized_url已经存在更小id的 raw 行时,当前 raw URL 会提前标记为重复,不再进入后续过滤链;更大id的未来/后续行不会反向影响当前行- URL 通过 RSS 或模型成功判定后仍写为
status=success,同时用accepted_by记录来源 success与blogs可能不同,因为upsert_blog会按identity_key合并同一站点
响应示例:
{
"by_filter_reason": {
"raw": 1000,
"rule:duplicate_url": 930,
"rule:same_domain": 880,
"rss:rss_feed_found": 700,
"model:model_consensus_all_non_blog": 620,
"success": 620,
"blogs": 590
},
"rule_drops": {
"rule:duplicate_url": 70,
"rule:same_domain": 50
},
"success_sources": {
"rss": 260,
"model": 360,
"unknown": 0
},
"funnel": {
"raw": 1000,
"after_rules": 700,
"model_rejected": 80,
"success": 620,
"blogs": 590
}
}用途:提交邮箱和密码并发送验证邮件。该接口只创建临时待验证注册记录,不创建登录 session,也不会把用户账号写入 users。只有用户通过验证码/验证链接完成 /api/auth/email/verify/confirm 后,系统才会创建持久化用户账号。游客无需入库;未登录请求即游客身份。
请求体:
{
"email": "user@example.com",
"password": "long enough"
}成功响应:
{
"sent": true,
"verification_token": "dev-verification-token",
"verification_url": "http://127.0.0.1:3000/profile?verify_token=dev-verification-token",
"expires_at": "2026-06-12T00:00:00+00:00"
}错误语义:
409 email_already_registered409 email_registration_pending422 invalid_email422 password_too_short502 email_delivery_failed
用途:使用邮箱和密码登录,创建新的 bearer session。请求体同注册接口,成功响应也同注册接口。
错误语义:
401 invalid_credentials422 invalid_email
用途:读取当前登录用户资料。
请求头:
Authorization: Bearer <session-token>
错误语义:
401 auth_required
用途:注销当前 session token。请求头同 /api/auth/me。
用途:为已经创建但尚未验证的普通用户或 admin 用户重新生成邮箱验证 token。未知邮箱和仍处于注册待验证阶段、尚未持久化的邮箱返回中性成功语义,避免暴露账号是否存在;待验证新注册应继续使用注册邮件中的链接完成账号创建。
邮件通道由 persistence-api 的 HEYBLOG_EMAIL_PROVIDER 控制。默认 disabled 模式不会连接 SMTP,API 响应默认只返回发送状态和过期时间,不暴露明文 token。需要本地调试或手动验证时,可显式设置 HEYBLOG_EMAIL_DEV_EXPOSE_TOKENS=true 让响应体返回一次性验证 token/link。设置 HEYBLOG_EMAIL_PROVIDER=smtp 后,系统会把验证链接发送到用户邮箱。
请求体:
{
"email": "user@example.com"
}成功响应:
{
"sent": true,
"verification_token": "dev-verification-token",
"verification_url": "http://127.0.0.1:3000/profile?verify_token=dev-verification-token",
"expires_at": "2026-06-10T00:00:00+00:00"
}生产 SMTP 且关闭 dev token 暴露后的成功响应:
{
"sent": true,
"expires_at": "2026-06-10T00:00:00+00:00"
}错误语义:
502 email_delivery_failed
用途:消费邮箱验证邮件链接中的一次性 token。对于新注册 token,该接口先创建持久化用户账号,再返回已验证用户资料;对于历史未验证账号 token,该接口把已有用户标记为已验证。token 只保存 hash,过期或已消费后不可复用。浏览器打开 /profile?verify_token=... 时,前端会自动调用该接口完成验证,随后提示用户登录。
请求体:
{
"token": "dev-verification-token"
}返回:创建或更新后的用户资料。新注册用户默认 role=user、email_verified=true,不会自动创建登录 session。
用途:请求密码重置 token。未知邮箱返回中性成功语义。
请求体:
{
"email": "user@example.com"
}默认响应隐藏明文 reset token,只返回发送状态和过期时间。需要本地调试或手动验证时,可显式设置 HEYBLOG_EMAIL_DEV_EXPOSE_TOKENS=true 返回可直接使用的 reset_token 与 reset_url。设置 HEYBLOG_EMAIL_PROVIDER=smtp 后,系统会把 reset link 发送到用户邮箱。后端始终只持久化 token hash。
生产 SMTP 且关闭 dev token 暴露后的成功响应:
{
"sent": true,
"expires_at": "2026-06-10T00:00:00+00:00"
}错误语义:
502 email_delivery_failed
用途:消费一次性密码重置 token,设置新密码,并撤销该用户所有旧 session。
请求体:
{
"token": "dev-reset-token",
"password": "new long enough"
}返回:更新后的用户资料。
用途:返回当前登录用户最近的随机博客标注选择。
查询参数:
limit: 可选,默认50,最大100
返回字段:
idnormalized_urllabel_idlabellabel_namecreated_atupdated_atblog: 若当前 URL 仍在blogs中,则返回博客摘要;否则为null
用途:返回当前登录用户的随机博客标注汇总。
请求头:
Authorization: Bearer <session-token>
返回字段:
label_count: 当前用户总共保存的标注选择次数
统一标识约定:
- 本节所有路由中的
blog_id都表示业务主键blogs.blog_id,不再表示数据库行主键blogs.id - blog 节点、详情、邻居摘要等响应会显式返回
blog_id
用途:为“发现博客”入口提供分页、搜索、发现型筛选与排序。
查询参数:
page: 页码,默认1,最小值为1page_size: 每页条数,默认50,最终会被限制在1..200q: 通用模糊搜索,匹配title/domain/urlsite: 站点筛选,匹配title/domainurl: URL 筛选,匹配url/normalized_urlstatus: 抓取状态精确筛选;会先做trim + uppercase,仅允许WAITING、PROCESSING、FINISHED、FAILEDstatuses: 多状态筛选,逗号分隔;会对每个值做trim + uppercase,仅允许WAITING、PROCESSING、FINISHED、FAILEDacceptance_status: 博客接受状态筛选,默认ACCEPTED;允许ACCEPTED、UNKNOWN、REJECTED。该字段表示 URL 是否已被 seed、RSS 或模型确认为博客,独立于crawl_statussort: 排序方式,允许id_asc、id_desc、recent_activity、connections、recently_discovered、randomhas_title: 是否要求有标题;支持布尔值,也接受1/0、true/false、yes/nohas_icon: 是否要求有 icon;支持布尔值,也接受1/0、true/false、yes/nomin_connections: 最小连接度阈值,负数会被归一化为0
归一化与排序规则:
- 空白字符串会被视为未传参
- 非法
status返回422 - 非法
statuses返回422 - 非法
acceptance_status返回422 - 非法
sort返回422 - 当
statuses存在时优先于status,用于同时查询多个crawl_status - 默认只返回
acceptance_status=ACCEPTED的 URL;crawl_status=FAILED只表示最近一次抓取尝试失败,不表示该 URL 不是博客 has_title/has_icon仅在传入真值时启用过滤;传入假值会保留参数值但不额外筛掉空字段记录id_asc按业务blog_id ASCrecent_activity按activity_at DESC, connection_count DESC, blog_id DESCconnections按connection_count DESC, activity_at DESC, blog_id DESCrecently_discovered按created_at DESC, blog_id DESCrandom按用户反馈权重随机返回,适合“随机博客”类入口;该模式会过滤掉blog_labels中已有非blog管理员标签计数的 URLid_desc按业务blog_id DESC- 若请求页码超出最后一页且结果集非空,服务端会回退到最后一页,并在响应中返回实际生效页码
响应结构见“数据模型”章节中的 BlogCatalogPageRecord。
当前前端使用方式:
- 首页搜索框使用
page=1&page_size=30&url=<输入 URL>&sort=id_desc查询已发现博客,并把返回项渲染为可滚动结果列表。
用途:随机博客页请求一组新的推荐卡片,并把本次刷新作为一条可追踪的 recommendation request 持久化。服务端会同时写入有序 impression 记录,所以后续点击、详情打开和标注事件可以归因到“哪次刷新中的第几个 URL”。
请求体:
{
"count": 9,
"visitor_id": "visitor_lx7...",
"session_id": "session_lx7...",
"source": "random_page",
"page_url": "http://localhost:3000/random",
"context": {
"refresh_kind": "manual"
}
}认证说明:
- 未登录也可调用;
visitor_id与session_id由前端本地生成,用于匿名统计。 - 登录后可带
Authorization: Bearer <session-token>;backend 会把用户 ID 转发给 persistence 以便后续用户维度分析。 - Authorization 是可选的;缺失、过期或无效 token 都按匿名用户处理,不会阻止随机博客加载。
行为说明:
- 当前 surface 固定为
random_blog_page - 当前 strategy 固定为
weighted_random,strategy_version = v1 - 只返回
crawl_status=FINISHED且acceptance_status=ACCEPTED的博客 - 随机排序复用 catalog 的
sort=random权重逻辑:管理员非 blog 标签会过滤,用户公开反馈会影响权重 count当前允许1..50;随机页默认请求9
成功响应示例:
{
"request_uuid": "r_abc",
"surface": "random_blog_page",
"strategy": "weighted_random",
"strategy_version": "v1",
"visitor_id": "visitor_lx7",
"session_id": "session_lx7",
"requested_count": 9,
"served_count": 9,
"created_at": "2026-06-07T13:30:00+00:00",
"items": [
{
"id": 12,
"url": "https://blog.example.com/",
"normalized_url": "https://blog.example.com/",
"request_uuid": "r_abc",
"impression_id": 101,
"position": 1
}
]
}错误语义:
401: bearer token 非法或过期422: count、visitor/session ID 或 JSON context 非法
用途:记录随机博客卡片上的用户行为。事件以 event_uuid 幂等写入,适合前端在详情跳转、外链打开、标注选择等动作发生时尽力而为上报。
请求体:
{
"event_uuid": "event_lx7...",
"event_type": "detail_open",
"blog_id": 12,
"visitor_id": "visitor_lx7...",
"session_id": "session_lx7...",
"entrance_kind": "random_blog_page",
"entrance_url": "http://localhost:3000/random",
"request_uuid": "r_abc",
"impression_id": 101,
"position": 1,
"interaction_order": 1,
"client_event_at": "2026-06-07T13:31:00.000Z",
"attributes": {
"label": "blog"
}
}支持的 event_type:
clickdetail_openexternal_openlabel_selectrefreshdismisscopy_url
行为说明:
- 同一个
event_uuid重复上报时不会重复计数,响应中会返回duplicate: true entrance_kind与entrance_url为必填字段。entrance_kind使用稳定、可聚合的路口种类,例如random_blog_page、home_search_result、blog_detail_discovery_path、blog_detail_relation_graph;entrance_url保留触发动作时的原始页面 URL 或上下文 URL,便于追溯具体来源。- 若传入
request_uuid或impression_id,服务端会校验它们存在且与当前 blog 的normalized_url匹配 - 前端不应因为事件上报失败而阻塞用户跳转或标注主流程
- 持久化时事件落到
blog_interactions,以normalized_url作为博客归因键;其中entrance_kind与entrance_url单独存列并建立索引,便于按稳定路口维度统计详情打开、外链打开和标签选择。
错误语义:
404: 目标 blog 不存在401: bearer token 非法或过期422: event type、request/impression 归因或 JSON attributes 非法
用途:返回单个博客在推荐系统中的曝光和交互统计,供详情页或后续运营面板展示。
成功响应示例:
{
"blog_id": 12,
"normalized_url": "https://blog.example.com/",
"impressions": 20,
"clicks": 1,
"detail_opens": 3,
"external_opens": 0,
"label_selects": 2,
"unique_visitors": 5,
"ctr": 0.2,
"last_interaction_at": "2026-06-07T13:31:00+00:00",
"by_event_type": {
"detail_open": 3,
"label_select": 2
}
}错误语义:
404: 目标 blog 不存在
用途:返回推荐请求、曝光和交互的策略级汇总。该接口位于 admin API 下,需要 Authorization: Bearer <HEYBLOG_ADMIN_TOKEN>。
成功响应示例:
{
"total_requests": 10,
"total_impressions": 90,
"total_interactions": 12,
"by_strategy": [
{
"surface": "random_blog_page",
"strategy": "weighted_random",
"strategy_version": "v1",
"requests": 10,
"impressions": 90,
"clicks": 8,
"unique_visitors": 6,
"ctr": 0.0888888889
}
]
}用途:返回后台统计小时快照。快照由 persistence-api 后台整点任务刷新,不依赖打开 admin 页面;该接口位于 admin API 下,需要 Authorization: Bearer <HEYBLOG_ADMIN_TOKEN> 或已验证 admin 用户 session token。
查询参数:
limit: 返回最近多少个自然小时快照,默认24,最大168
统计语义:
- 数据写入
admin_hourly_stats表,每条记录对应一个 UTC 自然小时窗口[hour_start, hour_start + 1h) persistence-api会在每个 UTC 整点刷新刚结束的小时和新的当前小时;服务启动和 admin 页面读取都不会触发实时刷新user_count: 当前 active 用户总数random_request_count: 该小时内 random blog 推荐请求数random_impression_count: 该小时内 random blog 推荐曝光数;随机页每次通常请求 9 个detail_open_count: 该小时内 random blog 卡片详情打开次数external_open_count: 该小时内 random blog 卡片外链打开次数detail_ctr:detail_open_count / random_impression_countexternal_ctr:external_open_count / random_impression_counttotal_click_ctr:(detail_open_count + external_open_count) / random_impression_count
成功响应示例:
{
"current_hour": {
"id": 1,
"hour_start": "2026-06-11T10:00:00+00:00",
"user_count": 12,
"random_request_count": 3,
"random_impression_count": 27,
"detail_open_count": 4,
"external_open_count": 5,
"detail_ctr": 0.1481481481,
"external_ctr": 0.1851851852,
"total_click_ctr": 0.3333333333,
"refreshed_at": "2026-06-11T10:05:00+00:00",
"created_at": "2026-06-11T10:05:00+00:00"
},
"latest": {
"id": 1,
"hour_start": "2026-06-11T10:00:00+00:00",
"user_count": 12,
"random_request_count": 3,
"random_impression_count": 27,
"detail_open_count": 4,
"external_open_count": 5,
"detail_ctr": 0.1481481481,
"external_ctr": 0.1851851852,
"total_click_ctr": 0.3333333333,
"refreshed_at": "2026-06-11T10:05:00+00:00",
"created_at": "2026-06-11T10:05:00+00:00"
},
"items": []
}用途:当首页 URL 搜索没有命中时,允许用户提交一个完整博客链接作为用户来源 seed。该接口只执行确定性规则过滤,跳过 RSS discovery 与模型共识;规则通过后会把 URL 同时写入 blogs 与 seeds。
请求体:
{
"homepage_url": "https://blog.example.com"
}成功语义:
- URL 先按当前 identity/canonicalization 规则归一化
- 只运行过滤链中的 rule filters;不会因为缺少 RSS、模型未加载或模型判非博客而拒绝
- 规则通过后,
blogs.acceptance_status = ACCEPTED blogs.accepted_by = user- 新建或历史
FAILED博客会处于crawl_status = WAITING,因此可被 crawler 领取并抓取友链 - 已经
FINISHED的博客不会被强制重置为WAITING - 同一 URL 会 upsert 到
seeds表,当前用source_path = user标记用户来源
成功响应示例:
{
"status": "QUEUED",
"blog_id": 123,
"inserted": true,
"blog": {
"id": 123,
"blog_id": 123,
"url": "https://blog.example.com/",
"normalized_url": "https://blog.example.com/",
"domain": "blog.example.com",
"acceptance_status": "ACCEPTED",
"accepted_by": "user",
"crawl_status": "WAITING"
}
}错误语义:
- URL 格式无法归一化或规则过滤拒绝时返回
422
用途:把已知 icon URL 作为同源图片返回,供 3D 图谱 WebGL texture 加载使用。
查询参数:
url: 绝对http/https图片 URL。前端通常传入icon_url或 favicon API fallback URL。
响应:
- 成功时返回远端图片字节,
Content-Type沿用远端图片 MIME,并设置Cache-Control: public, max-age=86400 - 仅允许公网 HTTP(S) URL;localhost、私网、link-local、reserved 等地址会返回
422 - 远端超时返回
504 - 远端非 2xx、非图片 MIME、或响应超过 1MB 时返回
502
说明:
- 该接口不改变
blogs.icon_url的持久化语义,只解决浏览器 WebGL 对跨域 texture 的 CORS 要求 - 普通
<img>展示仍可直接使用icon_url或前端 favicon fallback;图谱纹理建议统一使用该代理后的同源 URL
用途:随机博客页为单个博客 URL 增加一次公共用户标注。该接口写入 blog_labels_userlabel,表结构和 blog_labels 一致,均按 normalized_url 存储 title 与 label_id 计数字典;不会修改训练用的 blog_labels。
请求体:
{
"label": "other",
"previous_label": "blog"
}认证说明:
- 未登录也可提交;服务端只更新公开聚合计数。
- 登录后提交时可带
Authorization: Bearer <session-token>;服务端会同时记录该用户对当前 URL 的最新选择,并用已有选择推导旧 label,因此跨刷新切换也不会重复累加同一用户的旧选择。
行为说明:
label只接受随机博客页使用的四类标签:blog、company、other、unknownprevious_label可选;用于随机博客页内的单 URL 单选择切换。若传入且与label不同,服务端会先把旧 label 计数减1,再把新 label 计数加1- 前端同一张 URL 卡片重复点击已选中的 label 不会再次请求接口,也不会重复累加计数
- 随机博客加权时,所有 URL 默认权重为
10;设用户表中非blog计数为y,权重为10 / (1 + y);blog正反馈不再提升随机权重 - 权重只影响
sort=random的随机排序;管理员训练标签只负责过滤非 blog,不会被用户标注改变
错误语义:
404: 目标 blog id 不存在409: 目标不是已完成博客422: label 非法401: bearer token 非法或过期
成功响应示例:
{
"blog_id": 12,
"label_id": {
"1": 3,
"3": 1
},
"labels": [
{
"id": 1,
"name": "blog",
"slug": "blog",
"count": 3,
"labeled_at": "2026-05-26T00:20:00+00:00"
}
],
"label_slugs": ["blog", "other"],
"last_labeled_at": "2026-05-26T00:20:00+00:00",
"is_labeled": true
}- 统一 discovery 主入口固定以
statuses=WAITING,PROCESSING&sort=id_asc渲染“当前博客状态”板块 - 发现页只请求当前页,不再拉全量 blog 列表
- 该请求默认不做 5 秒轮询,也不依赖窗口聚焦自动刷新
- 发现页会利用新增字段直接渲染博客卡片的活跃度、连接度与身份完整度提示
用途:对单个博客首页 URL 做数据库存在性判断,供统一 discovery 页的“检查博客 URL 是否已收录”区域使用。
查询参数:
url: 博客首页 URL,必填
匹配阶梯:
- 先复用 blog identity canonicalization 规则,把输入归一化为
normalized_query_url - 优先按 canonical homepage identity 精确匹配
- 若 identity 未命中,再回退到
normalized_url精确相等匹配 - 若仍未命中,则返回空数组;当前不做 substring / domain contains 型广义搜索
响应结构:
{
"query_url": "https://alpha.example/",
"normalized_query_url": "https://alpha.example/",
"items": [],
"total_matches": 0,
"match_reason": null
}补充说明:
match_reason当前固定为identity_key、normalized_url或null- 该接口是薄 lookup payload,不复用 catalog 的分页 envelope
- 统一 discovery 页的 lookup 状态会单独映射到
lookup=URL 参数,不与 queue 分页/排序参数混用
用途:返回单个 blog 详情,并追加该 blog 的双向关系聚合结果与粗糙推荐。
行为说明:
- 若 blog 不存在,返回
404 - 返回内容基于单 blog 记录扩展了
incoming_edges与outgoing_edges - 返回
discovery_path描述该博客进入网络的路径:手动 seed/user 添加,或由 crawler 沿友链逐级发现 - 返回
relation_graphs描述详情页“博客关联”模块使用的两层入链/出链关系图;两层深度内的入链/出链关系完整返回,不按节点或边数量裁剪
额外字段:
crawl_status: 当前抓取执行状态,例如WAITING、PROCESSING、FAILED、FINISHED;详情页会直接展示该字段crawl_error_kind: 最近一次抓取失败分类;当crawl_status=FAILED时,详情页会把该字段作为失败原因展示,例如timeout、page_too_large、http_status、request_errorincoming_edges: 所有to_blog_id == blog_id的边,每条边额外携带neighbor_blogoutgoing_edges: 所有from_blog_id == blog_id的边,每条边额外携带neighbor_blogrecommended_blogs: “朋友的朋友”推荐列表,规则是“当前博客的友链认识、但当前博客还没直接认识的博客”discovery_path: 发现路径。mode=manual表示该博客由accepted_by=seed/user手动进入网络;mode=crawled表示通过raw_discovered_urls从当前博客逐级追溯 source blog,直到 seed/user 源头、无法继续追溯或检测到循环;正常长路径会完整返回,不按固定深度截断relation_graphs:{ incoming, outgoing },两个图默认各包含从当前博客出发的 2 层关系;incoming沿入链向上追溯,outgoing沿出链向下展开;两层深度内不按节点或边数量裁剪
其中 neighbor_blog 是详情页使用的邻居摘要,字段为:
idblog_iddomaintitleicon_url
用途:返回博客人工标注台使用的候选列表。标注台展示队列请求 labeled=false,只显示模型过滤前已经处理过、适合人工标注的 raw URL:raw_discovered_urls.status = success 或 raw_discovered_urls.status LIKE 'model:%',且该 URL 在 blog_labels 中不存在。候选 ID 始终使用当前 raw_discovered_urls.id,便于前端继续按候选行保存;若该 raw URL 已有对应 blog,则只借用 blog 上的 title/icon 等展示字段,不把 blogs.blog_id 作为标注 ID,也不会为了标注补建轻量 blog 行。已标注候选展示 title 时优先使用 blog_labels.title;若旧 label 行没有 title 但当前 blogs 有对应 title,候选加载会临时回填 blog_labels.title 并返回该 title。实际 label 会按 blog_labels.normalized_url 长期保存,避免清库重爬后 raw/blog id 改变导致标注丢失。
查询参数:
page: 页码,默认1page_size: 每页条数,默认50,最终会被限制在1..200q: 模糊搜索,匹配blogs.title/blog_labels.title/domain/url/normalized_urllabel: 标签slug精确筛选,例如blog、official、governmentlabeled: 标注状态筛选;支持1/0、true/false、yes/no。标注台候选队列固定传false,统计查询可传truesort: 排序方式,允许id_desc、recent_activity、recently_labeled
响应结构:
- 复用
BlogRecord的主体字段 - 追加
label_id、labels、label_slugs、last_labeled_at、is_labeled - 顶层追加
available_tags,用于前端渲染可选标签与新建标签后的刷新 - 分页包装结构与
GET /api/blogs/catalog一致
语义说明:
- 未标注状态通过
labels = []与is_labeled = false表达,不把“未标注”落成特殊标签 label_id是 label id 到计数的 JSON object,例如{"1": 10, "2": 1}- 一个 URL 可以同时拥有多个标签计数;
label查询参数表达“包含该标签”的筛选语义,而不是单值相等比较 - 候选范围不再等同于
crawl_status == FINISHED;模型过滤掉的model:*raw URL 也会进入标注台,避免训练数据只覆盖模型已放行样本 - 标注保存不要求目标一定存在于
blogs;保存时会先用候选 raw row id 解析出normalized_url,再把normalized_url、当前展示title、label_id写入单表blog_labels - 对只存在于
raw_discovered_urls、没有blogs.title的候选,前端加载页面时可调用POST /api/admin/blog-labeling/title-preview临时读取页面<title>;该预览不写数据库,只有用户提交标注并在请求体传入title后才持久化到blog_labels.title rule:*、平台/TLD/路径等非模型过滤结果不会进入该标注池- 查询实现会按每个
normalized_url的最早 labelable raw row 作为代表候选,并依赖raw_discovered_urls的 status / normalized URL 索引分页;打开 admin 页面或轮询刷新不应堆积全表 raw URL 聚合查询 - 该接口只服务于标注工作台,不改变现有发现页
GET /api/blogs/catalog的协议
成功响应示例:
{
"items": [
{
"id": 12,
"url": "https://alpha.example/",
"normalized_url": "https://alpha.example/",
"domain": "alpha.example",
"title": "Alpha Blog",
"crawl_status": "FINISHED",
"label_id": {
"1": 10,
"5": 1
},
"labels": [
{
"id": 1,
"name": "blog",
"slug": "blog",
"count": 10,
"labeled_at": "2026-04-05T20:01:00+00:00"
},
{
"id": 5,
"name": "official",
"slug": "official",
"count": 1,
"labeled_at": "2026-04-05T20:01:00+00:00"
}
],
"label_slugs": ["blog", "official"],
"last_labeled_at": "2026-04-05T20:01:00+00:00",
"is_labeled": true
}
],
"available_tags": [
{
"id": 1,
"name": "blog",
"slug": "blog",
"created_at": null,
"updated_at": null
},
{
"id": 5,
"name": "official",
"slug": "official",
"created_at": null,
"updated_at": null
}
],
"page": 1,
"page_size": 50,
"total_items": 1,
"total_pages": 1,
"has_next": false,
"has_prev": false,
"filters": {
"q": null,
"label": "official",
"labeled": true,
"sort": "recently_labeled"
},
"sort": "recently_labeled"
}用途:返回标注台实时统计。该接口直接按 blog_labels 全表聚合,不依赖当前 raw URL 候选池;清空 crawler 数据或 raw id 变化不会影响已持久化 label 的统计。
响应结构:
{
"total_labeled": 2373,
"by_label": {
"blog": 651,
"company": 226,
"other": 1496,
"unknown": 0
}
}语义说明:
total_labeled表示blog_labels.label_id非空的 URL 数by_label表示包含该 label 的 URL 数;即使某个 URL 对同一 label 的计数大于 1,也只计为 1 个 URL- label slug 来自
blog_label_tags,未知 id 会以数字字符串返回
用途:返回当前所有可用标签类型定义,供前端渲染和复用。标签定义保存在 blog_label_tags,用于解释 blog_labels.label_id 中的数字 key。
响应结构:
- 返回数组,每项包含
id、name、slug、created_at、updated_at - 默认映射为:
blog=1、company=2、other=3、unknown=4、official=5、government=6 - 标签按 id 升序返回
用途:创建或复用一个标签定义。该接口写入 blog_label_tags,使后续保存的 label_id 能稳定解析为 label 名称。
请求体:
{
"name": "government"
}行为说明:
- 服务端会对
name进行 trim,并生成稳定 slug - 同一 slug 会返回相同的稳定 id;默认标签会使用固定 id
- 空白或非法名称返回
422
成功响应示例:
{
"id": 6,
"name": "government",
"slug": "government",
"created_at": null,
"updated_at": null
}用途:检查当前人工标注 parquet 快照状态,不修改文件。
响应结构:
{
"path": "/app/data/exports/blog-label-training.parquet",
"filename": "blog-label-training.parquet",
"exists": true,
"saved_count": 200,
"total_labeled": 237,
"missing_count": 37,
"batch_size": 100,
"rewritten": false,
"message": "已保存 200 条数据,总计有 label 的有 237 条数据。",
"updated_at": "2026-05-24T18:50:18+00:00"
}用途:检查保存的 parquet 文件是否已经包含所有有 label 的 URL 数据;若发现缺失、文件不存在、已保存数据多于当前数据库事实,或当前总量到达 100 条边界,则重写 parquet 快照。
行为说明:
- parquet 文件固定写入
HEYBLOG_EXPORT_DIR/blog-label-training.parquet - 只保留三列:
url、title、label;title来自保存标注时写入的blog_labels.title - 语义与 CSV 导出一致,一个
blog x label组合对应一行 - 补齐/重建以
blog_labels为事实来源,导出所有已持久化 label;不要求 URL 仍存在于当前 raw URL 候选池 - 响应结构与
parquet-status一致,rewritten表示本次是否实际写入文件
用途:重置 parquet 文件,并按当前保存流程从数据库中所有有 label 的数据重新生成一遍。该接口用于未来新增字段或保存流程变化时避免在旧文件上原地迁移。
响应结构与 parquet-status 一致;成功时 rewritten 为 true。
用途:下载当前人工标注 parquet 文件。
响应类型:
application/vnd.apache.parquet
行为说明:
- 下载前会先执行一次
parquet-sync语义,确保缺失数据被补齐 - 响应头包含
content-disposition: attachment; filename="blog-label-training.parquet" - 响应头包含
x-heyblog-label-saved-count与x-heyblog-label-total-count,便于调用方核对下载时的保存数量
用途:为标注台 raw-only 候选实时读取页面标题。该接口只返回临时展示 title,不写入 blogs 或 blog_labels。
请求体:
{
"url": "https://raw-only.example/"
}成功响应:
{
"url": "https://raw-only.example/",
"title": "Raw Only Blog"
}行为说明:
- 仅接受
http:///https://URL - 使用短超时抓取目标 HTML 并提取
<title>;失败时前端应继续允许按 URL/domain 标注 - 返回的
title只有随PUT /api/admin/blog-labeling/labels/{blog_id}的title字段提交后,才会写入blog_labels.title
用途:替换单个标注候选 URL 的整组人工标签。路径中的 blog_id 为兼容旧前端字段名,实际应传当前候选的 raw URL id。
请求体:
{
"title": "Raw Only Blog",
"label_id": {
"1": 10,
"2": 1
}
}行为说明:
- 请求体中的
label_id是完整替换语义,而不是增量 patch;key 为 label id 字符串,value 为该 label 的累计标注次数 - 兼容旧请求体
{"tag_ids": [1, 5]},服务端会转换为{"1": 1, "5": 1} - 请求体中的
title可选;传入非空值时优先作为blog_labels.title保存,适用于 raw-only 候选的临时 title - 同一个 URL 可以同时拥有多个标签计数
- 传
{}或空tag_ids表示“清空该 URL 当前所有标签” - label 以
blog_labels.normalized_url为长期键保存,并同步保存标注时的title;清空 crawler 数据再重新爬取后,只要 URL 再次进入 labelable raw URL 池,就可用于导出原有标签
错误语义:
404: 候选 raw URL id 不存在409: 目标不是 labelable raw URL,拒绝写入训练样本标签422:label_id/tag_ids非法
成功响应示例:
{
"blog_id": 12,
"label_id": {
"1": 10,
"2": 1
},
"labels": [
{
"id": 1,
"name": "blog",
"slug": "blog",
"count": 10,
"labeled_at": "2026-04-05T20:12:00+00:00"
},
{
"id": 2,
"name": "company",
"slug": "company",
"count": 1,
"labeled_at": "2026-04-05T20:12:00+00:00"
}
],
"label_slugs": ["blog", "company"],
"last_labeled_at": "2026-04-05T20:12:00+00:00",
"is_labeled": true
}recommended_blogs 的每个元素包含:
blog: 推荐博客本身,结构沿用扩展后的BlogRecordreason: 当前固定为mutual_connectionmutual_connection_count: 有多少个共同中间博客指向了这个推荐博客via_blogs: 中间博客摘要列表,字段与neighbor_blog相同
推荐策略说明:
- 只基于当前博客的出边做一层扩展
- 排除自己
- 排除已经与当前博客直接相连的博客
- 这是阶段 1 的可替换粗糙实现,目标是先提供可解释的发现入口,而不是最终推荐系统
前端现状:
- 当前博客详情页直接消费该接口作为主数据源
- 详情页不再额外请求 legacy 全量 blog / edge 接口
- incoming/outgoing 关系、邻居名称映射和“朋友的朋友”推荐都由后端在该接口内聚合
用途:返回图页默认使用的结构化初始子图。
常用查询参数:
strategy:degree或seedlimit: 默认子图规模上限,当前最大允许10000sample_mode:off/count/percent;图谱页默认不启用采样sample_value: 当采样开启时的数量或百分比;count会先用固定随机种子选择一个起点,再按 BFS 扩展到目标节点数,避免返回大量互不相连的随机点sample_seed: 固定随机种子,便于复现随机起点与补充分量顺序
响应结构:
{
"nodes": [],
"edges": [],
"meta": {
"strategy": "degree",
"limit": 180,
"sample_mode": "off",
"sample_value": null,
"sample_seed": 7,
"sampled": false,
"focus_node_id": null,
"hops": null,
"has_stable_positions": true,
"snapshot_version": "20260331T000000000000Z",
"generated_at": "2026-03-31T00:00:00+00:00",
"source": "snapshot",
"total_nodes": 5306,
"total_edges": 9758,
"available_nodes": 5306,
"available_edges": 9758,
"selected_nodes": 180,
"selected_edges": 264,
"snapshot_namespace": "legacy"
}
}说明:
nodes元素沿用BlogRecord,并额外携带x、y、degree、incoming_count、outgoing_count、priority_score、component_id- 当
has_stable_positions为true时,前端会优先使用这些坐标直接渲染,而不是首次实时跑力导布局 - 当前图谱页用 0 到当前 blog 总数的滑块选择
N,默认值为min(200, total_blogs);点击确认后请求strategy=seed&limit=N,直接按 blog id 升序选择前 N 个 blog 节点,并只返回这些节点之间的边。图谱节点不按crawl_status过滤,因为发现关系本身可能来自抓取失败或尚未完成的父节点;只要边的两端 blog 仍存在,就会参与图谱投影 - 当
sample_mode != off时,会返回可复现的随机起点 BFS 子图;若起点所在连通分量不足目标规模,会按同一随机序列继续从其他分量 BFS 补足 - 服务在返回前会检查底层 graph 是否已变化;若当前仓库数据与最新 snapshot 不一致,会先重建 snapshot,再返回最新视图
snapshot_namespace用于区分当前 view 依赖的 snapshot 来源;当前默认值为legacy
用途:基于当前节点返回邻域扩展结果,供图页“展开 1 跳 / 2 跳”使用。
查询参数:
hops: 允许1或2limit: 邻域节点上限
响应结构与 GET /api/graph/views/core 相同,但:
meta.strategy固定为neighborhoodmeta.focus_node_id为当前中心节点meta.hops为实际展开跳数
错误说明:
- 当目标 blog 不在当前已完成图谱快照中时,返回
404 graph_node_not_found
用途:返回最新离线图快照 manifest。
响应结构:
{
"version": "20260331T000000000000Z",
"generated_at": "2026-03-31T00:00:00+00:00",
"source": "snapshot",
"snapshot_namespace": "legacy",
"has_stable_positions": true,
"total_nodes": 5306,
"total_edges": 9758,
"available_nodes": 5306,
"available_edges": 9758,
"graph_fingerprint": "4d9c...a1f3",
"file": "graph-layout-20260331T000000000000Z.legacy.json"
}说明:
- 这是对前端可见的受控发布边界,浏览器不应直接依赖 crawler 导出目录路径
- 若本地尚无快照文件,或底层 graph 数据已经变化,服务会先基于当前数据构建并落盘,再返回 manifest
用途:返回指定版本的离线图快照。
响应结构与 GET /api/graph/views/core 类似,但包含完整 snapshot 范围的 nodes / edges 以及顶层 version、generated_at。
- 当前 public API 已不再暴露 legacy 的
/api/logs与/api/search。 - 运行日志统一由
shared.observability输出到类型目录,默认是logs/app/、logs/error/、logs/access/;每个类型目录下再按服务分目录,保存<service>-YYYYMMDD-HH.log小时切片,Docker Compose 中对应volumes/logs。 - legacy
/internal/logs仍保留兼容入口,但当前不会把 crawl log 写入业务数据库。 search服务仍保留为内部可重建索引组件,供 health 检查与 reindex 维护链路使用,并在缓存为空时回退到persistence-api /internal/search-snapshot。- 浏览器当前没有直接依赖的 public 搜索页;public 发现主路径已经收敛到
catalog / lookup / detail / graph views。
用途:导入种子博客。若 seeds 表已有记录,则直接以 seeds 表为来源回灌 blogs;仅当 seeds 表为空时才从 seed.csv 初始化。
调用链:
backend->crawler /internal/crawl/bootstrap
持久化行为:
- 每个 seed URL 会 upsert 到
blogs,并标记accepted_by=seed - 当
seeds表为空时,会从seed.csv读取非空 URL,并同步 upsert 到seeds表,记录原始 URL、规范化 URL、domain、关联blog_id、来源 CSV 路径与 CSV 数据行号 - 当
seeds表不为空时,导入动作直接 replayseeds表记录到blogs,不会读取seed.csv seeds.normalized_url唯一;重复导入同一个 seed 会刷新记录,不会创建重复 seed 行- 管理员数据库 reset 会保留
seeds表数据,只清空其旧blog_id关联;下一次导入会重新把 seed 行关联到新建或复用的 blog
响应字段:
seed_path: 配置的种子 CSV 文件路径;当seeds表不为空时,该字段仅表示 fallback CSV 路径imported: 新导入的 blog 数量
用途:执行一次同步爬取批次。
查询参数:
max_nodes: 可选,本次最多处理多少个 blog
调用链:
backend调crawler /internal/crawl/run- 执行成功后,
backend尝试调用search /internal/search/reindex - 即使重建索引失败,也不会让本次 crawl 请求失败
典型响应:
{
"processed": 3,
"discovered": 12,
"failed": 0,
"exports": {
"nodes_csv": "...",
"edges_csv": "...",
"graph_json": "..."
}
}返回字段语义:
processed: 本次实际处理的 blog 数discovered: 本次发现并入库的新链接总数failed: 本次处理失败的 blog 数exports: 导出文件信息
用途:重置数据库中的 crawler 相关数据,便于测试和开发时快速回到初始状态。
行为说明:
- 仅允许在 crawler 运行器不处于
starting/running/stopping时调用 - 若运行器忙碌,返回
409,错误详情为crawler_busy - 会清空
blogs、edges、raw_discovered_urls - 不会删除 users、sessions、seeds、人工 label、recommendation 事件等其它表;
seeds.blog_id会置空以解除到blogs的引用 - backend 在数据库重置后会尝试调用
search /internal/search/reindex - 即使 search 重建失败,数据库重置结果仍会返回,并附带
search_reindexed=false
成功响应示例:
{
"ok": true,
"blogs_deleted": 12,
"edges_deleted": 34,
"raw_discovered_urls_deleted": 56,
"logs_deleted": 0,
"search_reindexed": true,
"search": {
"blogs": 0,
"edges": 0,
"logs": 0,
"cache_path": "..."
}
}用途:查看 crawler 运行时完整快照。
结构见“数据模型”中的 RuntimeSnapshot。
补充字段:
maintenance_in_progress: backend 当前是否处于管理员维护窗口;为true时新的 runtime 启动与批处理请求会被拒绝
用途:查看当前正在执行的 blog 简要信息。
相比 /api/admin/runtime/status,它仍聚焦“当前任务”,但现在会保留 worker 视角的摘要,方便 UI 直接渲染当前活跃 worker 列表。
返回字段:
runner_statusactive_run_idworker_countactive_workerscurrent_worker_idcurrent_blog_idcurrent_urlcurrent_stagetask_started_atelapsed_secondslast_started_atlast_stopped_atlast_errorlast_resultworkers
用途:启动后台持续运行的 crawler 循环。
行为说明:
- 若当前已在
starting/running/stopping,直接返回当前快照 - 成功启动后会创建新的
active_run_id
用途:请求后台 crawler 在安全点停止。
行为说明:
- 若当前已是
idle,直接返回当前快照 - 否则将状态切到
stopping
用途:在运行器空闲时同步执行一批 crawl 任务。
补充说明:
请求体:
{
"max_nodes": 10
}响应分两类:
- 运行器忙碌时:
{
"accepted": false,
"reason": "runtime_busy",
"runtime": {}
}- 成功执行时:
{
"accepted": true,
"mode": "batch",
"result": {},
"runtime": {}
}补充说明:
- backend 在 batch 完成后也会尝试重建 search 索引
- search 重建失败不会影响主流程返回
源码位置: crawler/main.py
基础信息:
- 服务名:
HeyBlog Crawler Service - 默认端口:
8010
接口列表:
返回:
{
"status": "ok"
}用途:导入种子数据。该流程优先 replay seeds 表到 blogs;只有 seeds 表为空时才读取 seed CSV 并同步维护 blogs 与 seeds。
实际执行:CrawlPipeline.bootstrap_seeds()
用途:同步执行一次爬取。
查询参数:
max_nodes: 可选
实际执行:CrawlPipeline.run_once(max_nodes=max_nodes)
用途:返回运行时完整快照。
用途:返回当前任务摘要。
用途:启动后台循环。
用途:请求停止后台循环。
用途:执行同步 batch。
请求体:
{
"max_nodes": 10
}源码位置: search/main.py
基础信息:
- 服务名:
HeyBlog Search Service - 默认端口:
8020
接口列表:
返回:
{
"status": "ok"
}用途:搜索缓存索引;缓存不存在或为空时回退到 persistence 快照。
查询参数:
q: 搜索词
返回结构:
querykindlimitblogsedgeslogs
补充说明:
kind的合法值为all、blogs、relationsedges结果会附带from_blog与to_blog摘要,便于上游直接渲染关系线索
用途:重建搜索缓存文件。
返回字段:
blogs: 索引内 blog 数edges: 索引内 edge 数logs: 索引内 log 数cache_path: 索引缓存文件路径
源码位置: persistence_api/main.py
基础信息:
- 服务名:
HeyBlog Persistence Service - 默认端口:
8030
接口列表:
返回:
{
"status": "ok"
}用途:为 backend 提供分页 blog catalog 查询。
查询参数与返回 envelope 与 GET /api/blogs/catalog 一致。
补充说明:
- 归一化逻辑在 persistence 层统一处理,SQLite 与 PostgreSQL 共享同一套分页/筛选规则
- 支持
sort、has_title、has_icon、min_connections等发现型参数 - 支持
statuses多状态过滤与id_asc排序,供统一 discovery 队列视图使用 - blog 行数据会直接带上连接度、活跃度和身份完整度等派生字段
用途:为 backend 创建随机博客推荐批次,并写入 recommendation_requests 与 recommendation_impressions;曝光表以 normalized_url 持久归因,不保存 blog_id。
请求体字段与 POST /api/recommendations/random-blog-batches 一致,额外允许 backend 传入已解析的 user_id。
用途:为 backend 写入幂等推荐交互事件,数据落到 blog_interactions,并以 normalized_url 持久归因。
请求体字段与 POST /api/recommendation-events 一致,额外允许 backend 传入已解析的 user_id。其中 entrance_kind 与 entrance_url 仍为必填字段,persistence-api 会清洗长度并写入 blog_interactions.entrance_kind / blog_interactions.entrance_url。
用途:返回单个博客的推荐曝光、点击/详情打开、标注选择、独立访客和 CTR 统计。
返回结构与 GET /api/blogs/{blog_id}/stats 一致。
用途:返回 strategy/surface/version 维度的推荐请求、曝光、交互和 CTR 汇总。
返回结构与 GET /api/admin/recommendation-stats 一致。
用途:为 backend 提供数据库权威的博客 URL 存在性查询。
补充说明:
- 返回薄 lookup payload,而不是 catalog 分页 envelope
- 命中顺序固定为
identity_key -> normalized_url -> empty match_reason只允许identity_key、normalized_url或null
用途:为 crawler 在遇到重复 raw URL 时解析已存在的目标 blog id。
响应:
{
"id": 1
}补充说明:
- 未找到已接受 blog 时返回
{ "id": null } - 该接口不改变 raw URL 去重语义;crawler 仍可把重复 URL 标记为
rule:duplicate_url,但会用这里返回的 id 补写新的源博客到目标博客的边 - 主要用于保留 A->C 已存在后,B 后续发现 C 时的 B->C 关系
用途:取出下一个待处理 blog,并立即将其状态更新为 PROCESSING。
行为说明:
- 只从
crawl_status = 'WAITING'中选择 - 选中后立刻更新为
PROCESSING
用途:按 id 查询单个 blog,并聚合详情页所需的 incoming_edges / outgoing_edges 与邻居摘要。
用途:插入 blog,若 normalized_url 已存在则直接返回已有 id。
请求体:
{
"url": "https://example.com/",
"normalized_url": "https://example.com/",
"domain": "example.com",
"email": "owner@example.com"
}其中 email 为可选字段。
响应:
{
"id": 1,
"inserted": true
}补充说明:
- repository 会优先按
normalized_url与identity_key复用已有 blog。 - 对满足“tenant-like homepage 子域”启发式的 URL,入库时会直接把 blog URL /
normalized_url规范化为 registrable root 的 canonical URL;例如zhuruilei.66law.cn会收敛为https://66law.cn/。像*.github.io、*.gitee.io这类显式排除的共享托管域名不受该规则影响。
用途:回写单个 blog 的抓取结果。
请求体:
{
"crawl_status": "FINISHED",
"status_code": 200,
"friend_links_count": 12
}响应:
{
"ok": true
}用途:供 backend admin API 调用,把所有失败 blog 重新入队。
响应:
{
"requeued": 733
}用途:插入一条边,若 (from_blog_id, to_blog_id) 已存在则忽略。
请求体:
{
"from_blog_id": 1,
"to_blog_id": 2,
"link_url_raw": "https://example.com/",
"link_text": "友情链接"
}响应:
{
"ok": true
}用途:legacy 兼容入口。当前实现会接收该请求并返回成功,但不会再把 crawl log 写入业务数据库;运行日志请查看统一日志目录。
请求体:
{
"blog_id": 1,
"stage": "crawl",
"result": "success",
"message": "Crawled https://example.com/"
}响应:
{
"ok": true
}{
"ok": true
}用途:返回聚合统计。
返回字段:
total_blogstotal_edgesaverage_friend_linksstatus_countspending_tasksprocessing_tasksfailed_tasksfinished_tasks
用途:返回结构化初始子图。
查询参数与公共 GET /api/graph/views/core 一致。
用途:返回单节点邻域扩展结果。
补充说明:
- 当前只支持
1或2跳扩展 - 当目标 blog 不在当前已完成图谱快照中时,返回
404 graph_node_not_found
用途:返回最新 snapshot manifest。
用途:返回指定版本 snapshot payload。
用途:返回当前 persistence 图读后端的 readiness 信息,用于 rollout、shadow parity 与故障排查。
响应示例:
{
"graph_backend": "legacy",
"configured_graph_backend": "age",
"age_enabled": false,
"age_sync_state": "not_configured",
"parity_status": "unknown",
"latest_snapshot_namespace": "legacy",
"latest_snapshot_manifest": "graph-layout-latest.legacy.json",
"age_graph_name": "heyblog_graph",
"last_error": null
}说明:
graph_backend表示当前真正对外提供 graph read 的后端;当前 rollout 默认仍为legacyconfigured_graph_backend表示配置层声明的目标后端;在 shadow 阶段它可以与graph_backend不同
用途:显式触发 AGE shadow graph 重建。该动作与普通 graph read 解耦,不会在 /internal/graph/views/core 或邻域读取时隐式触发。
用途:为 search 服务提供全量搜索快照。
响应:
{
"blogs": [],
"edges": [],
"logs": []
}补充说明:
- 其中
logs固定为空数组,用于保持 search 快照结构兼容
用途:重置 persistence 层中的 crawler 数据。
行为说明:
- 清空
blogs、edges、raw_discovered_urls - 不删除其它表;
seeds.blog_id会置空以解除到blogs的引用 logs_deleted固定返回0
响应:
{
"ok": true,
"blogs_deleted": 12,
"edges_deleted": 34,
"raw_discovered_urls_deleted": 56,
"logs_deleted": 0
}以下字段来自当前仓库实现与前端类型定义,适合作为现阶段统一理解口径。
来源:
字段:
| 字段 | 类型 | 说明 |
|---|---|---|
id |
number |
blog 主键 |
url |
string |
原始 URL |
normalized_url |
string |
归一化 URL,用于抓取与展示 |
identity_key |
string |
blog 身份键,例如 site:langhai.cc/ |
identity_reason_codes |
string[] |
当前 identity 解析命中的原因码 |
identity_ruleset_version |
string |
解析该 identity 时使用的规则版本 |
domain |
string |
域名 |
email |
string | null |
博主联系邮箱;仅在用户自助优先录入时写入,默认 null |
title |
string | null |
站点主页解析出的 <title>,缺失时为 null |
icon_url |
string | null |
站点标签页 icon URL;仅在 crawler 从页面 metadata 提取并验证可访问后持久化,缺失或验证失败时为 null。前端可使用第三方 favicon API 做展示兜底,但不回写该字段 |
status_code |
number | null |
最近抓取 HTTP 状态码 |
acceptance_status |
string |
博客接受状态,当前主要使用 ACCEPTED 与 UNKNOWN;该字段决定“是否被确认为博客” |
accepted_by |
string | null |
接受来源,例如 seed、rss、model |
accepted_at |
string | null |
URL 被确认为博客的时间 |
crawl_error_kind |
string | null |
最近一次抓取失败分类,例如 timeout、page_too_large、http_status |
crawl_error_message |
string | null |
最近一次抓取失败详情摘要 |
last_crawl_attempt_at |
string | null |
最近一次抓取尝试时间 |
successful_crawl_at |
string | null |
最近一次成功完成抓取时间 |
crawl_status |
string |
当前抓取状态,常见值有 WAITING PROCESSING FAILED FINISHED |
friend_links_count |
number |
最近一次抓取发现的友链数 |
last_crawled_at |
string | null |
最近抓取时间 |
created_at |
string |
创建时间 |
updated_at |
string |
更新时间 |
incoming_count |
number |
指向该博客的边数 |
outgoing_count |
number |
该博客指向外部的边数 |
connection_count |
number |
incoming_count + outgoing_count |
activity_at |
string | null |
用于发现排序的活跃时间,优先取 last_crawled_at,否则回退到 updated_at |
identity_complete |
boolean |
当前是否同时具备非空 title 与 icon_url |
来源:
字段:
| 字段 | 类型 | 说明 |
|---|---|---|
items |
BlogRecord[] |
当前页 blog 列表 |
page |
number |
当前实际页码;超出范围时可能回退到最后一页 |
page_size |
number |
当前实际每页大小 |
total_items |
number |
满足筛选条件的总记录数 |
total_pages |
number |
总页数;无结果时为 0 |
has_next |
boolean |
是否存在下一页 |
has_prev |
boolean |
是否存在上一页 |
filters.q |
string | null |
通用搜索关键词,匹配 title / domain / url |
filters.site |
string | null |
站点筛选关键词,匹配 title / domain |
filters.url |
string | null |
URL 筛选关键词,匹配 url / normalized_url |
filters.status |
string | null |
状态筛选值 |
filters.sort |
string |
当前生效排序 |
filters.has_title |
boolean | null |
是否要求存在标题 |
filters.has_icon |
boolean | null |
是否要求存在 icon |
filters.min_connections |
number |
最小连接度阈值 |
sort |
string |
当前生效排序;与 filters.sort 保持一致 |
字段:
| 字段 | 类型 | 说明 |
|---|---|---|
...BlogRecord |
BlogRecord |
详情页主博客信息 |
incoming_edges |
BlogRelationRecord[] |
指向当前博客的关系列表 |
outgoing_edges |
BlogRelationRecord[] |
当前博客指向外部的关系列表 |
recommended_blogs |
BlogRecommendationRecord[] |
“朋友的朋友”推荐列表 |
discovery_path |
BlogDiscoveryPath |
发现路径摘要,从源头博客到当前博客的有序步骤 |
relation_graphs |
{ incoming, outgoing } |
两层入链/出链关系图,供详情页“博客关联”模块展示;两层深度内不按节点或边数量裁剪 |
其中:
BlogRelationRecord = EdgeRecord + { neighbor_blog: BlogNeighborSummary \| null }BlogRecommendationRecord = { blog, reason, mutual_connection_count, via_blogs }BlogNeighborSummary字段为id、domain、title、icon_urlBlogDiscoveryPath = { mode, origin_source, origin_label, target_source, truncated, steps },其中truncated为历史兼容字段,当前始终为falseBlogDiscoveryStep包含blog邻居摘要、blog_id、url、domain、accepted_by、accepted_label、raw_id、raw_source_blog_id、raw_accepted_by、discovered_atBlogRelationGraph = { direction, focus_blog_id, depth, nodes, edges },其中direction为incoming或outgoing,depth默认是2
字段:
| 字段 | 类型 | 说明 |
|---|---|---|
id |
number |
edge 主键 |
from_blog_id |
number |
起点 blog id |
to_blog_id |
number |
终点 blog id |
link_url_raw |
string |
页面中抽取到的原始链接 |
link_text |
string | null |
链接文本 |
discovered_at |
string |
发现时间 |
字段:
| 字段 | 类型 | 说明 |
|---|---|---|
query |
string |
原始查询词 |
kind |
"all" | "blogs" | "relations" |
当前搜索范围 |
limit |
number |
当前生效返回上限 |
blogs |
BlogRecord[] |
博客搜索结果 |
edges |
SearchEdgeRecord[] |
关系搜索结果 |
logs |
LogRecord[] |
当前恒为空数组 |
其中 SearchEdgeRecord = EdgeRecord + { from_blog: BlogNeighborSummary \| null, to_blog: BlogNeighborSummary \| null }。
来源: crawler/contracts/runtime.py 与 crawler/runtime/service.py
字段:
| 字段 | 类型 | 说明 |
|---|---|---|
runner_status |
string |
运行器状态,常见值有 idle starting running stopping error |
maintenance_in_progress |
boolean | null |
backend 维护锁状态;存在且为 true 时表示管理员规则重扫正在进行 |
active_run_id |
string | null |
当前运行 ID |
worker_count |
number |
当前 runtime 配置的 worker 数量 |
active_workers |
number |
当前仍持有 blog 任务、尚未完成收尾的 worker 数量;在 stopping 期间也会计入 |
current_worker_id |
string | null |
当前代表 worker 标识,优先选择活跃 worker |
current_blog_id |
number | null |
当前处理 blog id |
current_url |
string | null |
当前处理 URL |
current_stage |
string | null |
当前阶段,如 crawling completed error |
task_started_at |
string | null |
当前代表 worker 的任务开始时间 |
elapsed_seconds |
number | null |
当前代表 worker 的任务已耗时秒数 |
last_started_at |
string | null |
最近启动时间 |
last_stopped_at |
string | null |
最近停止时间 |
last_error |
string | null |
最近错误 |
last_result |
object | null |
最近执行结果 |
workers |
RuntimeWorkerSnapshot[] |
各 worker 的运行快照列表 |
字段:
| 字段 | 类型 | 说明 |
|---|---|---|
worker_id |
string |
worker 标识,如 worker-1 |
worker_index |
number |
worker 序号,1-based |
status |
string |
worker 状态,如 idle waiting running completed error stopping |
current_blog_id |
number | null |
当前处理 blog id |
current_url |
string | null |
当前处理 URL |
current_stage |
string | null |
当前阶段,如 crawling completed waiting_for_work |
task_started_at |
string | null |
当前任务开始时间 |
last_transition_at |
string | null |
最近一次状态迁移时间 |
last_completed_at |
string | null |
最近一次完成时间 |
last_error |
string | null |
最近错误 |
processed |
number |
当前 run 内已处理 blog 数 |
discovered |
number |
当前 run 内已发现 blog 数 |
failed |
number |
当前 run 内失败 blog 数 |
elapsed_seconds |
number | null |
当前任务已耗时秒数;worker 空闲时为 null |
字段:
| 字段 | 类型 | 说明 |
|---|---|---|
total_blogs |
number |
blog 总数 |
total_edges |
number |
edge 总数 |
average_friend_links |
number |
平均友链数 |
status_counts |
Record<string, number> |
各状态计数 |
pending_tasks |
number |
WAITING 数量 |
processing_tasks |
number |
PROCESSING 数量 |
failed_tasks |
number |
FAILED 数量 |
finished_tasks |
number |
FINISHED 数量 |
- 前端 ->
backend /api/* backend->persistence-api获取 blog catalog、blog detail、graph views、graph snapshots 与 statsbackend->crawler获取运行时状态
- 管理员前端/调用方 ->
POST /api/admin/crawl/bootstrap backend->crawler /internal/crawl/bootstrapcrawler->persistence-api /internal/seeds检查是否已有持久化 seed- 若已有 seed:
crawlerreplayseeds表到blogs - 若没有 seed:
crawler读取seed.csv,再通过persistence-api /internal/blogs/upsert同时写入/刷新blogs与seeds crawler-> 结构化日志管线
- 管理员前端/调用方 ->
POST /api/admin/crawl/run backend->crawler /internal/crawl/runcrawler->persistence-api /internal/queue/nextcrawler抓取与解析页面crawler->persistence-api /internal/blogs/upsertcrawler->persistence-api /internal/edgescrawler->persistence-api /internal/blogs/{id}/resultcrawler-> 结构化日志管线backend->search /internal/search/reindex(尽力而为)
- 管理员前端/调用方 ->
POST /api/admin/runtime/run-batch backend->crawler /internal/runtime/run-batch- batch 完成后
backend尝试触发 search reindex
基于当前实现,现阶段可以先按下面的口径做统筹:
- 对外协议以
backend /api/*为准,前端不要直接依赖内部服务接口 - 内部服务接口已经比较清晰,但目前没有统一版本号,也没有显式 OpenAPI schema 文档归档
- legacy 的 raw blog/edge/graph/log/search 公共读取端点已经移除,当前对外建议继续围绕 catalog、detail、graph view、user seed 和 admin runtime 组织能力
/api/admin/crawl/run使用 query 参数max_nodes,而/api/admin/runtime/run-batch使用 JSON bodymax_nodes,风格不完全一致,后续可统一search当前是轻量缓存式实现,属于可重建索引,不是强一致检索服务services/*只是兼容入口,后续文档与新开发应优先引用顶层目录backend/、crawler/、search/、persistence_api/
- 后端聚合服务: backend/main.py
- 爬虫服务: crawler/main.py
- 运行时控制: crawler/runtime/service.py
- 爬虫主流程: crawler/crawling/pipeline.py
- 搜索服务: search/main.py
- 持久化服务: persistence_api/main.py
- 仓储实现: persistence_api/repository.py
- 数据库 schema: persistence_api/schema.py
- 前端 API 类型: frontend/src/lib/api.ts