Ctrl K
ComunidadGuías…

site/site.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 站点身份和读者看得到的文案。换成你的行业时,先改这个文件。
// 网页和后端都读它;改完重新构建(docker compose up --build)即可生效。
// 域名不在这里:部署时用环境变量 SITE_URL 设置。

import type { Founder } from "@aihot/contracts/site-config";

/**
 * 日报、周报、月报什么时候出(北京时间,HH:mm):日报收这个时间之前的 24 小时,周报在每个自然周之后的周一出,
 * 月报在每月 1 日出。排程、成刊时间窗口、缺期告警和所有提到时间的文案都读它(public/ 里的文件写占位
 * {{dailyTime}}、{{weeklyTime}}、{{monthlyTime}});排程每半小时检查一次,所以写整点或半点。
 */
export const EDITION_TIMES = { daily: "08:00", weekly: "10:00", monthly: "10:30" };

/** “每天 08:00”“每周一 10:00”“每月 1 日 10:30”:写进句子里的出刊时间。 */
export const EDITION_WHEN = {
  daily: `每天 ${EDITION_TIMES.daily}`,
  weekly: `每周一 ${EDITION_TIMES.weekly}`,
  monthly: `每月 1 日 ${EDITION_TIMES.monthly}`,
};

export const SITE = {
  /** 站名:导航、页面标题、分享图、RSS、MCP、后台都用它。 */
  name: "MyHOT",
  /**
   * 行业词:拼进默认说法里,比如“AI 日报”“AI 动态”。
   * 改成“法律”“HR”“黄金”之类,页面上就会变成“法律日报”“法律动态”。
   */
  subject: "AI",
  /** 首页的完整标题(浏览器标签、搜索结果)。 */
  homeTitle: "MyHOT — AI 行业动态 · 每日精选与日报",
  /** 主题目录页(/topics)的标题。 */
  topicsTitle: "AI 主题:公司与模型、技术方向、内容形态的最新动态",
  /** 反馈表单输入框里的示例。 */
  feedbackExample: "例如:我在搜索某个关键词时遇到……我原本想……",
  /** 反馈页标题下面的一句话。 */
  feedbackLead: "发现 bug、想要的功能、看不顺眼的地方,都可以告诉我们。",
  /** 反馈表单邮箱框里的提示。 */
  feedbackEmailHint: "留下邮箱,我们可以回信联系你",
  /** 一句话介绍:搜索引擎、分享卡片、RSS、llms.txt 会用。 */
  description: `从一批 AI 信源里挑出值得看的动态,把同一件事的多篇报道归到一起,${EDITION_WHEN.daily} 出一份日报。`,
  /** llms.txt 里一句话介绍下面的一段详细介绍(选填)。 */
  llmsIntro: null as string | null,
  /** 一行小字:分享图、海报下方。 */
  tagline: "值得关注的 AI 动态",
  /** 搜索引擎读到的关键词(首页结构化数据)。 */
  keywords: ["AI 资讯", "AI 新闻", "AI 日报", "AI 行业动态"] as string[],
  /**
   * 搜索引擎站长平台的网站验证(选填):meta 名 → 平台给的验证码,放在首页的 head 里。
   * 比如 "baidu-site-verification": "codeva-…"、"msvalidate.01": "…"。验证通过后也要留着,平台会定期复查。
   */
  siteVerification: {} as Record<string, string>,
  /** 网站开始收录的年份(结构化数据的时间范围,选填)。 */
  since: null as string | null,
  /** 界面语言(HTML lang、og:locale)。 */
  locale: "zh-CN",
  /** 默认域名,只在没设置 SITE_URL 时使用。 */
  defaultUrl: "http://localhost:3000",
  /** 标准图标(favicon.ico、icon.png、icon-192.png、apple-icon.png、logo.svg)以外也放在网站根目录的图标,site/brand/ 里的文件名(选填);manifest.webmanifest 或外站引用了它们时用。 */
  rootIcons: [] as string[],
  /**
   * MCP 工具名的前缀(小写字母、数字、下划线),工具会叫 myhot_get_latest、myhot_search……
   * 已经有人接入后就不要再改。
   */
  mcpPrefix: "myhot",
  /**
   * 公开接口(MCP、OpenAPI、llms.txt)的版本号,只升不降。
   * 改了接口里已有的字段或含义时升主版本,并在部署说明里写清。
   */
  interfaceVersion: "4.0.0",
  /** 对外联系邮箱(选填):llms.txt 和给 Agent 的使用说明里会写。 */
  contactEmail: null as string | null,
  /** 关于页底部的一行小字(选填)。 */
  footerNote: "由 AIHOT 开源框架驱动",
  /** 中国大陆网站的 ICP 备案号(选填),填了就显示在侧栏底部和“我的”页底部,并链接到工信部备案系统。 */
  icp: null as string | null,
  /** 中国大陆网站的公安备案信息(选填),填了显示在侧栏底部和“我的”页底部。 */
  psb: null as string | null,
  /** 源码的 GitHub 仓库地址(选填),填了就在侧栏底部和“我的”页底部显示“GitHub 开源”。 */
  github: null as string | null,
  /** 页脚的“由 X 驱动”(选填):填了就在侧栏底部和“我的”页底部显示,链到它的网址。默认链回上游 AIHOT,不想要就写 null。 */
  poweredBy: { name: "AIHOT", url: "https://aihot.news" } as { name: string; url: string } | null,
  /** 结构化数据里的网站运营者(搜索引擎用)。 */
  organization: {
    name: "MyHOT",
    /** 创始人(选填)。 */
    founder: null as Founder | null,
  },
  /** 抓取信源时报上的名字和版本(User-Agent 里用),不要冒用别的站。 */
  crawlerName: "MyHOTBot/1.0",
} as const;

