# dsh-auto-email — JS 移植契约 ## 0. 这是什么 `dsh-auto-email` 是一个 Python 参考实现的**原生 Node 重写版**,封装成 DSH Desktop 插件。 > 那份 Python 参考实现**不随本仓库发布**(它含作者本机的专属配置)。下文出现「Python 参考实现」时指的就是它;本文件记录的是它定下的行为约定,读者只依本文件与 `lib/` 里的注释即可理解全部规则。 **Python 版是行为基准,不是灵感来源。** 算法、阈值、正则、LLM prompt 原文、邮件文案措辞、emoji,全部逐条对齐。移植工作不是重新设计;看到 Python 里任何「看起来可以改进」的地方,保持原样并在注释里记一句 `// parity: <为什么原版这么做>`。 ## 1. 硬性约束 1. **零运行时依赖。** 只允许 `node:` 内置模块(`node:fs`、`node:path`、`node:crypto`、`node:url`…)与全局 `fetch` / `AbortSignal` / `TextDecoder`。**不得**出现 `rss-parser`、`nodemailer`、`cheerio`、`xml2js`、`axios`、`node-fetch` 等任何包,`package.json` 里也不许加 `dependencies`。 2. **ESM。** 用 `import` / `export`。不写 `"use strict"`,不写 `require`。 3. **模块顶层零副作用。** 不许在顶层读文件、发请求、`process.exit`、`console.log`。需要日志就接收调用方注入的 `log` 函数。 4. **风格:** 2 空格缩进、单引号、行尾不留空格、文件末尾一个换行。中文文案原样保留(含 emoji 与全角标点)。 5. **每个 `export` 都要有 JSDoc**:一句话说清做什么,`@param` / `@returns`。内部函数在逻辑不显然处写一行注释。 6. **不要发明数据结构。** 所有条目一律用 `lib/item.js` 的 `createItem()`(见 `lib/item.js:1`,已是最终形态,不要改)。 7. **时间基准。** 沿用 Python 里的 `CST = UTC+8` 语义:窗口比较用 UTC 瞬时,展示用 `Asia/Shanghai`。不要依赖运行机器的本地时区。 ## 2. 共享数据结构 `createItem()` 的字段与语义见 `lib/item.js`。要点: - `published` 是 `Date` 或 `null`(**绝不**用 `Invalid Date`)。 - `extra` 是自由袋,已知键:`category`、`event`、`headline`、`budget`、`aged`、`event_merged`。 - `llm_importance === 0` / `llm_novelty === 0` / `llm_topic === ''` 表示「还没经过 LLM。」所有下游判断必须先看这个哨兵值。 - `cluster_sources` 存的是**带层级后缀的显示串**(`"量子位(T2)"`),不是裸信源 key。这是刻意的:徽章要如实标注层级。 - `cluster_best` 是集群中层级最高成员的信源名 —— 文章真正的出处。 配置对象 `cfg` 沿用 Python 的键名,这样两边可以互相 diff: ```js cfg = { llm: { base_url, api_key, model }, digest: { top_n, window_hours, fallback_window_hours, min_importance, max_candidates_for_ranking, arxiv_max_items, hn_max_items, send_when_empty }, email: { /* 只给 mail.js 用,digest.js 不碰 */ }, } ``` ## 3. 子任务 A —— `lib/sources.js` 对照 `sources.py` 逐函数移植。**导出面(JS 一律用对象参数):** ```js export const UA // 同 Python 的 UA 串 export const HEADERS // { 'User-Agent': UA, ... },同 Python export function clean_text(s, limit = 900) -> string export function canonical_url(u) -> string export function parse_date(s) -> Date | null export async function httpGet(url, { tries = 3, timeout = 25, headers } = {}) -> string // 返回响应体文本 export const SOURCES // 10 个信源描述对象,字段与 Python 的 SOURCES 一一对应 export const AI_RE // 只用于 simonw 的 AI 关键词过滤 export const ARXIV_QUERY // 同 Python 的模板串 export function setLogger(fn) // fetch_rss 内部要打日志时用;未设置则静默 export async function fetch_rss(src, { max_items = 60 } = {}) -> Item[] export async function fetch_arxiv(src, { max_items = 22 } = {}) -> Item[] export async function fetch_anthropic(src, { max_items = 12 } = {}) -> Item[] export async function fetch_hn(src, { max_items = 30, window_hours = 30 } = {}) -> Item[] export async function fetch_meta_description(url, { timeout = 20 } = {}) -> string export async function fetch_all({ window_hours = 26, arxiv_max = 22, hn_max = 30, retry_delay = 20, log = () => {} } = {}) -> { items: Item[], errors: string[] } ``` **移植时必须保住的细节**(都在 Python 里,别漏): - `_get` / `httpGet`:指数退避;**专门处理 HTTP 429** —— 读 `Retry-After` 头,不是数字就退避 `8.0 * (i + 1)` 秒,clamp 到 3–60 秒后**重试**;对 403/404/410 **立即放弃**(确定性失败重试无意义)。HTTP 失败要抛错,错误信息里保留状态码。 - `fetch_rss`:支持 RSS 2.0 / RSS 1.0(RDF) / Atom 三种。`src.filter_ai` 为真时按 `AI_RE` 过滤(simonw 用的)。 - `fetch_arxiv`:用 `ARXIV_QUERY` 模板,`max(max_items * 2, 40)` 条请求,tries=4、timeout=35。 - `fetch_anthropic`:**没有官方 RSS**,抓 `https://www.anthropic.com/news` 页面正则解析。页面上有**两种卡片布局**:FeaturedGrid 用 `

`,PublicationList 用 ``。标题正则要按 Python 的 `_TITLE_PATS` 顺序兜底(h2 → h3 → `__title` span → div),日期用 `