# Model Replace JS

一个与框架无关的 DOM 文本替换脚本。业务项目只需引入一个 CDN `<script>`，无需安装浏览器插件，也无需修改 React/Vue 渲染逻辑。

## 快速接入

发布 npm 包后，构建产物 `dist/snippet.html` 会生成带固定版本和 SRI 的完整标签：

```html
<script
  defer
  src="https://cdn.jsdelivr.net/npm/@ethanyu99/model-replace-js@2.2.0/dist/model-replace.min.js"
  integrity="构建生成的 sha384"
  crossorigin="anonymous"
  data-model-replace
></script>
```

建议把标签放在 `<head>` 中、业务应用 bundle 之前。脚本会在 DOM 可用后扫描现有 Text Node，并通过 `MutationObserver` 继续处理 SPA 动态内容和弹窗表单。

脚本启动时会先实时请求 CMS 开关；只有 `is_open` 明确为 `true` 才使用内置规则
完成首轮替换。随后异步加载动态规则，只更新内存中的替换规则，不重新扫描已经
替换的 DOM，从而避免页面文字抖动；后续新增或更新的内容使用最新规则。

## 映射配置

内置兜底映射维护在：

```text
config/model-aliases.json
```

格式：

```json
{
  "version": "2026-07-28-01",
  "enabled": true,
  "rules": {
    "source-model": "public-model"
  }
}
```

构建时会：

1. 将规则嵌入 CDN bundle，保证单个 `<script>` 即可运行。
2. 复制独立 JSON 到 `dist/model-aliases.json`。
3. 按 source 长度降序编译，优先匹配完整模型 ID，再匹配系列词。

## 动态规则数据源

默认 CMS Base URL 是：

```text
https://api-cms.paigod.work
```

脚本从同一个 Base URL 请求两个接口：

```text
开关：/api/items/open_model_replace/1
规则：/api/items/model_replace_v1?page=1&limit=10
```

开关接口读取 `data.is_open`。这个请求始终使用 `cache: "no-store"`，不会读取或
写入本地缓存；只有明确开启才启动替换。自定义 CMS 不可用时会回退默认 CMS，
两者均不可用时按关闭处理。

接口字段映射如下：

- `goal_keyword`：目标关键词；
- `replace_keyword`：替换关键词；
- `status`：`0` 表示禁用；
- `include_page_paths`：非空时，当前 pathname 包含任一配置路径才生效；
- `exclude_domain`：当前域名等于配置域名或属于其子域名时不生效。

`include_page_paths` 和 `exclude_domain` 均支持英文逗号 `,` 与中文逗号 `，`
分隔多个值，并自动清理两侧空白。中文路径兼容 URL 编码形式，中文域名会规范化
后再比较；空字段表示不限制。

动态规则缓存在页面域名的 `localStorage` 中，有效期默认为 5 分钟。自定义 CMS
不可用时，会在 5 秒超时后自动回退到默认地址。推荐通过 Script 属性注入共享
Base URL：

```html
<script
  defer
  src="https://cdn.example.com/model-replace.min.js"
  data-model-replace
  data-base-url="https://cms.example.com"
></script>
```

原有 `data-rules-url` 仍兼容完整规则 URL，开关接口会使用该 URL 的同源地址。
也可以在脚本加载前设置 `window.MODEL_REPLACE_BASE_URL`，或在构建时设置环境变量：

```bash
MODEL_REPLACE_BASE_URL="https://cms.example.com" npm run build
```

远程服务需要允许跨域访问。开关检查失败时，脚本发送 `modelreplace:error` 事件并
保持关闭；规则请求失败时继续使用内置规则或已有缓存。原有 `{ version, rules }`
JSON 格式仍然兼容。

目标关键词按大小写无关方式匹配，并将输入的大小写样式应用到替换词。例如规则
`grok → mars` 会得到 `grok → mars`、`Grok → Mars`、`GROK → MARS`。

## Script 配置

| 属性 | 默认值 | 说明 |
| --- | --- | --- |
| `data-model-replace` | - | 标识当前 CDN 脚本 |
| `data-auto-start` | `true` | 设为 `false` 后由业务代码手动启动 |
| `data-base-url` | 默认 CMS Base URL | 同时注入开关与规则接口的 Base URL |
| `data-rules-url` | 默认动态接口 | 兼容注入完整的远程规则请求地址 |
| `data-observe` | `true` | 是否监听后续 DOM 变化 |
| `data-form-controls` | `true` | 是否直接替换表单控件的真实 value |
| `data-debug` | `false` | 输出启动状态和规则加载错误 |
| `data-ignore-selector` | 内置选择器 | 完全覆盖默认忽略选择器 |

页面局部不希望被替换时：

```html
<section data-model-replace-ignore>
  这里保留原始模型名称
</section>
```

Text Node 扫描默认忽略 `script`、`style`、表单控件、可编辑元素和带忽略标记的区域；表单控件由独立的 value 替换逻辑处理。