/** 使用规则和隐私说明两页(正文在 pages/ 里)。 */
export const POLICY = {
  terms: {
    /** 页面名:导航、页脚、页面标题都用它。 */
    name: "使用规则",
    description: "本站网页、RSS、公开 API 与 MCP 的使用规则。",
    /** llms.txt 里对这一页的一句说明(选填)。 */
    covers: null as string | null,
    /** Agent 接入页的 RSS、API 两栏各自提醒的使用规则(选填)。 */
    notes: null as null | { rss: string; api: string },
    /**
     * 讲清哪些用途要先取得授权的话(选填):llms 接在 llms.txt“使用说明”的版权说明后面,
     * agent 写在给 Agent 的使用说明“使用规则”一节的开头。
     */
    license: null as null | { llms: string; agent: string },
    /**
     * 公开 API、RSS 和 OpenAPI 文件声明使用规则的响应头(选填):原样附上,再加一个指向这一页的
     * Link 头(rel="terms-of-service");浏览器里的调用方也读得到它们。
     */
    headers: null as null | Record<string, string>,
  },
  privacy: {
    description: "本站如何处理浏览器本地数据、反馈资料与访问日志。",
    /** llms.txt 里对这一页的一句说明(选填)。 */
    covers: null as string | null,
  },
  /**
   * X 帖子本身的文字和图片算不算全文:算的话,只在这篇允许站内全文时显示(信源允许全文、正文也取到了);
   * 不算的话总是显示,和标题、摘要一样。
   */
  xPostIsFullText: true,
} as const;

/** 导航(电脑侧栏、手机底栏、“我的”页)。 */
export const NAV = {
  /**
   * 不放进导航的引擎入口,目前只支持热点榜("/hot")和主题("/topics")。页面照样能打开;原来回到它的返回按钮和链接改回精选,
   * 写 "/hot" 时首页也不放当前热点。
   */
  hidden: [] as Array<"/hot" | "/topics">,
};

/** 条目卡片和详情页上的几处说法和显示。 */
export const ITEM_COPY = {
  /** 模型写的那句理由叫什么:卡片、详情页、Markdown 导出、给 Agent 的回答和群推送都用它。 */
  reasonLabel: "推荐理由",
  /** 读者在网页和分享图上看不看得到 AI 评分。只管显示:公开 API 和 MCP 的数据照样带 score,后台照常显示。 */
  showScore: true,
};

/** 关于页的一张二维码卡片。 */
interface ContactCard {
  kind: string;
  title: string;
  note: string;
  /** 站外链接过的根目录文件名(选填),比如 qr-wechat.jpg:这个地址总是跳到现在的二维码。 */
  alias?: string;
}

