flower-trellis 0.5.2 → 0.5.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/enhancements/0.6/.agents/skills/trellis-release/SKILL.md +1 -1
- package/enhancements/0.6/.claude/skills/trellis-release/SKILL.md +1 -1
- package/enhancements/MANIFEST.json +2 -2
- package/enhancements/common/.common/.claude/skills/craft-rpa/SKILL.md +82 -32
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/browser-controller.js +772 -0
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/config.js +54 -0
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/dashboard.html +16 -13
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/inject.js +84 -78
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/launch.js +103 -212
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/logger.js +256 -179
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/network-capture.js +339 -0
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/package.json +2 -1
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/test/browser-controller.test.js +90 -0
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/test/browser-smoke.test.js +121 -0
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/test/control-http.test.js +100 -0
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/test/logger.test.js +14 -0
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/test/network-capture.test.js +199 -0
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/test/runtime-layout.test.js +96 -0
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/test/trace.test.js +36 -0
- package/enhancements/common/.common/.claude/skills/craft-rpa/scripts/control-client.js +95 -0
- package/enhancements/common/.common/.claude/skills/craft-rpa/scripts/jsonl-to-trace.js +122 -5
- package/enhancements/common/.common/.claude/skills/craft-rpa/scripts/package.json +3 -0
- package/enhancements/common/.common/.claude/skills/craft-rpa/scripts/run.sh +107 -40
- package/enhancements/common/.common/.codex/skills/craft-rpa/SKILL.md +82 -32
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/browser-controller.js +772 -0
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/config.js +54 -0
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/dashboard.html +16 -13
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/inject.js +84 -78
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/launch.js +103 -212
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/logger.js +256 -179
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/network-capture.js +339 -0
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/package.json +2 -1
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/test/browser-controller.test.js +90 -0
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/test/browser-smoke.test.js +121 -0
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/test/control-http.test.js +100 -0
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/test/logger.test.js +14 -0
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/test/network-capture.test.js +199 -0
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/test/runtime-layout.test.js +96 -0
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/test/trace.test.js +36 -0
- package/enhancements/common/.common/.codex/skills/craft-rpa/scripts/control-client.js +95 -0
- package/enhancements/common/.common/.codex/skills/craft-rpa/scripts/jsonl-to-trace.js +122 -5
- package/enhancements/common/.common/.codex/skills/craft-rpa/scripts/package.json +3 -0
- package/enhancements/common/.common/.codex/skills/craft-rpa/scripts/run.sh +107 -40
- package/package.json +3 -3
- package/src/builtin-plugins/skill-garden/content-adapter.js +97 -4
- package/src/commands/init.js +13 -2
- package/src/commands/plugin.js +8 -4
- package/src/commands/update.js +7 -1
- package/src/lib/banner.js +10 -1
- package/src/lib/command-completion.js +43 -0
|
@@ -1,255 +1,146 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* 内部直接 require 了 logger.js 并启动它,所以一条命令就能起完整套环境,
|
|
5
|
-
* 不再需要"另开终端跑 logger.js"。
|
|
6
|
-
*
|
|
7
|
-
* 用 Playwright 的 launchPersistentContext 启动一个带独立用户数据目录的浏览器实例,
|
|
8
|
-
* 通过 addInitScript 让 inject.js 在每个页面加载前自动执行——
|
|
9
|
-
* 跳转、新标签页、iframe 全部自动覆盖,无需任何手动重注入。
|
|
10
|
-
*
|
|
11
|
-
* 使用方式:
|
|
12
|
-
* node launch.js # 打开空白页,自己输 URL
|
|
13
|
-
* node launch.js https://target.com # 直接打开指定起始网址
|
|
14
|
-
*
|
|
15
|
-
* 前置条件(一次性):
|
|
16
|
-
* 1. 安装依赖:npm install
|
|
17
|
-
* 2. 浏览器:默认用系统已装 Chrome(USE_SYSTEM_CHROME=true)
|
|
18
|
-
* 没有系统 Chrome 时改成 false,并执行 npx playwright install chromium
|
|
19
|
-
*
|
|
20
|
-
* 数据存放:
|
|
21
|
-
* - profile/ : 用户数据目录(cookie / localStorage / 登录态等),下次启动自动复用
|
|
22
|
-
* - session.jsonl : 事件日志(由内置 logger 写入)
|
|
2
|
+
* 一键启动日志服务、持久化浏览器、网络采集和页面控制。
|
|
23
3
|
*/
|
|
24
|
-
const { chromium } = require('playwright');
|
|
25
|
-
const path = require('path');
|
|
26
4
|
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const PLAYWRIGHT_MODULE = process.env.CRAFT_RPA_PLAYWRIGHT_MODULE || 'playwright';
|
|
7
|
+
const { chromium } = require(PLAYWRIGHT_MODULE);
|
|
8
|
+
const { createBrowserController } = require('./browser-controller');
|
|
9
|
+
const {
|
|
10
|
+
MAX_FALLBACK_PAYLOAD_BYTES,
|
|
11
|
+
MAX_NETWORK_BODY_BYTES,
|
|
12
|
+
getLoggerOrigin,
|
|
13
|
+
resolvePort,
|
|
14
|
+
} = require('./config');
|
|
27
15
|
const { startLogger } = require('./logger');
|
|
16
|
+
const { installNetworkCapture } = require('./network-capture');
|
|
28
17
|
|
|
29
|
-
// 启动参数:第一个 CLI 参数作为起始 URL,没传就开空白页
|
|
30
18
|
const START_URL = process.argv[2] || 'about:blank';
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const PROFILE_DIR = path.join(__dirname, 'profile');
|
|
34
|
-
|
|
35
|
-
// 注入脚本路径
|
|
19
|
+
const PROFILE_DIR = path.resolve(process.env.CRAFT_RPA_PROFILE_DIR || path.join(__dirname, 'profile'));
|
|
20
|
+
const SESSION_LOG = path.resolve(process.env.CRAFT_RPA_SESSION_FILE || path.join(__dirname, 'session.jsonl'));
|
|
36
21
|
const INJECT_SCRIPT = path.join(__dirname, 'inject.js');
|
|
22
|
+
const PORT = resolvePort();
|
|
23
|
+
const USE_SYSTEM_CHROME = process.env.CRAFT_RPA_USE_SYSTEM_CHROME !== 'false';
|
|
24
|
+
const HEADLESS = process.env.CRAFT_RPA_HEADLESS === 'true';
|
|
37
25
|
|
|
38
|
-
// 是否使用系统 Chrome
|
|
39
|
-
// 在 Windows / macOS 上跑:true(用真实系统 Chrome,更真实的 UA、扩展、字体)
|
|
40
|
-
// 在 WSL2 Linux / Docker 容器里跑:false(用 Playwright 内置 Chromium,避免找不到 Chrome)
|
|
41
|
-
const USE_SYSTEM_CHROME = true;
|
|
42
|
-
|
|
43
|
-
// 前置检查:inject.js 必须存在,避免起完浏览器才发现没注入
|
|
44
26
|
if (!fs.existsSync(INJECT_SCRIPT)) {
|
|
45
27
|
console.error(`[launch] inject.js not found: ${INJECT_SCRIPT}`);
|
|
46
28
|
process.exit(1);
|
|
47
29
|
}
|
|
48
30
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
* 打开 URL(默认在当前最后一个 tab,可选新开 tab)
|
|
63
|
-
* @param {string} url
|
|
64
|
-
* @param {{newTab?: boolean}} opts
|
|
65
|
-
*/
|
|
66
|
-
async open(url, opts = {}) {
|
|
67
|
-
if (!ctx) throw new Error('browser not ready yet');
|
|
68
|
-
const pages = ctx.pages();
|
|
69
|
-
const page = opts.newTab || pages.length === 0 ? await ctx.newPage() : pages[pages.length - 1];
|
|
70
|
-
if (url && url !== 'about:blank') {
|
|
71
|
-
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
72
|
-
}
|
|
73
|
-
await page.bringToFront().catch(() => {});
|
|
74
|
-
return { ok: true, url: page.url(), index: ctx.pages().indexOf(page) };
|
|
75
|
-
},
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* 列出所有打开的 tab
|
|
79
|
-
* @return {Array<{index:number, url:string, title:string}>}
|
|
80
|
-
*/
|
|
81
|
-
async listPages() {
|
|
82
|
-
if (!ctx) return [];
|
|
83
|
-
const pages = ctx.pages();
|
|
84
|
-
return Promise.all(pages.map(async (p, i) => ({
|
|
85
|
-
index: i,
|
|
86
|
-
url: p.url(),
|
|
87
|
-
title: await p.title().catch(() => ''),
|
|
88
|
-
})));
|
|
89
|
-
},
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* 关闭指定 tab
|
|
93
|
-
* @param {number} index tab 序号
|
|
94
|
-
*/
|
|
95
|
-
async close(index) {
|
|
96
|
-
if (!ctx) throw new Error('browser not ready');
|
|
97
|
-
const pages = ctx.pages();
|
|
98
|
-
if (!pages[index]) throw new Error('no such page: ' + index);
|
|
99
|
-
await pages[index].close();
|
|
100
|
-
return { ok: true };
|
|
101
|
-
},
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* 把指定 tab 提到最前
|
|
105
|
-
* @param {number} index tab 序号
|
|
106
|
-
*/
|
|
107
|
-
async focus(index) {
|
|
108
|
-
if (!ctx) throw new Error('browser not ready');
|
|
109
|
-
const pages = ctx.pages();
|
|
110
|
-
if (!pages[index]) throw new Error('no such page: ' + index);
|
|
111
|
-
await pages[index].bringToFront();
|
|
112
|
-
return { ok: true };
|
|
113
|
-
},
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* 刷新指定 tab(不传则刷新第一个)
|
|
117
|
-
* @param {number} [index]
|
|
118
|
-
*/
|
|
119
|
-
async reload(index = 0) {
|
|
120
|
-
if (!ctx) throw new Error('browser not ready');
|
|
121
|
-
const p = ctx.pages()[index];
|
|
122
|
-
if (!p) throw new Error('no such page: ' + index);
|
|
123
|
-
await p.reload({ waitUntil: 'domcontentloaded' });
|
|
124
|
-
return { ok: true, url: p.url() };
|
|
125
|
-
},
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* 浏览器后退
|
|
129
|
-
* @param {number} [index]
|
|
130
|
-
*/
|
|
131
|
-
async back(index = 0) {
|
|
132
|
-
if (!ctx) throw new Error('browser not ready');
|
|
133
|
-
const p = ctx.pages()[index];
|
|
134
|
-
if (!p) throw new Error('no such page: ' + index);
|
|
135
|
-
await p.goBack({ waitUntil: 'domcontentloaded' }).catch(() => {});
|
|
136
|
-
return { ok: true, url: p.url() };
|
|
137
|
-
},
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* 浏览器前进
|
|
141
|
-
* @param {number} [index]
|
|
142
|
-
*/
|
|
143
|
-
async forward(index = 0) {
|
|
144
|
-
if (!ctx) throw new Error('browser not ready');
|
|
145
|
-
const p = ctx.pages()[index];
|
|
146
|
-
if (!p) throw new Error('no such page: ' + index);
|
|
147
|
-
await p.goForward({ waitUntil: 'domcontentloaded' }).catch(() => {});
|
|
148
|
-
return { ok: true, url: p.url() };
|
|
149
|
-
},
|
|
31
|
+
/**
|
|
32
|
+
* 获取当前会话目录,优先使用 run.sh 传入值。
|
|
33
|
+
*
|
|
34
|
+
* @return {string} 会话目录绝对路径。
|
|
35
|
+
*/
|
|
36
|
+
function resolveSessionDir() {
|
|
37
|
+
if (process.env.CRAFT_RPA_SESSION_DIR) return path.resolve(process.env.CRAFT_RPA_SESSION_DIR);
|
|
38
|
+
try {
|
|
39
|
+
return path.dirname(fs.realpathSync(SESSION_LOG));
|
|
40
|
+
} catch (error) {
|
|
41
|
+
return path.dirname(SESSION_LOG);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
150
44
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
45
|
+
/**
|
|
46
|
+
* 给页面挂调试和主 frame 导航监听。
|
|
47
|
+
*
|
|
48
|
+
* @param {import('playwright').Page} page Playwright 页面。
|
|
49
|
+
* @return {void}
|
|
50
|
+
*/
|
|
51
|
+
function attachPageListeners(page) {
|
|
52
|
+
page.on('console', message => console.log(`[browser:${message.type()}] ${message.text()}`));
|
|
53
|
+
page.on('pageerror', error => console.log(`[browser:pageerror] ${error.message}`));
|
|
54
|
+
page.on('framenavigated', frame => {
|
|
55
|
+
if (frame === page.mainFrame()) console.log(`[launch] navigated : ${frame.url()}`);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
164
58
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
59
|
+
(async () => {
|
|
60
|
+
let context = null;
|
|
61
|
+
let shuttingDown = false;
|
|
62
|
+
const sessionDir = resolveSessionDir();
|
|
63
|
+
const controller = createBrowserController({ getContext: () => context, sessionDir });
|
|
64
|
+
const loggerServer = startLogger({ port: PORT, logFile: SESSION_LOG, browserController: controller });
|
|
168
65
|
|
|
169
66
|
console.log(`[launch] profile dir : ${PROFILE_DIR}`);
|
|
67
|
+
console.log(`[launch] session dir : ${sessionDir}`);
|
|
170
68
|
console.log(`[launch] start url : ${START_URL}`);
|
|
171
69
|
console.log(`[launch] browser : ${USE_SYSTEM_CHROME ? 'system chrome' : 'bundled chromium'}`);
|
|
172
70
|
|
|
173
|
-
// 启动浏览器(带持久化 profile)
|
|
174
71
|
const launchOptions = {
|
|
175
|
-
headless:
|
|
176
|
-
viewport:
|
|
177
|
-
args: ['--start-maximized'],
|
|
178
|
-
ignoreHTTPSErrors: true,
|
|
179
|
-
// 关键:禁用页面 CSP 检查
|
|
180
|
-
// 目标站点(如 oracle.com)通常设置了严格的 connect-src,
|
|
181
|
-
// 会拦截注入脚本向 http://localhost:7777 的 fetch,导致一条事件都发不出来
|
|
182
|
-
bypassCSP: true,
|
|
72
|
+
headless: HEADLESS,
|
|
73
|
+
viewport: HEADLESS ? { width: 1440, height: 1000 } : null,
|
|
74
|
+
args: HEADLESS ? [] : ['--start-maximized'],
|
|
75
|
+
ignoreHTTPSErrors: true,
|
|
183
76
|
};
|
|
184
|
-
if (USE_SYSTEM_CHROME)
|
|
185
|
-
|
|
186
|
-
}
|
|
187
|
-
const context = await chromium.launchPersistentContext(PROFILE_DIR, launchOptions);
|
|
188
|
-
ctx = context; // 绑定到上面 browserController 引用的闭包变量
|
|
189
|
-
|
|
190
|
-
// 核心:每个页面加载前自动注入 inject.js
|
|
191
|
-
// 含:首次打开、整页跳转、新标签页、iframe、history.back/forward 后的页面
|
|
192
|
-
await context.addInitScript({ path: INJECT_SCRIPT });
|
|
77
|
+
if (USE_SYSTEM_CHROME) launchOptions.channel = 'chrome';
|
|
78
|
+
context = await chromium.launchPersistentContext(PROFILE_DIR, launchOptions);
|
|
193
79
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
}
|
|
212
|
-
});
|
|
213
|
-
}
|
|
80
|
+
await context.exposeBinding('__craftRpaAppendEvents', async (source, payload) => {
|
|
81
|
+
const events = Array.isArray(payload) ? payload : [payload];
|
|
82
|
+
loggerServer.appendEvents(events);
|
|
83
|
+
return { ok: true, count: events.length };
|
|
84
|
+
});
|
|
85
|
+
const injectContent = fs.readFileSync(INJECT_SCRIPT, 'utf8');
|
|
86
|
+
await context.addInitScript({
|
|
87
|
+
content: `globalThis.__CRAFT_RPA_CONFIG__ = ${JSON.stringify({
|
|
88
|
+
loggerUrl: `${getLoggerOrigin({ port: PORT })}/log`,
|
|
89
|
+
maxFallbackPayloadBytes: MAX_FALLBACK_PAYLOAD_BYTES,
|
|
90
|
+
})};\n${injectContent}`,
|
|
91
|
+
});
|
|
92
|
+
const networkCapture = installNetworkCapture(context, {
|
|
93
|
+
appendEvents: events => loggerServer.appendEvents(events),
|
|
94
|
+
port: PORT,
|
|
95
|
+
maxBodyBytes: MAX_NETWORK_BODY_BYTES,
|
|
96
|
+
});
|
|
214
97
|
|
|
215
|
-
// 新创建的 page(target=_blank / window.open)自动挂监听
|
|
216
98
|
context.on('page', page => {
|
|
217
99
|
console.log(`[launch] new page : ${page.url() || '(blank)'}`);
|
|
218
100
|
attachPageListeners(page);
|
|
219
101
|
});
|
|
220
102
|
|
|
221
|
-
|
|
103
|
+
/**
|
|
104
|
+
* 关闭网络监听、浏览器和 logger,避免 profile 或 JSONL 半写入。
|
|
105
|
+
*
|
|
106
|
+
* @param {string} reason 退出原因。
|
|
107
|
+
* @return {Promise<void>} 关闭完成。
|
|
108
|
+
*/
|
|
109
|
+
async function shutdown(reason) {
|
|
110
|
+
if (shuttingDown) return;
|
|
111
|
+
shuttingDown = true;
|
|
112
|
+
console.log(`[launch] ${reason}, shutting down...`);
|
|
113
|
+
networkCapture.dispose();
|
|
114
|
+
try { await context.close(); } catch (error) { /* 浏览器可能已关闭。 */ }
|
|
115
|
+
await new Promise(resolve => loggerServer.close(resolve));
|
|
116
|
+
}
|
|
117
|
+
|
|
222
118
|
context.on('close', () => {
|
|
119
|
+
if (shuttingDown) return;
|
|
120
|
+
shuttingDown = true;
|
|
223
121
|
console.log('[launch] browser closed, shutting down logger');
|
|
224
|
-
loggerServer.close();
|
|
225
|
-
process.exit(0);
|
|
122
|
+
loggerServer.close(() => process.exit(0));
|
|
226
123
|
});
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
await context.close();
|
|
233
|
-
} catch (e) {
|
|
234
|
-
// 浏览器可能已关闭,忽略
|
|
235
|
-
}
|
|
236
|
-
loggerServer.close();
|
|
237
|
-
process.exit(0);
|
|
124
|
+
process.on('SIGINT', () => {
|
|
125
|
+
void shutdown('SIGINT received').then(() => process.exit(0));
|
|
126
|
+
});
|
|
127
|
+
process.on('SIGTERM', () => {
|
|
128
|
+
void shutdown('SIGTERM received').then(() => process.exit(0));
|
|
238
129
|
});
|
|
239
130
|
|
|
240
|
-
// 打开起始页:复用已有的初始 about:blank 标签,避免开两个窗口
|
|
241
131
|
const pages = context.pages();
|
|
242
132
|
const page = pages.length > 0 ? pages[0] : await context.newPage();
|
|
243
|
-
// 给初始 page 也挂上监听(context.on('page') 不会对已存在的 page 触发)
|
|
244
133
|
attachPageListeners(page);
|
|
245
134
|
if (START_URL !== 'about:blank') {
|
|
246
135
|
try {
|
|
247
136
|
await page.goto(START_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
248
|
-
} catch (
|
|
249
|
-
console.warn(`[launch] initial goto failed: ${
|
|
137
|
+
} catch (error) {
|
|
138
|
+
console.warn(`[launch] initial goto failed: ${error.message}(可通过 control 或浏览器地址栏继续)`);
|
|
250
139
|
}
|
|
251
140
|
}
|
|
252
141
|
|
|
253
|
-
console.log('[launch] ready,
|
|
254
|
-
|
|
255
|
-
|
|
142
|
+
console.log('[launch] ready, browser can be operated by user, Dashboard, or run.sh control');
|
|
143
|
+
})().catch(error => {
|
|
144
|
+
console.error('[launch] fatal:', error.stack || error.message);
|
|
145
|
+
process.exit(1);
|
|
146
|
+
});
|