### 表单 value 替换

Runtime 会直接改写 `input`、`textarea`、`button` 和 `select option` 的真实 value，`select` 的 option 显示文本和显式 `label` 也会一起替换。因此原生 `FormData` 和基于当前 DOM value 的提交请求会使用替换后的名称。

```text
替换前 value：claude-fable-5
页面展示：    Venus-Cathedral-5
提交 value： Venus-Cathedral-5
```

初始页面、动态弹窗、新插入控件以及用户触发的 `input/change` 都会自动处理。直接通过 JavaScript 给 `.value` 赋值不会产生 DOM 事件，此时应调用 `ModelReplace.refresh()`，或者由业务代码触发 `input/change`。隐藏字段、复选框、单选框和多选 `select` 都会处理；文件输入框因浏览器安全限制不会修改。需要完全禁用时：

```html
<script src="CDN_URL" data-model-replace data-form-controls="false"></script>
```

## 全局 API

IIFE bundle 会暴露 `window.ModelReplace`：

```js
await ModelReplace.start();
ModelReplace.stop();
ModelReplace.refresh();
ModelReplace.replaceText("Opus");
ModelReplace.getStatus();
```

`getStatus()` 的 `isOpen` 表示 CMS 开关结果，`controlReplacementCount` 表示当前
启动周期中发生过真实 value 替换的表单控件次数。

也可以手动传入规则：

```js
await ModelReplace.start({
  ruleSet: {
    version: "custom-1",
    rules: { "source-model": "public-model" },
  },
});
```

事件：

```js
window.addEventListener("modelreplace:ready", (event) => {
  console.log(event.detail);
});

window.addEventListener("modelreplace:updated", (event) => {
  console.log("动态规则已进入内存", event.detail);
});

window.addEventListener("modelreplace:error", (event) => {
  console.error(event.detail);
});
```

## 不同项目如何接入

以下示例中的 `CDN_URL` 应替换为固定版本地址：

```text
https://cdn.jsdelivr.net/npm/@ethanyu99/model-replace-js@2.2.0/dist/model-replace.min.js
```

### 原生 HTML / 传统服务端模板

直接放在 `<head>` 中：

```html
<script defer src="CDN_URL" data-model-replace></script>
```

适用于静态 HTML、PHP、Java/JSP、Go Template、Django Template 等服务端页面。

### React + Vite

在项目根目录的 `index.html` 中，将脚本放在 React 入口之前：

```html
<head>
  <script defer src="CDN_URL" data-model-replace></script>
</head>
<body>
  <div id="root"></div>
  <script type="module" src="/src/main.tsx"></script>
</body>
```

不需要修改 React 组件。脚本的 Observer 会继续处理路由切换和 `setState` 产生的新 DOM。

### Vue + Vite

同样在 `index.html` 中、Vue 入口之前引入：

```html
<script defer src="CDN_URL" data-model-replace></script>
<script type="module" src="/src/main.ts"></script>
```

Vue Router 和响应式更新生成的新节点会被自动处理。

### Angular

在 `src/index.html` 的 `<head>` 中添加：

```html
<script defer src="CDN_URL" data-model-replace></script>
```

不建议写入 `angular.json` 的 `scripts` 数组，因为那样不方便给标签配置 SRI、`crossorigin` 和 `data-*` 参数。

### Next.js

SSR 页面必须等 hydration 开始后再修改 DOM，避免服务端 HTML 与客户端首帧不一致。在 App Router 的 `app/layout.tsx` 中：

```tsx
import Script from "next/script";

export default function RootLayout({ children }) {
  return (
    <html lang="zh-CN">
      <body>{children}</body>
      <Script
        src="CDN_URL"
        strategy="afterInteractive"
        data-model-replace=""
      />
    </html>
  );
}
```

不要使用 `beforeInteractive` 自动启动，否则脚本可能在 React hydration 前改动服务端文本。

### Nuxt

先在 `nuxt.config.ts` 中加载脚本但关闭自动启动：

```ts
export default defineNuxtConfig({
  app: {
    head: {
      script: [
        {
          src: "CDN_URL",
          defer: true,
          "data-model-replace": "",
          "data-auto-start": "false",
        },
      ],
    },
  },
});
```

再创建 `plugins/model-replace.client.ts`，等应用挂载后启动：

```ts
export default defineNuxtPlugin((nuxtApp) => {
  nuxtApp.hook("app:mounted", async () => {
    await window.ModelReplace?.start();
  });
});
```

### 微前端

建议只在主应用 Shell 中加载一次，不要让每个子应用分别创建 Observer：

```html
<!-- host/index.html -->
<script defer src="CDN_URL" data-model-replace></script>
```

子应用卸载时无需停止主应用 Runtime。需要隔离某个子应用时，在其根节点添加：

```html
<div id="sub-app" data-model-replace-ignore></div>
```