/** 关于页的文案。数字(信源数、收录数、精选数、日报期数)来自站内实时统计,不用写在这里。 */
export const ABOUT = {
  kicker: `关于 ${SITE.name}`,
  /** 页面描述(搜索结果、分享卡片)。 */
  description: `关于 ${SITE.name}:${SITE.description}`,
  /** 大标题:第一行正常颜色,第二行强调色。 */
  headline: ["AI 圈每天都有新动静,", "值得看的,只有几条。"] as [string, string],
  /** 标题下面的一段话。{sources} 会换成实时的信源数(两边自动加空格,所以 {sources} 两边不写空格);统计没取到时换成 sourcesFallback。 */
  lead: `${SITE.name} 替你盯着{sources}个信源:抓取、归并、打分、精选,${EDITION_WHEN.daily} 出一份日报。免费,不用注册。`,
  sourcesFallback: "十几",
  /** 信源河动画下面的四个环节。 */
  steps: {
    collect: "官方博客、媒体和个人的订阅源都在看;更新越勤的源看得越勤,最快 15 分钟看一次。",
    store: "抓到的都存下来,同一件事的报道归到一起,热点榜就是从这里算出来的。",
    select: `模型先看是不是这个行业的事、有没有实际信息,再写中文标题、摘要和${ITEM_COPY.reasonLabel};营销稿和重复转发进不来。`,
    publish: `${EDITION_WHEN.daily} 出日报,${EDITION_WHEN.weekly} 出周报,${EDITION_WHEN.monthly} 出月报。`,
  },
  /**
   * 作者块(选填),null 就不显示。
   * avatarSourceId:一个 X 账号信源的 id,头像取它的(选填)。
   * 二维码在后台“设置”里上传,或者放进 site/brand/contact/;没有二维码就不显示那张卡片。
   */
  maker: null as null | {
    name: string;
    avatarSourceId?: string | null;
    greeting: string[];
    wechat?: ContactCard;
    feishu?: ContactCard;
  },
  /** 页面底部的版权与下架说明,中间接“反馈页”的链接。 */
  copyright: [`${SITE.name} 是聚合摘要和阅读索引,原文版权归各来源所有。如果你是来源方,希望更正、下架或调整展示方式,可以通过`, "联系我们。"] as [string, string],
  /** 页面底部“使用规则”链接的锚点 id(选填):外部文档写死过这个锚点就填上,以后不要改。 */
  termsAnchor: null as string | null,
} as const;

/** 后台页面上给管理员的提示(选填)。 */
export const ADMIN = {
  /** “反馈”页标题下的一行。 */
  feedbackNote: null as string | null,
  /** 确认框里补的一句本站规定:封禁反馈来源时。 */
  banNote: null as string | null,
  /** 确认框里补的一句本站规定:调整付费服务的请求上限时。 */
  budgetNote: null as string | null,
};

/** Agent 接入页的示例。 */
export const AGENT = {
  /** MCP 工具表里“搜索”一行:能搜什么、可以怎么问。 */
  search: { scope: "按公司、产品、人物或话题搜最近 7 天", ask: "这家公司最近发了什么?" },
};

/** 日报、周报、月报版面上的说法。 */
export const REPORTS = {
  /** 报头下面的出版者一行。 */
  imprint: SITE.name.toUpperCase(),
  /** 报头旁边的一个词。 */
  motto: SITE.subject as string,
  /** 每种报告页面的描述(搜索结果、分享卡片),不带句号;llms.txt 介绍周报、月报时也用它。 */
  descriptions: {
    daily: `${SITE.name} ${subjectAfter(`${EDITION_WHEN.daily}(北京时间)发布的`, "行业精编日报")}`,
    weekly: subjectAfter("每周", "行业综合回顾"),
    monthly: subjectAfter("每月", "行业盘点"),
  },
  /**
   * 一期里的一条怎么称呼(“4 件大事”):没有头条时的标题(“这一天的 4 件 AI 大事”)、报头和往期目录的条数、
   * 周报月报没有总述时的那句话,以及订阅说明里的“按栏目分好的大事”都用它。
   */
  entry: { measure: "件", noun: "大事" },
  /** 报头上其余几个数字后面的说法;精选数和日报期数在关于页、主题页也这样写。 */
  metricUnits: { sourcesCount: "个来源", firstPartyEvents: "件一手发布", selectedCount: "条精选", reportsCovered: "期日报" },
  /** 报告分享图上“共几条”的说法。 */
  shareUnit: "件大事",
  /** 日报时段内有资料经过评判、但没有新大事时的标题与导语。 */
  quiet: { title: "今日安静,无大事发生", paragraph: `${subjectAfter("北京时间 {start} 至 {end},没有新的", "大事")}。` },
};

/** 运维告警(只发给站长)里随部署而变的几处说法。 */
export const ALERTS = {
  /** 多少分钟没有收录新文章就告警“网站停止收录新内容”(最多一天);环境变量 ALERT_QUIET_MINUTES 优先。 */
  quietMinutes: 360,
  /** 编辑信源一直抓取成功、只是没有新文章时,多少分钟才告警(最多一天)。 */
  quietWhileFetchingMinutes: 720,
  /** 同一条告警里,“没有”后面补一句平时的收录量;null 就不写。 */
  usualFlow: null as string | null,
  /** worker 停了的告警里,怎么看它的日志。 */
  workerLogs: "看 worker 的日志(docker compose logs worker)",
};

