# 信源 信源在后台“信源”页管理:新建、试抓一次看看抓到什么、改频率、启停、看失败原因和最近的条目。首次启动时,`industry/sources.json` 里的示范信源会被导入。 ## 六种信源 | 类型 | 适合 | 需要 | |---|---|---| | `rss` | 有 RSS / Atom 的博客、媒体、Substack、公众号转 RSS 服务 | 无 | | `web_list` | 没有 RSS 的网页列表(新闻页、博客列表、更新日志) | 写选择器;按需配置 Jina Reader 渲染(按次计费) | | `json_list` | 返回 JSON 的接口(GitHub Releases 等) | 写字段路径 | | `x_search` | X(推特)账号 | SocialData 的 key,按请求计费 | | `mp_account` | 微信公众号 | 极致了(Dajiala)的 key,按请求计费 | | `external` | 你自己的脚本推送进来的内容 | `INGEST_TOKEN`,见下文 | `json_list` 的地址或配置可能携带凭据,只跟随同源重定向(协议、主机和端口都相同)。若接口搬到另一个来源,请直接更新信源地址;不要依赖跨源跳转传递认证信息。 每种信源认哪些配置项写在 [`config-keys.ts`](../packages/backend/src/sources/config-keys.ts)。填了不认识的配置项,保存会被拒绝、抓取会直接失败并在后台显示原因,不会悄悄退回通用解析。 ## 先预览,再创建 进入 `/admin/sources/new`,填写 ID、名称,选择类型,再把[下一节](#每种信源怎么配置)对应的 JSON 填入“采集配置(JSON)”。这里填的是配置对象,不要包上 `kind` 或 `config`。切换类型会重置配置,先选类型再粘贴。 名称里全角括号中的备注(比如“某媒体(热点 RSS)”)只给后台看,读者在网页、RSS、Agent Markdown、MCP 和搜索里看到的是去掉备注的名字;X 账号写成 `X:显示名 (@handle)` 的,读者只看到显示名。公开 API 的 JSON 里仍是完整的名字。 `rss`、`web_list`、`json_list`、`x_search` 可以点“预览抓取”:显示条目总数和前 20 条的标题、原文链接、发布时间、摘要,不把条目存入文章库。检查抓到的是文章而不是导航,日期与原文一致,再点“创建”。预览和正式采集套用同一组过滤(地址前缀、分类、噪声词、地址改写、`publishedAfter`),看到的就是正式采集会收的条目;但它不等于完成生产采集,也不经过后续详情补齐、精选和公开发布流程。 预览仍会发出抓取请求;X 和 Jina 预览也可能产生费用,经过付费回执和预算。`COLLECT_ENABLED=false` 只关闭后台自动采集,不能用它来保证手动预览不访问外部服务。下面的本地 HTML/JSON 示例不需要任何 API key。 ## 每种信源怎么配置 ### rss ```json { "feedUrl": "https://example.com/feed.xml" } ``` 可选:`summaryIsBody`(确认文字订阅里的摘要就是全文时,将 RSS 的 `description`、Atom 的 `summary` 当正文,不再去抓原文页)、`allowCategories` / `denyCategories`(按订阅里的分类过滤)。 所有信源的 YouTube、Vimeo 视频播放页都不作为文章正文提取。Media RSS 的视频描述保留为摘要,即使设置了 `summaryIsBody` 也不会把描述当成视频字幕;订阅通过 `content` / `content:encoded` 提供的真实文字仍可使用。其他文章中嵌入视频,不影响文章本身的正文读取。 没有网页的播客单集(没有 ``、`guid` 也不是网址),原文链接用它的音频或视频文件,浏览器可以直接播放;这类单集不抓网页,按订阅里的文字处理。 ### web_list 支持普通 CSS 选择器,`div` 列表也能采集。关键是 `itemSelector` 要选中**每条新闻**,而不是包住所有新闻的容器。例如: ```html
``` 对应配置(`example.com` 是占位地址,换成目标网页;可运行版本见[本地示例](#本地跑通-htmljson-示例)): ```json { "url": "https://example.com/news", "parseMode": "html", "itemSelector": ".news-list > .news-item", "linkSelector": "h2 a", "titleSelector": "h2 a", "publishedAtSelector": "time", "allowUrlPrefixes": ["https://example.com/posts/"] } ``` - `itemSelector` 在整个页面找条目;`linkSelector`、`titleSelector` 在每个条目内取第一个匹配节点,也可以匹配条目自身。选 `.news-list` 只会得到一个容器,通常只取到第一条新闻;只写 `div` 又会混入嵌套容器。要选重复出现的新闻节点。 - 链接取自 `href`;`/posts/first` 等相对链接按列表重定向后的最终地址解析,也可用 `baseUrl` 指定基准地址。标题取节点文字。重复链接会合并;Markdown 列表先出现“Read more”等按钮、后出现真实标题时,使用真实标题。指向列表自身的链接通常会跳过。 - 日期在条目内查找 `publishedAtSelector`,依次尝试 `datetime`、`content`、`title` 属性和文字,无法解析的提示文字不会遮住同一节点的有效日期。没有时区的日期时间可用 `publishedAtUtcOffset`(默认 `+08:00`);自带时区的时间保留原时区语义,纯 `YYYY-MM-DD` 按 UTC 零点读。不完整的月份、产品版本号和不存在的日历日期不作为发布日期。 - `parseMode`:普通网页默认 `html`;`markdown` 按 Markdown 链接读;`docusaurus_changelog` 读更新日志标题;`intercom_changelog` 按 Intercom 帮助中心的日期章节读取标题和正文,每节保留自己的网址锚点,默认按 UTC 日历日读取,也可指定 `publishedAtUtcOffset`。需要 Jina 时,显式把 `url` 写成 `https://r.jina.ai/https://目标站/路径` 并配置 `JINA_API_KEY`,不是抓不到就自动切换。Jina 默认返回 Markdown;要继续使用 CSS 选择器,显式设 `parseMode: "html"`。 - `detail`:列表缺日期、标题或摘要时抓详情页补齐(`publishedAtSelector`、`titleSelector`、`summarySelector` 等)。读取失败或本轮 `maxFetches` 用完的条目,后续抓取继续补全;补元数据时保留已确认的正文。已有真实标题不会因为列表仍写“Read the Blog”而被换回按钮文字,也不会仅因标题较长再次读取详情。需要强制使用详情标题时设置 `titleAuthoritative: true`。详情页和正文提取读到不带时区的时间时,先用 `detail.publishedAtUtcOffset`,未设则继承信源的 `publishedAtUtcOffset`,两处都未设时默认 `+08:00`。RSS、JSON 列表也可以用 `detail`。 - Jina 列表的 `detail.titleRegex`、`publishedAtRegex` 匹配 Jina 文本,可共用同一次详情响应;CSS 选择器读取原站 HTML。应按实际可读取的页面选择规则。 - `allowUrlPrefixes` / `denyUrlPrefixes`:只收某些路径下的文章。 ### json_list 假设接口返回下面的结构,`itemsPath` 指向数组,其他字段路径相对**每个数组元素**填写。路径用点分隔,不是 JSONPath,不写 `$` 或 `[*]`。 ```json { "data": { "items": [ { "id": "first", "title": "第一条示例新闻", "url": "https://example.com/posts/first", "summary": "第一条新闻的摘要。", "published_at": "2026-10-01T09:00:00+08:00" } ] } } ``` 对应配置: ```json { "url": "https://example.com/api/news", "mode": "json_api", "itemsPath": "data.items", "titlePaths": ["title"], "urlTemplate": "{raw:url}", "summaryPaths": ["summary"], "publishedAtPath": "published_at", "externalIdPath": "id" } ``` - 接口本身返回数组时,省略 `itemsPath`。`titlePaths`、`summaryPaths`、`authorPaths` 是候选路径数组,按顺序取第一个非空值,例如 `["title", "name"]`。 - 已有完整网址时用 `{raw:url}`;只有 slug 时可用 `https://example.com/posts/{slug}`。`{字段路径}` 会编码字段值,`{raw:字段路径}` 原样插入。JSON 列表不会自动把相对网址补成绝对网址,模板应产出完整的 HTTP(S) 地址。 - `summaryIsBody`:确认接口里的摘要就是全文时设为 `true`,把 `summaryPaths` 取到的文字当正文,不再去抓原文页。 - 日期建议返回带时区的 ISO 字符串;`2026-09-30 17:43:58` 这类不带时区的时间,与网页列表一样按 `publishedAtUtcOffset` 读(默认 `+08:00`);数字时间戳分别设 `publishedAtUnit: "epoch_s"`(秒)或 `"epoch_ms"`(毫秒),`20261001` 这类日期设 `"yyyymmdd"`。 - 缺少标题或无法生成链接的条目会跳过。非空数组全部映射失败时,会报 `no items mapped (check title/url paths)`;路径不是数组时,会报 `items path did not resolve to an array`。 ### 本地跑通 HTML/JSON 示例 仓库提供两份虚构示例:[news.html](examples/sources/news.html) 和 [news.json](examples/sources/news.json),各有两条新闻。它们用于核对选择器和字段映射,不是运营信源;示例文章链接不提供正文。 在仓库根目录、Node.js 24.11 以上运行以下命令。服务只监听本机,只提供这两份文件;用 `Ctrl+C` 停止。 ```bash node --input-type=module -e ' import http from "node:http"; import { readFileSync } from "node:fs"; const files = { "/news.html": ["text/html; charset=utf-8", readFileSync("docs/examples/sources/news.html")], "/news.json": ["application/json", readFileSync("docs/examples/sources/news.json")] }; http.createServer((req, res) => { const file = files[req.url]; res.writeHead(file ? 200 : 404, { "content-type": file ? file[0] : "text/plain" }); res.end(file ? file[1] : "Not found"); }).listen(8787, "127.0.0.1");' ``` 在同一台机器上,按[非 Docker 部署方式](deploy.md#不用-docker)准备好数据库,用 `npm run dev:api`、`npm run dev:web` 运行开发 API 和网页,保持采集、模型、飞书和 IndexNow 开关关闭,不启动 worker。仅为此次本机示例,在开发 API 进程设置 `ALLOW_PRIVATE_NETWORK_FETCH=true` 后重启;默认禁止抓内网地址,生产环境拒绝启用此项,验证后移除。若 API 在容器或另一台服务器,`127.0.0.1` 指向它自己,此命令的地址不能直接用于该部署。 分别选择 `web_list`、`json_list`,粘贴配置并点“预览抓取”,无需创建信源: ```json { "url": "http://127.0.0.1:8787/news.html", "parseMode": "html", "itemSelector": ".news-list > .news-item", "linkSelector": "h2 a", "titleSelector": "h2 a", "publishedAtSelector": "time", "allowUrlPrefixes": ["http://127.0.0.1:8787/posts/"] } ``` ```json { "url": "http://127.0.0.1:8787/news.json", "mode": "json_api", "itemsPath": "data.items", "titlePaths": ["title"], "urlTemplate": "http://127.0.0.1:8787/posts/{id}", "summaryPaths": ["summary"], "publishedAtPath": "published_at", "externalIdPath": "id" } ``` 两次都应显示 **2 条**,依次为下表内容。JSON 示例还显示对应摘要,HTML 示例没有配置摘要提取。预览 API 的日期是 UTC,后台界面按 `+08:00` 显示为 09:00 和 10:00。 | 标题 | 原文链接 | 预览 API 的 publishedAt | |---|---|---| | 第一条示例新闻 | `http://127.0.0.1:8787/posts/first` | `2026-10-01T01:00:00.000Z` | | 第二条示例新闻 | `http://127.0.0.1:8787/posts/second` | `2026-10-01T02:00:00.000Z` | 可以把 HTML 的 `itemSelector` 暂改成 `.news-list` 对照:只会返回第一条。改回 `.news-list > .news-item` 后恢复两条,这就是“列表容器”和“每条新闻”的区别。 抓真实网页时先看原始 HTTP 响应中有没有新闻节点。普通 HTML 模式不执行 JavaScript;浏览器里看得到、响应里没有的内容,不能靠换一个 CSS 选择器生成。优先找 RSS 或 JSON 接口,或按需配置 Jina / 自己维护的外部采集。`no items matched (html)` 还可能是选择器不匹配、没有 `href` 或标题、链接被前缀规则过滤;先核对响应与配置,不必先更换采集器。 ### x_search 这类信源使用 SocialData 搜索,不是把 X 个人主页 URL 填入网页列表。以 `https://x.com/SomeAccount` 为例,取用户名 `SomeAccount`,不要包含 `@` 或整段 URL: ```json { "query": "from:SomeAccount -filter:replies", "searchType": "Latest" } ``` `SomeAccount` 是占位用户名,换成你要关注的真实账号。`-filter:replies` 排除回复;`Latest` 按最新内容查找。 在后端运行环境的 `.env` 配置 `SOCIALDATA_API_KEY`,重启 API(预览)和 worker(定时采集)后生效;不要把 key 写进信源 JSON 或提交到仓库。先在后台“设置 → 付费请求上限”检查 SocialData 额度,再按需预览。未配置 key 会报 `SOCIALDATA_API_KEY is not configured`;服务拒绝请求或预算熔断时看后台错误原因。预览可能有付费请求,本文不要求用真实服务验证,结果取决于账号和服务当时的返回。 生产自动采集时,普通账号会被自动合并成一次搜索(每次最多二十几个账号),省请求数;单个信源的手动预览不是合并采集。 ### mp_account ```json { "ghid": "gh_xxxxxxxx", "nickname": "公众号名称" } ``` 每个公众号按它的抓取间隔检查一次(查列表按次计费),新文章的正文一并取回。 需要在后端 `.env` 配置 `DAJIALA_KEY`,`ghid` 换成公众号原始 ID。配置字段用上面的 `ghid` / `nickname`,不要写成 `biz` / `name`。当前 `mp_account` 不支持“预览抓取”;`external` 也不支持主动试抓,按下方推送接口接入。 ## 分级、参与方式与全文 - **分级** `tier`:`T1` 官方一手(官网、官方博客、机构)、`T1_5` 官方账号与准官方创作者、`T2` 媒体与个人、`EXCLUDE_MP` 不参与精选。入选门槛按分级不同(`industry/selection.ts`)。 - **参与方式** `participation_mode`:`editorial` 进精选和全部动态;`hot_signal` 不单独展示,只作为“大家在讨论什么”的热度证据;`isolated` 不进任何公开页面。 - **一手**:只有 `T1` 算一手,不单独设置。同一条新闻有几篇报道时,代表报道优先选一手的,事件页也优先展示一手报道。 - **发布方**(可选,写在 `config` 里): - `publisherUrlPrefixes`:`T1` 信源自己文章网址的前缀,比如 `["https://example.com/blog/"]`。别的信源(聚合站、转帖)带来的这些网址的文章,只要能认定是唯一一个官方信源的,就改记到它名下。网页列表信源不写时,认它自己列表所在的路径(这篇也要在它的列表里出现过)。 - `publisherRole`:`T1_5` 账号的身份,`organization`(机构官方账号)或 `person`(官方人员)。再填上信源的 `owner_entity_id`(`industry/taxonomy.ts` 的 `ENTITIES` id),这个账号发的、主体正是这家公司的新闻,就能当代表报道(排在 `T1` 之后)。 - **全文**:`site_fulltext` 决定站内能不能显示全文,`syndicate_fulltext` 决定全文 RSS 能不能带正文。两者**默认都关**,只显示摘要和原文链接;来源明确允许时再打开。公众号、付费墙内容不会因为技术上抓得到就获得全文展示。 ## 抓取频率 每个信源有自己的抓取间隔。每天 04:20 会按近 7 天的产出自动调整:产出多的抓得勤,最短 15 分钟(经 Jina 读的网页列表最短 60 分钟);最长的,一般信源 60 分钟,X 账号和经 Jina 读的网页列表 120 分钟,只作热度证据的 180 分钟。合并搜索的 X 账号跟着合并后的节奏:进精选的半小时一次,只作热度证据的一小时一次。 抓取失败不推进位置,下次从同一处继续;连续失败的信源在后台标红,每周一会在运营群发一份信源周报(配置了飞书内部群时)。 ## 规则:旧文不刷屏 首次发现时原文已经发布超过 48 小时的资料、新信源第一次导入的存量条目、标记为回灌的推送,都按原文时间归档:不进入“今天”,也不推送。没有可信发布时间的资料先不公开(不进公开列表、精选、报告、热点和推送),从原信源或原文页读到日期后再判断新旧。这条规则所有入口共用,防止一次性导入历史内容刷屏。 新信源第一次导入时,只取列表里前 `_aihot.initialBackfillLimit` 条(默认 30)、`_aihot.initialBackfillMonths` 个月以内(默认 12)的条目。之后的抓取只收发布时间在信源第一次导入前 48 小时以内或之后的条目,列表里更早的存量不再进来,不会为整个订阅存档付费;第一次导入以后发布的条目,排在长列表第几条都会收。列表上没有发布时间的条目照收,按上面的规则等读到日期再判断。X 账号按抓取位置往后读,不受这条限制。 如果只想从一个固定时间之后开始监控,可在 RSS、网页列表、JSON 配置中设置 `publishedAfter`,例如 `"2026-10-01T00:00:00.000Z"`。必须是完整的 UTC 时间,可省略毫秒;预览、首次和后续采集都只接受**严格晚于**它且发布日期可信的条目,未知日期也会排除。起点当天的单页更新章节若仍使用同一发布日期,之后追加的内容也不会被纳入;选择起点时要把这个限制考虑进去。 ## 外部推送接口 自己写脚本抓的内容,可以推进站里,走和普通采集一样的判重、精选和归组。 ``` POST /api/ingest/items Authorization: Bearer