From 92ddb8424632225bfc06787256df853e565c437c Mon Sep 17 00:00:00 2001 From: Jack Date: Mon, 17 Aug 2026 21:21:06 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=A0=A1=E9=AA=8C=E7=8B=AC=E7=AB=8B?= =?UTF-8?q?=E7=AB=99=E5=85=AC=E5=BC=80=E5=BF=AB=E7=85=A7=E5=B9=B6=E8=A1=A5?= =?UTF-8?q?=E5=85=85=E8=87=AA=E6=89=98=E7=AE=A1=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 7 + README.md | 1 + data/textile-intelligence.json | 2 +- docs/SELF_HOSTING_JAVA_CSHARP.md | 196 ++++++++++++++++++ public/assets/js/app.js | 2 +- public/assets/js/render-intelligence-core.js | 11 +- .../js/render-intelligence-platforms.js | 2 +- .../assets/js/render-intelligence-scenes.js | 2 +- public/assets/js/render-intelligence.js | 4 +- public/index.html | 4 +- scripts/verify_public_snapshot.py | 68 ++++++ tests/test_frontend.mjs | 4 +- tests/test_verify_public_snapshot.py | 31 +++ 13 files changed, 323 insertions(+), 11 deletions(-) create mode 100644 docs/SELF_HOSTING_JAVA_CSHARP.md create mode 100644 scripts/verify_public_snapshot.py create mode 100644 tests/test_verify_public_snapshot.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 26c559f..ff1ed20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## 2026-08-17 +### 更新时间字段与自托管交接 + +- 修正公开 JSON 快照把 `generated_at` 固定为 `2026-08-17T00:00:00Z` 的问题,恢复为真实 UTC 生成时刻;`as_of_date` 继续只表示北京时间业务日期。 +- 增加 `verify_public_snapshot.py` 发布后快照校验:若 IIS、Nginx、Pages 或 CDN 仍返回旧 `generated_at`,流水线明确失败,不把构建成功误当成线上部署成功。 +- 补充 Java/C# 脱离 GitHub Actions 与 Pages 的完整交接方案,覆盖公司 Git/Gitea、自托管 Runner、内地网络代理/VPN、Windows/IIS、Linux/Nginx、制品发布、原子切换和回滚。 +- 明确更新节点与 Web 节点分离,生产访问端只提供最终静态目录,不需要 Python、Node、数据库或模型服务。 + ### 日历数据规则与旧快照修复 - 按当前 UTC 日期重建公开日历快照,过滤已结束事件并保留未来事件的可用排序。 diff --git a/README.md b/README.md index e88ef0f..ec05f7f 100644 --- a/README.md +++ b/README.md @@ -309,3 +309,4 @@ python scripts\build_site.py --site-url http://127.0.0.1:8765 - [C# 后端与前端交接](docs/CSHARP-NODE-HANDOFF.md) - [SEO/GEO 规范](docs/SEO-GEO.md) - [运维手册](docs/OPERATIONS.md) +- [Java/C# 脱离 GitHub 与 Pages 的自托管方案](docs/SELF_HOSTING_JAVA_CSHARP.md) diff --git a/data/textile-intelligence.json b/data/textile-intelligence.json index 717a539..e7398f2 100644 --- a/data/textile-intelligence.json +++ b/data/textile-intelligence.json @@ -1,6 +1,6 @@ { "version": 2, - "generated_at": "2026-08-17T00:00:00Z", + "generated_at": "2026-08-17T12:30:21Z", "as_of_date": "2026-08-17", "horizon_date": "2028-12-31", "source_target": 59, diff --git a/docs/SELF_HOSTING_JAVA_CSHARP.md b/docs/SELF_HOSTING_JAVA_CSHARP.md new file mode 100644 index 0000000..576b5c0 --- /dev/null +++ b/docs/SELF_HOSTING_JAVA_CSHARP.md @@ -0,0 +1,196 @@ +# 脱离 GitHub 与 Pages 的 Java/C# 自托管方案 + +本文是给公司 Java/C# 后端、运维和测试人员的交接说明。GitHub Actions 和 GitHub Pages 只是当前演示环境,不是系统运行时依赖。生产环境可以完全不使用 GitHub、Pages、Python 常驻进程、数据库或大模型服务。 + +## 一、先区分两个节点 + +不要让对外 Web 服务器负责抓取外网: + +```text +更新节点(可访问外网) + -> 抓取官方源、解析、去重、翻译、校验 + -> 生成 JSON、HTML、Sitemap、JSON-LD、robots.txt、llms.txt + -> 生成完整 dist/ 发布包 + -> 原子推送到 Web 节点 + +Web 节点(只提供静态文件) + -> IIS / Nginx / 对象存储 / CDN + -> 不运行 Python、Java、C#、Node、数据库或模型 +``` + +更新节点可以是公司内网 Windows Server、Linux Server、公司 CI Runner 或一台具备外网出口的云主机。Web 节点即使完全不能访问外网也没有问题。 + +## 二、时间字段不能混用 + +- `generated_at`:本次 JSON 生成完成的 UTC 时刻,例如 `2026-08-17T12:30:21Z`。页面“更新时间”应展示它,并按访问者时区格式化。 +- `as_of_date`:业务判断日期,当前按 `Asia/Shanghai` 计算,例如 `2026-08-17`。它用于日历过滤、备货窗口和“今日必看”,不是精确更新时间。 +- `verified_at` / `checked_at`:单条来源最近一次核验时间。 + +更新程序禁止把 `generated_at` 固定为当天 `00:00:00Z`,也不能用 `as_of_date` 代替它。生成失败时保留上一版完整 JSON,不能把时间改新但把内容留空。 + +### GitHub Actions 成功不等于线上版本成功 + +资源检查绿色只代表 `dist/` 可以构建,不代表 Cloudflare Pages、IIS 或 Nginx 已经切到这次制品。发布后必须核对公开 JSON 的 `generated_at`、`as_of_date` 和本次构建日志;本仓库提供 `scripts/verify_public_snapshot.py`,可在 Gitea Runner 或 Windows/Linux 更新节点复用。 + +## 三、Java/C# 要复刻的最小流水线 + +### 独立站实际命令 + +```text +python scripts/update_all.py --site-url https://intel.example.com +``` + +`update_all.py` 会先抓取、清洗、翻译并生成公开 JSON,再调用 `build_site.py` 生成静态 SEO 页面;稳定版可以用 Java `ScheduledExecutorService` 或 .NET `BackgroundService` 编排同样的阶段,但不要让 Web 服务器承担抓取任务。 + +### 1. 抓取层 + +- Java:`java.net.http.HttpClient`、OkHttp 或公司统一 HTTP 客户端。 +- C#:`HttpClientFactory` + `HttpClient`。 +- 每个源独立超时、有限重试、User-Agent、响应大小上限和内容类型检查。 +- HTML 解析使用 Java Jsoup 或 C# AngleSharp;JSON 使用 Jackson 或 `System.Text.Json`。 +- 记录最终 URL、HTTP 状态、抓取时间、正文哈希、证据词和错误原因。 + +### 2. 规范化与去重层 + +必须保留以下字段语义: + +```text +stable id +source_id / source / authority +url / source_url +title / title_original / title_english +source_language +published_at / verified_at +content_hash +start_date / end_date / as_of_date +needs_translation / translation_url / translation_fallback_url +``` + +日历至少按“规范化节日名 + 日期 + 国家/地区 + 来源类型”去重;跨天假期合并为一张卡,在 `related_dates` 或 `date_variants` 保存不同国家日期。不要用标题模糊相似度把不同国家的“独立日”“国庆日”强行合并。 + +### 3. 翻译层 + +当前项目没有 LLM、多模态理解、生图或提示词链路。翻译只是普通 HTTP 适配器: + +```text +原文 -> 内容指纹缓存 -> Google 公共翻译(可用时) + -> Yandex 网页翻译入口(Google 不可用时) + -> 保留上一版中文或原文 +``` + +中文官网、中文报名页、中文平台页不生成翻译按钮。CPSC、ZATCA 等 Google 不稳定域名可以保留 Yandex 回退;两边都不支持的域名隐藏按钮。翻译失败不得清空已验证的中文内容。 + +### 4. 校验与发布层 + +在替换正式目录前必须检查: + +- 权威源成功数等于目标数;核心栏目非空。 +- JSON 可解析、数据结构版本正确、`generated_at` 是合法 UTC ISO 8601。 +- 所有公开 URL 为 `http/https`,没有内部缓存、日志、代理凭据或抓取状态文件。 +- `sitemap.xml`、canonical、JSON-LD、robots.txt 和 `llms.txt` 与本次 `site_url` 一致。 +- 任何一个关键步骤失败,都不替换旧版 `dist/`。 + +## 四、中国内地网络的三种部署方案 + +### 方案 A:公司外网更新节点,内网 Web 节点 + +更新节点放在具备合规外网出口的云主机或公司代理网络中。它运行定时任务,成功后把 `dist/` 和公开 JSON 作为制品上传到内网 Web 节点。Web 节点只需要访问内网制品仓库,不需要 VPN。 + +适合:抓取源较多、公司有统一代理或云主机、希望 Web 服务器最稳定的情况。 + +### 方案 B:公司 Gitea + Runner,不使用 Pages + +1. 代码放在公司 Gitea。 +2. 在有外网出口的 Windows/Linux 机器安装 Gitea Runner。 +3. Runner 执行更新、测试、构建和制品上传。 +4. IIS/Nginx 服务器只接收成功的 `dist/` 压缩包并原子解压切换。 + +Gitea 只负责代码和流水线,不要求启用 Pages。Runner 可以部署在外网可访问区,Web 服务器部署在内网。 + +### 方案 C:公司 GitHub 账号 + 自托管 Runner + +可以使用公司 GitHub 账号保存代码,但不要依赖 Pages。自托管 Runner 在具备外网访问能力的服务器上执行: + +```text +git checkout +更新数据 +运行测试 +构建 dist/ +上传制品 +部署到 IIS/Nginx +``` + +GitHub 只作为 Git 和 CI 控制面;真正的静态文件部署到公司服务器、对象存储或 CDN。 + +## 五、没有 VPN 时怎么办 + +不要把“所有服务器必须安装 VPN”当成唯一方案,按网络边界选择: + +- **HTTPS 代理**:为更新节点配置 `HTTP_PROXY/HTTPS_PROXY`,代理凭据放在系统密钥库或 CI Secret,不写入仓库。 +- **统一出口网关**:由网络团队提供固定出口、域名白名单和审计日志,应用只访问允许的官方域名。 +- **远程更新 Worker**:在可访问外网的云主机执行抓取,向内网制品仓库上传构建包;内网服务器只下载制品。 +- **分源策略**:国内可直连源由内网更新;境外源交给外网 Worker;两边通过 JSON 制品合并。 +- **人工制品交接**:极端情况下由外网机器生成带 SHA-256 的 `dist.zip`,通过公司文件系统或制品库转入内网,部署端验证哈希后发布。 + +抓取节点无法访问某个源时,应保留上一版条目并记录源失败;不能因为单个国外站点不可达而清空整站数据。 + +## 六、Windows Server 部署 + +### 更新任务 + +- 使用 Windows Task Scheduler 或 Windows Service/Worker Service,每日固定时间运行 `update_all`。 +- 工作目录使用独立服务账号;代理、翻译和 Git 凭据使用系统环境变量或凭据管理器。 +- 更新过程先写 `runtime/update-*` 临时目录,成功构建后再替换正式目录。 + +### IIS 静态发布 + +推荐目录结构: + +```text +D:\sites\sansheng\releases\20260817-123021\dist\ +D:\sites\sansheng\current -> 当前发布目录 +``` + +部署步骤:解压到新版本目录、验证 `index.html/data/*.json/sitemap.xml`、切换 IIS 站点物理路径或目录联接、保留最近 3-5 个版本。不要在 IIS 正在读取时直接覆盖正在服务的 `dist/`。 + +## 七、Linux Server 部署 + +- 使用 `systemd timer`、容器 CronJob 或公司 CI 调度更新节点。 +- Web 节点使用 Nginx,静态根目录指向版本目录;通过软链接原子切换 `current`。 +- 用 `rsync --delete` 或制品仓库同步完整目录,不要只复制部分 JSON。 +- 发布后执行 HTTP 冒烟检查:首页、JSON、Sitemap、robots、一个详情页和一个图片资源。 +- 保留最近 3-5 个版本目录,回滚只需把软链接切回上一版本并 reload Nginx。 + +## 八、Git 与无 Git 运行方式 + +生产更新不要求服务器执行 `git pull`。推荐顺序: + +1. GitHub/Gitea/公司 Git 只保存源代码。 +2. Runner 或更新服务构建不可变制品 `dist-YYYYMMDD-HHMMSS.zip`。 +3. 制品库保存 SHA-256、构建日志和版本号。 +4. Web 节点只接收已验证制品。 + +如果公司不允许生产服务器连接 Git,使用制品上传即可;如果公司需要审计,保留提交 SHA、数据 `generated_at`、构建日志和部署人。 + +## 九、回滚与故障处理 + +- 抓取失败:保留上一版 JSON 和 HTML,标记源失败,不发布半成品。 +- 翻译服务失败:保留上一版中文,仍提供官方原文链接。 +- 构建失败:不切换 `current`,继续服务上一版。 +- 发布后发现问题:切回上一版本目录,随后用固定快照重建。 +- 代码回滚必须连同公开 JSON、翻译缓存契约和前端构建结果一起回滚,避免字段版本不匹配。 + +## 十、交接验收 + +Java/C# 重构完成后至少通过: + +```text +同一份输入快照 -> 输出 JSON 字段和数量稳定 +相同内容指纹 -> 不重复翻译 +单源失败 -> 旧内容保留 +构建失败 -> dist 不替换 +Windows/Linux -> 可定时运行、可查看日志、可回滚 +无 VPN Web 节点 -> 仍能提供完整静态站 +generated_at -> 精确 UTC 时刻,不是当天 00:00 的占位值 +as_of_date -> 按 Asia/Shanghai 的业务日期计算 +``` diff --git a/public/assets/js/app.js b/public/assets/js/app.js index 4804b97..e7e7383 100644 --- a/public/assets/js/app.js +++ b/public/assets/js/app.js @@ -1,4 +1,4 @@ -import { getIntelligenceSection, renderIntelligence, setIntelligenceSection, shareIntelligence } from "./render-intelligence.js?v=20260815a"; +import { getIntelligenceSection, renderIntelligence, setIntelligenceSection, shareIntelligence } from "./render-intelligence.js?v=20260817a"; const DATA_ROOT = "./data"; diff --git a/public/assets/js/render-intelligence-core.js b/public/assets/js/render-intelligence-core.js index 26befb3..14ac152 100644 --- a/public/assets/js/render-intelligence-core.js +++ b/public/assets/js/render-intelligence-core.js @@ -309,7 +309,16 @@ function formatDate(item) { function formatVerified(value) { const raw = String(value || ""); if (!raw) return ""; - return raw.slice(0, 16).replace("T", " "); + const parsed = new Date(raw); + if (Number.isNaN(parsed.getTime())) return raw.slice(0, 16).replace("T", " "); + const parts = new Intl.DateTimeFormat(undefined, { + year: "numeric", month: "2-digit", day: "2-digit", + hour: "2-digit", minute: "2-digit", hour12: false, hourCycle: "h23", + }).formatToParts(parsed).reduce((result, part) => { + result[part.type] = part.value; + return result; + }, {}); + return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}`; } function sectionOf(item) { diff --git a/public/assets/js/render-intelligence-platforms.js b/public/assets/js/render-intelligence-platforms.js index 6c8a374..fbe8879 100644 --- a/public/assets/js/render-intelligence-platforms.js +++ b/public/assets/js/render-intelligence-platforms.js @@ -1,5 +1,5 @@ import { state } from "./render-intelligence-state.js?v=20260814e"; -import { create, normalized, matchesFilters, makeBadge, makeExternalLink, makePlatformActionGroup, appendTags, compactList } from "./render-intelligence-core.js?v=20260815a"; +import { create, normalized, matchesFilters, makeBadge, makeExternalLink, makePlatformActionGroup, appendTags, compactList } from "./render-intelligence-core.js?v=20260817a"; function makePlatformCard(item) { const card = create("article", "platform-card"); diff --git a/public/assets/js/render-intelligence-scenes.js b/public/assets/js/render-intelligence-scenes.js index e480e71..131d0dc 100644 --- a/public/assets/js/render-intelligence-scenes.js +++ b/public/assets/js/render-intelligence-scenes.js @@ -1,5 +1,5 @@ import { state } from "./render-intelligence-state.js?v=20260814e"; -import { TEXT, create, openLightbox, normalized, matchesFilters, makeVisual, makeBadge, makeExternalLink, sourceLinkLabel, currentTranslationProvider, appendTranslationLinks, appendTags, formatDate, formatVerified, makeShareButton } from "./render-intelligence-core.js?v=20260815a"; +import { TEXT, create, openLightbox, normalized, matchesFilters, makeVisual, makeBadge, makeExternalLink, sourceLinkLabel, currentTranslationProvider, appendTranslationLinks, appendTags, formatDate, formatVerified, makeShareButton } from "./render-intelligence-core.js?v=20260817a"; function makeExhibitionCard(item) { const article = create("article", "exhibition-card"); diff --git a/public/assets/js/render-intelligence.js b/public/assets/js/render-intelligence.js index 38afa4b..43b861a 100644 --- a/public/assets/js/render-intelligence.js +++ b/public/assets/js/render-intelligence.js @@ -1,8 +1,8 @@ import { state } from "./render-intelligence-state.js?v=20260814e"; -import { TEXT, create, detectViewerCountry, currentTranslationProvider, formatVerified, sectionItems, matchesFilters, makeCard, renderCalendar } from "./render-intelligence-core.js?v=20260815a"; +import { TEXT, create, detectViewerCountry, currentTranslationProvider, formatVerified, sectionItems, matchesFilters, makeCard, renderCalendar } from "./render-intelligence-core.js?v=20260817a"; import { renderExhibitions, renderPolicies, renderStandards, renderTrendNews, renderTrendSearchHub, trendItems } from "./render-intelligence-scenes.js?v=20260814i"; import { renderActivities } from "./render-intelligence-activities.js?v=20260814h"; -import { renderPlatforms } from "./render-intelligence-platforms.js?v=20260815a"; +import { renderPlatforms } from "./render-intelligence-platforms.js?v=20260817a"; function renderStats(data, container) { if (!container) return; diff --git a/public/index.html b/public/index.html index 088394d..b8b7581 100644 --- a/public/index.html +++ b/public/index.html @@ -25,7 +25,7 @@ - + @@ -78,6 +78,6 @@
- + diff --git a/scripts/verify_public_snapshot.py b/scripts/verify_public_snapshot.py new file mode 100644 index 0000000..6a68052 --- /dev/null +++ b/scripts/verify_public_snapshot.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""确认公开静态站已经提供本次生成的情报快照。""" + +from __future__ import annotations + +import argparse +import json +import sys +import time +from datetime import datetime, timezone +from pathlib import Path +from urllib.error import HTTPError, URLError +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit +from urllib.request import Request, urlopen + + +def check_url(base_url: str, path: str, expected_file: Path, timeout: int = 20) -> tuple[str, str]: + expected = json.loads(expected_file.read_text(encoding="utf-8")) + expected_generated = str(expected.get("generated_at") or "") + expected_as_of = str(expected.get("as_of_date") or "") + if not expected_generated: + raise ValueError("本地快照缺少 generated_at") + parts = urlsplit(base_url.rstrip("/") + "/" + path.lstrip("/")) + query = dict(parse_qsl(parts.query, keep_blank_values=True)) + query["deploy-check"] = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S") + url = urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), "")) + request = Request(url, headers={ + "Accept": "application/json", + "Cache-Control": "no-cache", + "User-Agent": "Sansheng-Intelligence-Deploy-Check/1.0", + }) + with urlopen(request, timeout=timeout) as response: + remote = json.load(response) + remote_generated = str(remote.get("generated_at") or "") + remote_as_of = str(remote.get("as_of_date") or "") + if remote_generated != expected_generated or remote_as_of != expected_as_of: + raise RuntimeError( + f"公开快照仍不是本次版本:expected generated_at={expected_generated}, as_of_date={expected_as_of}; " + f"remote generated_at={remote_generated}, as_of_date={remote_as_of}" + ) + return remote_generated, remote_as_of + + +def main() -> int: + parser = argparse.ArgumentParser(description="校验公开静态站是否已切换到指定情报快照") + parser.add_argument("--base-url", required=True, help="公开站点根地址") + parser.add_argument("--expected-file", default="data/textile-intelligence.json", help="本地情报快照") + parser.add_argument("--path", default="/data/textile-intelligence.json", help="公开 JSON 路径") + parser.add_argument("--retries", type=int, default=12, help="失败后重试次数") + parser.add_argument("--interval", type=int, default=30, help="重试间隔秒数") + args = parser.parse_args() + expected_file = Path(args.expected_file) + for attempt in range(max(0, args.retries) + 1): + try: + generated, as_of = check_url(args.base_url, args.path, expected_file) + print(f"公开快照已生效:generated_at={generated} as_of_date={as_of}") + return 0 + except (HTTPError, URLError, TimeoutError, ValueError, RuntimeError) as exc: + if attempt >= max(0, args.retries): + print(f"公开快照校验失败:{exc}", file=sys.stderr) + return 1 + print(f"公开快照尚未切换({attempt + 1}/{args.retries}):{exc}") + time.sleep(max(1, args.interval)) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_frontend.mjs b/tests/test_frontend.mjs index 7f41881..aa6eef0 100644 --- a/tests/test_frontend.mjs +++ b/tests/test_frontend.mjs @@ -10,9 +10,9 @@ test("独立首页只展示家纺情报并加载独立入口", async () => { const dongguanCss = await text("public/assets/fonts/dongguan-bold/result.css"); assert.match(html, /三笙AI情报局/); assert.match(html, /assets\/js\/app\.js/); - assert.match(html, /assets\/js\/app\.js\?v=20260815a/); + assert.match(html, /assets\/js\/app\.js\?v=20260817a/); assert.match(html, /assets\/css\/base\.css\?v=20260814h/); - assert.match(html, /assets\/css\/intelligence\.css\?v=20260815a/); + assert.match(html, /assets\/css\/intelligence\.css\?v=20260817a/); assert.match(html, /]+class="intelligence-metrics"/); assert.match(html, /]+class="intelligence-utility-row"/); assert.doesNotMatch(html, /id="intelligenceShareBtn"/); diff --git a/tests/test_verify_public_snapshot.py b/tests/test_verify_public_snapshot.py new file mode 100644 index 0000000..3ab4b90 --- /dev/null +++ b/tests/test_verify_public_snapshot.py @@ -0,0 +1,31 @@ +import json +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from scripts.verify_public_snapshot import check_url + + +class VerifyPublicSnapshotTests(unittest.TestCase): + def test_public_snapshot_must_match_generated_at_and_as_of_date(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "textile-intelligence.json" + path.write_text(json.dumps({"generated_at": "2026-08-17T12:30:21Z", "as_of_date": "2026-08-17"}), encoding="utf-8") + + class Response: + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def read(self): + return b'{"generated_at":"2026-08-17T12:30:21Z","as_of_date":"2026-08-17"}' + + with patch("scripts.verify_public_snapshot.urlopen", return_value=Response()): + self.assertEqual(check_url("https://example.test", "/data/textile-intelligence.json", path), ("2026-08-17T12:30:21Z", "2026-08-17")) + + +if __name__ == "__main__": + unittest.main()