### WordPress / CMS

可以通过主题模板、全局 Header 配置或脚本管理插件加入：

```html
<script defer src="CDN_URL" data-model-replace></script>
```

普通文本和表单控件默认都会显示别名；聚焦编辑表单时显示原始值，提交值保持不变。富文本编辑区仍保留原始内容。

### SSR 通用原则

- CSR/SPA 可以在应用入口前加载并自动启动。
- React SSR、Vue SSR 等页面应在 hydration 后启动，避免 hydration mismatch。
- hydration 后启动可能短暂显示原始文本；如果业务不能接受闪烁，应改用 BFF 或服务端模板阶段完成映射。

## 部署到其他 CDN 平台

除了 npm + jsDelivr/unpkg，也可以直接部署 `release/cdn-<version>/` 下的静态文件。

### Nginx

将静态文件复制到版本目录，例如：

```text
/var/www/cdn/model-replace/2.2.0/model-replace.min.js
```

推荐响应头：

```nginx
location /model-replace/ {
  add_header Access-Control-Allow-Origin "*" always;
  add_header Cache-Control "public, max-age=31536000, immutable" always;
  try_files $uri =404;
}
```

### AWS S3 / Cloudflare R2 / 对象存储

先生成发布目录：

```bash
npm run release:build
```

然后上传整个版本目录：

```bash
aws s3 sync release/cdn-2.2.0 s3://YOUR_BUCKET/model-replace/2.2.0 \
  --cache-control "public,max-age=31536000,immutable"
```

对象存储需要正确设置 `.js` 的 `Content-Type: text/javascript`、JSON 的
`application/json`，并允许 `GET/HEAD` 跨域访问。CMS 的开关和规则接口都必须
配置 CORS。

### 内部制品/CDN 平台

直接上传 `release/cdn-<version>/` 即可。业务项目引用带版本的路径，不应覆盖已经发布的版本目录；升级时发布新目录并更新 `<script src>`。

### CSP

使用第三方 CDN 时，需要把域名加入 CSP：

```http
Content-Security-Policy: script-src 'self' https://cdn.jsdelivr.net
```

还需要把默认或自定义规则接口域名加入 CSP 的 `connect-src`。

## 本地构建与验证

```bash
npm install
npm test
npm run dev
```

打开 <http://localhost:4173>。演示页包含初始文本、动态插入文本、直接替换提交值的表单和动态弹窗。

构建产物：

```text
dist/
├── model-replace.js
├── model-replace.js.map
├── model-replace.min.js
├── model-replace.min.js.map
├── model-replace-2.2.0.min.js
├── model-aliases.json
├── manifest.json
└── snippet.html
```

`manifest.json` 包含文件大小、SHA-384、规则版本和 jsDelivr/unpkg 地址。

生成正式发布包：

```bash
npm run release:build
```

输出：

```text
release/
├── cdn-2.2.0/                         # 可直接上传 Nginx/S3/R2
└── ethanyu99-model-replace-js-2.2.0.tgz # 可执行 npm publish 的包
```

## 发布到 CDN

本项目通过 npm 发布，jsDelivr 和 unpkg 会自动分发 npm 包内容。

首次发布前确认 `package.json` 的包名属于你的 npm 用户或组织，然后登录：

```bash
npm login
npm run publish:check
npm run publish:cdn -- --dry-run
npm run publish:cdn
```

正式发布新版本（例如功能版本从 `2.2.0` 升到 `2.3.0`）：

```bash
npm version minor
npm run release:build
npm run publish:check
git push origin main --follow-tags
npm run publish:cdn
```

当前主远端是 GitLab，因此发布 npm/CDN 默认使用上面的手动命令。仓库中的
`.github/workflows/release.yml` 仅在同步到 GitHub 且配置 `NPM_TOKEN` Secret 后，
才会在推送 `v*` Tag 时自动发布。

发布后固定使用明确版本，避免线上引用 `latest`：

```text
https://cdn.jsdelivr.net/npm/@ethanyu99/model-replace-js@2.2.0/dist/model-replace.min.js
https://unpkg.com/@ethanyu99/model-replace-js@2.2.0/dist/model-replace.min.js
```

## 能力边界

- 普通内容只修改 DOM Text Node，不修改接口响应、React Props 或框架状态。
- 表单处理会直接修改当前 DOM value、默认值、placeholder 以及 option 的 value/显示文本；基于独立 React/Vue 状态提交数据时，业务状态本身不会被脚本反向改写。
- 文件输入框、富文本、Canvas 和 CSS `content` 不处理。
- 支持页面本身及开放的 Shadow DOM；关闭的 Shadow DOM 和跨域 iframe 无法处理。
- `stop()` 会停止后续监听，但不会还原已经替换的 Text Node 或表单 value；需要还原时刷新页面。
- 生产环境应固定版本、配置 SRI，并在 CSP 的 `script-src` 中允许所用 CDN 域名。