/** 后台新建信源时的默认设置。 */
export const SOURCE_DEFAULTS = {
  /** 站内展示全文;false 时只显示摘要和原文链接。 */
  siteFulltext: false,
};

/**
 * 社区站的信源(填信源 id):算热度时按发帖的账号计,一个账号算一个独立来源,而不是整个信源只算一个。
 * dev 是 dev.to 的文章流,hn 是 Hacker News 的帖子流。
 */
export const COMMUNITY_FEEDS: { dev: string[]; hn: string[] } = {
  dev: [],
  hn: [],
};

/** 各页分享图(/og/pages/*.png)上的文字。主题目录页的那张按主题数自动生成。 */
export const CARDS: Record<string, { kicker: string; title: string; subtitle: string; accent?: "hot" | "amber" }> = {
  site: { kicker: subjectAfter("每日", "精选"), title: SITE.tagline, subtitle: SITE.description },
  all: { kicker: subjectAfter("全部", "动态"), title: "所有信源的最新动态,一站看完", subtitle: "按时间汇总各信源的最新动态,可按类别与标签筛选。" },
  hot: { kicker: "热点榜", title: "过去 48 小时,大家在讨论什么", subtitle: "热度指数、趋势与组成热度的公开来源。", accent: "hot" },
  daily: { kicker: withSubject("日报"), title: subjectAfter(`每天 ${spokenTime(EDITION_TIMES.daily)},一份读得完的`, "日报"), subtitle: `${subjectAfter("前一天值得关注的", "动态")}。` },
  weekly: { kicker: withSubject("周报"), title: `一周${REPORTS.entry.noun},一次看清`, subtitle: "本周的主线、重要发布与值得回看的讨论。" },
  monthly: { kicker: withSubject("月报"), title: "一个月的变化", subtitle: "月度主线与关键事件回顾。" },
  about: { kicker: "关于", title: `关于 ${SITE.name}`, subtitle: SITE.description },
  terms: { kicker: "使用规则", title: `${SITE.name} 使用规则`, subtitle: "网页、API、RSS 与 MCP 的使用范围。" },
  privacy: { kicker: "隐私说明", title: `${SITE.name} 隐私说明`, subtitle: "访问日志、浏览器本地数据与反馈资料的处理方式。" },
  changelog: { kicker: "更新日志", title: `${SITE.name} 更新日志`, subtitle: "功能更新、优化、公告与下线记录。" },
  feedback: { kicker: "反馈", title: "告诉我们哪里可以更好", subtitle: "内容、功能、接入,或来源方的更正与下架请求。" },
  agent: { kicker: "Agent 接入", title: `把 ${SITE.name} 接进你的 Agent`, subtitle: "MCP、RSS、API 三种方式,匿名只读,无需 API Key。" },
};

/** 公开接口的访问约定里随部署而变的几处:给 Agent 的使用说明、llms.txt 会写。 */
export const ACCESS = {
  /** 同一 IP 每分钟大约能请求多少次,超过会收到 429 并带 Retry-After(选填,由部署的反向代理限流);null 表示不限流,说明里不提。 */
  ratePerMinute: null as number | null,
  /** 请写程序同步数据的人报上的 User-Agent(选填),写在 JSON 接口的说明后面。 */
  userAgent: null as string | null,
};

/** 这个部署自己的几处安排(选填)。 */
export const DEPLOYMENT = {
  /** 凭据分组文件(models.env、collectors.env……)默认放在哪个目录,相对仓库根目录;环境变量 AIHOT_CREDENTIALS_DIR 优先,都没有就只读环境变量。 */
  credentialsDir: null as string | null,
  /** 凭据分组的文件名(放在凭据目录下,选填):没写的分组用“分组名.env”,比如 models.env。 */
  credentialFiles: {} as Partial<Record<string, string>>,
  /** 这个部署额外要求的凭据([分组, 环境变量名]);生产 API 启动时检查,默认没有额外要求。 */
  requiredSecrets: [] as const,
  /** 线上 api 收到的 Host(CDN 回源用的域名,选填);本地开发时,网页开发服务器转给 api 的请求也换成它,和线上一致。 */
  originHost: null as string | null,
  /** 反向代理把没登录的后台访问转去登录时,用哪个请求头带上原来的地址(选填,登录后回到那里)。 */
  loginReturnHeader: null as string | null,
  /**
   * 图片代理从原站取图的流量上限:超过后没缓存的图先返回 503,等这一分钟或这一天过去,当天额度用完会进运营日报;
   * null 就不设上限。环境变量 IMGPROXY_UPSTREAM_MB_PER_MINUTE、IMGPROXY_UPSTREAM_GB_PER_DAY 优先。
   */
  imageUpstreamBudget: null as null | { mbPerMinute: number; gbPerDay: number },
  /**
   * 已实测应由服务器直接连接、不走出网代理(EGRESS_PROXY_URL)的域名,采集和图片共用(选填)。
   * 每次重定向重新按目标域名选路,直连仍检查实际连接地址。
   */
  directFetchHosts: [] as string[],
  /**
   * 精选评测(scripts/eval-selection.ts)不带参数时用的金标集:文件(相对仓库根目录)、抽样条数、只抽哪一份、门槛扫描范围。
   * null 就用 .data/gold.jsonl 的全部样本(最多 200 条),在 40–90 之间扫描。
   */
  selectionGold: null as null | { file: string; sample: number; split: string; sweep: [number, number] },
};

