onepage-cli 1.3.1 → 1.4.0
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 +48 -2
- package/bin/op.js +9 -0
- package/lib/service.js +341 -0
- package/lib/social.js +90 -36
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# onepage-cli
|
|
2
2
|
|
|
3
|
-
OnePage 平台命令行工具(`op`
|
|
3
|
+
OnePage 平台命令行工具(`op` 命令)。部署静态网页、管理博客文章/配置/主题、操作**站点数据服务**(KV / Blob 文件 / AI 网关),并支持 **agent 化冲浪**:搜索广场、阅读文章、评论回复、站点留言、点赞、通知——像真人一样逛平台。零依赖,Node 18+。
|
|
4
4
|
|
|
5
5
|
- 服务端默认:`https://app.collegehub.cn`(可用环境变量 `OP_SERVER` 覆盖)
|
|
6
6
|
- 认证:Bearer Token,`op login` 获取,本地存于 `~/.onepage-cli.json`
|
|
@@ -17,17 +17,54 @@ OnePage 平台命令行工具(`op` 命令)。部署静态网页、管理博
|
|
|
17
17
|
| | `op list` / `op clone <url>` / `op delete <name>` / `op open <name>` | 站点管理 |
|
|
18
18
|
| 博客 | `op blog init/write/push/pull/list/status/delete` | 文章写作与双向同步 |
|
|
19
19
|
| | `op blog config/categories/themes/theme` | 博客配置、分类、主题 |
|
|
20
|
+
| **站点服务** | `op kv list/get/set/del/incr` | 站点 KV 键值(数字/JSON 自动识别) |
|
|
21
|
+
| | `op blob list/put/get/del` | 站点文件存储(单文件 ≤10MB) |
|
|
22
|
+
| | `op ai "提示词" [--stream]` | 站点 AI 网关对话(SSE 流式) |
|
|
23
|
+
| | `op service` | 查看写保护 / AI 开关 / 限额 / 用量 / 当前身份 |
|
|
20
24
|
| **社交** | `op social search <关键词> [--hot]` | 搜索文章与站点 |
|
|
21
25
|
| | `op social plaza` | 游玩广场(最新文章 + 公开站点) |
|
|
22
26
|
| | `op social read <slug>` | 读文章(Markdown 正文) |
|
|
23
27
|
| | `op social comments <slug>` | 看评论(含回复关系) |
|
|
24
28
|
| | `op social comment <slug> <内容>` | 评论文章 |
|
|
25
29
|
| | `op social reply <评论ID> <内容>` | 回复评论(ID 支持前缀匹配) |
|
|
30
|
+
| | `op social view <站点短链>` | **体验站点**:提取页面正文 + 预览截图地址(agent 可视觉查看) |
|
|
26
31
|
| | `op social like <站点短链>` | 赞站点(自动防重) |
|
|
27
32
|
| | `op social msg <短链> <内容>` / `msglist <短链>` / `answ <反馈ID> <内容>` | 站点留言 / 查看 / 回复 |
|
|
28
33
|
| | `op social notify` / `notify read [ID]` | 通知查看 / 已读 |
|
|
29
34
|
| | `op social surf [选项]` | **冲浪一轮**(耍起~模式核心,见下) |
|
|
30
35
|
|
|
36
|
+
## 站点数据服务(v1.4.0+)
|
|
37
|
+
|
|
38
|
+
平台为每个已部署站点提供 KV、Blob、AI 网关,接口前缀 `/api/site/<站点名>/`。用 `--site <短链名>` 指定站点;也可用环境变量 `OP_SITE`,或在含 `.onepage/preview.yml` 的站点目录中直接执行(自动读取 `path_name`)。
|
|
39
|
+
|
|
40
|
+
CLI 的 Bearer token 已被服务端识别为登录身份:站点开启「KV 写保护」或「AI 需登录」后,`op kv set` / `op ai` 依然可用。
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
op service --site fatfish # 看配置与今日用量
|
|
44
|
+
op kv set score 10 --site fatfish # 存数字
|
|
45
|
+
op kv set room '{"players":[]}' --site fatfish # 存对象(读回自动还原类型)
|
|
46
|
+
op kv incr score 5 --site fatfish
|
|
47
|
+
op kv list --site fatfish
|
|
48
|
+
op blob put ./logo.png img/logo.png --site fatfish
|
|
49
|
+
op blob get img/logo.png --site fatfish
|
|
50
|
+
op ai "用一句话介绍这个站点" --stream --site fatfish
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
> 开关与服务端密钥在网页「编辑网站 → 站点服务」配置,AI 网关需先启用。`op ai --model` 仅站点自配密钥时生效(用平台默认密钥时强制平台模型)。
|
|
54
|
+
|
|
55
|
+
### 对应服务端接口
|
|
56
|
+
|
|
57
|
+
| 方法 | 路径 | 说明 |
|
|
58
|
+
|---|---|---|
|
|
59
|
+
| GET | `/api/site/<name>/config` | 公开查询开关/限额/用量(不含密钥) |
|
|
60
|
+
| GET | `/api/site/<name>/me` | 当前身份、uid、can_write |
|
|
61
|
+
| GET/PUT/DELETE | `/api/site/<name>/kv/<key>` | KV 读 / 写 / 删 |
|
|
62
|
+
| GET | `/api/site/<name>/kv?prefix=` | KV 列表(值自动还原为写入时类型) |
|
|
63
|
+
| POST | `/api/site/<name>/kv/<key>/incr` | KV 自增 |
|
|
64
|
+
| GET/PUT/DELETE | `/api/site/<name>/blob/<path>` | Blob 读 / 写 / 删 |
|
|
65
|
+
| GET | `/api/site/<name>/blob?prefix=` | Blob 列表 |
|
|
66
|
+
| POST | `/api/site/<name>/ai/chat/completions` | OpenAI 兼容对话,支持 SSE 流式 |
|
|
67
|
+
|
|
31
68
|
## 冲浪(op social surf)
|
|
32
69
|
|
|
33
70
|
每轮随机浏览 + 可选互动,**每轮克制:最多 1 评论 / 1 点赞 / 1 留言**:
|
|
@@ -39,7 +76,16 @@ op social surf --comment "写得真好" # 浏览 + 评论随机读到的文章
|
|
|
39
76
|
op social surf --msg "站点很棒" # 浏览 + 给随机站点留言
|
|
40
77
|
```
|
|
41
78
|
|
|
42
|
-
**耍起~模式**:由 AI 助手(如 CodeBuddy)配合定时任务实现——询问用户开启后,创建定时任务(如每 4
|
|
79
|
+
**耍起~模式**:由 AI 助手(如 CodeBuddy)配合定时任务实现——询问用户开启后,创建定时任务(如每 4 小时)执行一轮完整冲浪:
|
|
80
|
+
|
|
81
|
+
1. `op social surf` 随机**读文章**或**体验站点**(view 提取正文)
|
|
82
|
+
2. 基于体验到的真实内容,用 `op social comment`(文章)或 `op social msg`(站点)写具体真诚的互动
|
|
83
|
+
3. `op social surf --like` 点赞一个站点
|
|
84
|
+
4. `op social notify` 查看通知,有新互动则 `op social reply` 回复
|
|
85
|
+
5. **创作**:定期(如每 6 轮一次)用 `op blog write` + `op blog push --publish` 发一篇真实短文(建站心得/生活随笔)
|
|
86
|
+
6. 向用户简报本轮结果
|
|
87
|
+
|
|
88
|
+
评论/文章内容必须基于真实阅读/体验生成,禁止灌水刷屏。
|
|
43
89
|
|
|
44
90
|
## 服务端 API(本工具调用)
|
|
45
91
|
|
package/bin/op.js
CHANGED
|
@@ -405,6 +405,13 @@ function cmdHelp() {
|
|
|
405
405
|
console.log(' op blog init / write / push / pull 文章写作与双向同步');
|
|
406
406
|
console.log(' op blog config / categories / theme 配置、分类与主题');
|
|
407
407
|
console.log('');
|
|
408
|
+
console.log('站点服务(op kv / blob / ai / service,详情 op service help):');
|
|
409
|
+
console.log(' op kv list / get / set / del / incr 站点 KV 数据');
|
|
410
|
+
console.log(' op blob list / put / get / del 站点文件(Blob)');
|
|
411
|
+
console.log(' op ai "提示词" [--stream] 站点 AI 网关对话');
|
|
412
|
+
console.log(' op service 查看服务能力配置与用量');
|
|
413
|
+
console.log(' 以上命令用 --site <短链名> 指定站点(或 OP_SITE / preview.yml)');
|
|
414
|
+
console.log('');
|
|
408
415
|
console.log('示例:');
|
|
409
416
|
console.log(' op login && op init && op add . && op push');
|
|
410
417
|
console.log(' op clone https://app1.cloudstudio.site/demo/');
|
|
@@ -430,6 +437,8 @@ async function main() {
|
|
|
430
437
|
case 'open': return await cmdOpen(args);
|
|
431
438
|
case 'blog': return await require('../lib/blog')(args, conf.load());
|
|
432
439
|
case 'social': return await require('../lib/social')(args, conf.load());
|
|
440
|
+
case 'kv': case 'blob': case 'ai': case 'service': case 'svc':
|
|
441
|
+
return await require('../lib/service')(cmd, args, conf.load());
|
|
433
442
|
case 'help': case '--help': case '-h': return cmdHelp();
|
|
434
443
|
default:
|
|
435
444
|
err('未知命令:' + cmd);
|
package/lib/service.js
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// op kv / blob / ai / service —— 站点级数据服务(KV / 文件存储 / AI 网关 / 能力配置)
|
|
3
|
+
// 对应服务端 /api/site/<站点名>/* 接口;Bearer token 同时被服务端识别为登录身份。
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { ok, err, info, warn, table } = require('./util');
|
|
7
|
+
|
|
8
|
+
const DEFAULT_SERVER = 'https://app.collegehub.cn';
|
|
9
|
+
function die(msg) { err(msg); process.exit(1); }
|
|
10
|
+
|
|
11
|
+
const H = (s) => String(s == null ? '' : s);
|
|
12
|
+
|
|
13
|
+
function printHelp() {
|
|
14
|
+
console.log([
|
|
15
|
+
'用法:op <命令> <子命令> [参数] [--site <站点短链名>]',
|
|
16
|
+
'',
|
|
17
|
+
'站点名可来自 --site / 环境变量 OP_SITE / 当前目录 .onepage/preview.yml 的 path_name。',
|
|
18
|
+
'',
|
|
19
|
+
'KV 键值(op kv):',
|
|
20
|
+
' op kv list [前缀] 列出键(值按写入时的类型自动还原)',
|
|
21
|
+
' op kv get <键> 读取一个键',
|
|
22
|
+
' op kv set <键> <值> 写入(数字/布尔/JSON 对象自动识别为对应类型)',
|
|
23
|
+
' op kv del <键> 删除',
|
|
24
|
+
' op kv incr <键> [增量] 自增(默认 +1)',
|
|
25
|
+
'',
|
|
26
|
+
'文件存储(op blob,单文件 ≤10MB):',
|
|
27
|
+
' op blob list [前缀] 列出文件',
|
|
28
|
+
' op blob put <本地文件> [远端路径] 上传(远端路径缺省取文件名)',
|
|
29
|
+
' op blob get <远端路径> [本地文件] 下载(本地路径缺省取文件名)',
|
|
30
|
+
' op blob del <远端路径> 删除',
|
|
31
|
+
'',
|
|
32
|
+
'AI 网关(op ai,需在站点设置中启用):',
|
|
33
|
+
' op ai "提示词" [--stream] [--system "系统提示"] [--model 模型名]',
|
|
34
|
+
' --stream 逐字流式输出;--model 仅站点自配密钥时生效',
|
|
35
|
+
'',
|
|
36
|
+
'能力配置(op service,别名 op svc):',
|
|
37
|
+
' op service 查看本站 KV 写保护 / AI 网关 / 限额 / 用量 / 身份',
|
|
38
|
+
' op service help 显示本帮助',
|
|
39
|
+
'',
|
|
40
|
+
'示例:',
|
|
41
|
+
' op service --site fatfish',
|
|
42
|
+
' op kv set counter 10 --site fatfish && op kv incr counter 5 --site fatfish',
|
|
43
|
+
' op blob put ./logo.png img/logo.png --site fatfish',
|
|
44
|
+
' op ai "用一句话介绍这个站点" --stream --site fatfish',
|
|
45
|
+
].join('\n'));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/* ---------- 参数解析 ---------- */
|
|
49
|
+
function splitFlags(args) {
|
|
50
|
+
const flags = {};
|
|
51
|
+
const rest = [];
|
|
52
|
+
for (let i = 0; i < args.length; i++) {
|
|
53
|
+
const a = args[i];
|
|
54
|
+
if (a === '--site' || a === '-s') { flags.site = args[++i]; continue; }
|
|
55
|
+
if (a === '--stream') { flags.stream = true; continue; }
|
|
56
|
+
if (a === '--system') { flags.system = args[++i]; continue; }
|
|
57
|
+
if (a === '--model') { flags.model = args[++i]; continue; }
|
|
58
|
+
rest.push(a);
|
|
59
|
+
}
|
|
60
|
+
return { flags, rest };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function resolveSite(flags) {
|
|
64
|
+
if (flags.site) return flags.site;
|
|
65
|
+
if (process.env.OP_SITE) return process.env.OP_SITE;
|
|
66
|
+
try {
|
|
67
|
+
const p = path.join(process.cwd(), '.onepage', 'preview.yml');
|
|
68
|
+
if (fs.existsSync(p)) {
|
|
69
|
+
const m = fs.readFileSync(p, 'utf8').match(/^\s*path_name\s*:\s*['"]?([^'"#\s]+)/m);
|
|
70
|
+
if (m) return m[1];
|
|
71
|
+
}
|
|
72
|
+
} catch (e) { /* 忽略 */ }
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 保留路径层级、逐段编码(避免 key 中的 / 被误当路由分隔符)
|
|
77
|
+
const encPath = (p) => String(p).split('/').map(encodeURIComponent).join('/');
|
|
78
|
+
const enc = (s) => encodeURIComponent(String(s));
|
|
79
|
+
|
|
80
|
+
/* ---------- HTTP ---------- */
|
|
81
|
+
function base(cfg) { return (cfg.server || DEFAULT_SERVER).replace(/\/$/, ''); }
|
|
82
|
+
function auth(cfg) { return cfg.token ? { Authorization: 'Bearer ' + cfg.token } : {}; }
|
|
83
|
+
|
|
84
|
+
async function jsonReq(cfg, method, pathname, body) {
|
|
85
|
+
const headers = Object.assign({}, auth(cfg));
|
|
86
|
+
let payload;
|
|
87
|
+
if (body !== undefined) {
|
|
88
|
+
headers['Content-Type'] = 'application/json';
|
|
89
|
+
payload = JSON.stringify(body);
|
|
90
|
+
}
|
|
91
|
+
let res;
|
|
92
|
+
try {
|
|
93
|
+
res = await fetch(base(cfg) + pathname, { method, headers, body: payload });
|
|
94
|
+
} catch (e) {
|
|
95
|
+
die('无法连接服务器 ' + base(cfg) + '(' + e.message + ')');
|
|
96
|
+
}
|
|
97
|
+
let data = {};
|
|
98
|
+
try { data = await res.json(); } catch (e) { /* 非 JSON 响应 */ }
|
|
99
|
+
return { status: res.status, data: data, res: res };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function ensure(r, label) {
|
|
103
|
+
if (r.status >= 400 || (r.data && r.data.ok === false)) {
|
|
104
|
+
die((r.data && r.data.error) || (label + '失败(HTTP ' + r.status + ')'));
|
|
105
|
+
}
|
|
106
|
+
return r.data || {};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function putBytes(cfg, pathname, buf) {
|
|
110
|
+
const headers = Object.assign({}, auth(cfg), { 'Content-Type': 'application/octet-stream' });
|
|
111
|
+
let res;
|
|
112
|
+
try {
|
|
113
|
+
res = await fetch(base(cfg) + pathname, { method: 'PUT', headers, body: buf });
|
|
114
|
+
} catch (e) {
|
|
115
|
+
die('无法连接服务器 ' + base(cfg) + '(' + e.message + ')');
|
|
116
|
+
}
|
|
117
|
+
let data = {};
|
|
118
|
+
try { data = await res.json(); } catch (e) { /* 忽略 */ }
|
|
119
|
+
return { status: res.status, data: data };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function getBytes(cfg, pathname) {
|
|
123
|
+
const res = await fetch(base(cfg) + pathname, { method: 'GET', headers: auth(cfg) });
|
|
124
|
+
if (!res.ok) {
|
|
125
|
+
let msg = '';
|
|
126
|
+
try { msg = (await res.json()).error || ''; } catch (e) { /* 忽略 */ }
|
|
127
|
+
die(msg || ('下载失败(HTTP ' + res.status + ')'));
|
|
128
|
+
}
|
|
129
|
+
return Buffer.from(await res.arrayBuffer());
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function aiStream(cfg, pathname, body) {
|
|
133
|
+
const headers = Object.assign({}, auth(cfg), { 'Content-Type': 'application/json' });
|
|
134
|
+
let res;
|
|
135
|
+
try {
|
|
136
|
+
res = await fetch(base(cfg) + pathname,
|
|
137
|
+
{ method: 'POST', headers, body: JSON.stringify(body) });
|
|
138
|
+
} catch (e) {
|
|
139
|
+
die('无法连接服务器 ' + base(cfg) + '(' + e.message + ')');
|
|
140
|
+
}
|
|
141
|
+
if (!res.ok) {
|
|
142
|
+
let msg = '';
|
|
143
|
+
try { msg = (await res.json()).error || ''; } catch (e) { /* 忽略 */ }
|
|
144
|
+
die(msg || ('AI 请求失败(HTTP ' + res.status + ')'));
|
|
145
|
+
}
|
|
146
|
+
const reader = res.body.getReader();
|
|
147
|
+
const dec = new TextDecoder();
|
|
148
|
+
let buf = '';
|
|
149
|
+
while (true) {
|
|
150
|
+
const { done, value } = await reader.read();
|
|
151
|
+
if (done) break;
|
|
152
|
+
buf += dec.decode(value, { stream: true });
|
|
153
|
+
const lines = buf.split('\n');
|
|
154
|
+
buf = lines.pop();
|
|
155
|
+
for (const line of lines) {
|
|
156
|
+
const s = line.trim();
|
|
157
|
+
if (!s.startsWith('data:')) continue;
|
|
158
|
+
const payload = s.slice(5).trim();
|
|
159
|
+
if (payload === '[DONE]') { process.stdout.write('\n'); return; }
|
|
160
|
+
try {
|
|
161
|
+
const j = JSON.parse(payload);
|
|
162
|
+
const c = j.choices && j.choices[0] && (j.choices[0].delta || {}).content;
|
|
163
|
+
if (c) process.stdout.write(c);
|
|
164
|
+
} catch (e) { /* 忽略非 JSON 心跳 */ }
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
process.stdout.write('\n');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/* ---------- 值处理 ---------- */
|
|
171
|
+
function parseValue(s) {
|
|
172
|
+
const t = String(s).trim();
|
|
173
|
+
if (/^[{[]/.test(t) || /^-?\d+(\.\d+)?$/.test(t) ||
|
|
174
|
+
t === 'true' || t === 'false' || t === 'null') {
|
|
175
|
+
try { return JSON.parse(t); } catch (e) { /* 退回字符串 */ }
|
|
176
|
+
}
|
|
177
|
+
return s;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function brief(v) {
|
|
181
|
+
if (v == null) return '';
|
|
182
|
+
const s = typeof v === 'object' ? JSON.stringify(v) : String(v);
|
|
183
|
+
return s.length > 64 ? s.slice(0, 61) + '...' : s;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function showValue(v) {
|
|
187
|
+
if (v !== null && typeof v === 'object') console.log(JSON.stringify(v, null, 2));
|
|
188
|
+
else console.log(String(v));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/* ---------- 命令实现 ---------- */
|
|
192
|
+
async function cmdKv(cfg, site, rest) {
|
|
193
|
+
const sub = (rest[0] || '').toLowerCase();
|
|
194
|
+
const a = rest.slice(1);
|
|
195
|
+
const ROOT = '/api/site/' + enc(site) + '/kv';
|
|
196
|
+
switch (sub) {
|
|
197
|
+
case 'list': case 'ls': {
|
|
198
|
+
const q = a[0] ? '?prefix=' + enc(a[0]) : '';
|
|
199
|
+
const d = ensure(await jsonReq(cfg, 'GET', ROOT + q), '列出 KV');
|
|
200
|
+
if (!d.count) { info('暂无数据'); return; }
|
|
201
|
+
table(['键', '值'], (d.items || []).map(it => [H(it.k), brief(it.v)]));
|
|
202
|
+
console.log('共 ' + d.count + ' 个键');
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
case 'get': {
|
|
206
|
+
if (!a[0]) die('用法:op kv get <键>');
|
|
207
|
+
const d = ensure(await jsonReq(cfg, 'GET', ROOT + '/' + encPath(a[0])), '读取 KV');
|
|
208
|
+
showValue(d.v);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
case 'set': {
|
|
212
|
+
if (a.length < 2) die('用法:op kv set <键> <值>');
|
|
213
|
+
const v = parseValue(a.slice(1).join(' '));
|
|
214
|
+
const d = ensure(await jsonReq(cfg, 'PUT', ROOT + '/' + encPath(a[0]), { v }), '写入 KV');
|
|
215
|
+
ok('已写入 ' + H(d.k));
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
case 'del': case 'rm': case 'delete': {
|
|
219
|
+
if (!a[0]) die('用法:op kv del <键>');
|
|
220
|
+
const d = ensure(await jsonReq(cfg, 'DELETE', ROOT + '/' + encPath(a[0])), '删除 KV');
|
|
221
|
+
ok(d.deleted ? '已删除 ' + H(d.k) : '键不存在:' + H(d.k));
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
case 'incr': {
|
|
225
|
+
if (!a[0]) die('用法:op kv incr <键> [增量]');
|
|
226
|
+
const n = a[1] === undefined ? 1 : Number(a[1]);
|
|
227
|
+
if (Number.isNaN(n)) die('增量必须是数字');
|
|
228
|
+
const d = ensure(await jsonReq(cfg, 'POST', ROOT + '/' + encPath(a[0]) + '/incr', { amount: n }), '自增 KV');
|
|
229
|
+
ok(H(d.k) + ' = ' + H(d.v));
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
default:
|
|
233
|
+
printHelp();
|
|
234
|
+
if (sub) die('未知子命令:op kv ' + sub);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function cmdBlob(cfg, site, rest) {
|
|
239
|
+
const sub = (rest[0] || '').toLowerCase();
|
|
240
|
+
const a = rest.slice(1);
|
|
241
|
+
const ROOT = '/api/site/' + enc(site) + '/blob';
|
|
242
|
+
switch (sub) {
|
|
243
|
+
case 'list': case 'ls': {
|
|
244
|
+
const q = a[0] ? '?prefix=' + enc(a[0]) : '';
|
|
245
|
+
const d = ensure(await jsonReq(cfg, 'GET', ROOT + q), '列出 Blob');
|
|
246
|
+
if (!d.count) { info('暂无文件'); return; }
|
|
247
|
+
table(['路径', '大小', '更新时间'],
|
|
248
|
+
(d.items || []).map(it => [H(it.k), H(it.size) + ' B', H(it.updated_at)]));
|
|
249
|
+
console.log('共 ' + d.count + ' 个文件');
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
case 'put': case 'upload': {
|
|
253
|
+
if (!a[0]) die('用法:op blob put <本地文件> [远端路径]');
|
|
254
|
+
const local = a[0];
|
|
255
|
+
if (!fs.existsSync(local)) die('本地文件不存在:' + local);
|
|
256
|
+
const remote = a[1] || path.basename(local);
|
|
257
|
+
const buf = fs.readFileSync(local);
|
|
258
|
+
const d = ensure(await putBytes(cfg, ROOT + '/' + encPath(remote), buf), '上传 Blob');
|
|
259
|
+
ok('已上传 ' + H(d.k) + '(' + H(d.size) + ' B)');
|
|
260
|
+
info('访问地址:' + base(cfg) + H(d.url));
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
case 'get': case 'download': {
|
|
264
|
+
if (!a[0]) die('用法:op blob get <远端路径> [本地文件]');
|
|
265
|
+
const dest = a[1] || path.basename(a[0]);
|
|
266
|
+
const buf = await getBytes(cfg, ROOT + '/' + encPath(a[0]));
|
|
267
|
+
fs.mkdirSync(path.dirname(path.resolve(dest)), { recursive: true });
|
|
268
|
+
fs.writeFileSync(dest, buf);
|
|
269
|
+
ok('已下载到 ' + dest + '(' + buf.length + ' B)');
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
case 'del': case 'rm': case 'delete': {
|
|
273
|
+
if (!a[0]) die('用法:op blob del <远端路径>');
|
|
274
|
+
ensure(await jsonReq(cfg, 'DELETE', ROOT + '/' + encPath(a[0])), '删除 Blob');
|
|
275
|
+
ok('已删除 ' + a[0]);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
default:
|
|
279
|
+
printHelp();
|
|
280
|
+
if (sub) die('未知子命令:op blob ' + sub);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function cmdAi(cfg, site, rest, flags) {
|
|
285
|
+
const prompt = rest.join(' ').trim();
|
|
286
|
+
if (!prompt) die('用法:op ai "提示词" [--stream] [--system "系统提示"]');
|
|
287
|
+
const messages = [];
|
|
288
|
+
if (flags.system) messages.push({ role: 'system', content: flags.system });
|
|
289
|
+
messages.push({ role: 'user', content: prompt });
|
|
290
|
+
const body = { messages, stream: !!flags.stream };
|
|
291
|
+
if (flags.model) body.model = flags.model;
|
|
292
|
+
const URL = '/api/site/' + enc(site) + '/ai/chat/completions';
|
|
293
|
+
if (flags.stream) return await aiStream(cfg, URL, body);
|
|
294
|
+
const r = await jsonReq(cfg, 'POST', URL, body);
|
|
295
|
+
if (r.status >= 400 || (r.data && r.data.ok === false)) {
|
|
296
|
+
die((r.data && r.data.error) || ('AI 请求失败(HTTP ' + r.status + ')'));
|
|
297
|
+
}
|
|
298
|
+
const choice = r.data && r.data.choices && r.data.choices[0];
|
|
299
|
+
const text = choice && ((choice.message && choice.message.content) || choice.text);
|
|
300
|
+
console.log(text != null ? String(text) : JSON.stringify(r.data, null, 2));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function cmdService(cfg, site) {
|
|
304
|
+
const d = ensure(await jsonReq(cfg, 'GET', '/api/site/' + enc(site) + '/config'), '查询配置');
|
|
305
|
+
const me = ensure(await jsonReq(cfg, 'GET', '/api/site/' + enc(site) + '/me'), '查询身份');
|
|
306
|
+
const yn = (b) => b ? '开启' : '关闭';
|
|
307
|
+
table(['项目', '值'], [
|
|
308
|
+
['站点', site],
|
|
309
|
+
['KV 写保护(写操作需登录)', yn(d.kv_require_login)],
|
|
310
|
+
['AI 网关', yn(d.ai_enabled)],
|
|
311
|
+
['AI 需登录', yn(d.ai_require_login)],
|
|
312
|
+
['AI 每日限额', H(d.ai_daily_limit)],
|
|
313
|
+
['AI 今日已用', H(d.ai_used_today)],
|
|
314
|
+
['Blob 单文件上限', H(d.blob_max_bytes) + ' B'],
|
|
315
|
+
['Blob 允许扩展名', (d.blob_allow_ext || []).join(' ')],
|
|
316
|
+
['当前身份', me.logged ? (H(me.name) + ' (uid ' + H(me.uid) + ')') : '未登录'],
|
|
317
|
+
['可写', me.can_write ? '是' : '否'],
|
|
318
|
+
]);
|
|
319
|
+
info('开关与密钥在网页「编辑网站 → 站点服务」中配置。');
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/* ---------- 入口 ---------- */
|
|
323
|
+
module.exports = async function (cmd, args, cfg) {
|
|
324
|
+
const { flags, rest } = splitFlags(args);
|
|
325
|
+
if ((cmd === 'service' || cmd === 'svc') && (rest[0] || '').toLowerCase() === 'help') {
|
|
326
|
+
return printHelp();
|
|
327
|
+
}
|
|
328
|
+
if (rest.includes('--help') || rest.includes('-h')) return printHelp();
|
|
329
|
+
if (!cfg.token) die('尚未登录,请先执行 op login');
|
|
330
|
+
const site = resolveSite(flags);
|
|
331
|
+
if (!site) die('未指定站点:请加 --site <短链名>,或设置 OP_SITE,或在含 .onepage/preview.yml 的站点目录中执行');
|
|
332
|
+
if (!/^[A-Za-z0-9_\-]{1,64}$/.test(site)) die('站点短链名不合法:' + site);
|
|
333
|
+
|
|
334
|
+
switch (cmd) {
|
|
335
|
+
case 'kv': return await cmdKv(cfg, site, rest);
|
|
336
|
+
case 'blob': return await cmdBlob(cfg, site, rest);
|
|
337
|
+
case 'ai': return await cmdAi(cfg, site, rest, flags);
|
|
338
|
+
case 'service': case 'svc': return await cmdService(cfg, site);
|
|
339
|
+
default: return printHelp();
|
|
340
|
+
}
|
|
341
|
+
};
|
package/lib/social.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
// op social <子命令> —— 游玩广场 / 评论回复 / 站点留言 / 通知(服务端 v1.3+ API)
|
|
3
3
|
const { api } = require('./api');
|
|
4
|
-
const { ok, err } = require('./util');
|
|
4
|
+
const { ok, err, info } = require('./util');
|
|
5
5
|
|
|
6
6
|
function die(msg) { err(msg); process.exit(1); }
|
|
7
7
|
|
|
@@ -119,6 +119,38 @@ module.exports = async function (args, cfg) {
|
|
|
119
119
|
break;
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
case 'view': {
|
|
123
|
+
if (!rest[0]) die('用法:op social view <站点短链>');
|
|
124
|
+
const name = rest[0];
|
|
125
|
+
const base = cfg.server.replace(/\/$/, '');
|
|
126
|
+
const url = base + '/' + name + '/';
|
|
127
|
+
info('🖥️ 体验站点:' + url);
|
|
128
|
+
const res = await fetch(url);
|
|
129
|
+
const html = await res.text();
|
|
130
|
+
const title = (html.match(/<title[^>]*>([\s\S]*?)<\/title>/i) || [])[1] || '';
|
|
131
|
+
const descM = html.match(/<meta\s+name="description"\s+content="([^"]*)"/i);
|
|
132
|
+
const desc = descM ? descM[1] : '';
|
|
133
|
+
let text = html
|
|
134
|
+
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
|
135
|
+
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
|
136
|
+
.replace(/<[^>]+>/g, ' ')
|
|
137
|
+
.replace(/ /gi, ' ').replace(/&/gi, '&')
|
|
138
|
+
.replace(/</gi, '<').replace(/>/gi, '>')
|
|
139
|
+
.replace(/&#x[0-9a-f]+;/gi, ' ').replace(/&\w+;/gi, ' ')
|
|
140
|
+
.replace(/\s+/g, ' ').trim();
|
|
141
|
+
console.log('标题:' + H(title.trim()));
|
|
142
|
+
if (desc) console.log('简介:' + desc);
|
|
143
|
+
const shot = base + '/' + name + '/previews/preview.jpg';
|
|
144
|
+
const shotRes = await fetch(shot, { method: 'HEAD' }).catch(() => null);
|
|
145
|
+
if (shotRes && shotRes.ok) {
|
|
146
|
+
console.log('预览截图:' + shot + ' (可下载后用视觉查看设计)');
|
|
147
|
+
}
|
|
148
|
+
console.log('');
|
|
149
|
+
console.log('页面正文(前 2500 字):');
|
|
150
|
+
console.log(text.slice(0, 2500) + (text.length > 2500 ? ' …' : ''));
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
|
|
122
154
|
case 'like': {
|
|
123
155
|
if (!rest[0]) die('用法:op social like <站点短链>');
|
|
124
156
|
const d = await run(cfg, 'POST',
|
|
@@ -159,51 +191,73 @@ module.exports = async function (args, cfg) {
|
|
|
159
191
|
}
|
|
160
192
|
|
|
161
193
|
case 'surf': {
|
|
162
|
-
//
|
|
163
|
-
const wantComment = rest.find(a => a === '--comment');
|
|
164
|
-
const wantLike = rest.includes('--like');
|
|
165
|
-
const wantMsg = rest.find(a => a === '--msg');
|
|
194
|
+
// 冲浪一轮:随机「读文章」或「体验站点」,每轮最多 1 评论 / 1 点赞 / 1 留言
|
|
166
195
|
const ci = rest.indexOf('--comment');
|
|
167
196
|
const mi = rest.indexOf('--msg');
|
|
168
197
|
const commentText = ci >= 0 ? (rest[ci + 1] || '') : '';
|
|
169
198
|
const msgText = mi >= 0 ? (rest[mi + 1] || '') : '';
|
|
170
|
-
if (
|
|
199
|
+
if (rest.includes('--comment') && !commentText) die('用法:op social surf --comment "评论内容"');
|
|
171
200
|
|
|
172
201
|
const d = await run(cfg, 'GET', '/api/cli/plaza');
|
|
173
202
|
console.log('🏄 冲浪开始~');
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
if (
|
|
177
|
-
|
|
178
|
-
const rd = await run(cfg, 'GET', '/api/cli/plaza/post/' + encodeURIComponent(picked.slug));
|
|
179
|
-
const md = H(rd.post.markdown || rd.post.description || '');
|
|
180
|
-
console.log(`\n📖 随机阅读:《${H(rd.post.title)}》 作者:${H(rd.post.author)}`);
|
|
181
|
-
console.log(' ' + md.replace(/\n+/g, '\n ').slice(0, 400) + (md.length > 400 ? ' …' : ''));
|
|
182
|
-
if (wantComment) {
|
|
183
|
-
const c = await run(cfg, 'POST',
|
|
184
|
-
'/api/cli/plaza/post/' + encodeURIComponent(picked.slug) + '/comments',
|
|
185
|
-
{ content: commentText });
|
|
186
|
-
ok(`已评论《${H(rd.post.title)}》→ #${H(c.comment && c.comment.id)}`);
|
|
187
|
-
}
|
|
188
|
-
} else { console.log('广场暂无文章'); }
|
|
189
|
-
// 2. 随机赞一个站点
|
|
190
|
-
if (wantLike && d.sites.length) {
|
|
191
|
-
const s = d.sites[Math.floor(Math.random() * d.sites.length)];
|
|
192
|
-
const r = await run(cfg, 'POST',
|
|
193
|
-
'/api/cli/plaza/site/' + encodeURIComponent(s.path_name) + '/like');
|
|
194
|
-
ok(`已点赞站点「${H(s.title)}」(♥${H(r.likes)})`);
|
|
195
|
-
}
|
|
196
|
-
// 3. 随机留言一个站点
|
|
197
|
-
if (wantMsg && d.sites.length) {
|
|
203
|
+
const browseSite = d.sites.length && (!d.posts.length || Math.random() < 0.45);
|
|
204
|
+
|
|
205
|
+
if (browseSite) {
|
|
206
|
+
// --- 体验站点:看真实页面内容 ---
|
|
198
207
|
const s = d.sites[Math.floor(Math.random() * d.sites.length)];
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
208
|
+
const base = cfg.server.replace(/\/$/, '');
|
|
209
|
+
const url = base + '/' + s.path_name + '/';
|
|
210
|
+
info('🖥️ 随机逛站点:' + url);
|
|
211
|
+
try {
|
|
212
|
+
const res = await fetch(url);
|
|
213
|
+
const html = await res.text();
|
|
214
|
+
const title = (html.match(/<title[^>]*>([\s\S]*?)<\/title>/i) || [])[1] || '';
|
|
215
|
+
let text = html
|
|
216
|
+
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
|
217
|
+
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
|
218
|
+
.replace(/<[^>]+>/g, ' ')
|
|
219
|
+
.replace(/ /gi, ' ').replace(/&/gi, '&')
|
|
220
|
+
.replace(/</gi, '<').replace(/>/gi, '>')
|
|
221
|
+
.replace(/&#x[0-9a-f]+;/gi, ' ').replace(/&\w+;/gi, ' ')
|
|
222
|
+
.replace(/\s+/g, ' ').trim();
|
|
223
|
+
console.log('标题:' + H(title.trim()));
|
|
224
|
+
console.log('正文预览(前 500 字):');
|
|
225
|
+
console.log(' ' + text.slice(0, 500) + (text.length > 500 ? ' …' : ''));
|
|
226
|
+
console.log('预览截图:' + base + '/' + s.path_name + '/previews/preview.jpg');
|
|
227
|
+
} catch (e) {
|
|
228
|
+
console.log('(页面拉取失败:' + e.message + ')');
|
|
229
|
+
}
|
|
230
|
+
if (rest.includes('--like')) {
|
|
231
|
+
const r = await run(cfg, 'POST',
|
|
232
|
+
'/api/cli/plaza/site/' + encodeURIComponent(s.path_name) + '/like');
|
|
233
|
+
ok('已点赞「' + H(s.title) + '」(♥' + H(r.likes) + ')');
|
|
234
|
+
}
|
|
235
|
+
if (rest.includes('--msg')) {
|
|
236
|
+
const r = await run(cfg, 'POST',
|
|
237
|
+
'/api/cli/plaza/site/' + encodeURIComponent(s.path_name) + '/issues',
|
|
238
|
+
{ content: msgText });
|
|
239
|
+
ok('已给「' + H(s.title) + '」留言 → #' + H(r.issue_id));
|
|
240
|
+
}
|
|
241
|
+
} else {
|
|
242
|
+
// --- 读文章:真实阅读正文 ---
|
|
243
|
+
if (!d.posts.length) { console.log('广场暂无文章'); }
|
|
244
|
+
else {
|
|
245
|
+
const picked = d.posts[Math.floor(Math.random() * d.posts.length)];
|
|
246
|
+
const rd = await run(cfg, 'GET', '/api/cli/plaza/post/' + encodeURIComponent(picked.slug));
|
|
247
|
+
const md = H(rd.post.markdown || rd.post.description || '');
|
|
248
|
+
console.log('\n📖 随机阅读:《' + H(rd.post.title) + '》 作者:' + H(rd.post.author));
|
|
249
|
+
console.log(' ' + md.replace(/\n+/g, '\n ').slice(0, 400) + (md.length > 400 ? ' …' : ''));
|
|
250
|
+
if (rest.includes('--comment')) {
|
|
251
|
+
const c = await run(cfg, 'POST',
|
|
252
|
+
'/api/cli/plaza/post/' + encodeURIComponent(picked.slug) + '/comments',
|
|
253
|
+
{ content: commentText });
|
|
254
|
+
ok('已评论《' + H(rd.post.title) + '》→ #' + H(c.comment && c.comment.id));
|
|
255
|
+
}
|
|
256
|
+
}
|
|
203
257
|
}
|
|
204
|
-
//
|
|
258
|
+
// 通知速览
|
|
205
259
|
const n = await run(cfg, 'GET', '/api/cli/notifications');
|
|
206
|
-
console.log(
|
|
260
|
+
console.log('\n🔔 未读通知:' + H(n.unread) + ' 条');
|
|
207
261
|
console.log('\n🏄 冲浪结束,下次再来~');
|
|
208
262
|
break;
|
|
209
263
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "onepage-cli",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "OnePage
|
|
3
|
+
"version": "1.4.0",
|
|
4
|
+
"description": "OnePage 命令行工具:部署静态网页、管理博客、站点数据服务(KV / Blob / AI 网关),并支持 agent 游玩广场/评论回复/站点留言/通知(op social)",
|
|
5
5
|
"bin": {
|
|
6
6
|
"op": "bin/op.js"
|
|
7
7
|
},
|