- 项目简介
- 线上入口
- 核心能力
- 技术栈
- 技术架构图
- 仓库结构
- 快速开始
- 常用命令
- Playwright 页面冒烟截图巡检
- 关键配置
- 保护敏感配置
- 表单隐私说明
- 部署到 Cloudflare Workers
- 路由总览
- 相关文件
- 公开 API
- 贡献
- 授权
N·C·T 是一个用来记录、整理、公开展示“扭转治疗”相关机构与经历信息的站点。它提供匿名表单、公开地图、博客文章、多语言界面,以及 Node.js 与 Cloudflare Workers 双运行时部署能力,方便在不同环境下持续运行。
历史曾用名和域名
我们承诺不以任何理由主动收集不必要的个人信息。
| 页面 | 链接 |
|---|---|
| 站点首页 | https://www.victimsunion.org |
| 匿名表单 | https://www.victimsunion.org/form |
| 公开地图 | https://www.victimsunion.org/map |
| 隐私说明 | https://www.victimsunion.org/privacy |
| 模块 | 说明 |
|---|---|
| 匿名表单 | 支持匿名提交,带基础防刷、限流与审计日志 |
| 公开地图 | 对外展示机构数据,并提供 GET /api/map-data 接口 |
| 博客内容 | 支持博客列表、文章详情与 Markdown 渲染 |
| 多语言界面 | 支持简体中文、繁体中文、英文,以及部分动态翻译 |
| 站点基础设施 | 自动输出 robots.txt、sitemap.xml、静态资源版本号 |
| 双运行时部署 | 支持本地 Node.js 运行,也支持 Cloudflare Workers 部署 |
| 类别 | 选型 |
|---|---|
| 服务端 | Node.js 20+, Express 5 |
| 模板引擎 | EJS |
| 前端 | 原生 JavaScript + Leaflet + Chart.js |
| 部署运行时 | Node.js / Cloudflare Workers |
| 数据写入 | Google Form |
| 地图数据源 | Google Apps Script 私有源,可回退到公开 API |
| 翻译能力 | Google Cloud Translation API,可选启用 |
| 配置安全 | 自带 secure-config 密文生成工具 |
flowchart TD
U[用户 / 浏览器]
U --> R{部署入口}
R -->|Node.js| N[app/server.js]
R -->|Cloudflare Workers| W[worker.mjs]
W -->|转发 Node 兼容处理| N
N --> A[app/app.js<br/>Express 应用装配]
A --> M[中间件层<br/>Helmet / i18n / Maintenance / Body Parser]
A --> P[页面路由<br/>app/routes/pageRoutes.js]
A --> F[表单路由<br/>app/routes/formRoutes.js]
A --> I[API 路由<br/>app/routes/apiRoutes.js]
P --> V[视图模板<br/>views/*.ejs]
P --> C1[内容与站点数据<br/>blog/*.md / data.json / friends.json]
P --> S1[站点输出<br/>robots.txt / sitemap.xml]
F --> FS[formService<br/>表单校验 + Google Form 字段映射]
F --> FP[formProtectionService<br/>honeypot + 填写耗时 token]
F --> FC[formConfirmationService<br/>确认签名]
F --> GF[(Google Form)]
I --> MD[mapDataService<br/>地图缓存 + 私有源优先 + 公开源回退]
I --> TS[textTranslationService<br/>翻译缓存 + 冷却机制]
I --> AO[areaOptionsService<br/>省市区选项本地化]
MD --> GAS[(Google Apps Script 私有数据源)]
MD --> PM[(Public Map Data API 回退源)]
TS --> GCT[(Google Cloud Translation API)]
V --> J[前端脚本<br/>public/js/*.js]
J --> I
J --> CN[/cn.json GeoJSON/]
W --> W1[Workers 额外处理<br/>/cn.json 与 /api/map-data 响应完整性保护]
补充说明:
- Node.js 与 Workers 共用同一套 Express 业务逻辑,Workers 只在入口层额外保护大 JSON 响应。
- 页面、表单、API 三类路由分开管理,主要业务逻辑沉到
service层。 - 地图页、表单联动和自动补全共用
/api/*能力,避免维护多套数据入口。
.
├── app/
│ ├── middleware/ # i18n、维护模式等中间件
│ ├── routes/ # 页面、表单、API 路由
│ ├── services/ # 表单、地图、翻译、博客等核心服务
│ ├── app.js # Express 应用装配
│ └── server.js # Node.js 启动入口
├── config/ # 运行时配置、i18n、表单规则、安全配置
├── public/ # 静态资源、GeoJSON、前端脚本与样式
├── views/ # EJS 模板
├── blog/ # Markdown 博客文章
├── scripts/ # 运维脚本,例如 secure-config
├── tests/ # 自动化测试
└── worker.mjs # Cloudflare Workers 入口
git clone https://github.com/NO-CONVERSION-THERAPY/NCT.git
cd NCT
npm installNode 模式:
cp .env.example .env
npm startWorkers 模式:
cp .dev.vars.example .dev.vars
npm run dev:workers建议:
- 本地开发先保持
FORM_DRY_RUN="true",避免误提交到正式 Google Form。 - Node 模式使用
.env,Workers 模式使用.dev.vars,不要混用。 - 完整配置注释请直接查看
.env.example与.dev.vars.example。
| 命令 | 说明 |
|---|---|
npm start |
以 Node.js 启动应用 |
npm run dev:workers |
使用 Wrangler 本地调试 Workers 版本 |
npm test |
运行测试 |
npm run playwright:install |
安装 Playwright 所需的 Chromium 浏览器 |
npm run test:smoke |
运行 Playwright 页面冒烟截图巡检,输出到 test-results/playwright-smoke/ |
npm run build |
做一次启动级别的构建检查 |
npm run secure-config -- bootstrap-env --env-file ".env" |
从现有环境文件读取明文值,回写密文,并删除对应的明文变量 |
npm run secure-config -- bootstrap --form-id "..." --google-script-url "..." |
一次性生成 FORM_PROTECTION_SECRET 与对应密文 |
npm run secure-config -- generate-secret |
生成高强度 FORM_PROTECTION_SECRET |
这套巡检会启动本地应用并用 Playwright 打开关键页面,检查页面级 console.error、未捕获异常、同源请求失败,并为每个目标页输出整页截图。
- 覆盖首页、表单页、地图页、关于页、隐私页、博客列表、博客详情、调试页、提交错误页、维护页,以及表单预览、确认、成功流程。
- 截图与清单文件会输出到
test-results/playwright-smoke/,其中manifest.json会记录页面路径、HTTP 状态码和对应截图文件。 - 巡检会对地图接口注入稳定的测试数据,避免公开数据波动导致截图不稳定。
- 这套用例默认不包含在
npm test里,因为它依赖浏览器二进制和系统运行库,更适合作为单独的冒烟巡检步骤。
首次运行:
npm run playwright:install
npm run test:smoke环境说明:
- 如果 Linux 环境缺少 Playwright 运行 Chromium 的系统库,浏览器可能无法启动,例如报错缺少
libglib-2.0.so.0。 - 遇到这类问题时,请先补齐系统依赖,或改在带有 Playwright 运行库的容器、CI 镜像中执行。
README 只保留最常用配置;完整变量说明请查看 .env.example。
| 变量 | 用途 |
|---|---|
SITE_URL |
站点正式网址,用于 sitemap、robots 与 canonical 输出 |
FORM_DRY_RUN |
true 时只预览提交,不真正发往 Google Form |
FORM_PROTECTION_SECRET |
表单保护与密文解密的核心 secret,正式环境务必显式配置 |
FORM_ID / FORM_ID_ENCRYPTED |
Google Form ID,二选一 |
GOOGLE_SCRIPT_URL / GOOGLE_SCRIPT_URL_ENCRYPTED |
私有 Apps Script 数据源,二选一 |
PUBLIC_MAP_DATA_URL |
公开地图回退源,私有源慢或暂时不可用时会先顶上 |
GOOGLE_CLOUD_TRANSLATION_API_KEY |
启用翻译能力时必填 |
MAINTENANCE_MODE |
全站维护开关 |
MAINTENANCE_NOTICE |
维护页公告文字 |
RATE_LIMIT_REDIS_URL |
多实例部署时建议配置的共享限流存储 |
配置原则:
FORM_ID与FORM_ID_ENCRYPTED只选一个。GOOGLE_SCRIPT_URL与GOOGLE_SCRIPT_URL_ENCRYPTED只选一个。- 使用密文配置时,必须显式配置
FORM_PROTECTION_SECRET。 - Workers 正式部署时,敏感值请放到 Cloudflare
Variables and Secrets,不要写进仓库或wrangler.jsonc。 - 如果暂时不使用密文配置,至少请把
FORM_ID、GOOGLE_SCRIPT_URL与FORM_PROTECTION_SECRET都设为 Secret。 - 如果使用密文配置,推荐把
FORM_PROTECTION_SECRET设为 Secret,而FORM_ID_ENCRYPTED与GOOGLE_SCRIPT_URL_ENCRYPTED可用 Text 或 Secret。
如果你不想把 FORM_ID 或 GOOGLE_SCRIPT_URL 以明文形式放在普通环境变量里,可以改用密文配置。
如果你已经把 FORM_ID 与 GOOGLE_SCRIPT_URL 写进 .env 或 .dev.vars,最省事的方式是直接从文件读取并原地转换:
npm run secure-config -- bootstrap-env --env-file ".env"它会直接更新目标环境文件:
- 写入
FORM_PROTECTION_SECRET - 写入
FORM_ID_ENCRYPTED - 写入
GOOGLE_SCRIPT_URL_ENCRYPTED - 删除对应的
FORM_ID/GOOGLE_SCRIPT_URL明文项
Workers 本地调试时,也可以改读 .dev.vars:
npm run secure-config -- bootstrap-env --env-file ".dev.vars"提示:本地运行环境在中国大陆地区时,表单实际提交到 Google Form 可能受到网络环境影响。开发时建议先使用
FORM_DRY_RUN="true"。
如果你只想分步操作,也可以先生成 secret,再分别加密:
npm run secure-config -- generate-secretnpm run secure-config -- encrypt --purpose form-id --secret "你的_FORM_PROTECTION_SECRET" --value "你的_GOOGLE_FORM_ID"
npm run secure-config -- encrypt --purpose google-script-url --secret "你的_FORM_PROTECTION_SECRET" --value "你的_GOOGLE_SCRIPT_URL"需要明确的边界:
- 这能降低明文出现在仓库、日志、普通配置栏位或调试页中的风险。
- 这不是替代后端鉴权的方案。如果攻击者能读取服务端所有 secrets,密文与解密 secret 最终仍可能一起暴露。
- 真正要防止绕过网站验证,最可靠的方法仍然是不要把最终写入入口设计成可匿名直打的公开 Google Form。
当前表单页与 /privacy 页面对外使用的说明如下:
隐私说明:本问卷中填写的出生年份、性别等个人基本信息将被严格保密,相关经历、机构曝光信息可能在本站公开页面展示。提交内容会通过 Google Form / Google 表格保存和整理;请勿在可能公开的字段中填写身份证号、私人电话、家庭住址等个人敏感信息。
如果你后续调整了公开字段范围,记得同步更新:
- 表单页提示文案
form.privacyNotice - 隐私页
/privacy - README 中这段说明
本项目正式部署以 GitHub + Workers Builds 为主。
npm install
cp .dev.vars.example .dev.vars
npm run dev:workers
npm test在 Cloudflare Dashboard 中:
- 进入
Workers & Pages - 点击
Create application - 选择
Import a repository - 授权 GitHub App 并选择本项目仓库
| 项目 | 建议值 |
|---|---|
Root directory |
. |
Build command |
留空 |
Deploy command |
npm run deploy:workers |
补充:
- 正式部署分支可在
Settings -> Build -> Branch control中调整。 - 仓库中的
wrangler.jsonc只保留必要的RUNTIME_TARGET="workers",其余变量请放到 Dashboard 或本地.dev.vars。
部署建议:
- 最简单且正确的做法,是把
FORM_ID、GOOGLE_SCRIPT_URL、FORM_PROTECTION_SECRET都设成 Secret。 - 如果你要进一步降低明文误暴露风险,再改用
FORM_ID_ENCRYPTED、GOOGLE_SCRIPT_URL_ENCRYPTED,并保留FORM_PROTECTION_SECRET为 Secret。
| 名称 | 类型 | 说明 |
|---|---|---|
SITE_URL |
Text | 正式站点网址 |
FORM_DRY_RUN |
Text | 正式环境建议为 false |
FORM_PROTECTION_SECRET |
Secret | 表单保护与密文解密所需 |
FORM_ID |
Secret | 明文 Google Form ID,简单方案推荐这样配置 |
FORM_ID_ENCRYPTED |
Text 或 Secret | 加密后的 Google Form ID,使用时留空 FORM_ID |
GOOGLE_SCRIPT_URL |
Secret | 明文私有数据源 URL,简单方案推荐这样配置 |
GOOGLE_SCRIPT_URL_ENCRYPTED |
Text 或 Secret | 加密后的私有数据源 URL,使用时留空 GOOGLE_SCRIPT_URL |
PUBLIC_MAP_DATA_URL |
Text | 没有私有数据源时的回退公开 API |
GOOGLE_CLOUD_TRANSLATION_API_KEY |
Secret | 只有启用翻译时才需要 |
MAINTENANCE_MODE |
Text | 需要全站维护时设为 true |
MAINTENANCE_NOTICE |
Text | 维护公告文字 |
RATE_LIMIT_REDIS_URL |
Secret | 多实例部署建议配置 |
如果你不想使用 *.workers.dev,可以在 Settings -> Domains & Routes 中新增自定义域名。绑定完成后,记得同步更新:
SITE_URLPUBLIC_MAP_DATA_URL
正式部署完成后,建议至少手动验证以下路径:
//map/form/blog/api/map-data/sitemap.xml/robots.txt
如果 FORM_DRY_RUN="false",也要实测表单是否能成功送到 Google Form。
- 模板、博客 Markdown 与 JSON 文件会从 Workers 的
/bundle读取。 - 翻译服务已移除
curl子进程兜底,现在固定使用 Google Cloud Translation API。 sitemap.xml在 Workers 上会优先使用文章元数据中的CreationDate作为lastmod。- 若未配置共享 Redis,限流会退回单实例内存模式,跨实例一致性较弱。
Q: 本地 npm start 和 Workers 版本会冲突吗?
A: 不会。两者只是不同的本地运行入口。
Q: 这个项目要不要额外跑前端 build?
A: 目前不需要。Workers Builds 的 Build command 一般留空即可。
Q: 为什么 Deploy command 用的是 npm run deploy:workers?
A: 因为它会调用 npx wrangler deploy,并且与本仓库的 package.json 保持一致。
默认情况下,所有页面路由都会经过 i18n 中间件,因此都支持通过 ?lang=zh-CN、?lang=zh-TW、?lang=en 切换界面语言。若开启维护模式,页面与 API 还会先经过维护拦截。
| 路径 | 说明 | 备注 |
|---|---|---|
/ |
站点首页,提供表单、地图、文库等入口 | 对应 views/index.ejs |
/form |
匿名表单页,下发地区选项、前端校验规则与防刷 token | 会附带敏感页面响应头,禁止索引 |
/map |
地图总览页,展示机构分布、统计与公开数据列表 | 支持 ?inputType= 预设筛选 |
/map/record/:recordSlug |
地图提交详情页,独立展示单条提交内容并支持同机构记录上下翻页 | 从 /map 的“查看详情页”进入,对应 views/map_record.ejs |
/aboutus |
关于页,展示项目说明与友链/致谢信息 | 会读取 friends.json |
/privacy |
隐私政策与 Cookie 说明页 | 用于公开说明数据使用边界 |
/blog |
文库列表页,展示博客文章与标签筛选 | 支持 ?tag=<tagId> |
/port/:id |
单篇文章详情页 | :id 会严格限制在 blog/ 目录内解析,防止路径穿越 |
/debug |
调试页,展示当前语言、API 地址、调试模式等信息 | 仅 DEBUG_MOD=true 时可访问 |
/debug/submit-error |
提交失败页预览,方便单独查看错误页样式与预填 Google Form 链接 | 仅 DEBUG_MOD=true 时可访问 |
.env.example:Node 模式环境变量示例.dev.vars.example:Workers 本地调试示例wrangler.jsonc:Workers 配置scripts/secure-config.js:敏感配置加密工具worker.mjs:Cloudflare Workers 入口
如果你要调整公开字段、提交流程或数据上游,建议连同 /privacy 与表单页提示文案一起检查,避免对外说明和实际行为脱节。
公开接口:
https://nct.hosinoeiji.workers.dev/api/map-data
如果你是自行部署,则改用你自己的域名,例如:
https://你的域名/api/map-data
返回值示例:
{
"avg_age": 17,
"last_synced": 1774925078387,
"statistics": [
{ "province": "河南", "count": 12 },
{ "province": "湖北", "count": 66 }
],
"data": [
{
"name": "学校名称",
"addr": "学校地址",
"province": "省份",
"prov": "区、县",
"else": "其他补充内容",
"lat": 36.62728,
"lng": 118.58882,
"experience": "经历描述",
"HMaster": "负责人/校长姓名",
"scandal": "已知丑闻",
"contact": "学校联系方式",
"inputType": "受害者本人"
}
]
}字段说明:
lat/lng:经纬度last_synced:毫秒级 Unix 时间戳- 真正的机构列表位于
data字段
<script>
fetch('https://nct.hosinoeiji.workers.dev/api/map-data')
.then((res) => res.json())
.then((payload) => {
console.log(payload.data);
});
</script>如果你想把数据做成地图,可直接配合 Leaflet 等前端地图库使用;本项目自己的 /map 页面就是一个完整示例。
欢迎提交 issue、PR,或 fork 后自行部署。
在提交前建议至少确认:
npm test若你是针对部署、环境变量或表单流程做修改,也建议一并验证:
/form/submit/api/map-data/blog
本项目授权信息请参见 LICENSE。