/** RSS 订阅源的说明里随站点而变的说法。 */
export const FEED_COPY = {
  /** “全部动态”源的说明里,除了未审内容、低相关条目、低分条目和已合并重复条目,还写明不含的内容(选填)。 */
  allLeavesOut: [] as string[],
};

/**
 * 公开接口(API、RSS、MCP)里和网页不同的类别(选填)。上线后不要改:接口参数和订阅地址里有类别的 key。
 * merge:并进另一类发布的类别,key 是行业包里的类别,值是它并进的类别(公开接口比网页少一类时用);
 * feedLabels:分类 RSS 标题里的名字,替换行业包里的 feedLabel(并进了别的类别时,名字常常也要跟着改)。
 */
export const PUBLIC_CATEGORIES = {
  merge: {},
  feedLabels: {},
} as const;

/** “AI 日报”这类说法:行业词和名词之间,英文词加空格,中文词不加。 */
export function withSubject(noun: string): string {
  return /[A-Za-z0-9]$/.test(SITE.subject) ? `${SITE.subject} ${noun}` : `${SITE.subject}${noun}`;
}

/** “按主题看 AI”“往期 AI 日报”这类说法:行业词接在中文后面,英文词前加空格,中文词不加;noun 照 withSubject 接上。 */
export function subjectAfter(text: string, noun?: string): string {
  const gap = /^[A-Za-z0-9]/.test(SITE.subject) ? " " : "";
  return `${text}${gap}${noun ? withSubject(noun) : SITE.subject}`;
}

/** “8 点”“10 点 30 分”:口语里的 HH:mm。 */
function spokenTime(time: string): string {
  const [hour, minute] = time.split(":").map(Number) as [number, number];
  return `${hour} 点${minute ? ` ${minute} 分` : ""}`;
}

/**
 * 入口页在搜索结果里的标题:读者会搜的词放前面,页面自动在最后接上“ · 站名”(首页用 SITE.homeTitle)。
 * 日期一律是这一期、这一页内容的日期,不是今天的日期。换行业时把里面的说法一起换掉。
 */
export const SEO_TITLES = {
  /** 热点榜(/hot)。 */
  hot: `${withSubject("热点榜")}:今日热门事件排行`,
  /** 全部动态(/all),不含搜索和筛选结果。 */
  all: `${subjectAfter("最新", "动态")}:实时更新`,
  /** Agent 接入页(/agent)。 */
  agent: `${withSubject("资讯")} API、MCP 与 RSS:接进你的 Agent`,
  /** 关于页(/about)的完整标题,已含站名。 */
  about: `关于 ${SITE.name}:${SITE.tagline}`,
  /** 日报、周报、月报的最新一期(/daily、/weekly、/monthly);date 是这一期的日期:“11月7日”“10月26日—11月1日”“2026年10月”。 */
  latestReport: (kind: "daily" | "weekly" | "monthly", date: string): string =>
    ({ daily: `${withSubject("日报")}:${subjectAfter("每日", "新闻")}(${date})`, weekly: `${withSubject("周报")}:一周大事回顾(${date})`, monthly: `${withSubject("月报")}:${date} 大事回顾` })[kind],
  /** 主题页第一页:公司搜“最新消息”,方向和内容类型搜“最新进展”;updated 是最新一篇精选的日期(“11月7日”),没有时为 null。 */
  topic: (name: string, group: "company" | "field" | "genre", updated: string | null): string =>
    `${name} ${group === "company" ? "最新消息与动态" : "最新动态与进展"}${updated ? `(${updated}更新)` : ""}`,
};