koishi-plugin-rocom 1.0.13 → 1.0.18
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/lib/atlas-service.d.ts +33 -0
- package/lib/atlas-service.js +439 -0
- package/lib/client.d.ts +20 -2
- package/lib/client.js +99 -9
- package/lib/commands/account.js +1 -0
- package/lib/commands/egg.js +41 -11
- package/lib/commands/merchant.js +11 -2
- package/lib/commands/query.js +141 -9
- package/lib/commands/tools.js +104 -0
- package/lib/commands/wiki.js +167 -6
- package/lib/egg-service.d.ts +45 -12
- package/lib/egg-service.js +177 -90
- package/lib/index.d.ts +5 -0
- package/lib/index.js +180 -5775
- package/lib/pet-data-service.d.ts +62 -0
- package/lib/pet-data-service.js +468 -0
- package/lib/render-templates/pet-data/index.html +172 -0
- package/lib/render-templates/pet-data/style.css +553 -0
- package/lib/render-templates/wiki/detail/index.html +84 -0
- package/lib/render-templates/wiki/list/index.html +84 -0
- package/lib/render-templates/wiki/menu/index.html +65 -0
- package/lib/render-templates/wiki/pet/index.html +158 -0
- package/lib/render-templates/wiki/pet/style.css +393 -0
- package/lib/render-templates/wiki/shared/style.css +431 -0
- package/lib/render-templates/wiki/skill/index.html +79 -0
- package/lib/render-templates/wiki/skill/style.css +233 -0
- package/lib/render-templates/wiki/suggestions/index.html +84 -0
- package/lib/render.js +37 -8
- package/lib/types.d.ts +9 -1
- package/lib/user.d.ts +23 -1
- package/lib/user.js +32 -1
- package/lib/wiki-catalog.d.ts +19 -0
- package/lib/wiki-catalog.js +124 -0
- package/lib/wiki-service.d.ts +220 -0
- package/lib/wiki-service.js +1463 -0
- package/package.json +1 -1
- package/readme.md +4 -5
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Context } from 'koishi';
|
|
2
|
+
export type AtlasProgressCallback = (percent: number, stage: string) => void | Promise<void>;
|
|
3
|
+
export declare class AtlasService {
|
|
4
|
+
private dataDir;
|
|
5
|
+
private zipUrls;
|
|
6
|
+
private gitUrl;
|
|
7
|
+
constructor(dataDir: string, options?: {
|
|
8
|
+
zipUrls?: string[];
|
|
9
|
+
gitUrl?: string;
|
|
10
|
+
});
|
|
11
|
+
get atlasDir(): string;
|
|
12
|
+
private indexPath;
|
|
13
|
+
private petsDir;
|
|
14
|
+
private aliasPath;
|
|
15
|
+
isReady(): boolean;
|
|
16
|
+
loadIndex(): Record<string, string>;
|
|
17
|
+
private stripYamlValue;
|
|
18
|
+
loadPetAliases(): Record<string, string[]>;
|
|
19
|
+
private localImagePath;
|
|
20
|
+
private existingImagePath;
|
|
21
|
+
findMatch(query: string): {
|
|
22
|
+
name: string;
|
|
23
|
+
imagePath: string;
|
|
24
|
+
candidates: string[];
|
|
25
|
+
};
|
|
26
|
+
private prepareAtlasDir;
|
|
27
|
+
private replaceAtlasDir;
|
|
28
|
+
private downloadZipWithProgress;
|
|
29
|
+
download(ctx: Context, progressCb?: AtlasProgressCallback): Promise<{
|
|
30
|
+
imageCount: number;
|
|
31
|
+
totalBytes: number;
|
|
32
|
+
}>;
|
|
33
|
+
}
|
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.AtlasService = void 0;
|
|
7
|
+
const koishi_1 = require("koishi");
|
|
8
|
+
const node_child_process_1 = require("node:child_process");
|
|
9
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
10
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
11
|
+
const node_zlib_1 = __importDefault(require("node:zlib"));
|
|
12
|
+
const wiki_service_1 = require("./wiki-service");
|
|
13
|
+
const logger = new koishi_1.Logger('rocom-atlas');
|
|
14
|
+
const DEFAULT_ATLAS_ZIP_URLS = [
|
|
15
|
+
'https://codeload.github.com/Entropy-Increase-Team/Rocom-Atlas/zip/refs/heads/main',
|
|
16
|
+
'https://github.com/Entropy-Increase-Team/Rocom-Atlas/archive/refs/heads/main.zip',
|
|
17
|
+
];
|
|
18
|
+
const DEFAULT_ATLAS_GIT_URL = 'https://github.com/Entropy-Increase-Team/Rocom-Atlas.git';
|
|
19
|
+
// ===== 最小 ZIP 解包(stored + deflate),避免引入额外依赖 =====
|
|
20
|
+
function extractZip(buffer, destDir) {
|
|
21
|
+
// 定位 End of Central Directory(自尾部反向搜索签名 0x06054b50)
|
|
22
|
+
let eocd = -1;
|
|
23
|
+
const minEocd = Math.max(0, buffer.length - 65557);
|
|
24
|
+
for (let i = buffer.length - 22; i >= minEocd; i--) {
|
|
25
|
+
if (buffer.readUInt32LE(i) === 0x06054b50) {
|
|
26
|
+
eocd = i;
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (eocd < 0)
|
|
31
|
+
throw new Error('ZIP 结构异常:找不到中央目录结尾');
|
|
32
|
+
const entryCount = buffer.readUInt16LE(eocd + 10);
|
|
33
|
+
let offset = buffer.readUInt32LE(eocd + 16);
|
|
34
|
+
for (let i = 0; i < entryCount; i++) {
|
|
35
|
+
if (buffer.readUInt32LE(offset) !== 0x02014b50)
|
|
36
|
+
throw new Error('ZIP 结构异常:中央目录签名错误');
|
|
37
|
+
const compressionMethod = buffer.readUInt16LE(offset + 10);
|
|
38
|
+
const compressedSize = buffer.readUInt32LE(offset + 20);
|
|
39
|
+
const nameLength = buffer.readUInt16LE(offset + 28);
|
|
40
|
+
const extraLength = buffer.readUInt16LE(offset + 30);
|
|
41
|
+
const commentLength = buffer.readUInt16LE(offset + 32);
|
|
42
|
+
const localHeaderOffset = buffer.readUInt32LE(offset + 42);
|
|
43
|
+
const name = buffer.subarray(offset + 46, offset + 46 + nameLength).toString('utf8');
|
|
44
|
+
offset += 46 + nameLength + extraLength + commentLength;
|
|
45
|
+
// 拒绝路径穿越
|
|
46
|
+
const normalized = name.replace(/\\/g, '/');
|
|
47
|
+
if (normalized.includes('..'))
|
|
48
|
+
continue;
|
|
49
|
+
const target = node_path_1.default.join(destDir, ...normalized.split('/'));
|
|
50
|
+
if (!node_path_1.default.resolve(target).startsWith(node_path_1.default.resolve(destDir)))
|
|
51
|
+
continue;
|
|
52
|
+
if (normalized.endsWith('/')) {
|
|
53
|
+
node_fs_1.default.mkdirSync(target, { recursive: true });
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
// 读本地文件头拿数据真实偏移(本地头的 name/extra 长度可能与中央目录不同)
|
|
57
|
+
if (buffer.readUInt32LE(localHeaderOffset) !== 0x04034b50)
|
|
58
|
+
throw new Error('ZIP 结构异常:本地文件头签名错误');
|
|
59
|
+
const localNameLength = buffer.readUInt16LE(localHeaderOffset + 26);
|
|
60
|
+
const localExtraLength = buffer.readUInt16LE(localHeaderOffset + 28);
|
|
61
|
+
const dataStart = localHeaderOffset + 30 + localNameLength + localExtraLength;
|
|
62
|
+
const compressed = buffer.subarray(dataStart, dataStart + compressedSize);
|
|
63
|
+
let content;
|
|
64
|
+
if (compressionMethod === 0)
|
|
65
|
+
content = Buffer.from(compressed);
|
|
66
|
+
else if (compressionMethod === 8)
|
|
67
|
+
content = node_zlib_1.default.inflateRawSync(compressed);
|
|
68
|
+
else
|
|
69
|
+
throw new Error(`ZIP 不支持的压缩方式: ${compressionMethod}`);
|
|
70
|
+
node_fs_1.default.mkdirSync(node_path_1.default.dirname(target), { recursive: true });
|
|
71
|
+
node_fs_1.default.writeFileSync(target, content);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function gitClone(dst, gitUrl) {
|
|
75
|
+
return new Promise((resolve, reject) => {
|
|
76
|
+
// --progress 让 git 在非 TTY 下也输出克隆进度(写到 stderr),逐行转发到日志。
|
|
77
|
+
const child = (0, node_child_process_1.spawn)('git', ['clone', '--depth', '1', '--single-branch', '--progress', gitUrl, dst], { env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } });
|
|
78
|
+
const stderrLines = [];
|
|
79
|
+
let stderrTail = '';
|
|
80
|
+
child.stderr.on('data', (chunk) => {
|
|
81
|
+
stderrTail += chunk.toString('utf8');
|
|
82
|
+
// git 进度行用 \r 刷新,按 \r 和 \n 一起切
|
|
83
|
+
const lines = stderrTail.split(/[\r\n]+/);
|
|
84
|
+
stderrTail = lines.pop() || '';
|
|
85
|
+
for (const line of lines) {
|
|
86
|
+
const text = line.trim();
|
|
87
|
+
if (!text)
|
|
88
|
+
continue;
|
|
89
|
+
stderrLines.push(text);
|
|
90
|
+
logger.info(`git clone: ${text}`);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
const timer = setTimeout(() => {
|
|
94
|
+
child.kill();
|
|
95
|
+
reject(new Error('git clone Rocom-Atlas 超时(180 秒)'));
|
|
96
|
+
}, 180000);
|
|
97
|
+
child.on('error', (error) => {
|
|
98
|
+
clearTimeout(timer);
|
|
99
|
+
reject(new Error(`git clone Rocom-Atlas 失败:${error.message}`));
|
|
100
|
+
});
|
|
101
|
+
child.on('close', (code) => {
|
|
102
|
+
clearTimeout(timer);
|
|
103
|
+
if (code === 0)
|
|
104
|
+
resolve();
|
|
105
|
+
else
|
|
106
|
+
reject(new Error(`git clone Rocom-Atlas 失败(exit ${code}):${stderrLines.slice(-3).join(' | ')}`));
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
class AtlasService {
|
|
111
|
+
dataDir;
|
|
112
|
+
zipUrls;
|
|
113
|
+
gitUrl;
|
|
114
|
+
constructor(dataDir, options = {}) {
|
|
115
|
+
this.dataDir = dataDir;
|
|
116
|
+
this.zipUrls = options.zipUrls?.length ? options.zipUrls : DEFAULT_ATLAS_ZIP_URLS;
|
|
117
|
+
this.gitUrl = options.gitUrl || DEFAULT_ATLAS_GIT_URL;
|
|
118
|
+
}
|
|
119
|
+
get atlasDir() {
|
|
120
|
+
return node_path_1.default.join(this.dataDir, 'rocom_atlas');
|
|
121
|
+
}
|
|
122
|
+
indexPath() {
|
|
123
|
+
return node_path_1.default.join(this.atlasDir, 'path.json');
|
|
124
|
+
}
|
|
125
|
+
petsDir() {
|
|
126
|
+
return node_path_1.default.join(this.atlasDir, 'pets');
|
|
127
|
+
}
|
|
128
|
+
aliasPath() {
|
|
129
|
+
return node_path_1.default.join(this.atlasDir, 'othername', 'pets.yaml');
|
|
130
|
+
}
|
|
131
|
+
isReady() {
|
|
132
|
+
return node_fs_1.default.existsSync(this.indexPath()) && node_fs_1.default.existsSync(this.petsDir());
|
|
133
|
+
}
|
|
134
|
+
loadIndex() {
|
|
135
|
+
try {
|
|
136
|
+
const payload = JSON.parse(node_fs_1.default.readFileSync(this.indexPath(), 'utf-8'));
|
|
137
|
+
const pets = payload?.pets;
|
|
138
|
+
return pets && typeof pets === 'object' ? pets : {};
|
|
139
|
+
}
|
|
140
|
+
catch (e) {
|
|
141
|
+
logger.warn(`读取本地图鉴索引失败: ${e}`);
|
|
142
|
+
return {};
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
stripYamlValue(value) {
|
|
146
|
+
const text = String(value || '').trim();
|
|
147
|
+
if (text.length >= 2 && text[0] === text[text.length - 1] && ["'", '"'].includes(text[0])) {
|
|
148
|
+
return text.slice(1, -1).trim();
|
|
149
|
+
}
|
|
150
|
+
return text;
|
|
151
|
+
}
|
|
152
|
+
// 解析上游简单结构的 pets.yaml:`正式名:` + 缩进 `- 别名` 列表。
|
|
153
|
+
loadPetAliases() {
|
|
154
|
+
const filePath = this.aliasPath();
|
|
155
|
+
if (!node_fs_1.default.existsSync(filePath))
|
|
156
|
+
return {};
|
|
157
|
+
const aliases = {};
|
|
158
|
+
let currentName = '';
|
|
159
|
+
try {
|
|
160
|
+
const lines = node_fs_1.default.readFileSync(filePath, 'utf-8').split(/\r?\n/);
|
|
161
|
+
for (const line of lines) {
|
|
162
|
+
const stripped = line.trim();
|
|
163
|
+
if (!stripped || stripped.startsWith('#'))
|
|
164
|
+
continue;
|
|
165
|
+
if (!/^\s/.test(line) && stripped.endsWith(':')) {
|
|
166
|
+
currentName = this.stripYamlValue(stripped.slice(0, -1));
|
|
167
|
+
if (currentName) {
|
|
168
|
+
const key = (0, wiki_service_1.normalizeQueryText)(currentName);
|
|
169
|
+
aliases[key] = aliases[key] || [];
|
|
170
|
+
if (!aliases[key].includes(currentName))
|
|
171
|
+
aliases[key].push(currentName);
|
|
172
|
+
}
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (currentName && stripped.startsWith('-')) {
|
|
176
|
+
const alias = this.stripYamlValue(stripped.slice(1));
|
|
177
|
+
if (!alias)
|
|
178
|
+
continue;
|
|
179
|
+
const key = (0, wiki_service_1.normalizeQueryText)(alias);
|
|
180
|
+
aliases[key] = aliases[key] || [];
|
|
181
|
+
if (!aliases[key].includes(currentName))
|
|
182
|
+
aliases[key].push(currentName);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
catch (e) {
|
|
187
|
+
logger.warn(`读取本地图鉴别名失败: ${e}`);
|
|
188
|
+
return {};
|
|
189
|
+
}
|
|
190
|
+
return aliases;
|
|
191
|
+
}
|
|
192
|
+
localImagePath(atlasRelPath) {
|
|
193
|
+
const rel = String(atlasRelPath || '').replace(/\\/g, '/').replace(/^\/+/, '');
|
|
194
|
+
if (!rel)
|
|
195
|
+
return '';
|
|
196
|
+
const candidate = node_path_1.default.resolve(this.atlasDir, ...rel.split('/'));
|
|
197
|
+
const root = node_path_1.default.resolve(this.atlasDir);
|
|
198
|
+
if (!candidate.startsWith(root))
|
|
199
|
+
return '';
|
|
200
|
+
return candidate;
|
|
201
|
+
}
|
|
202
|
+
existingImagePath(index, name) {
|
|
203
|
+
const filePath = this.localImagePath(index[name] || '');
|
|
204
|
+
return filePath && node_fs_1.default.existsSync(filePath) ? filePath : '';
|
|
205
|
+
}
|
|
206
|
+
findMatch(query) {
|
|
207
|
+
const trimmed = String(query || '').trim();
|
|
208
|
+
if (!trimmed)
|
|
209
|
+
return { name: '', imagePath: '', candidates: [] };
|
|
210
|
+
if (/^\d+$/.test(trimmed)) {
|
|
211
|
+
const filePath = node_path_1.default.join(this.petsDir(), `${trimmed}.png`);
|
|
212
|
+
if (node_fs_1.default.existsSync(filePath))
|
|
213
|
+
return { name: `#${trimmed}`, imagePath: filePath, candidates: [] };
|
|
214
|
+
}
|
|
215
|
+
const index = this.loadIndex();
|
|
216
|
+
if (!Object.keys(index).length)
|
|
217
|
+
return { name: '', imagePath: '', candidates: [] };
|
|
218
|
+
const normalizedQuery = (0, wiki_service_1.normalizeQueryText)(trimmed);
|
|
219
|
+
let exactKey = '';
|
|
220
|
+
for (const name of Object.keys(index)) {
|
|
221
|
+
if ((0, wiki_service_1.normalizeQueryText)(name) === normalizedQuery) {
|
|
222
|
+
exactKey = name;
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (exactKey) {
|
|
227
|
+
const filePath = this.existingImagePath(index, exactKey);
|
|
228
|
+
if (filePath)
|
|
229
|
+
return { name: exactKey, imagePath: filePath, candidates: [] };
|
|
230
|
+
}
|
|
231
|
+
const aliasMap = this.loadPetAliases();
|
|
232
|
+
const exactAliasCandidates = (aliasMap[normalizedQuery] || []).filter(name => this.existingImagePath(index, name));
|
|
233
|
+
if (exactAliasCandidates.length) {
|
|
234
|
+
const only = exactAliasCandidates[0];
|
|
235
|
+
return { name: only, imagePath: this.existingImagePath(index, only), candidates: [] };
|
|
236
|
+
}
|
|
237
|
+
const candidates = [];
|
|
238
|
+
const addCandidate = (name) => {
|
|
239
|
+
if (!candidates.includes(name) && this.existingImagePath(index, name))
|
|
240
|
+
candidates.push(name);
|
|
241
|
+
};
|
|
242
|
+
for (const name of Object.keys(index)) {
|
|
243
|
+
const normalizedName = (0, wiki_service_1.normalizeQueryText)(name);
|
|
244
|
+
if (normalizedQuery && (normalizedName.includes(normalizedQuery) || normalizedQuery.includes(normalizedName))) {
|
|
245
|
+
addCandidate(name);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
for (const [alias, names] of Object.entries(aliasMap)) {
|
|
249
|
+
if (normalizedQuery && (alias.includes(normalizedQuery) || normalizedQuery.includes(alias))) {
|
|
250
|
+
for (const name of names)
|
|
251
|
+
addCandidate(name);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (candidates.length === 1) {
|
|
255
|
+
const only = candidates[0];
|
|
256
|
+
return { name: only, imagePath: this.existingImagePath(index, only), candidates: [] };
|
|
257
|
+
}
|
|
258
|
+
return { name: '', imagePath: '', candidates: candidates.slice(0, 10) };
|
|
259
|
+
}
|
|
260
|
+
prepareAtlasDir(sourceRoot, preparedDir) {
|
|
261
|
+
node_fs_1.default.mkdirSync(preparedDir, { recursive: true });
|
|
262
|
+
for (const name of ['path.json', 'index', 'othername', 'pets']) {
|
|
263
|
+
const src = node_path_1.default.join(sourceRoot, name);
|
|
264
|
+
const dst = node_path_1.default.join(preparedDir, name);
|
|
265
|
+
if (node_fs_1.default.existsSync(src)) {
|
|
266
|
+
node_fs_1.default.cpSync(src, dst, { recursive: true });
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (!node_fs_1.default.existsSync(node_path_1.default.join(preparedDir, 'path.json')))
|
|
270
|
+
throw new Error('Atlas 缺少 path.json');
|
|
271
|
+
if (!node_fs_1.default.existsSync(node_path_1.default.join(preparedDir, 'othername', 'pets.yaml')))
|
|
272
|
+
throw new Error('Atlas 缺少 othername/pets.yaml');
|
|
273
|
+
const petsDir = node_path_1.default.join(preparedDir, 'pets');
|
|
274
|
+
if (!node_fs_1.default.existsSync(petsDir))
|
|
275
|
+
throw new Error('Atlas 缺少 pets 图片目录');
|
|
276
|
+
const imageCount = node_fs_1.default.readdirSync(petsDir).filter(name => name.toLowerCase().endsWith('.png')).length;
|
|
277
|
+
let totalBytes = 0;
|
|
278
|
+
const walk = (dir) => {
|
|
279
|
+
for (const entry of node_fs_1.default.readdirSync(dir, { withFileTypes: true })) {
|
|
280
|
+
const entryPath = node_path_1.default.join(dir, entry.name);
|
|
281
|
+
if (entry.isDirectory())
|
|
282
|
+
walk(entryPath);
|
|
283
|
+
else
|
|
284
|
+
totalBytes += node_fs_1.default.statSync(entryPath).size;
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
walk(preparedDir);
|
|
288
|
+
return { imageCount, totalBytes };
|
|
289
|
+
}
|
|
290
|
+
replaceAtlasDir(preparedDir) {
|
|
291
|
+
const target = node_path_1.default.resolve(this.atlasDir);
|
|
292
|
+
const dataRoot = node_path_1.default.resolve(this.dataDir);
|
|
293
|
+
if (!target.startsWith(dataRoot))
|
|
294
|
+
throw new Error('图鉴目标目录不在插件数据目录内');
|
|
295
|
+
if (node_fs_1.default.existsSync(target))
|
|
296
|
+
node_fs_1.default.rmSync(target, { recursive: true, force: true });
|
|
297
|
+
node_fs_1.default.mkdirSync(node_path_1.default.dirname(target), { recursive: true });
|
|
298
|
+
try {
|
|
299
|
+
node_fs_1.default.renameSync(preparedDir, target);
|
|
300
|
+
}
|
|
301
|
+
catch (e) {
|
|
302
|
+
// Windows 跨盘符(如 C: 临时目录 → D: 数据目录)rename 会抛 EXDEV/EPERM,回退为复制。
|
|
303
|
+
if (['EXDEV', 'EPERM'].includes(e?.code)) {
|
|
304
|
+
logger.info(`rename 失败(${e.code}),回退为复制方式迁移图鉴目录`);
|
|
305
|
+
node_fs_1.default.cpSync(preparedDir, target, { recursive: true });
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
throw e;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
// 流式下载压缩包:每 2 秒输出一次已下载字节数/速度,避免大文件下载期间毫无动静。
|
|
313
|
+
async downloadZipWithProgress(ctx, url) {
|
|
314
|
+
const started = Date.now();
|
|
315
|
+
const res = await ctx.http(url, { responseType: 'stream', timeout: 180000 });
|
|
316
|
+
const totalHeader = Number(res.headers?.get?.('content-length'));
|
|
317
|
+
const totalBytes = Number.isFinite(totalHeader) && totalHeader > 0 ? totalHeader : 0;
|
|
318
|
+
const totalText = totalBytes ? `${(totalBytes / 1024 / 1024).toFixed(1)} MB` : '未知大小';
|
|
319
|
+
logger.info(`开始下载图鉴压缩包:${url}(${totalText})`);
|
|
320
|
+
const chunks = [];
|
|
321
|
+
let received = 0;
|
|
322
|
+
let lastLogAt = Date.now();
|
|
323
|
+
const reader = res.data.getReader();
|
|
324
|
+
while (true) {
|
|
325
|
+
const { done, value } = await reader.read();
|
|
326
|
+
if (done)
|
|
327
|
+
break;
|
|
328
|
+
if (value) {
|
|
329
|
+
chunks.push(Buffer.from(value));
|
|
330
|
+
received += value.byteLength;
|
|
331
|
+
}
|
|
332
|
+
const now = Date.now();
|
|
333
|
+
if (now - lastLogAt >= 2000) {
|
|
334
|
+
lastLogAt = now;
|
|
335
|
+
const mb = (received / 1024 / 1024).toFixed(1);
|
|
336
|
+
const speed = (received / 1024 / (now - started) * 1000 / 1024).toFixed(2);
|
|
337
|
+
const percentText = totalBytes ? `${Math.min(99, Math.round(received / totalBytes * 100))}%,` : '';
|
|
338
|
+
logger.info(`图鉴压缩包下载中:${percentText}已收到 ${mb} MB,平均 ${speed} MB/s`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
const elapsed = ((Date.now() - started) / 1000).toFixed(1);
|
|
342
|
+
logger.info(`图鉴压缩包下载完成:共 ${(received / 1024 / 1024).toFixed(1)} MB,耗时 ${elapsed} 秒`);
|
|
343
|
+
return Buffer.concat(chunks);
|
|
344
|
+
}
|
|
345
|
+
async download(ctx, progressCb) {
|
|
346
|
+
const emit = async (percent, stage) => {
|
|
347
|
+
if (progressCb)
|
|
348
|
+
await progressCb(Math.max(0, Math.min(100, Math.round(percent))), stage);
|
|
349
|
+
};
|
|
350
|
+
// 清理上次崩溃或中断留下的临时目录。
|
|
351
|
+
node_fs_1.default.mkdirSync(this.dataDir, { recursive: true });
|
|
352
|
+
for (const entry of node_fs_1.default.readdirSync(this.dataDir, { withFileTypes: true })) {
|
|
353
|
+
if (entry.isDirectory() && entry.name.startsWith('.atlas_tmp_')) {
|
|
354
|
+
cleanupTempDir(node_path_1.default.join(this.dataDir, entry.name));
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
// 临时目录放在数据目录同盘,确保 renameSync 原子操作不跨文件系统(避免 EXDEV)。
|
|
358
|
+
const tmpRoot = node_fs_1.default.mkdtempSync(node_path_1.default.join(this.dataDir, '.atlas_tmp_'));
|
|
359
|
+
const extractDir = node_path_1.default.join(tmpRoot, 'extract');
|
|
360
|
+
let preparedDir = node_path_1.default.join(tmpRoot, 'rocom_atlas');
|
|
361
|
+
const cloneDir = node_path_1.default.join(tmpRoot, 'clone');
|
|
362
|
+
try {
|
|
363
|
+
let zipBuffer = null;
|
|
364
|
+
const zipErrors = [];
|
|
365
|
+
for (const url of this.zipUrls) {
|
|
366
|
+
try {
|
|
367
|
+
await emit(10, '正在下载图鉴压缩包');
|
|
368
|
+
zipBuffer = await this.downloadZipWithProgress(ctx, url);
|
|
369
|
+
await emit(80, '图鉴压缩包下载完成');
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
372
|
+
catch (e) {
|
|
373
|
+
logger.warn(`图鉴压缩包下载失败:${url}: ${e}`);
|
|
374
|
+
zipErrors.push(`${url}: ${e}`);
|
|
375
|
+
zipBuffer = null;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
let stats;
|
|
379
|
+
if (zipBuffer) {
|
|
380
|
+
await emit(80, '正在解压图鉴文件');
|
|
381
|
+
logger.info('正在解压图鉴压缩包…');
|
|
382
|
+
node_fs_1.default.mkdirSync(extractDir, { recursive: true });
|
|
383
|
+
extractZip(zipBuffer, extractDir);
|
|
384
|
+
logger.info('图鉴压缩包解压完成');
|
|
385
|
+
const roots = node_fs_1.default.readdirSync(extractDir, { withFileTypes: true })
|
|
386
|
+
.filter(entry => entry.isDirectory())
|
|
387
|
+
.map(entry => node_path_1.default.join(extractDir, entry.name));
|
|
388
|
+
if (!roots.length)
|
|
389
|
+
throw new Error('Atlas 压缩包结构异常');
|
|
390
|
+
stats = this.prepareAtlasDir(roots[0], preparedDir);
|
|
391
|
+
}
|
|
392
|
+
else {
|
|
393
|
+
logger.warn(`GitHub zip 下载失败,尝试 git clone:${zipErrors.join(' | ')}`);
|
|
394
|
+
await emit(20, '正在 git clone 图鉴仓库');
|
|
395
|
+
await gitClone(cloneDir, this.gitUrl);
|
|
396
|
+
await emit(80, '正在整理图鉴文件');
|
|
397
|
+
stats = this.prepareAtlasDir(cloneDir, preparedDir);
|
|
398
|
+
}
|
|
399
|
+
this.replaceAtlasDir(preparedDir);
|
|
400
|
+
preparedDir = '';
|
|
401
|
+
logger.info(`本地图鉴缓存已更新:${stats.imageCount} 张图片,${(stats.totalBytes / 1024 / 1024).toFixed(1)} MB`);
|
|
402
|
+
await emit(100, '本地图鉴缓存已更新');
|
|
403
|
+
return stats;
|
|
404
|
+
}
|
|
405
|
+
finally {
|
|
406
|
+
cleanupTempDir(tmpRoot);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
exports.AtlasService = AtlasService;
|
|
411
|
+
// Windows 下 git clone 产生的 .git pack 文件是只读的,直接 rmSync 会抛 EPERM;
|
|
412
|
+
// 先带重试删,失败则清掉只读属性再删。清理失败只告警,不影响下载结果。
|
|
413
|
+
function cleanupTempDir(tmpRoot) {
|
|
414
|
+
try {
|
|
415
|
+
node_fs_1.default.rmSync(tmpRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 });
|
|
416
|
+
}
|
|
417
|
+
catch {
|
|
418
|
+
try {
|
|
419
|
+
const clearReadonly = (dir) => {
|
|
420
|
+
for (const entry of node_fs_1.default.readdirSync(dir, { withFileTypes: true })) {
|
|
421
|
+
const entryPath = node_path_1.default.join(dir, entry.name);
|
|
422
|
+
try {
|
|
423
|
+
node_fs_1.default.chmodSync(entryPath, 0o666);
|
|
424
|
+
}
|
|
425
|
+
catch {
|
|
426
|
+
// ignore
|
|
427
|
+
}
|
|
428
|
+
if (entry.isDirectory())
|
|
429
|
+
clearReadonly(entryPath);
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
clearReadonly(tmpRoot);
|
|
433
|
+
node_fs_1.default.rmSync(tmpRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 });
|
|
434
|
+
}
|
|
435
|
+
catch (e) {
|
|
436
|
+
logger.warn(`清理图鉴临时目录失败(不影响下载结果):${tmpRoot}: ${e}`);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
package/lib/client.d.ts
CHANGED
|
@@ -66,7 +66,7 @@ export declare class RocomClient {
|
|
|
66
66
|
getLineupList(ctx: Context, fwToken: string, pageNo?: number, category?: string, userIdentifier?: string): Promise<any>;
|
|
67
67
|
getExchangePosters(ctx: Context, fwToken: string, pageNo?: number, userIdentifier?: string): Promise<any>;
|
|
68
68
|
getMerchantInfo(ctx: Context, refresh?: boolean): Promise<any>;
|
|
69
|
-
queryPetSize(ctx: Context, diameter: number, weight: number,
|
|
69
|
+
queryPetSize(ctx: Context, diameter: number, weight: number, pool?: string, pageNo?: number, pageSize?: number, userIdentifier?: string): Promise<any>;
|
|
70
70
|
getActivitiesInfo(ctx: Context, refresh?: boolean, userIdentifier?: string): Promise<any>;
|
|
71
71
|
syncConfig(ctx: Context, userIdentifier?: string): Promise<any>;
|
|
72
72
|
getAnnouncementList(ctx: Context, params?: {
|
|
@@ -80,7 +80,6 @@ export declare class RocomClient {
|
|
|
80
80
|
order?: string;
|
|
81
81
|
}, userIdentifier?: string): Promise<any>;
|
|
82
82
|
getAnnouncementDetail(ctx: Context, threadId: number | string, userIdentifier?: string): Promise<any>;
|
|
83
|
-
getEggSearch(ctx: Context, height: number, weight: number, pageNo?: number, pageSize?: number, userIdentifier?: string): Promise<any>;
|
|
84
83
|
getEggGroups(ctx: Context, userIdentifier?: string): Promise<any>;
|
|
85
84
|
getEggGroupPets(ctx: Context, groupIds: string | number[], matchMode?: 'any' | 'all', pageNo?: number, pageSize?: number, userIdentifier?: string): Promise<any>;
|
|
86
85
|
getEggPetGroups(ctx: Context, query: string, limit?: number, userIdentifier?: string): Promise<any>;
|
|
@@ -95,6 +94,10 @@ export declare class RocomClient {
|
|
|
95
94
|
getEggExchangeEvents(ctx: Context, subscriptionId: string | number, afterEventId?: string, limit?: number, userIdentifier?: string): Promise<any>;
|
|
96
95
|
ingameHomeInfo(ctx: Context, uid: string, options?: IngameTaskPollOptions): Promise<any>;
|
|
97
96
|
ingameMerchantInfo(ctx: Context, shopId: string | number): Promise<any>;
|
|
97
|
+
ingamePetData(ctx: Context, uid: string, extras?: {
|
|
98
|
+
petGid?: string | number;
|
|
99
|
+
npcId?: string | number;
|
|
100
|
+
}, options?: IngameTaskPollOptions): Promise<any>;
|
|
98
101
|
getFriendship(ctx: Context, fwToken: string, userIds: string, userIdentifier?: string): Promise<any>;
|
|
99
102
|
getStudentState(ctx: Context, fwToken: string, accountType?: number, userIdentifier?: string): Promise<any>;
|
|
100
103
|
getStudentPerks(ctx: Context, fwToken: string, area?: number, accountType?: number, userIdentifier?: string): Promise<any>;
|
|
@@ -104,4 +107,19 @@ export declare class RocomClient {
|
|
|
104
107
|
id?: number;
|
|
105
108
|
name?: string;
|
|
106
109
|
}): Promise<any>;
|
|
110
|
+
private wikiPagedParams;
|
|
111
|
+
listWikiPets(ctx: Context, q?: string, pageNo?: number, pageSize?: number, filters?: Record<string, any>): Promise<any>;
|
|
112
|
+
getWikiPet(ctx: Context, petId: string | number): Promise<any>;
|
|
113
|
+
getWikiPetProfile(ctx: Context, petId: string | number): Promise<any>;
|
|
114
|
+
getWikiPetSkills(ctx: Context, petId: string | number): Promise<any>;
|
|
115
|
+
getWikiPetFamily(ctx: Context, petId: string | number): Promise<any>;
|
|
116
|
+
getWikiPetHandbook(ctx: Context, petId: string | number): Promise<any>;
|
|
117
|
+
listWikiSkills(ctx: Context, q?: string, pageNo?: number, pageSize?: number, filters?: Record<string, any>): Promise<any>;
|
|
118
|
+
getWikiSkill(ctx: Context, skillId: string | number): Promise<any>;
|
|
119
|
+
getWikiSkillPets(ctx: Context, skillId: string | number): Promise<any>;
|
|
120
|
+
getWikiCatalogs(ctx: Context): Promise<any>;
|
|
121
|
+
getWikiOptions(ctx: Context): Promise<any>;
|
|
122
|
+
getWikiPath(ctx: Context, path: string, params?: Record<string, any>): Promise<any>;
|
|
123
|
+
listWikiCatalogItems(ctx: Context, path: string, q?: string, pageNo?: number, pageSize?: number, search?: boolean): Promise<any>;
|
|
124
|
+
get wikiAssetBaseUrl(): string;
|
|
107
125
|
}
|
package/lib/client.js
CHANGED
|
@@ -360,7 +360,8 @@ class RocomClient {
|
|
|
360
360
|
return String(payload?.task_id || payload?.taskId || payload?.taskID || '').trim();
|
|
361
361
|
}
|
|
362
362
|
// 判断一份 payload 是否为“已完成的业务结果”(而非排队占位)。
|
|
363
|
-
// 家园接口完成时返回 rows/home_info;玩家、商店完成时返回 source/title/rows
|
|
363
|
+
// 家园接口完成时返回 rows/home_info;玩家、商店完成时返回 source/title/rows;
|
|
364
|
+
// 家园详情(pet/data)完成时返回 npc_pets/npc_pet。
|
|
364
365
|
static isCompletedGatewayPayload(payload) {
|
|
365
366
|
if (!payload || typeof payload !== 'object')
|
|
366
367
|
return false;
|
|
@@ -370,6 +371,10 @@ class RocomClient {
|
|
|
370
371
|
return true;
|
|
371
372
|
if (payload.source !== undefined)
|
|
372
373
|
return true;
|
|
374
|
+
if (Array.isArray(payload.npc_pets))
|
|
375
|
+
return true;
|
|
376
|
+
if (payload.npc_pet && typeof payload.npc_pet === 'object')
|
|
377
|
+
return true;
|
|
373
378
|
if (String(payload.title || '').trim())
|
|
374
379
|
return true;
|
|
375
380
|
return false;
|
|
@@ -611,11 +616,16 @@ class RocomClient {
|
|
|
611
616
|
random_goods: 'all',
|
|
612
617
|
});
|
|
613
618
|
}
|
|
614
|
-
async queryPetSize(ctx, diameter, weight,
|
|
615
|
-
const params = this.scopedParams({
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
+
async queryPetSize(ctx, diameter, weight, pool = 'magic', pageNo = 1, pageSize = 30, userIdentifier = '') {
|
|
620
|
+
const params = this.scopedParams({
|
|
621
|
+
diameter,
|
|
622
|
+
weight,
|
|
623
|
+
pool: pool || 'magic',
|
|
624
|
+
include_display_only: 'false',
|
|
625
|
+
page_no: Math.max(Number(pageNo) || 1, 1),
|
|
626
|
+
page_size: Math.min(Math.max(Number(pageSize) || 30, 1), 100),
|
|
627
|
+
}, userIdentifier);
|
|
628
|
+
return this.get(ctx, '/api/v1/games/rocom/wiki/pet-size/query', this.wegameHeaders('', userIdentifier, 'bot', 'koishi'), params);
|
|
619
629
|
}
|
|
620
630
|
async getActivitiesInfo(ctx, refresh = false, userIdentifier = '') {
|
|
621
631
|
return this.get(ctx, '/api/v1/games/rocom/activities/info', this.wegameHeaders('', userIdentifier, 'bot', 'koishi'), this.scopedParams({ refresh: refresh ? 'true' : 'false' }, userIdentifier));
|
|
@@ -632,9 +642,6 @@ class RocomClient {
|
|
|
632
642
|
async getAnnouncementDetail(ctx, threadId, userIdentifier = '') {
|
|
633
643
|
return this.get(ctx, '/api/v1/games/rocom/announcement/detail', this.wegameHeaders('', userIdentifier, 'bot', 'koishi'), this.scopedParams({ thread_id: threadId }, userIdentifier));
|
|
634
644
|
}
|
|
635
|
-
async getEggSearch(ctx, height, weight, pageNo = 1, pageSize = 20, userIdentifier = '') {
|
|
636
|
-
return this.get(ctx, '/api/v1/games/rocom/egg/search', this.wegameHeaders('', userIdentifier, 'bot', 'koishi'), this.scopedParams({ height, weight, page_no: pageNo, page_size: pageSize }, userIdentifier));
|
|
637
|
-
}
|
|
638
645
|
async getEggGroups(ctx, userIdentifier = '') {
|
|
639
646
|
return this.get(ctx, '/api/v1/games/rocom/egg/groups', this.wegameHeaders('', userIdentifier, 'bot', 'koishi'), this.scopedParams({}, userIdentifier));
|
|
640
647
|
}
|
|
@@ -695,6 +702,27 @@ class RocomClient {
|
|
|
695
702
|
const { status, data } = await this.requestIngameWithFallback(ctx, '/api/v1/games/rocom/ingame/merchant/info', params);
|
|
696
703
|
return status === null ? null : data;
|
|
697
704
|
}
|
|
705
|
+
// 家园详情:批量或单只查询家园摆放精灵的完整 ingame 数据(上游 v3.6.1)。
|
|
706
|
+
// UID 参数名为 target_uin;单只查询需同时提供 pet_gid 和 npc_id。
|
|
707
|
+
async ingamePetData(ctx, uid, extras = {}, options = {}) {
|
|
708
|
+
const sanitizedUid = this.sanitizeUid(uid);
|
|
709
|
+
if (!sanitizedUid) {
|
|
710
|
+
this.setLastError('UID 不能为空');
|
|
711
|
+
return null;
|
|
712
|
+
}
|
|
713
|
+
const waitMs = Number(options.waitMs) || 20000;
|
|
714
|
+
const payload = { target_uin: sanitizedUid, wait_ms: waitMs };
|
|
715
|
+
if (extras.petGid !== undefined && extras.petGid !== null && String(extras.petGid) !== '')
|
|
716
|
+
payload.pet_gid = extras.petGid;
|
|
717
|
+
if (extras.npcId !== undefined && extras.npcId !== null && String(extras.npcId) !== '')
|
|
718
|
+
payload.npc_id = extras.npcId;
|
|
719
|
+
const path = '/api/v1/games/rocom/ingame/pet/data';
|
|
720
|
+
const first = await this.requestIngameWithFallback(ctx, path, payload);
|
|
721
|
+
return this.pollIngameTask(ctx, first, options, {
|
|
722
|
+
queuedNoTaskId: '家园详情任务已入队,但未返回 task_id',
|
|
723
|
+
stillQueued: (taskId) => `家园详情任务仍在排队,请稍后重试(task_id: ${taskId})`,
|
|
724
|
+
});
|
|
725
|
+
}
|
|
698
726
|
async getFriendship(ctx, fwToken, userIds, userIdentifier = '') {
|
|
699
727
|
return this.get(ctx, '/api/v1/games/rocom/social/friendship', this.rocomHeaders(fwToken, userIdentifier), { user_ids: userIds });
|
|
700
728
|
}
|
|
@@ -726,5 +754,67 @@ class RocomClient {
|
|
|
726
754
|
params.name = options.name;
|
|
727
755
|
return this.get(ctx, '/api/v1/games/rocom/pet/detail', this.wegameHeaders(), params);
|
|
728
756
|
}
|
|
757
|
+
// ===== 新版 RoCom Wiki API(上游 v3.6.0)=====
|
|
758
|
+
wikiPagedParams(q = '', pageNo = 1, pageSize = 10, filters = {}) {
|
|
759
|
+
const params = {
|
|
760
|
+
page_no: Math.max(Number(pageNo) || 1, 1),
|
|
761
|
+
page_size: Math.min(Math.max(Number(pageSize) || 10, 1), 100),
|
|
762
|
+
};
|
|
763
|
+
if (q)
|
|
764
|
+
params.q = q;
|
|
765
|
+
for (const [key, value] of Object.entries(filters)) {
|
|
766
|
+
if (value !== undefined && value !== null && value !== '')
|
|
767
|
+
params[key] = value;
|
|
768
|
+
}
|
|
769
|
+
return params;
|
|
770
|
+
}
|
|
771
|
+
async listWikiPets(ctx, q = '', pageNo = 1, pageSize = 10, filters = {}) {
|
|
772
|
+
return this.get(ctx, '/api/v1/games/rocom/wiki/pets', this.wegameHeaders(), this.wikiPagedParams(q, pageNo, pageSize, filters));
|
|
773
|
+
}
|
|
774
|
+
async getWikiPet(ctx, petId) {
|
|
775
|
+
return this.get(ctx, `/api/v1/games/rocom/wiki/pets/${petId}`, this.wegameHeaders());
|
|
776
|
+
}
|
|
777
|
+
async getWikiPetProfile(ctx, petId) {
|
|
778
|
+
return this.get(ctx, `/api/v1/games/rocom/wiki/pets/${petId}/profile`, this.wegameHeaders(), undefined, { silentFailureDetails: true });
|
|
779
|
+
}
|
|
780
|
+
async getWikiPetSkills(ctx, petId) {
|
|
781
|
+
return this.get(ctx, `/api/v1/games/rocom/wiki/pets/${petId}/skills`, this.wegameHeaders(), undefined, { silentFailureDetails: true });
|
|
782
|
+
}
|
|
783
|
+
async getWikiPetFamily(ctx, petId) {
|
|
784
|
+
return this.get(ctx, `/api/v1/games/rocom/wiki/pets/${petId}/family`, this.wegameHeaders(), undefined, { silentFailureDetails: true });
|
|
785
|
+
}
|
|
786
|
+
async getWikiPetHandbook(ctx, petId) {
|
|
787
|
+
return this.get(ctx, `/api/v1/games/rocom/wiki/pets/${petId}/handbook`, this.wegameHeaders(), undefined, { silentFailureDetails: true });
|
|
788
|
+
}
|
|
789
|
+
async listWikiSkills(ctx, q = '', pageNo = 1, pageSize = 10, filters = {}) {
|
|
790
|
+
return this.get(ctx, '/api/v1/games/rocom/wiki/skills', this.wegameHeaders(), this.wikiPagedParams(q, pageNo, pageSize, filters));
|
|
791
|
+
}
|
|
792
|
+
async getWikiSkill(ctx, skillId) {
|
|
793
|
+
return this.get(ctx, `/api/v1/games/rocom/wiki/skills/${skillId}`, this.wegameHeaders(), undefined, { silentFailureDetails: true });
|
|
794
|
+
}
|
|
795
|
+
async getWikiSkillPets(ctx, skillId) {
|
|
796
|
+
return this.get(ctx, `/api/v1/games/rocom/wiki/skills/${skillId}/pets`, this.wegameHeaders(), undefined, { silentFailureDetails: true });
|
|
797
|
+
}
|
|
798
|
+
async getWikiCatalogs(ctx) {
|
|
799
|
+
return this.get(ctx, '/api/v1/games/rocom/wiki/catalogs', this.wegameHeaders());
|
|
800
|
+
}
|
|
801
|
+
async getWikiOptions(ctx) {
|
|
802
|
+
return this.get(ctx, '/api/v1/games/rocom/wiki/options', this.wegameHeaders(), undefined, { silentFailureDetails: true });
|
|
803
|
+
}
|
|
804
|
+
async getWikiPath(ctx, path, params = {}) {
|
|
805
|
+
const trimmed = String(path || '').trim();
|
|
806
|
+
if (!trimmed.startsWith('/api/v1/games/rocom/wiki/')) {
|
|
807
|
+
this.setLastError('非法 Wiki 路径');
|
|
808
|
+
return null;
|
|
809
|
+
}
|
|
810
|
+
return this.get(ctx, trimmed, this.wegameHeaders(), params);
|
|
811
|
+
}
|
|
812
|
+
async listWikiCatalogItems(ctx, path, q = '', pageNo = 1, pageSize = 10, search = true) {
|
|
813
|
+
const params = this.wikiPagedParams(search ? q : '', pageNo, pageSize);
|
|
814
|
+
return this.getWikiPath(ctx, path, params);
|
|
815
|
+
}
|
|
816
|
+
get wikiAssetBaseUrl() {
|
|
817
|
+
return this.baseUrl;
|
|
818
|
+
}
|
|
729
819
|
}
|
|
730
820
|
exports.RocomClient = RocomClient;
|