dsh-wkgeo-ops 0.2.3 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -10
- package/client/client.js +355 -46
- package/lib/index.js +305 -2
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
**悟空GEO·网站运营 —— 一站式建站与上线,全在对话里完成**
|
|
4
4
|
|
|
5
5
|
把 `wkgeo` CLI(仓库内 `cli/wkgeo.mjs`,或用 `WKGEO_CLI` 指到你本机 CLI)的站点运营能力接进 DeepSeek Harness:
|
|
6
|
-
**agent
|
|
6
|
+
**agent 21 个工具** + **左侧栏「悟空GEO-网站运营」入口(点开是中央面板)** + **右侧边栏「网站运营」面板(含三步建站向导)**。不新增任何 npm 依赖,插件内**不重写**任何 HTTP / SQL 业务逻辑——工具只负责 `spawn node cli/wkgeo.mjs … --json` 再解析信封。
|
|
7
7
|
|
|
8
8
|
- key / 包名 / cordis 插件名:`dsh-wkgeo-ops`
|
|
9
|
-
- 版本:**0.2.
|
|
9
|
+
- 版本:**0.2.4**(2026-10-30 发布到公共 npm,可直接 `npm i dsh-wkgeo-ops`;0.2.4 内容:新增 5 个 GEO/SEO 工具——`wkgeo_geo_audit` / `wkgeo_geo_llms` / `wkgeo_geo_robots` / `wkgeo_geo_brands` / `wkgeo_seo_audit`(工具 16 → 21)、`description` 换成 A 版文案、`exports` 放宽 `"./lib/*"` 与 `"./assets/*"` 以便外部深层引用。历史:0.2.3 加插件图标 `assets/icon.png`(去底透明品牌猴子头)、中文标题与说明走 `locale/zh.json` + `locale/en.json`、`description` 换成定稿文案;0.2.2 左栏入口文字改为「悟空GEO-网站运营」、补 `LICENSE`、README 去掉本机绝对路径;0.2.0 被 npm 判 staged 冲突,首个正式版是 0.2.1)
|
|
10
10
|
- 宿主:DSH Web 客户端(`dsh.client.platform = "web"`)+ cordis 服务端插件
|
|
11
11
|
- v2 口径(老板 2026-10-30):**三步向导**(①站点名 ②语言 ③内容来源)+ **模板 / 网址 / 文件 三选或多选**
|
|
12
12
|
- v4 口径(老板 2026-10-30):「初始化先选**站点根目录** → **站点卡片**一个一个显示(能点进去)→ 详情里有**本地文件夹**」;不再用一张表铺列表
|
|
@@ -22,7 +22,7 @@ integrations/dsh-wkgeo-ops/
|
|
|
22
22
|
├─ cordis.patch.yml # 装载层:- insert: id/name = dsh-wkgeo-ops
|
|
23
23
|
├─ lib/
|
|
24
24
|
│ ├─ wizard.js # 纯函数:三步向导状态机 + 来源归一 + 能力探测 + 命令构造 + 建站闸门
|
|
25
|
-
│ └─ index.js # 服务端 cordis 插件:
|
|
25
|
+
│ └─ index.js # 服务端 cordis 插件:21 个工具 + 6 条路由 + 执行层(可单测)
|
|
26
26
|
├─ client/
|
|
27
27
|
│ └─ client.js # Web 客户端:左侧栏入口 + 面板(初始化/卡片网格/详情/向导/日志 五态)
|
|
28
28
|
├─ skill/ # agent 技能:wkgeo-site-content(建站清单契约 + 示例 manifest)
|
|
@@ -86,10 +86,15 @@ v0.2.0 新增的 9 个工具都是**薄封装**:`spawn node cli/wkgeo.mjs …
|
|
|
86
86
|
| `wkgeo_template_pack` | `dir`*、`out`、`base` | `template pack --dir <目录> [--out <zip>] [--base] --json`(300s) | 打包结果(`data.zip` / 文件数) |
|
|
87
87
|
| `wkgeo_template_publish` | `dir`*、`key`、`confirm`、`noPrune`、`archive`、`noPreview` | `template publish --dir <目录> [--key] [--dry-run] [--confirm] [--no-prune] [--archive] [--no-preview] --json`(300s) | **发布到模板市场**:默认 dry-run,`confirm:true` 才写 |
|
|
88
88
|
| `wkgeo_domains_list` | `site` | 有 `site` → `domain records --site <id> --json`;无 `site` → `sites list --json` 聚合 | 有 site:`data.domains[]`(TXT/CNAME 记录、`verify_status`、证书 `not_after`、ESA 状态);无 site:逐站域名汇总。**CLI 没有 `domain list` 子命令**,聚合路径会在 `data.source` 里写明 |
|
|
89
|
+
| `wkgeo_geo_audit` | `dir`* | `geo audit <dir> --json` | **只读** GEO 体检:`data.score/grade/pages/summary{error,warn,info,total}` + `issues[]`(`severity/file/rule/message/suggestion`);渲染给分数、分布、前 5 条 |
|
|
90
|
+
| `wkgeo_geo_llms` | `dir`*、`confirm` | `geo llms <dir> [--confirm] --json` | 生成 `llms.txt` + `llms-full.txt`;**默认 dry-run(只预览不写盘)**,`confirm:true` 才写;回 `data.written[{name,path,bytes,chars}]` |
|
|
91
|
+
| `wkgeo_geo_robots` | `dir`*、`mode`(allow-ai\|block-ai\|show,默认 show)、`confirm` | `geo robots <dir> --mode <m> [--confirm] --json` | `show` **只读**(看每个 AI 爬虫 allowed/blocked/未列出);`allow-ai`/`block-ai` **会改 `robots.txt`**,默认 dry-run 回 diff,`confirm:true` 才写 |
|
|
92
|
+
| `wkgeo_geo_brands` | `project`(域名,可选)→ `--domain` | `geo brands [--domain <域>] --json` | 品牌收录 / AI 引用跟踪;**后端未配置(`WKGEO_GEO_API`)时 CLI 回 `status:"not_configured"`**,渲染翻成人话 + 接口契约,**不伪造数据** |
|
|
93
|
+
| `wkgeo_seo_audit` | `dir`*、`url` | `seo audit <dir> --json` | **只读** SEO 体检 13 项:`data.score/grade/summary/issues[]`;**只给 `url` 不联网**(CLI 无抓取能力)→ 回 `supported:false` + `gap` 指向 `wkgeo template init` 的网址模式先落本地目录 |
|
|
89
94
|
|
|
90
95
|
`*` = 必填。实现细节:
|
|
91
96
|
|
|
92
|
-
- **`parameters` 必须是 raw JSON Schema**(`{ type:'object', properties, required }`),由 `paramsSchema(properties, required)` 统一生成,`required` 恒输出;传「属性名 → 属性 schema」的映射会被模型 API 判 `schema must be a JSON Schema of 'type': "object", got 'type: null'`(`INVALID_REQUEST`,整轮被打回)。回归脚本:`verify/tool-schema.mjs`(纯 node,不需 Electron/ajv,期望 `RESULT: ALL PASS(
|
|
97
|
+
- **`parameters` 必须是 raw JSON Schema**(`{ type:'object', properties, required }`),由 `paramsSchema(properties, required)` 统一生成,`required` 恒输出;传「属性名 → 属性 schema」的映射会被模型 API 判 `schema must be a JSON Schema of 'type': "object", got 'type: null'`(`INVALID_REQUEST`,整轮被打回)。回归脚本:`verify/tool-schema.mjs`(纯 node,不需 Electron/ajv,期望 `RESULT: ALL PASS(21 个工具)`、exit 0)。
|
|
93
98
|
- **DSH schema 子集(v0.2.0 硬约束)**:宿主 `dsh-tools/lib/types/json-schema.js` 只认 `type / oneOf / properties / required / additionalProperties / items / enum / const` + 注解 `description/title/default/examples`;其它键(`format/pattern/minLength/minItems/anyOf/allOf/$ref/...`)一律报 `JsonSchemaError: unsupported JSON schema`。**属性级 `required: true` 只能出现在 `type:'object'` 的属性上**——加在 boolean/integer/array/string 上就是老板 2026-10-30 装插件失败的那条错(`schema.properties.ok.required is not supported on type "boolean"`);必填一律用顶层 `required: [...]`。`verify/schema-subset.mjs` 复刻了这套校验器,`verify/dsh-compat.mjs` 会拦住违规写法。
|
|
94
99
|
- 一律 `spawn(node, [cli, ...args])`(`shell:false`、`windowsHide:true`);**普通命令硬超时 30s**,**长任务(模板生成 / 建站 / 导入)300s**(`LONG_TIMEOUT_MS`),超时 `SIGKILL` 回 `code:"TIMEOUT"`;stdout 上限 4MB。
|
|
95
100
|
- 只解析 CLI 的 JSON 信封;失败只回 `error.code/message/hint`,**不打印任何 token、不落日志**。
|
|
@@ -135,11 +140,11 @@ DSH 设置里的插件页 →「**添加插件**」→ 填包名:
|
|
|
135
140
|
dsh-wkgeo-ops
|
|
136
141
|
```
|
|
137
142
|
|
|
138
|
-
装上后重启 DSH 即生效(左侧栏图标 + 右侧栏页签 +
|
|
143
|
+
装上后重启 DSH 即生效(左侧栏图标 + 右侧栏页签 + 21 个工具一起出现)。等价命令行:
|
|
139
144
|
|
|
140
145
|
```powershell
|
|
141
146
|
npm i dsh-wkgeo-ops # 或 npm i -g / 在 DSH profile 目录里装
|
|
142
|
-
npm view dsh-wkgeo-ops version # 0.2.
|
|
147
|
+
npm view dsh-wkgeo-ops version # 0.2.4
|
|
143
148
|
```
|
|
144
149
|
|
|
145
150
|
> **唯一本机相关项**:工具要调 `wkgeo` CLI。插件默认取仓库内 `cli/wkgeo.mjs`;装到别的机器 / 别的仓库时,设环境变量 `WKGEO_CLI=<你那边的仓库>\cli\wkgeo.mjs`(可选 `WKGEO_NODE_BIN`)后重启 DSH。没配也能装、能激活,只是工具调用会回 `SPAWN_FAILED` 并给出提示。
|
|
@@ -157,7 +162,7 @@ npm view dsh-wkgeo-ops version # 0.2.3
|
|
|
157
162
|
|
|
158
163
|
> `cordis.patch.yml` 里**不需要**再写 `- id: dsh-wkgeo-ops`:`dsh.profile.bundles` 里的包按 bundle 层挂载,包内自带的 patch 会自动应用。
|
|
159
164
|
|
|
160
|
-
**C. 验证(装完应同时出现两处入口 +
|
|
165
|
+
**C. 验证(装完应同时出现两处入口 + 21 个工具)**
|
|
161
166
|
|
|
162
167
|
| 位置 | 现象 |
|
|
163
168
|
|---|---|
|
|
@@ -167,13 +172,15 @@ npm view dsh-wkgeo-ops version # 0.2.3
|
|
|
167
172
|
|
|
168
173
|
三个**只读**工具可以立刻验通(不写任何东西):`wkgeo_sites_list`(站点列表)、`wkgeo_domains_list`(域名/证书状态)、`wkgeo_templates_list`(站点模板)。
|
|
169
174
|
|
|
175
|
+
GEO / SEO 一侧同样有立刻能验的只读入口(都**不写任何东西**):`wkgeo_geo_audit`(GEO 体检,20 项打分)、`wkgeo_seo_audit`(SEO 体检,13 项)、`wkgeo_geo_robots`(`mode` 缺省 `show`:看每个 AI 爬虫放行/禁止)、`wkgeo_geo_llms` 与 `wkgeo_geo_robots` 的 `allow-ai`/`block-ai` 模式默认 **dry-run 只预览**,要真写盘必须显式 `confirm:true`。`wkgeo_geo_brands` 需要后端基址 `WKGEO_GEO_API`,没配时如实回 `not_configured`(不伪造数据)。
|
|
176
|
+
|
|
170
177
|
左栏入口是插件自己注册的(`client/client.js` 的 `sidebar.panellist` + `main` 两个槽位、共用 id `wkgeo-ops`),**装插件本身不会自动往左栏塞菜单**——所以看到图标就说明这版代码已生效;只看到右侧栏页签则说明装的是旧版(左栏入口是 2026-10-30 加的)。
|
|
171
178
|
|
|
172
179
|
**D. 别人(另一台机器 / 另一个 profile)首次安装的差异**
|
|
173
180
|
|
|
174
181
|
- 左栏 / 右栏入口与工具都随包走,**换机器一样出现**;`client` 侧不依赖本机路径。
|
|
175
182
|
- ⚠️ **唯一本机相关的东西是 wkgeo CLI 路径**:`lib/index.js` 的 `DEFAULT_CLI_PATH` 默认指向仓库内 `cli/wkgeo.mjs`。别人机器上 CLI 调不通时,设环境变量 `WKGEO_CLI=<其仓库>/cli/wkgeo.mjs`(`WKGEO_NODE_BIN` 可指定 node)后重启 DSH 即可——左栏图标仍然在,只是面板取数会报 `SPAWN_FAILED`。
|
|
176
|
-
-
|
|
183
|
+
- 插件中心卡片的图标与本地化描述随包走(`icon` + `locale/zh.json` / `locale/en.json`,0.2.3 起有),不影响功能。
|
|
177
184
|
- 已发布公共 npm:`npm i dsh-wkgeo-ops` / 插件中心填 `dsh-wkgeo-ops`(v0.2.0 起可装);本仓库目录安装仍可拷目录 / `file:` 依赖。
|
|
178
185
|
|
|
179
186
|
## 6. 自测
|
|
@@ -182,9 +189,9 @@ npm view dsh-wkgeo-ops version # 0.2.3
|
|
|
182
189
|
node --check integrations\dsh-wkgeo-ops\lib\index.js # exit 0
|
|
183
190
|
node --check integrations\dsh-wkgeo-ops\lib\wizard.js # exit 0
|
|
184
191
|
node --check integrations\dsh-wkgeo-ops\client\client.js # exit 0
|
|
185
|
-
node integrations\dsh-wkgeo-ops\verify\tool-schema.mjs #
|
|
192
|
+
node integrations\dsh-wkgeo-ops\verify\tool-schema.mjs # 21 个工具 schema 全过 · RESULT: ALL PASS / exit 0(纯 node,不需 Electron/ajv)
|
|
186
193
|
node integrations\dsh-wkgeo-ops\verify\panel-registration.mjs # 页签/面板注册静态断言(guide 必须函数、左栏两条、错误边界)· RESULT: ALL PASS / exit 0
|
|
187
|
-
node integrations\dsh-wkgeo-ops\verify\dsh-compat.mjs # 离线兼容自检:node --check ×3 + schema 子集 + 属性级 required 扫描 + 包形态 · PASSED=
|
|
194
|
+
node integrations\dsh-wkgeo-ops\verify\dsh-compat.mjs # 离线兼容自检:node --check ×3 + schema 子集 + 属性级 required 扫描 + 包形态 · PASSED=44 FAILED=0 / exit 0
|
|
188
195
|
node .tmp-build\t183\tool-smoke.mjs # 16 工具:离线参数映射断言 7/7 PASS + 4 个真实只读调用(sites/domains/templates/聚合成 25 站)· REAL_READONLY_OK=true
|
|
189
196
|
node .tmp-build\t159\smoke.mjs # SMOKE OK — 40 checks passed / exit 0
|
|
190
197
|
node .tmp-build\t161\api-test.mjs # 23/23 PASS · RESULT: ALL PASS / exit 0(v4 三条新路由)
|
|
@@ -224,3 +231,5 @@ node .tmp-build\t161\shoot.mjs # 自动起服务 + Chrome headless(CDP) 1440
|
|
|
224
231
|
9. **站点 ↔ 本地文件夹是本机私有映射**:wkgeo 站点模型里没有「本地目录」字段,映射只存在 `<DSH_PROFILE_DIR>/wkgeo-ops/config.json`(根目录只能设一个、文件夹必须在根目录之内)。换机器 / 换 profile 要重新设根目录并重绑;不会随站点同步。
|
|
225
232
|
10. **发布到 npm 要用官方 registry**:本机 `~/.npmrc` 默认 `registry=https://registry.npmmirror.com`(镜像无凭证),发布必须 `npm publish --registry https://registry.npmjs.org`(账号 `wkgeocn`);`npm view` 同理。
|
|
226
233
|
11. **`wkgeo_domains_list` 无 `site` 时走聚合**:CLI 没有 `domain list` 子命令,聚合用 `sites list` 的 `domain/domains[]` 字段,拿不到 TXT/CNAME/证书明细(要明细就传 `site`)。
|
|
234
|
+
12. **`wkgeo_seo_audit` 不联网**:CLI 的 `seo audit` 只吃本地目录(`cli/lib/commands/seo.mjs` 里没有任何 http/fetch),传网址会被当成目录名(实测回 `目录不存在:…\https:\…`)。所以工具里「只给 `url`」一律回 `{supported:false, gap:{code:'URL_AUDIT_UNSUPPORTED'}}` 并提示先用 `wkgeo_template_init`(`mode=url`)把站落成本地目录,**不假装抓取成功**。
|
|
235
|
+
13. **`wkgeo_geo_brands` 需要后端**:`WKGEO_GEO_API` 没配时 CLI 回 `status:"not_configured"`(exit 0、零请求、零假数据),工具原样转述成人话 + 接口契约;配好之前拿不到任何品牌收录数据。
|
package/client/client.js
CHANGED
|
@@ -131,6 +131,25 @@ window.__ModuleLoader__.load({
|
|
|
131
131
|
initTitle: '第一步:先选站点根目录',
|
|
132
132
|
initHint: '所有站点的本地文件夹都放在这个根目录下。可以直接粘路径,也可以点「选择根目录」一层层点进来。',
|
|
133
133
|
initNote: '根目录只存在本机(DSH profile 里),不上传;只有根目录里面的文件夹能绑定给站点。',
|
|
134
|
+
// ---- v5:首次进入三步(① 选页根目录 → ② 授权 → ③ 建站,老板 2026-10-30 定) ----
|
|
135
|
+
stepRoot: '选页根目录',
|
|
136
|
+
stepAuth: '授权',
|
|
137
|
+
stepCreate: '建站',
|
|
138
|
+
stepRootDone: '页根目录:{dir}',
|
|
139
|
+
stepHint: '首次进入按顺序走:第 1 步选页根目录 → 第 2 步授权 → 第 3 步建站。',
|
|
140
|
+
authTitle: '第二步:授权 wkgeo 账号',
|
|
141
|
+
authHint: '建站要走 wkgeo CLI 的写接口,必须先授权。在终端里执行下面这条命令,浏览器会打开授权页;完成后回到这里点「我已授权,重新检查」。',
|
|
142
|
+
authCommand: 'wkgeo login',
|
|
143
|
+
authCheck: '我已授权,重新检查',
|
|
144
|
+
authChecking: '正在检查授权…',
|
|
145
|
+
authOk: '已授权:能读到站点列表',
|
|
146
|
+
authNote: '授权凭据由 wkgeo CLI 自己保存,本插件不读取、不显示、不上传账号密码或 token。',
|
|
147
|
+
needRoot: '先去第 1 步选页根目录,选定后才能建站。',
|
|
148
|
+
needAuth: '先去第 2 步完成授权(wkgeo login),之后才能建站。',
|
|
149
|
+
blockedTitle: '还差一步才能建站',
|
|
150
|
+
crashTitle: '悟空GEO-网站运营 · 面板异常',
|
|
151
|
+
crashRefresh: '刷新界面',
|
|
152
|
+
regWarn: '面板自检:',
|
|
134
153
|
rootLabel: '站点根目录',
|
|
135
154
|
rootUnset: '未设置根目录',
|
|
136
155
|
rootPick: '选择根目录',
|
|
@@ -248,6 +267,25 @@ window.__ModuleLoader__.load({
|
|
|
248
267
|
initTitle: 'Step 1: choose a site root folder',
|
|
249
268
|
initHint: 'Every site keeps its local folder under this root. Paste a path, or click "Choose root" and browse into it.',
|
|
250
269
|
initNote: 'The root is stored locally (in your DSH profile), never uploaded. Only folders inside the root can be bound to a site.',
|
|
270
|
+
// ---- v5: first run = root folder -> authorization -> create ----
|
|
271
|
+
stepRoot: 'root folder',
|
|
272
|
+
stepAuth: 'authorize',
|
|
273
|
+
stepCreate: 'create',
|
|
274
|
+
stepRootDone: 'Root folder: {dir}',
|
|
275
|
+
stepHint: 'First run goes in order: step 1 pick a root folder, step 2 authorize, step 3 create.',
|
|
276
|
+
authTitle: 'Step 2: authorize the wkgeo account',
|
|
277
|
+
authHint: 'Creating a site uses the wkgeo CLI write API, so authorization is required. Run the command below in a terminal; a browser page opens for authorization, then come back and click "I have authorized, check again".',
|
|
278
|
+
authCommand: 'wkgeo login',
|
|
279
|
+
authCheck: 'I have authorized, check again',
|
|
280
|
+
authChecking: 'Checking authorization…',
|
|
281
|
+
authOk: 'Authorized: the site list is readable',
|
|
282
|
+
authNote: 'Credentials are kept by the wkgeo CLI itself; this plugin never reads, shows or uploads your password or token.',
|
|
283
|
+
needRoot: 'Pick a root folder in step 1 first, then you can create a site.',
|
|
284
|
+
needAuth: 'Finish authorization in step 2 (wkgeo login) before creating a site.',
|
|
285
|
+
blockedTitle: 'One more step before creating a site',
|
|
286
|
+
crashTitle: 'wkgeo site ops · panel error',
|
|
287
|
+
crashRefresh: 'Reload UI',
|
|
288
|
+
regWarn: 'Panel self-check: ',
|
|
251
289
|
rootLabel: 'Site root folder',
|
|
252
290
|
rootUnset: 'no root folder yet',
|
|
253
291
|
rootPick: 'Choose root',
|
|
@@ -331,7 +369,9 @@ window.__ModuleLoader__.load({
|
|
|
331
369
|
height: '100%',
|
|
332
370
|
width: '100%',
|
|
333
371
|
minWidth: 0,
|
|
334
|
-
|
|
372
|
+
// 老板 2026-10-30 白屏复盘:主区容器是 flex 项,历史上有过「高度塌成 0、看起来全白」的情况,
|
|
373
|
+
// 这里给一个最小高度兜底;真渲染出来了,内容就一定会占可见高度。
|
|
374
|
+
minHeight: '320px',
|
|
335
375
|
flex: '1 1 auto',
|
|
336
376
|
gap: '10px',
|
|
337
377
|
padding: '12px',
|
|
@@ -956,12 +996,8 @@ window.__ModuleLoader__.load({
|
|
|
956
996
|
}
|
|
957
997
|
render() {
|
|
958
998
|
if (this.state.error) {
|
|
959
|
-
|
|
960
|
-
return h(
|
|
961
|
-
'div',
|
|
962
|
-
{ style: { ...STYLE.err, margin: '12px', fontSize: '12px', whiteSpace: 'pre-wrap' } },
|
|
963
|
-
'面板渲染出错:' + msg.slice(0, 600),
|
|
964
|
-
)
|
|
999
|
+
// 老板 2026-10-30:渲染异常不许变成一片空白 —— 消息 + 堆栈摘要直接打在面板上。
|
|
1000
|
+
return h(PanelCrash, { error: this.state.error, where: '面板渲染(PanelBoundary 捕获)' })
|
|
965
1001
|
}
|
|
966
1002
|
return this.props.children
|
|
967
1003
|
}
|
|
@@ -1512,6 +1548,107 @@ window.__ModuleLoader__.load({
|
|
|
1512
1548
|
)
|
|
1513
1549
|
}
|
|
1514
1550
|
|
|
1551
|
+
/**
|
|
1552
|
+
* 面板自检记录(老板 2026-10-30 白屏复盘):
|
|
1553
|
+
* 旧代码里注册失败被 `try{...}catch{return undefined}` 静默吞掉 —— 左栏有图标、点进去一片空白,
|
|
1554
|
+
* 界面上一个字都没有,只能靠猜。现在所有注册失败都记进这里,面板会把它打在首屏/错误页上。
|
|
1555
|
+
*/
|
|
1556
|
+
const REGISTRATION = { ok: {}, errors: [] }
|
|
1557
|
+
|
|
1558
|
+
function noteRegistration(label, err) {
|
|
1559
|
+
if (!err) {
|
|
1560
|
+
REGISTRATION.ok[label] = true
|
|
1561
|
+
return
|
|
1562
|
+
}
|
|
1563
|
+
const msg = String((err && err.message) || err || 'unknown')
|
|
1564
|
+
REGISTRATION.ok[label] = false
|
|
1565
|
+
const entry = label + ' → ' + msg
|
|
1566
|
+
if (REGISTRATION.errors[REGISTRATION.errors.length - 1] !== entry) {
|
|
1567
|
+
REGISTRATION.errors.push(entry)
|
|
1568
|
+
if (REGISTRATION.errors.length > 6) REGISTRATION.errors.shift()
|
|
1569
|
+
}
|
|
1570
|
+
try {
|
|
1571
|
+
console.error('[wkgeo-ops] slot register failed · ' + label, err)
|
|
1572
|
+
} catch (_consoleErr) {
|
|
1573
|
+
/* 忽略 */
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
/** 错误兜底页:异常消息 + 堆栈摘要 + 注册自检,保证「绝不静默空白」。 */
|
|
1578
|
+
function PanelCrash(props) {
|
|
1579
|
+
const err = props ? props.error : null
|
|
1580
|
+
const where = (props && props.where) || '面板'
|
|
1581
|
+
const msg = String((err && err.message) || err || 'unknown')
|
|
1582
|
+
const stack = err && err.stack ? String(err.stack) : ''
|
|
1583
|
+
const trace = stack ? stack.split('\n').slice(0, 8).join('\n') : ''
|
|
1584
|
+
return h(
|
|
1585
|
+
'div',
|
|
1586
|
+
{ style: STYLE.wrap },
|
|
1587
|
+
h('div', { style: STYLE.bar }, h('span', { style: STYLE.barTitle }, MESSAGES.zh.crashTitle)),
|
|
1588
|
+
h(
|
|
1589
|
+
'div',
|
|
1590
|
+
{
|
|
1591
|
+
style: {
|
|
1592
|
+
...STYLE.err,
|
|
1593
|
+
whiteSpace: 'pre-wrap',
|
|
1594
|
+
wordBreak: 'break-word',
|
|
1595
|
+
fontFamily: 'ui-monospace, SFMono-Regular, Consolas, monospace',
|
|
1596
|
+
fontSize: '12px',
|
|
1597
|
+
lineHeight: '1.5',
|
|
1598
|
+
padding: '10px',
|
|
1599
|
+
border: '1px solid var(--dsw-alias-border-l2)',
|
|
1600
|
+
borderRadius: '8px',
|
|
1601
|
+
background: 'var(--dsw-alias-bg-layer-2)',
|
|
1602
|
+
},
|
|
1603
|
+
},
|
|
1604
|
+
'位置:' + where + '\n' + msg + (trace ? '\n\n堆栈摘要:\n' + trace : ''),
|
|
1605
|
+
),
|
|
1606
|
+
REGISTRATION.errors.length
|
|
1607
|
+
? h(
|
|
1608
|
+
'div',
|
|
1609
|
+
{ style: { ...STYLE.warn, whiteSpace: 'pre-wrap', fontSize: '11px' } },
|
|
1610
|
+
MESSAGES.zh.regWarn + '\n' + REGISTRATION.errors.slice(-4).join('\n'),
|
|
1611
|
+
)
|
|
1612
|
+
: null,
|
|
1613
|
+
h(
|
|
1614
|
+
'div',
|
|
1615
|
+
{ style: STYLE.actions },
|
|
1616
|
+
h(
|
|
1617
|
+
'button',
|
|
1618
|
+
{
|
|
1619
|
+
type: 'button',
|
|
1620
|
+
style: STYLE.btnPrimary,
|
|
1621
|
+
onClick: () => {
|
|
1622
|
+
try {
|
|
1623
|
+
if (typeof window !== 'undefined' && window.location) window.location.reload()
|
|
1624
|
+
} catch (_reloadErr) {
|
|
1625
|
+
/* 忽略 */
|
|
1626
|
+
}
|
|
1627
|
+
},
|
|
1628
|
+
},
|
|
1629
|
+
MESSAGES.zh.crashRefresh,
|
|
1630
|
+
),
|
|
1631
|
+
),
|
|
1632
|
+
)
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
/** 首次进入的三步指示条:① 选页根目录 → ② 授权 → ③ 建站 */
|
|
1636
|
+
function stepChip(t, label, done, active) {
|
|
1637
|
+
const mark = done ? '✓ ' : active ? '● ' : '○ '
|
|
1638
|
+
return h(
|
|
1639
|
+
'span',
|
|
1640
|
+
{
|
|
1641
|
+
style: {
|
|
1642
|
+
...STYLE.pill,
|
|
1643
|
+
color: done ? '#15803d' : active ? 'var(--dsw-alias-label-primary)' : 'var(--dsw-alias-label-tertiary)',
|
|
1644
|
+
borderColor: done ? '#15803d' : undefined,
|
|
1645
|
+
opacity: done || active ? 1 : 0.75,
|
|
1646
|
+
},
|
|
1647
|
+
},
|
|
1648
|
+
mark + label,
|
|
1649
|
+
)
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1515
1652
|
/** 面板主体 */
|
|
1516
1653
|
function PanelBody(ctx) {
|
|
1517
1654
|
const t = makeT(ctx)
|
|
@@ -1570,6 +1707,18 @@ window.__ModuleLoader__.load({
|
|
|
1570
1707
|
}, [])
|
|
1571
1708
|
|
|
1572
1709
|
const openWizard = () => {
|
|
1710
|
+
// 三步顺序(老板 2026-10-30):① 页根目录 → ② 授权 → ③ 建站。前两步没完成不许进向导。
|
|
1711
|
+
if (!rootReady) {
|
|
1712
|
+
setView('init')
|
|
1713
|
+
setInitError(t('needRoot'))
|
|
1714
|
+
return
|
|
1715
|
+
}
|
|
1716
|
+
if (!authed) {
|
|
1717
|
+
setInitError('')
|
|
1718
|
+
setView('list')
|
|
1719
|
+
load(true)
|
|
1720
|
+
return
|
|
1721
|
+
}
|
|
1573
1722
|
setWizard(defaultWizard())
|
|
1574
1723
|
setWizardError('')
|
|
1575
1724
|
setRun({ busy: false, ok: false, siteId: '', log: [], unfilled: [], gaps: [] })
|
|
@@ -1824,12 +1973,25 @@ window.__ModuleLoader__.load({
|
|
|
1824
1973
|
const rootReady = Boolean(cfg.rootDir)
|
|
1825
1974
|
const boundCount = Object.keys(folders || {}).length
|
|
1826
1975
|
const cfgDown = cfg.phase === 'error'
|
|
1976
|
+
// 第 2 步「授权」的判定:站点接口能拿到 ok 数据即视为已授权;明确返回 NOT_LOGGED_IN 即未授权。
|
|
1977
|
+
const authed = Boolean(data) && data.ok !== false && !isLoginIssue
|
|
1978
|
+
const needAuth = Boolean(data) && isLoginIssue
|
|
1979
|
+
const canCreate = rootReady && authed
|
|
1980
|
+
const blockedHint = !rootReady ? t('needRoot') : !authed ? t('needAuth') : ''
|
|
1981
|
+
|
|
1982
|
+
const steps = h(
|
|
1983
|
+
'div',
|
|
1984
|
+
{ style: { display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: '6px' } },
|
|
1985
|
+
stepChip(t, '1 · ' + t('stepRoot'), rootReady, !rootReady),
|
|
1986
|
+
stepChip(t, '2 · ' + t('stepAuth'), authed, rootReady && !authed),
|
|
1987
|
+
stepChip(t, '3 · ' + t('stepCreate'), false, canCreate),
|
|
1988
|
+
)
|
|
1827
1989
|
|
|
1828
1990
|
const head = h(
|
|
1829
1991
|
'div',
|
|
1830
1992
|
{ style: STYLE.bar },
|
|
1831
1993
|
h('span', { style: STYLE.barTitle }, t('title')),
|
|
1832
|
-
h('span', { style: { ...STYLE.barMeta, fontSize: '10px', opacity: 0.65 }, title: 'client build' }, '
|
|
1994
|
+
h('span', { style: { ...STYLE.barMeta, fontSize: '10px', opacity: 0.65 }, title: 'client build' }, 'v6'),
|
|
1833
1995
|
h(
|
|
1834
1996
|
'span',
|
|
1835
1997
|
{ style: { ...STYLE.badge, cursor: 'pointer' }, title: cfg.rootDir || t('rootUnset'), onClick: openBrowserRoot },
|
|
@@ -1837,7 +1999,17 @@ window.__ModuleLoader__.load({
|
|
|
1837
1999
|
),
|
|
1838
2000
|
h('button', { type: 'button', style: STYLE.btn, disabled: busy, onClick: () => load(true), title: t('refresh') }, busy ? t('refreshing') : t('refresh')),
|
|
1839
2001
|
sites.length > 0 && (view === 'list' || view === 'detail')
|
|
1840
|
-
? h(
|
|
2002
|
+
? h(
|
|
2003
|
+
'button',
|
|
2004
|
+
{
|
|
2005
|
+
type: 'button',
|
|
2006
|
+
style: canCreate ? STYLE.btn : { ...STYLE.btn, opacity: 0.55, cursor: 'not-allowed' },
|
|
2007
|
+
disabled: !canCreate,
|
|
2008
|
+
title: blockedHint || t('create'),
|
|
2009
|
+
onClick: openWizard,
|
|
2010
|
+
},
|
|
2011
|
+
t('create'),
|
|
2012
|
+
)
|
|
1841
2013
|
: null,
|
|
1842
2014
|
h(
|
|
1843
2015
|
'span',
|
|
@@ -1847,6 +2019,34 @@ window.__ModuleLoader__.load({
|
|
|
1847
2019
|
),
|
|
1848
2020
|
)
|
|
1849
2021
|
|
|
2022
|
+
// 第 2 步:授权卡片(放在建站之前,未授权时首屏就是它,建站按钮全部禁用并提示去哪一步)
|
|
2023
|
+
const authPanel = h(
|
|
2024
|
+
'div',
|
|
2025
|
+
{ style: STYLE.initCard },
|
|
2026
|
+
h('div', { style: STYLE.bar }, h('span', { style: STYLE.barTitle }, t('authTitle'))),
|
|
2027
|
+
h('div', { style: STYLE.hint }, t('authHint')),
|
|
2028
|
+
h('div', { style: STYLE.growRow }, h('code', { style: STYLE.code }, t('authCommand'))),
|
|
2029
|
+
h(
|
|
2030
|
+
'div',
|
|
2031
|
+
{ style: STYLE.actions },
|
|
2032
|
+
h(
|
|
2033
|
+
'button',
|
|
2034
|
+
{ type: 'button', style: STYLE.btnPrimary, disabled: busy, onClick: () => load(true) },
|
|
2035
|
+
busy ? t('authChecking') : t('authCheck'),
|
|
2036
|
+
),
|
|
2037
|
+
h('button', { type: 'button', style: STYLE.btn, onClick: () => setView('init') }, t('rootChange')),
|
|
2038
|
+
// 第 3 步的入口先亮出来但禁用:未授权时点不动,title 说明缺的是哪一步
|
|
2039
|
+
h(
|
|
2040
|
+
'button',
|
|
2041
|
+
{ type: 'button', style: STYLE.btn, disabled: true, title: blockedHint || t('needAuth') },
|
|
2042
|
+
t('create'),
|
|
2043
|
+
),
|
|
2044
|
+
),
|
|
2045
|
+
needAuth && !busy ? h('div', { style: STYLE.err }, t('loginHint')) : null,
|
|
2046
|
+
state.error ? h('div', { style: STYLE.err }, String(state.error).slice(0, 300)) : null,
|
|
2047
|
+
h('div', { style: STYLE.note }, t('authNote')),
|
|
2048
|
+
)
|
|
2049
|
+
|
|
1850
2050
|
const initPanel = h(
|
|
1851
2051
|
'div',
|
|
1852
2052
|
{ style: STYLE.initCard },
|
|
@@ -1945,6 +2145,9 @@ window.__ModuleLoader__.load({
|
|
|
1945
2145
|
})
|
|
1946
2146
|
} else if (!rootReady && !cfgDown) {
|
|
1947
2147
|
body = initPanel
|
|
2148
|
+
} else if (needAuth) {
|
|
2149
|
+
// 第 2 步:目录已选、但还没授权 → 先授权,建站按钮此时是禁用的
|
|
2150
|
+
body = authPanel
|
|
1948
2151
|
} else if (state.phase === 'loading' && !data) {
|
|
1949
2152
|
body = h('div', { style: STYLE.state }, h('div', { style: STYLE.stateTitle }, t('refreshing')))
|
|
1950
2153
|
} else if (state.error) {
|
|
@@ -1953,9 +2156,19 @@ window.__ModuleLoader__.load({
|
|
|
1953
2156
|
{ style: STYLE.state },
|
|
1954
2157
|
h('div', { style: STYLE.stateTitle }, isLoginIssue ? t('loginHint') : t('error')),
|
|
1955
2158
|
h('div', null, String(state.error).slice(0, 300)),
|
|
1956
|
-
|
|
2159
|
+
!canCreate ? h('div', { style: STYLE.hint }, blockedHint) : null,
|
|
1957
2160
|
h('button', { type: 'button', style: STYLE.btn, onClick: () => load(true) }, t('retry')),
|
|
1958
|
-
h(
|
|
2161
|
+
h(
|
|
2162
|
+
'button',
|
|
2163
|
+
{
|
|
2164
|
+
type: 'button',
|
|
2165
|
+
style: canCreate ? STYLE.btnPrimary : { ...STYLE.btnPrimary, opacity: 0.55, cursor: 'not-allowed' },
|
|
2166
|
+
disabled: !canCreate,
|
|
2167
|
+
title: blockedHint || t('create'),
|
|
2168
|
+
onClick: openWizard,
|
|
2169
|
+
},
|
|
2170
|
+
t('create'),
|
|
2171
|
+
),
|
|
1959
2172
|
)
|
|
1960
2173
|
} else if (sites.length === 0) {
|
|
1961
2174
|
body = h(
|
|
@@ -1963,7 +2176,18 @@ window.__ModuleLoader__.load({
|
|
|
1963
2176
|
{ style: STYLE.state },
|
|
1964
2177
|
h('div', { style: STYLE.stateTitle }, t('empty')),
|
|
1965
2178
|
h('div', null, t('emptyHint')),
|
|
1966
|
-
h('
|
|
2179
|
+
!canCreate ? h('div', { style: STYLE.hint }, blockedHint) : null,
|
|
2180
|
+
h(
|
|
2181
|
+
'button',
|
|
2182
|
+
{
|
|
2183
|
+
type: 'button',
|
|
2184
|
+
style: canCreate ? { ...STYLE.btnPrimary, ...STYLE.btnBlock } : { ...STYLE.btn, ...STYLE.btnBlock, opacity: 0.55, cursor: 'not-allowed' },
|
|
2185
|
+
disabled: !canCreate,
|
|
2186
|
+
title: blockedHint || t('createFirst'),
|
|
2187
|
+
onClick: openWizard,
|
|
2188
|
+
},
|
|
2189
|
+
t('createFirst'),
|
|
2190
|
+
),
|
|
1967
2191
|
h('div', { style: STYLE.hint }, '没登录会取不到站点:', h('code', { style: STYLE.code }, 'wkgeo login')),
|
|
1968
2192
|
)
|
|
1969
2193
|
} else {
|
|
@@ -1980,7 +2204,16 @@ window.__ModuleLoader__.load({
|
|
|
1980
2204
|
onPickFolder: openBrowserSite,
|
|
1981
2205
|
}),
|
|
1982
2206
|
),
|
|
1983
|
-
h(
|
|
2207
|
+
h(
|
|
2208
|
+
'div',
|
|
2209
|
+
{
|
|
2210
|
+
key: '__new__',
|
|
2211
|
+
style: canCreate ? STYLE.newCard : { ...STYLE.newCard, opacity: 0.55, cursor: 'not-allowed' },
|
|
2212
|
+
title: blockedHint || t('newCard'),
|
|
2213
|
+
onClick: canCreate ? openWizard : undefined,
|
|
2214
|
+
},
|
|
2215
|
+
canCreate ? t('newCard') : t('blockedTitle') + ':' + blockedHint,
|
|
2216
|
+
),
|
|
1984
2217
|
)
|
|
1985
2218
|
}
|
|
1986
2219
|
|
|
@@ -1988,6 +2221,8 @@ window.__ModuleLoader__.load({
|
|
|
1988
2221
|
'div',
|
|
1989
2222
|
{ style: STYLE.wrap },
|
|
1990
2223
|
head,
|
|
2224
|
+
steps,
|
|
2225
|
+
rootReady ? h('div', { style: { ...STYLE.hint, fontSize: '11px' }, title: cfg.rootDir }, t('stepRootDone', { dir: cfg.rootDir })) : null,
|
|
1991
2226
|
cfgDown ? h('div', { style: STYLE.warn }, t('cfgUnavailable')) : null,
|
|
1992
2227
|
rootReady && cfg.exists === false ? h('div', { style: STYLE.warn }, t('rootMissingNow')) : null,
|
|
1993
2228
|
folderError ? h('div', { style: STYLE.err }, String(folderError).slice(0, 300)) : null,
|
|
@@ -1998,25 +2233,98 @@ window.__ModuleLoader__.load({
|
|
|
1998
2233
|
data && data.ageMs != null ? t('updated', { t: new Date().toLocaleTimeString() }) : '',
|
|
1999
2234
|
boundCount > 0 ? ' · 📁 ' + boundCount : '',
|
|
2000
2235
|
),
|
|
2236
|
+
REGISTRATION.errors.length
|
|
2237
|
+
? h(
|
|
2238
|
+
'div',
|
|
2239
|
+
{ style: { ...STYLE.warn, whiteSpace: 'pre-wrap', fontSize: '11px' } },
|
|
2240
|
+
t('regWarn') + REGISTRATION.errors.slice(-3).join(' | '),
|
|
2241
|
+
)
|
|
2242
|
+
: null,
|
|
2001
2243
|
)
|
|
2002
2244
|
}
|
|
2003
2245
|
|
|
2004
2246
|
function apply(ctx) {
|
|
2005
2247
|
// 面板入口:错误边界包住面板本身 —— 渲染异常变成可复制的报错文本,绝不整块空白。
|
|
2006
|
-
|
|
2248
|
+
// 面板入口:① 函数级 try/catch 兜住「构造元素树就抛错」;② PanelBoundary 兜住渲染期异常。
|
|
2249
|
+
// 两条都不许再变成空白 —— 出错就打错误页(异常消息 + 堆栈摘要 + 注册自检)。
|
|
2250
|
+
const OpsPanel = () => {
|
|
2251
|
+
try {
|
|
2252
|
+
return h(PanelBoundary, null, h(PanelBody, ctx))
|
|
2253
|
+
} catch (err) {
|
|
2254
|
+
return h(PanelCrash, { error: err, where: 'OpsPanel 构造元素树' })
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2007
2257
|
|
|
2008
|
-
|
|
2258
|
+
/** 注册期兜底:失败必须记账(旧代码在这里静默 return undefined,正是白屏查不出来的原因)。 */
|
|
2259
|
+
const guard = (label, fn) => {
|
|
2009
2260
|
try {
|
|
2010
2261
|
if (typeof ctx.effect === 'function') return ctx.effect(fn, label)
|
|
2011
2262
|
return fn()
|
|
2012
|
-
} catch (
|
|
2263
|
+
} catch (err) {
|
|
2264
|
+
noteRegistration(label, err)
|
|
2013
2265
|
return undefined
|
|
2014
2266
|
}
|
|
2015
2267
|
}
|
|
2016
2268
|
|
|
2017
|
-
|
|
2269
|
+
const SLOT_RETRY_MAX = 20
|
|
2270
|
+
const SLOT_RETRY_MS = 300
|
|
2271
|
+
|
|
2272
|
+
/**
|
|
2273
|
+
* 槽位注册(白屏根因修复,老板 2026-10-30):
|
|
2274
|
+
* DSH 的 SlotCore.register 在「槽位还没被父 entry 的 children 表声明」时会**同步抛错**
|
|
2275
|
+
* (`slot "main" is not declared (a parent entry's children table must declare it)`)。
|
|
2276
|
+
* 旧代码是「直接 register + catch 静默吞掉」:只要那次 register 抛了,`main` 就永远不存在,
|
|
2277
|
+
* 结果正是「左栏图标在、点进去一片空白、界面没有任何报错」。
|
|
2278
|
+
* 官方范式(DSH 内置插件全部如此)是 ctx.slots.inject(name, () => ctx.slots.register(...)):
|
|
2279
|
+
* 声明前挂起、声明后自动补注册;这里再叠加定时重试,双保险。
|
|
2280
|
+
*/
|
|
2281
|
+
const slotRegister = (label, options, component) => {
|
|
2282
|
+
const name = options && options.name
|
|
2283
|
+
let off = null
|
|
2284
|
+
let timer = null
|
|
2285
|
+
let tries = 0
|
|
2286
|
+
let disposed = false
|
|
2287
|
+
const attempt = () => {
|
|
2288
|
+
if (disposed) return undefined
|
|
2289
|
+
try {
|
|
2290
|
+
off = ctx.slots.register(options, component)
|
|
2291
|
+
noteRegistration(label, null)
|
|
2292
|
+
return off
|
|
2293
|
+
} catch (err) {
|
|
2294
|
+
noteRegistration(label, err)
|
|
2295
|
+
tries += 1
|
|
2296
|
+
if (tries <= SLOT_RETRY_MAX && !timer) {
|
|
2297
|
+
timer = setTimeout(() => {
|
|
2298
|
+
timer = null
|
|
2299
|
+
attempt()
|
|
2300
|
+
}, SLOT_RETRY_MS)
|
|
2301
|
+
}
|
|
2302
|
+
return undefined
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
let offInject = null
|
|
2306
|
+
if (typeof ctx.slots.inject === 'function') {
|
|
2307
|
+
try {
|
|
2308
|
+
offInject = ctx.slots.inject(name, attempt)
|
|
2309
|
+
} catch (err) {
|
|
2310
|
+
noteRegistration(label, err)
|
|
2311
|
+
attempt()
|
|
2312
|
+
}
|
|
2313
|
+
} else {
|
|
2314
|
+
attempt()
|
|
2315
|
+
}
|
|
2316
|
+
return () => {
|
|
2317
|
+
disposed = true
|
|
2318
|
+
if (timer) clearTimeout(timer)
|
|
2319
|
+
timer = null
|
|
2320
|
+
if (typeof offInject === 'function') offInject()
|
|
2321
|
+
if (typeof off === 'function') off()
|
|
2322
|
+
}
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
guard('wkgeo-ops:locale', () => ctx.locale.register(NS, { zh: MESSAGES.zh, en: MESSAGES.en }))
|
|
2018
2326
|
|
|
2019
|
-
|
|
2327
|
+
guard('wkgeo-ops:tab', () =>
|
|
2020
2328
|
ctx.sidebarRightTabs.register({
|
|
2021
2329
|
id: PANEL_ID,
|
|
2022
2330
|
kind: 'wkgeo-ops',
|
|
@@ -2043,18 +2351,19 @@ window.__ModuleLoader__.load({
|
|
|
2043
2351
|
}),
|
|
2044
2352
|
)
|
|
2045
2353
|
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
),
|
|
2354
|
+
guard('wkgeo-ops:pane', () =>
|
|
2355
|
+
slotRegister(
|
|
2356
|
+
'sidebar.right.pane.tab',
|
|
2357
|
+
{ name: 'sidebar.right.pane.tab', key: PANEL_ID, locale: NS, inject: () => ({}) },
|
|
2358
|
+
OpsPanel,
|
|
2052
2359
|
),
|
|
2053
2360
|
)
|
|
2054
2361
|
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2362
|
+
guard('wkgeo-ops:pane-title', () =>
|
|
2363
|
+
slotRegister(
|
|
2364
|
+
'sidebar.right.pane.tab.title',
|
|
2365
|
+
{ name: 'sidebar.right.pane.tab.title', key: PANEL_ID },
|
|
2366
|
+
() => h('span', null, SIDEBAR_ROW_LABEL),
|
|
2058
2367
|
),
|
|
2059
2368
|
)
|
|
2060
2369
|
|
|
@@ -2063,8 +2372,9 @@ window.__ModuleLoader__.load({
|
|
|
2063
2372
|
// ① sidebar.panellist —— 左栏里的一行图标(id = PANEL_ID)
|
|
2064
2373
|
// ② main —— 点该图标后中央区域渲染的面板(key = PANEL_ID)
|
|
2065
2374
|
// 少一条就看不到入口(只有①会点不开,只有②没有图标)。停用插件时把界面交还对话。
|
|
2066
|
-
|
|
2067
|
-
|
|
2375
|
+
guard('wkgeo-ops:sidebar-row', () =>
|
|
2376
|
+
slotRegister(
|
|
2377
|
+
'sidebar.panellist',
|
|
2068
2378
|
{
|
|
2069
2379
|
name: 'sidebar.panellist',
|
|
2070
2380
|
id: PANEL_ID,
|
|
@@ -2073,24 +2383,23 @@ window.__ModuleLoader__.load({
|
|
|
2073
2383
|
locale: NS,
|
|
2074
2384
|
},
|
|
2075
2385
|
(owner = {}) => h(OpsIcon, { size: owner.size }),
|
|
2076
|
-
)
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
)
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
/* 尽力而为 */
|
|
2386
|
+
),
|
|
2387
|
+
)
|
|
2388
|
+
|
|
2389
|
+
guard('wkgeo-ops:main', () =>
|
|
2390
|
+
slotRegister('main', { name: 'main', key: PANEL_ID, locale: NS, inject: () => ({}) }, OpsPanel),
|
|
2391
|
+
)
|
|
2392
|
+
|
|
2393
|
+
guard('wkgeo-ops:cleanup', () => () => {
|
|
2394
|
+
try {
|
|
2395
|
+
const layout = typeof ctx.get === 'function' ? ctx.get('layout') : null
|
|
2396
|
+
const info = layout && layout.panelInfo
|
|
2397
|
+
if (info && typeof info.getSnapshot === 'function') {
|
|
2398
|
+
const snapshot = info.getSnapshot()
|
|
2399
|
+
if (snapshot && snapshot.activePanelId === PANEL_ID) layout.selectPanel(null)
|
|
2091
2400
|
}
|
|
2092
|
-
|
|
2093
|
-
|
|
2401
|
+
} catch (_err) {
|
|
2402
|
+
/* 尽力而为 */
|
|
2094
2403
|
}
|
|
2095
2404
|
})
|
|
2096
2405
|
}
|
package/lib/index.js
CHANGED
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
* dsh-wkgeo-ops · DSH 插件「网站运营」——服务端 cordis 插件
|
|
3
3
|
*
|
|
4
4
|
* 作用:把 wkgeo CLI(仓库内 cli/wkgeo.mjs,默认值见 DEFAULT_CLI_PATH)的站点运营能力,
|
|
5
|
-
* 以
|
|
5
|
+
* 以 21 个 DSH agent 工具 + 6 条同源 HTTP 路由(面板取数 / 建站向导 / 模板选项 / 根目录 / 浏览 / 站点文件夹)暴露给 DSH。
|
|
6
|
+
*
|
|
7
|
+
* v0.2.4(2026-10-30 老板「加 GEO/SEO CLI 工具」):新增 5 个工具 —— geo audit(只读体检)/ geo llms(默认 dry-run)/
|
|
8
|
+
* geo robots(show 只读,allow-ai / block-ai 需 confirm)/ geo brands(后台未配置时原样转述 not_configured)/ seo audit(只读)。
|
|
6
9
|
*
|
|
7
10
|
* v0.2.0(2026-10-30 老板「接收 dsh,完成 cli 后续能力对接,然后发布一个包」):
|
|
8
11
|
* 在原有 7 个工具上补齐 content apply / fill / check、templates list、template init / validate / pack / publish、domains list;
|
|
@@ -283,6 +286,59 @@ export function buildDomainRecordsArgs({ site } = {}) {
|
|
|
283
286
|
return args
|
|
284
287
|
}
|
|
285
288
|
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
// 1c. 纯函数:v0.2.4 新增 GEO / SEO 工具的 CLI 参数(同样只拼参数,不重写业务)
|
|
291
|
+
// ---------------------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
/** `geo robots --mode` 的三个合法值(与 CLI 的 --mode 完全一致) */
|
|
294
|
+
export const GEO_ROBOTS_MODES = ['allow-ai', 'block-ai', 'show']
|
|
295
|
+
|
|
296
|
+
/** mode 归一:非法 / 缺省一律落到只读的 show(绝不因为写错参数就改成写盘) */
|
|
297
|
+
export function normalizeGeoRobotsMode(mode) {
|
|
298
|
+
const value = typeof mode === 'string' ? mode.trim().toLowerCase() : ''
|
|
299
|
+
return GEO_ROBOTS_MODES.includes(value) ? value : 'show'
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** `geo audit <dir> --json`(只读) */
|
|
303
|
+
export function buildGeoAuditArgs({ dir } = {}) {
|
|
304
|
+
return ['geo', 'audit', String(dir == null ? '' : dir), '--json']
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** `geo llms <dir> [--check] [--base-url <url>] [--confirm] --json`(不加 --confirm 就是 CLI 自己的 dry-run) */
|
|
308
|
+
export function buildGeoLlmsArgs({ dir, check, baseUrl, confirm } = {}) {
|
|
309
|
+
const args = ['geo', 'llms', String(dir == null ? '' : dir)]
|
|
310
|
+
pushFlag(args, '--check', check)
|
|
311
|
+
if (baseUrl) args.push('--base-url', String(baseUrl))
|
|
312
|
+
pushFlag(args, '--confirm', confirm)
|
|
313
|
+
args.push('--json')
|
|
314
|
+
return args
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** `geo robots <dir> --mode <allow-ai|block-ai|show> [--confirm] --json` */
|
|
318
|
+
export function buildGeoRobotsArgs({ dir, mode, confirm } = {}) {
|
|
319
|
+
const args = ['geo', 'robots', String(dir == null ? '' : dir), '--mode', normalizeGeoRobotsMode(mode)]
|
|
320
|
+
pushFlag(args, '--confirm', confirm)
|
|
321
|
+
args.push('--json')
|
|
322
|
+
return args
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* `geo brands [--domain <域>] [--lang <语言>] --json`。
|
|
327
|
+
* 插件的 project 参数映射到 CLI 的 --domain(CLI 那个参数就叫 --domain)。
|
|
328
|
+
*/
|
|
329
|
+
export function buildGeoBrandsArgs({ project, lang } = {}) {
|
|
330
|
+
const args = ['geo', 'brands']
|
|
331
|
+
if (project) args.push('--domain', String(project))
|
|
332
|
+
if (lang) args.push('--lang', String(lang))
|
|
333
|
+
args.push('--json')
|
|
334
|
+
return args
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** `seo audit <dir> --json`(只读;CLI 这一层不联网,只认本地目录) */
|
|
338
|
+
export function buildSeoAuditArgs({ dir } = {}) {
|
|
339
|
+
return ['seo', 'audit', String(dir == null ? '' : dir), '--json']
|
|
340
|
+
}
|
|
341
|
+
|
|
286
342
|
/** 把用户给的站点路径规整成 CLI 认识的形式(前导斜杠、去空白、空则 "/") */
|
|
287
343
|
export function normalizePath(path) {
|
|
288
344
|
if (typeof path !== 'string') return '/'
|
|
@@ -1333,6 +1389,122 @@ export function renderDomainsText(value = {}) {
|
|
|
1333
1389
|
return renderCliText(value)
|
|
1334
1390
|
}
|
|
1335
1391
|
|
|
1392
|
+
/**
|
|
1393
|
+
* GEO / SEO 体检结果渲染:分数 + 等级 + 问题分布 + 关键 issue 前 5 条。
|
|
1394
|
+
* 两个 audit(geo / seo)共用;只读命令,所以不给「要不要写盘」这类提示。
|
|
1395
|
+
*/
|
|
1396
|
+
export function renderAuditText(value = {}, label = '体检') {
|
|
1397
|
+
if (!value || value.ok !== true) return renderCliText(value)
|
|
1398
|
+
const data = value.data || {}
|
|
1399
|
+
const lines = []
|
|
1400
|
+
const score = data.score
|
|
1401
|
+
const grade = data.grade ? '(' + data.grade + ')' : ''
|
|
1402
|
+
const pages = Number.isFinite(data.pages) ? data.pages : 0
|
|
1403
|
+
lines.push('✔ ' + label + ':' + (score != null ? score + ' / 100' + grade : '未给分数') + ' · ' + pages + ' 个页面' + (data.dir ? '\n 目录:' + data.dir : ''))
|
|
1404
|
+
const sum = data.summary || {}
|
|
1405
|
+
if (sum.total != null) {
|
|
1406
|
+
lines.push('问题分布:error ' + (sum.error || 0) + ' / warn ' + (sum.warn || 0) + ' / info ' + (sum.info || 0) + ',共 ' + (sum.total || 0) + ' 条' + (data.truncated ? '(已截断)' : ''))
|
|
1407
|
+
}
|
|
1408
|
+
const issues = Array.isArray(data.issues) ? data.issues : []
|
|
1409
|
+
const top = issues.slice(0, 5)
|
|
1410
|
+
top.forEach((it, i) => {
|
|
1411
|
+
const item = it || {}
|
|
1412
|
+
const where = item.file || item.page || item.url || ''
|
|
1413
|
+
lines.push(' ' + (i + 1) + '. [' + (item.severity || 'info') + '] ' + (where ? where + ' · ' : '') + (item.message || item.rule || ''))
|
|
1414
|
+
if (item.suggestion) lines.push(' → ' + item.suggestion)
|
|
1415
|
+
})
|
|
1416
|
+
if (issues.length > top.length) lines.push('… 其余 ' + (issues.length - top.length) + ' 条在工具返回值的 data.issues 里')
|
|
1417
|
+
else if (issues.length === 0) lines.push('没有发现问题。')
|
|
1418
|
+
return lines.join('\n')
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
/** 写盘清单渲染:CLI 里 written[] 可能是「路径字符串」也可能是 `{name,path,bytes}` 对象 */
|
|
1422
|
+
function writtenPaths(list) {
|
|
1423
|
+
if (!Array.isArray(list)) return []
|
|
1424
|
+
return list
|
|
1425
|
+
.map((item) => (typeof item === 'string' ? item : item && typeof item === 'object' ? item.path || item.name || '' : ''))
|
|
1426
|
+
.filter((item) => typeof item === 'string' && item !== '')
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
/** `geo llms` 渲染:写了哪两个文件(或 dry-run 预览)、覆盖多少页 */
|
|
1430
|
+
export function renderGeoLlmsText(value = {}) {
|
|
1431
|
+
if (!value || value.ok !== true) return renderCliText(value)
|
|
1432
|
+
const data = value.data || {}
|
|
1433
|
+
const dry = data.dry_run === true
|
|
1434
|
+
const lines = [dry ? '👀 geo llms 预览(dry-run,一个字节都没写)' : '✔ geo llms 已生成']
|
|
1435
|
+
const txt = data.llms_txt || {}
|
|
1436
|
+
const full = data.llms_full || {}
|
|
1437
|
+
lines.push(' - llms.txt:' + (txt.path || '(未给路径)') + (txt.chars != null ? '(' + txt.chars + ' 字符)' : ''))
|
|
1438
|
+
lines.push(' - llms-full.txt:' + (full.path || '(未给路径)') + (full.chars != null ? '(' + full.chars + ' 字符)' : ''))
|
|
1439
|
+
const written = writtenPaths(data.written)
|
|
1440
|
+
lines.push(written.length > 0 ? '写入:' + written.join(' / ') : '本次没有写任何文件' + (dry ? '(dry-run)' : ''))
|
|
1441
|
+
const pages = Array.isArray(data.pages) ? data.pages.length : 0
|
|
1442
|
+
const categories = Array.isArray(data.categories) ? data.categories : []
|
|
1443
|
+
lines.push('覆盖页面 ' + pages + ' 个' + (categories.length > 0 ? ',栏目:' + categories.join(' / ') : ''))
|
|
1444
|
+
const notes = Array.isArray(data.notes) ? data.notes : []
|
|
1445
|
+
notes.slice(0, 3).forEach((note) => lines.push('注意:' + (typeof note === 'string' ? note : note.message || previewJson(note, 200))))
|
|
1446
|
+
if (dry) lines.push('要真写盘:同一个工具带 confirm:true 重跑(写完仍是幂等的)。')
|
|
1447
|
+
return lines.join('\n')
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
/** `geo robots` 渲染:show 看每个爬虫的状态;allow-ai / block-ai 看 diff 与是否写盘 */
|
|
1451
|
+
export function renderGeoRobotsText(value = {}) {
|
|
1452
|
+
if (!value || value.ok !== true) return renderCliText(value)
|
|
1453
|
+
const data = value.data || {}
|
|
1454
|
+
const mode = data.mode || 'show'
|
|
1455
|
+
const lines = []
|
|
1456
|
+
if (mode === 'show') {
|
|
1457
|
+
lines.push('✔ robots.txt 的 AI 爬虫规则(只读,未写盘):' + (data.path || ''))
|
|
1458
|
+
lines.push(data.exists === false ? ' robots.txt 不存在:目前没有任何 AI 策略(也没有被整站封禁)' : ' robots.txt 已存在;wkgeo 托管段' + (data.managed_block ? '存在' : '不存在(还没生成过)'))
|
|
1459
|
+
const bots = Array.isArray(data.bots) ? data.bots : []
|
|
1460
|
+
bots.forEach((bot) => {
|
|
1461
|
+
lines.push(' - ' + (bot.bot || bot.user_agent || '?') + ':' + (bot.status || '') + (bot.explicit ? '(显式规则)' : '(未显式列出)') + (bot.rules ? ' · ' + bot.rules : ''))
|
|
1462
|
+
})
|
|
1463
|
+
const counts = data.counts || {}
|
|
1464
|
+
if (counts.allowed != null) lines.push('汇总:放行 ' + (counts.allowed || 0) + ' / 禁止 ' + (counts.blocked || 0) + ' / 未列出 ' + (counts.unlisted || 0))
|
|
1465
|
+
return lines.join('\n')
|
|
1466
|
+
}
|
|
1467
|
+
const dry = data.dry_run === true
|
|
1468
|
+
lines.push((dry ? '👀 geo robots ' + mode + ' 预览(dry-run,未写盘)' : '✔ geo robots ' + mode + ' 已写入') + (data.path ? '\n ' + data.path : ''))
|
|
1469
|
+
const added = Array.isArray(data.added) ? data.added.length : 0
|
|
1470
|
+
const removed = Array.isArray(data.removed) ? data.removed.length : 0
|
|
1471
|
+
lines.push('改动:' + (data.action || '(未给)') + (added > 0 ? ' · 新增 ' + added + ' 行' : '') + (removed > 0 ? ' · 删除 ' + removed + ' 行' : ''))
|
|
1472
|
+
const bots = Array.isArray(data.bots) ? data.bots : []
|
|
1473
|
+
if (bots.length > 0) lines.push('涉及 UA:' + bots.map((bot) => (typeof bot === 'string' ? bot : (bot && (bot.bot || bot.user_agent)) || '?')).join(' / '))
|
|
1474
|
+
const written = writtenPaths(data.written)
|
|
1475
|
+
if (written.length > 0) lines.push('写入:' + written.join(' / '))
|
|
1476
|
+
if (dry) lines.push(mode === 'block-ai' ? '注意:block-ai 会真的挡住 AI 收录;确认后带 confirm:true 重跑。' : '要真写盘:同一个工具带 confirm:true 重跑。')
|
|
1477
|
+
return lines.join('\n')
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
/** `geo brands` 渲染:未配置后端时把 not_configured 翻成人话(绝不假装成功) */
|
|
1481
|
+
export function renderGeoBrandsText(value = {}) {
|
|
1482
|
+
const data = (value && value.data) || {}
|
|
1483
|
+
if (value && value.ok === true && data.status === 'not_configured') {
|
|
1484
|
+
const contract = data.contract || {}
|
|
1485
|
+
const env = contract.env || 'WKGEO_GEO_API'
|
|
1486
|
+
const req = contract.request || {}
|
|
1487
|
+
const lines = [
|
|
1488
|
+
'⚠ 品牌收录跟踪还没接上(CLI 返回 status: not_configured)',
|
|
1489
|
+
' 后台基址没配置:本次一个请求都没发,也没有任何本地假数据。',
|
|
1490
|
+
'要启用:在运行 DSH 的环境里配置环境变量 ' + env + '(后台基址),再重跑本工具。',
|
|
1491
|
+
]
|
|
1492
|
+
if (req.path) lines.push('接口契约:' + (req.method || 'GET') + ' ' + req.path + '?domain=<站点域名>&lang=<语言> → brands[].platforms[](status: none | listed | top | cited)')
|
|
1493
|
+
lines.push('说明:品牌数据只能来自后台,CLI 绝不本地伪造「看起来像真数据」的占位记录。')
|
|
1494
|
+
return lines.join('\n')
|
|
1495
|
+
}
|
|
1496
|
+
if (!value || value.ok !== true) return renderCliText(value)
|
|
1497
|
+
const brands = Array.isArray(data.brands) ? data.brands : []
|
|
1498
|
+
if (brands.length === 0) return '✔ geo brands 成功,但后台返回的品牌列表为空(暂时没有收录 / 引用记录)。'
|
|
1499
|
+
const lines = ['✔ 品牌收录:' + brands.length + ' 个(平台 ' + (data.platforms != null ? data.platforms : '?') + ' 个)']
|
|
1500
|
+
brands.slice(0, 5).forEach((brand) => {
|
|
1501
|
+
const platforms = Array.isArray(brand.platforms) ? brand.platforms : []
|
|
1502
|
+
lines.push(' - ' + (brand.name || '(未命名)') + ':' + (platforms.length > 0 ? platforms.map((p) => (p.platform || '?') + '=' + (p.status || '?') + (p.rank != null ? ' #' + p.rank : '')).join(' / ') : '无平台数据'))
|
|
1503
|
+
})
|
|
1504
|
+
if (brands.length > 5) lines.push('… 其余 ' + (brands.length - 5) + ' 个见 data.brands')
|
|
1505
|
+
return lines.join('\n')
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1336
1508
|
/**
|
|
1337
1509
|
* 造 4 个工具定义。deps.run 可注入(测试用假输出)。
|
|
1338
1510
|
* @returns {Array<object>} 工具定义数组(形状对齐 DSH 已装插件的 tools.register)
|
|
@@ -1972,7 +2144,138 @@ export function createTools(deps = {}) {
|
|
|
1972
2144
|
},
|
|
1973
2145
|
}
|
|
1974
2146
|
|
|
1975
|
-
|
|
2147
|
+
// -------------------------------------------------------------------------
|
|
2148
|
+
// v0.2.4:GEO / SEO 六件套里的 5 个(geo audit / llms / robots / brands + seo audit)
|
|
2149
|
+
// 全部是薄封装:spawn node cli/wkgeo.mjs … --json → 解析信封 → 结构化返回。
|
|
2150
|
+
// -------------------------------------------------------------------------
|
|
2151
|
+
|
|
2152
|
+
const geoAudit = {
|
|
2153
|
+
name: 'wkgeo_geo_audit',
|
|
2154
|
+
description:
|
|
2155
|
+
'本地站点产物 GEO(生成式引擎优化)体检,底层 `wkgeo geo audit`:20 项加权评分(0-100)+ 等级(good / fair / poor)+ issues[](error / warn / info,每条带位置与改法)。**只读**:不联网、不写任何文件;要补 llms.txt / robots.txt 用 wkgeo_geo_llms / wkgeo_geo_robots。有问题也返回 ok:true(看 score / issues),只有目录不存在或目录里没有 .html 才 ok:false。',
|
|
2156
|
+
parameters: paramsSchema(
|
|
2157
|
+
{
|
|
2158
|
+
dir: { type: 'string', description: '站点产物目录(含 index.html 等静态页面;建议给绝对路径,如 F:\\cloudbase\\dist)' },
|
|
2159
|
+
},
|
|
2160
|
+
['dir'],
|
|
2161
|
+
),
|
|
2162
|
+
output: { schema: RESULT_SCHEMA, render: (_args, value) => [{ type: 'text', text: renderAuditText(value, 'GEO 体检(geo audit)') }] },
|
|
2163
|
+
async execute(args = {}) {
|
|
2164
|
+
const dir = textOf(args.dir)
|
|
2165
|
+
if (!dir) return missingArg('dir', '给站点产物目录(含 index.html 的目录,建议绝对路径)', 'geo audit')
|
|
2166
|
+
const res = await runCli(buildGeoAuditArgs({ dir }))
|
|
2167
|
+
return normalizeCliResult(res, { command: 'geo audit' })
|
|
2168
|
+
},
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
const geoLlms = {
|
|
2172
|
+
name: 'wkgeo_geo_llms',
|
|
2173
|
+
description:
|
|
2174
|
+
'按目录生成给 AI 读的两份索引:llms.txt(站点标题 + 描述 + 按栏目分节的「- [标题](URL):一句话摘要」)与 llms-full.txt(每页正文全文),底层 `wkgeo geo llms`。**不传 confirm 就是 dry-run**:只把两份文件内容算出来给你看,一个字节都不写;使用者确认后才带 confirm:true 真写盘(写到 <dir>/llms.txt 与 <dir>/llms-full.txt,幂等可重跑)。标题 / 摘要只从页面与站点清单里取,取不到就留空,不编造。',
|
|
2175
|
+
parameters: paramsSchema({
|
|
2176
|
+
dir: { type: 'string', description: '站点产物目录(写 <dir>/llms.txt 与 <dir>/llms-full.txt;建议绝对路径)' },
|
|
2177
|
+
confirm: { type: 'boolean', description: '确认真写盘才传 true;不传或 false = 只预览(dry-run)' },
|
|
2178
|
+
}),
|
|
2179
|
+
output: { schema: RESULT_SCHEMA, render: (_args, value) => [{ type: 'text', text: renderGeoLlmsText(value) }] },
|
|
2180
|
+
async execute(args = {}) {
|
|
2181
|
+
const dir = textOf(args.dir)
|
|
2182
|
+
if (!dir) return missingArg('dir', '给站点产物目录(llms.txt 会写在这个目录下)', 'geo llms')
|
|
2183
|
+
const confirm = args.confirm === true
|
|
2184
|
+
const res = await runCli(buildGeoLlmsArgs({ dir, confirm }))
|
|
2185
|
+
return normalizeCliResult(res, {
|
|
2186
|
+
command: 'geo llms',
|
|
2187
|
+
executed: confirm,
|
|
2188
|
+
needsConfirm: !confirm,
|
|
2189
|
+
reason: confirm ? '' : 'dry-run:只打印 llms.txt / llms-full.txt 的内容,一个字节都没写。看完无误后,同一个工具带 confirm:true 重跑才落盘。',
|
|
2190
|
+
})
|
|
2191
|
+
},
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2194
|
+
const geoRobots = {
|
|
2195
|
+
name: 'wkgeo_geo_robots',
|
|
2196
|
+
description:
|
|
2197
|
+
'管理 robots.txt 里的 AI 爬虫策略段(GPTBot / OAI-SearchBot / ClaudeBot / PerplexityBot / Google-Extended / Bingbot),底层 `wkgeo geo robots`。mode=show(默认)**只读**:列出当前每个 AI 爬虫的放行 / 封禁状态,不写盘;mode=allow-ai / block-ai 是**会写文件**的策略生成——不传 confirm 只给 diff(dry-run),确认后带 confirm:true 才写。幂等:策略段用 `# >>> wkgeo:geo-ai >>>` 包起来整段替换,段外人工规则一个字节都不动。**block-ai 会真的挡住 AI 收录**,除非使用者明确要求,别用。',
|
|
2198
|
+
parameters: paramsSchema({
|
|
2199
|
+
dir: { type: 'string', description: '站点产物目录(读 / 写 <dir>/robots.txt;建议绝对路径)' },
|
|
2200
|
+
mode: { type: 'string', enum: GEO_ROBOTS_MODES, description: 'allow-ai = 6 个 AI 爬虫各写 Allow: /;block-ai = 换成 Disallow: /(会真挡住 AI 收录,慎用);show = 只看当前规则' },
|
|
2201
|
+
confirm: { type: 'boolean', description: 'allow-ai / block-ai 要真写盘才传 true;不传 = 只打印 diff。show 忽略此参数' },
|
|
2202
|
+
}),
|
|
2203
|
+
output: { schema: RESULT_SCHEMA, render: (_args, value) => [{ type: 'text', text: renderGeoRobotsText(value) }] },
|
|
2204
|
+
async execute(args = {}) {
|
|
2205
|
+
const dir = textOf(args.dir)
|
|
2206
|
+
if (!dir) return missingArg('dir', '给站点产物目录(robots.txt 在这个目录下)', 'geo robots')
|
|
2207
|
+
const mode = normalizeGeoRobotsMode(args.mode)
|
|
2208
|
+
const confirm = mode !== 'show' && args.confirm === true
|
|
2209
|
+
const res = await runCli(buildGeoRobotsArgs({ dir, mode, confirm }))
|
|
2210
|
+
const reason =
|
|
2211
|
+
mode === 'show'
|
|
2212
|
+
? 'show 只读:只列当前 robots.txt 的 AI 规则与每个 UA 的策略状态,不写盘。'
|
|
2213
|
+
: confirm
|
|
2214
|
+
? ''
|
|
2215
|
+
: 'dry-run:只打印 diff,没写盘。确认要改 robots.txt 后带 confirm:true 重跑' + (mode === 'block-ai' ? '(block-ai 会真的挡住 AI 收录)。' : '。')
|
|
2216
|
+
return normalizeCliResult(res, {
|
|
2217
|
+
command: 'geo robots --mode ' + mode,
|
|
2218
|
+
executed: mode === 'show' ? true : confirm,
|
|
2219
|
+
needsConfirm: mode !== 'show' && !confirm,
|
|
2220
|
+
reason,
|
|
2221
|
+
})
|
|
2222
|
+
},
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
const geoBrands = {
|
|
2226
|
+
name: 'wkgeo_geo_brands',
|
|
2227
|
+
description:
|
|
2228
|
+
'品牌在 AI 平台上的收录 / 引用跟踪,底层 `wkgeo geo brands`(只读,不写文件)。**后台 API 未就绪**:没配置环境变量 WKGEO_GEO_API 时,CLI 一个请求都不发,返回 status=not_configured + 接口契约,本工具会把这句话原样翻成人话,**不会假装查到了品牌**。project 传站点域名(映射到 CLI 的 --domain)。',
|
|
2229
|
+
parameters: paramsSchema({
|
|
2230
|
+
project: { type: 'string', description: '项目 / 站点域名(如 example.com,映射到 CLI 的 --domain;可留空,留空只看配置状态)' },
|
|
2231
|
+
}),
|
|
2232
|
+
output: { schema: RESULT_SCHEMA, render: (_args, value) => [{ type: 'text', text: renderGeoBrandsText(value) }] },
|
|
2233
|
+
async execute(args = {}) {
|
|
2234
|
+
const project = textOf(args.project)
|
|
2235
|
+
const res = await runCli(buildGeoBrandsArgs({ project }))
|
|
2236
|
+
return normalizeCliResult(res, { command: 'geo brands' })
|
|
2237
|
+
},
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
const seoAudit = {
|
|
2241
|
+
name: 'wkgeo_seo_audit',
|
|
2242
|
+
description:
|
|
2243
|
+
'本地站点产物 SEO 体检,底层 `wkgeo seo audit`:13 项加权评分(title / description / canonical / og / JSON-LD / H1 / 标题层级 / 图片 alt / 内链死链 / llms.txt / sitemap.xml / robots.txt)+ 等级 + issues[](每条带位置与改法)。**只读**:不联网、不写任何文件。有问题也返回 ok:true(看 score / issues),只有目录不存在或没有 .html 才 ok:false。**注意**:CLI 这一层不联网,只认本地目录;url 只是别名入口,填了 url 没有 dir 时会明确告诉你先落成本地目录,不会假装抓取成功。',
|
|
2244
|
+
parameters: paramsSchema({
|
|
2245
|
+
dir: { type: 'string', description: '本地站点产物目录(含 index.html 等静态页面;建议绝对路径)。dir 与 url 二选一,优先 dir' },
|
|
2246
|
+
url: { type: 'string', description: '站点网址(http / https,如 https://www.example.com)。可填网址,但 CLI 的 seo audit 只做本地产物目录体检、不联网;只给 url 时会提示先落成本地目录再传 dir' },
|
|
2247
|
+
}),
|
|
2248
|
+
output: { schema: RESULT_SCHEMA, render: (_args, value) => [{ type: 'text', text: renderAuditText(value, 'SEO 体检(seo audit)') }] },
|
|
2249
|
+
async execute(args = {}) {
|
|
2250
|
+
const dir = textOf(args.dir)
|
|
2251
|
+
const url = textOf(args.url)
|
|
2252
|
+
if (!dir) {
|
|
2253
|
+
if (!url) return missingArg('dir', '给本地站点产物目录(dir,推荐),或给网址(url)——注意 CLI 的 seo audit 只做本地目录体检、不联网', 'seo audit')
|
|
2254
|
+
return {
|
|
2255
|
+
ok: false,
|
|
2256
|
+
code: -1,
|
|
2257
|
+
command: 'seo audit',
|
|
2258
|
+
supported: false,
|
|
2259
|
+
executed: false,
|
|
2260
|
+
needsConfirm: false,
|
|
2261
|
+
reason: '',
|
|
2262
|
+
gap: {
|
|
2263
|
+
code: 'URL_AUDIT_UNSUPPORTED',
|
|
2264
|
+
message: '`wkgeo seo audit` 只接受本地产物目录:CLI 这一层不联网,拿到网址也不会去抓页面。',
|
|
2265
|
+
instead: '先把网址落成本地目录再传 dir:wkgeo template init --mode url --url ' + url + ' --key <key> --out <目录>(Chrome 抓站镜像),或直接用已发布的产物目录(通常是站点的 dist 目录)。',
|
|
2266
|
+
},
|
|
2267
|
+
stderr: '',
|
|
2268
|
+
error: '只给 url 不能体检:`wkgeo seo audit` 只做本地产物目录、不联网(没有假装抓取成功)',
|
|
2269
|
+
errorCode: 'URL_AUDIT_UNSUPPORTED',
|
|
2270
|
+
errorHint: '用 dir 指向本地站点产物目录;要按网址体检就先把网址落成本地目录(wkgeo_template_init 的 mode=url),再传 dir。',
|
|
2271
|
+
}
|
|
2272
|
+
}
|
|
2273
|
+
const res = await runCli(buildSeoAuditArgs({ dir }))
|
|
2274
|
+
return normalizeCliResult(res, { command: 'seo audit' })
|
|
2275
|
+
},
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
return [sitesList, siteStatus, sitePreview, publish, siteCreate, templateFromUrl, templateFromDir, contentApply, contentFill, contentCheck, templatesList, templateInit, templateValidate, templatePack, templatePublish, domainsList, geoAudit, geoLlms, geoRobots, geoBrands, seoAudit]
|
|
1976
2279
|
}
|
|
1977
2280
|
|
|
1978
2281
|
// ---------------------------------------------------------------------------
|
package/locale/en.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"meta": {
|
|
3
3
|
"title": "Site Operations",
|
|
4
|
-
"description": "
|
|
4
|
+
"description": "All-in-one website operations for the AI era: AI site building, one-click import of existing sites, and automatic GEO/SEO structure generation."
|
|
5
5
|
}
|
|
6
6
|
}
|
package/locale/zh.json
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-wkgeo-ops",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.4",
|
|
4
|
+
"description": "AI 时代的一站式网站运营工具:AI 建站、老网站一键导入、GEO·SEO 结构自动生成。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"icon": "assets/icon.png",
|
|
@@ -40,6 +40,8 @@
|
|
|
40
40
|
"./client": "./client/client.js",
|
|
41
41
|
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
42
42
|
"./locale/*.json": "./locale/*.json",
|
|
43
|
+
"./lib/*": "./lib/*",
|
|
44
|
+
"./assets/*": "./assets/*",
|
|
43
45
|
"./package.json": "./package.json"
|
|
44
46
|
},
|
|
45
47
|
"peerDependencies": { "@deepseek-ai/cordis": "^4.0.1" }
|