aether-mcp-server 2.0.2 → 2.1.1
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/dist/bridge/debugging.js +133 -0
- package/dist/bridge/inspection.js +302 -0
- package/dist/bridge/interaction.js +586 -0
- package/dist/bridge/navigation.js +146 -0
- package/dist/bridge/session.js +287 -0
- package/dist/cdp-bridge.js +598 -1981
- package/dist/cdp-client.js +232 -366
- package/dist/element-collector.js +198 -0
- package/dist/eval-scripts.js +1024 -0
- package/dist/index.js +16 -28
- package/dist/locator-engine.js +21 -259
- package/dist/logger.js +105 -0
- package/dist/mcp-server.js +59 -0
- package/dist/page-snapshot-cache.js +17 -2
- package/dist/types.js +267 -0
- package/package.json +1 -1
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Browser navigation and tab management functions extracted from cdp-bridge.ts.
|
|
4
|
+
*
|
|
5
|
+
* Each function takes a CdpClient, params, and optional dependencies (snapshotCache,
|
|
6
|
+
* logger) and returns a result. This module is designed to be consumed by the bridge
|
|
7
|
+
* layer and can also be used directly by other parts of the server.
|
|
8
|
+
*/
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.connect = connect;
|
|
11
|
+
exports.navigate = navigate;
|
|
12
|
+
exports.getTabs = getTabs;
|
|
13
|
+
exports.newTab = newTab;
|
|
14
|
+
exports.switchTab = switchTab;
|
|
15
|
+
exports.closeTab = closeTab;
|
|
16
|
+
exports.smartNavigate = smartNavigate;
|
|
17
|
+
const eval_scripts_1 = require("../eval-scripts");
|
|
18
|
+
// ─── Connect ────────────────────────────────────────────────────────────
|
|
19
|
+
/**
|
|
20
|
+
* Connect to an existing Chrome instance on the given debugging port.
|
|
21
|
+
*/
|
|
22
|
+
async function connect(client, params) {
|
|
23
|
+
const port = params.port ?? 9222;
|
|
24
|
+
await client.connect(port);
|
|
25
|
+
return 'Connected to browser';
|
|
26
|
+
}
|
|
27
|
+
// ─── Navigate ────────────────────────────────────────────────────────────
|
|
28
|
+
/**
|
|
29
|
+
* Navigate the active tab to a URL and wait for the page to settle.
|
|
30
|
+
* Invalidates the snapshot cache after navigation.
|
|
31
|
+
*/
|
|
32
|
+
async function navigate(client, params, snapshotCache) {
|
|
33
|
+
await client.navigateAndWait(params.url, params.timeout ?? 10000);
|
|
34
|
+
snapshotCache.invalidate('navigate');
|
|
35
|
+
return 'Navigated';
|
|
36
|
+
}
|
|
37
|
+
// ─── Tab Management ─────────────────────────────────────────────────────
|
|
38
|
+
/**
|
|
39
|
+
* Get all open browser tabs (targets).
|
|
40
|
+
*/
|
|
41
|
+
async function getTabs(client, _params) {
|
|
42
|
+
const result = await client.sendCommand('Target.getTargets', {});
|
|
43
|
+
return result.targetInfos ?? [];
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Open a new tab with an optional URL.
|
|
47
|
+
*/
|
|
48
|
+
async function newTab(client, params) {
|
|
49
|
+
const result = await client.sendCommand('Target.createTarget', {
|
|
50
|
+
url: params.url ?? 'about:blank',
|
|
51
|
+
});
|
|
52
|
+
return result.targetId
|
|
53
|
+
? `Created new tab: ${result.targetId}`
|
|
54
|
+
: 'Created new tab';
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Switch the active debugging session to a different tab.
|
|
58
|
+
* Requires the targetId of the tab to switch to.
|
|
59
|
+
*/
|
|
60
|
+
async function switchTab(client, params) {
|
|
61
|
+
if (!params.targetId) {
|
|
62
|
+
throw new Error('targetId required to switch tabs');
|
|
63
|
+
}
|
|
64
|
+
await client.sendCommand('Target.activateTarget', {
|
|
65
|
+
targetId: params.targetId,
|
|
66
|
+
});
|
|
67
|
+
await client.switchToTarget(params.targetId, params.port ?? 9222);
|
|
68
|
+
return `Switched to tab ${params.targetId}`;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Close a tab by its targetId.
|
|
72
|
+
*/
|
|
73
|
+
async function closeTab(client, params) {
|
|
74
|
+
await client.sendCommand('Target.closeTarget', {
|
|
75
|
+
targetId: params.targetId,
|
|
76
|
+
});
|
|
77
|
+
return 'Closed tab';
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Navigate to a URL with optional popup dismissal, condition waiting, and
|
|
81
|
+
* screenshot capture. This is a higher-level wrapper around navigate().
|
|
82
|
+
*/
|
|
83
|
+
async function smartNavigate(client, params, snapshotCache, logger) {
|
|
84
|
+
const { url, waitFor, dismissPopups, screenshot, timeout } = params;
|
|
85
|
+
const timeoutMs = timeout ?? 30000;
|
|
86
|
+
try {
|
|
87
|
+
logger.info(`smartNavigate: Navigating to ${url}`, { timeout: timeoutMs });
|
|
88
|
+
await client.navigateAndWait(url, timeoutMs);
|
|
89
|
+
snapshotCache.invalidate('smartNavigate');
|
|
90
|
+
// Dismiss popups if requested (default: true)
|
|
91
|
+
if (dismissPopups !== false) {
|
|
92
|
+
logger.debug('smartNavigate: Dismissing popups');
|
|
93
|
+
await client.evaluate(eval_scripts_1.DISMISS_POPUPS_SCRIPT).catch((err) => {
|
|
94
|
+
logger.warn('smartNavigate: Popup dismissal failed', {
|
|
95
|
+
error: err.message,
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
// Wait for a specific condition after navigation
|
|
100
|
+
if (waitFor) {
|
|
101
|
+
if (waitFor.type === 'network_idle') {
|
|
102
|
+
logger.debug('smartNavigate: Waiting for network idle', {
|
|
103
|
+
timeout: waitFor.timeout ?? 3000,
|
|
104
|
+
});
|
|
105
|
+
await client
|
|
106
|
+
.waitForNetworkIdle(500, waitFor.timeout ?? 3000)
|
|
107
|
+
.catch(() => { });
|
|
108
|
+
}
|
|
109
|
+
else if (waitFor.type === 'element' && waitFor.selector) {
|
|
110
|
+
logger.debug('smartNavigate: Waiting for selector', {
|
|
111
|
+
selector: waitFor.selector,
|
|
112
|
+
timeout: waitFor.timeout ?? 5000,
|
|
113
|
+
});
|
|
114
|
+
await client
|
|
115
|
+
.waitForSelector(waitFor.selector, waitFor.timeout ?? 5000)
|
|
116
|
+
.catch(() => { });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// Collect current page info
|
|
120
|
+
const currentUrl = await client
|
|
121
|
+
.evaluate('window.location.href')
|
|
122
|
+
.catch(() => url);
|
|
123
|
+
const title = await client
|
|
124
|
+
.evaluate('document.title')
|
|
125
|
+
.catch(() => 'Unknown');
|
|
126
|
+
// Optionally take a screenshot
|
|
127
|
+
const screenshotData = screenshot === true
|
|
128
|
+
? await client.screenshot('jpeg', 70).catch(() => null)
|
|
129
|
+
: null;
|
|
130
|
+
logger.info('smartNavigate: Complete', {
|
|
131
|
+
finalUrl: currentUrl,
|
|
132
|
+
title,
|
|
133
|
+
hasScreenshot: screenshotData !== null,
|
|
134
|
+
});
|
|
135
|
+
return {
|
|
136
|
+
success: true,
|
|
137
|
+
url: currentUrl,
|
|
138
|
+
title,
|
|
139
|
+
screenshot: screenshotData,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
catch (e) {
|
|
143
|
+
logger.error('smartNavigate: Failed', { error: e.message, url });
|
|
144
|
+
return { success: false, url, title: 'Unknown', error: e.message };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Session, auth, and browser management functions extracted from cdp-bridge.ts.
|
|
4
|
+
*
|
|
5
|
+
* Each function takes a CdpClient (and optional dependencies like PageSnapshotCache)
|
|
6
|
+
* and returns a result. This module is designed to be consumed by the bridge layer
|
|
7
|
+
* and can also be used directly by other parts of the server.
|
|
8
|
+
*/
|
|
9
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
12
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
13
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
14
|
+
}
|
|
15
|
+
Object.defineProperty(o, k2, desc);
|
|
16
|
+
}) : (function(o, m, k, k2) {
|
|
17
|
+
if (k2 === undefined) k2 = k;
|
|
18
|
+
o[k2] = m[k];
|
|
19
|
+
}));
|
|
20
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
21
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
22
|
+
}) : function(o, v) {
|
|
23
|
+
o["default"] = v;
|
|
24
|
+
});
|
|
25
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
26
|
+
var ownKeys = function(o) {
|
|
27
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
28
|
+
var ar = [];
|
|
29
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
30
|
+
return ar;
|
|
31
|
+
};
|
|
32
|
+
return ownKeys(o);
|
|
33
|
+
};
|
|
34
|
+
return function (mod) {
|
|
35
|
+
if (mod && mod.__esModule) return mod;
|
|
36
|
+
var result = {};
|
|
37
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
38
|
+
__setModuleDefault(result, mod);
|
|
39
|
+
return result;
|
|
40
|
+
};
|
|
41
|
+
})();
|
|
42
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
43
|
+
exports.defaultAuthStatePath = defaultAuthStatePath;
|
|
44
|
+
exports.toCookieParam = toCookieParam;
|
|
45
|
+
exports.saveAuthState = saveAuthState;
|
|
46
|
+
exports.loadAuthState = loadAuthState;
|
|
47
|
+
exports.launchBrowser = launchBrowser;
|
|
48
|
+
exports.killBrowser = killBrowser;
|
|
49
|
+
exports.listBrowsers = listBrowsers;
|
|
50
|
+
exports.listBrowserProfiles = listBrowserProfiles;
|
|
51
|
+
exports.getCookies = getCookies;
|
|
52
|
+
exports.setCookie = setCookie;
|
|
53
|
+
exports.clearCache = clearCache;
|
|
54
|
+
const eval_scripts_1 = require("../eval-scripts");
|
|
55
|
+
const fs = __importStar(require("fs/promises"));
|
|
56
|
+
const path = __importStar(require("path"));
|
|
57
|
+
// ─── Helpers ───────────────────────────────────────────────────────────
|
|
58
|
+
/**
|
|
59
|
+
* Resolve the default path for the auth-state JSON file.
|
|
60
|
+
* Defaults to <cwd>/.aether/auth-state.json unless a custom path is provided.
|
|
61
|
+
*/
|
|
62
|
+
function defaultAuthStatePath(custom) {
|
|
63
|
+
if (custom)
|
|
64
|
+
return path.resolve(String(custom));
|
|
65
|
+
return path.resolve(process.cwd(), ".aether", "auth-state.json");
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Strip read-only fields from CDP Cookie objects so they can be passed
|
|
69
|
+
* to Network.setCookies as CookieParam objects.
|
|
70
|
+
*
|
|
71
|
+
* CDP Network.getAllCookies returns Cookie objects with extra fields
|
|
72
|
+
* (size, session, etc.) that Network.setCookies rejects.
|
|
73
|
+
* Keep only the writable CookieParam fields.
|
|
74
|
+
*/
|
|
75
|
+
function toCookieParam(c) {
|
|
76
|
+
const param = {
|
|
77
|
+
name: c.name,
|
|
78
|
+
value: c.value,
|
|
79
|
+
domain: c.domain,
|
|
80
|
+
path: c.path,
|
|
81
|
+
secure: c.secure,
|
|
82
|
+
httpOnly: c.httpOnly,
|
|
83
|
+
};
|
|
84
|
+
if (typeof c.expires === "number" && c.expires > 0)
|
|
85
|
+
param.expires = c.expires;
|
|
86
|
+
if (c.sameSite)
|
|
87
|
+
param.sameSite = c.sameSite;
|
|
88
|
+
if (c.priority)
|
|
89
|
+
param.priority = c.priority;
|
|
90
|
+
if (c.sourceScheme)
|
|
91
|
+
param.sourceScheme = c.sourceScheme;
|
|
92
|
+
if (typeof c.sourcePort === "number")
|
|
93
|
+
param.sourcePort = c.sourcePort;
|
|
94
|
+
if (c.partitionKey)
|
|
95
|
+
param.partitionKey = c.partitionKey;
|
|
96
|
+
return param;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Export the current session (cookies + localStorage + sessionStorage of the
|
|
100
|
+
* active origin) to a JSON file so a logged-in state can be reused later.
|
|
101
|
+
*/
|
|
102
|
+
async function saveAuthState(client, params, snapshotCache) {
|
|
103
|
+
const filePath = defaultAuthStatePath(params.path);
|
|
104
|
+
// Collect cookies (try Network API first, fall back to Storage API)
|
|
105
|
+
const cookiesRes = await client
|
|
106
|
+
.sendCommand("Network.getAllCookies", {})
|
|
107
|
+
.catch(() => client.sendCommand("Storage.getCookies", {}).catch(() => ({ cookies: [] })));
|
|
108
|
+
const cookies = (cookiesRes?.cookies || []).map((c) => toCookieParam(c));
|
|
109
|
+
// Collect localStorage and sessionStorage for the active origin
|
|
110
|
+
const storage = await client.evaluate(eval_scripts_1.EXPORT_STORAGE_SCRIPT).catch(() => null);
|
|
111
|
+
const state = {
|
|
112
|
+
version: 1,
|
|
113
|
+
savedAt: new Date().toISOString(),
|
|
114
|
+
cookies,
|
|
115
|
+
origins: storage ? [storage] : [],
|
|
116
|
+
};
|
|
117
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
118
|
+
await fs.writeFile(filePath, JSON.stringify(state, null, 2), "utf8");
|
|
119
|
+
return {
|
|
120
|
+
success: true,
|
|
121
|
+
path: filePath,
|
|
122
|
+
cookies: cookies.length,
|
|
123
|
+
origins: state.origins.length,
|
|
124
|
+
storageKeys: storage
|
|
125
|
+
? Object.keys(storage.localStorage).length +
|
|
126
|
+
Object.keys(storage.sessionStorage).length
|
|
127
|
+
: 0,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Restore a session saved by saveAuthState. Cookies are set globally; storage
|
|
132
|
+
* is restored for the active origin (navigate to the site first), then the tab
|
|
133
|
+
* is reloaded so the session takes effect.
|
|
134
|
+
*/
|
|
135
|
+
async function loadAuthState(client, params, snapshotCache) {
|
|
136
|
+
const filePath = defaultAuthStatePath(params.path);
|
|
137
|
+
let raw;
|
|
138
|
+
try {
|
|
139
|
+
raw = await fs.readFile(filePath, "utf8");
|
|
140
|
+
}
|
|
141
|
+
catch (e) {
|
|
142
|
+
return {
|
|
143
|
+
success: false,
|
|
144
|
+
path: filePath,
|
|
145
|
+
message: `Could not read auth state: ${e?.message}`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
let state;
|
|
149
|
+
try {
|
|
150
|
+
state = JSON.parse(raw);
|
|
151
|
+
}
|
|
152
|
+
catch (e) {
|
|
153
|
+
return {
|
|
154
|
+
success: false,
|
|
155
|
+
path: filePath,
|
|
156
|
+
message: `Invalid auth state JSON: ${e?.message}`,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
let cookiesSet = 0;
|
|
160
|
+
if (Array.isArray(state.cookies) && state.cookies.length) {
|
|
161
|
+
const cookieParams = state.cookies.map((c) => toCookieParam(c));
|
|
162
|
+
await client.setCookies(cookieParams).catch((err) => {
|
|
163
|
+
console.error("[Aether] setCookies failed during loadAuthState:", err?.message);
|
|
164
|
+
});
|
|
165
|
+
cookiesSet = cookieParams.length;
|
|
166
|
+
}
|
|
167
|
+
let storageRestored = 0;
|
|
168
|
+
let storageSkipped = 0;
|
|
169
|
+
const currentOrigin = await client
|
|
170
|
+
.evaluate("location.origin")
|
|
171
|
+
.catch(() => "");
|
|
172
|
+
for (const entry of state.origins || []) {
|
|
173
|
+
if (entry.origin && currentOrigin && entry.origin !== currentOrigin) {
|
|
174
|
+
storageSkipped++;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const data = JSON.stringify({
|
|
178
|
+
localStorage: entry.localStorage || {},
|
|
179
|
+
sessionStorage: entry.sessionStorage || {},
|
|
180
|
+
});
|
|
181
|
+
const ok = await client
|
|
182
|
+
.evaluate(`
|
|
183
|
+
(function() {
|
|
184
|
+
try {
|
|
185
|
+
const data = ${data};
|
|
186
|
+
for (const k in data.localStorage) localStorage.setItem(k, data.localStorage[k]);
|
|
187
|
+
for (const k in data.sessionStorage) sessionStorage.setItem(k, data.sessionStorage[k]);
|
|
188
|
+
return true;
|
|
189
|
+
} catch (e) { return false; }
|
|
190
|
+
})()
|
|
191
|
+
`)
|
|
192
|
+
.catch(() => false);
|
|
193
|
+
if (ok)
|
|
194
|
+
storageRestored++;
|
|
195
|
+
}
|
|
196
|
+
if (params.reload !== false) {
|
|
197
|
+
await client.reload(false).catch(() => { });
|
|
198
|
+
}
|
|
199
|
+
snapshotCache.invalidate("load_auth_state");
|
|
200
|
+
return {
|
|
201
|
+
success: true,
|
|
202
|
+
path: filePath,
|
|
203
|
+
cookiesSet,
|
|
204
|
+
storageRestored,
|
|
205
|
+
storageSkipped,
|
|
206
|
+
note: storageSkipped > 0
|
|
207
|
+
? "Some storage origins were skipped; navigate to that origin before loading to restore them."
|
|
208
|
+
: undefined,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Launch a new browser instance using automatic detection.
|
|
213
|
+
* If no browser is specified, the client will auto-detect available browsers.
|
|
214
|
+
*/
|
|
215
|
+
async function launchBrowser(client, options) {
|
|
216
|
+
await client.launchAuto({
|
|
217
|
+
browser: options?.browser,
|
|
218
|
+
headless: options?.headless,
|
|
219
|
+
port: options?.port,
|
|
220
|
+
profile: options?.profile,
|
|
221
|
+
profileDirectory: options?.profileDirectory,
|
|
222
|
+
userDataDir: options?.userDataDir,
|
|
223
|
+
});
|
|
224
|
+
const profileLabel = options?.profile || options?.profileDirectory;
|
|
225
|
+
return profileLabel
|
|
226
|
+
? `Browser launched successfully with profile "${profileLabel}"`
|
|
227
|
+
: "Browser launched successfully";
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Kill the currently managed browser process.
|
|
231
|
+
*/
|
|
232
|
+
async function killBrowser(client) {
|
|
233
|
+
await client.killBrowser();
|
|
234
|
+
return "Browser killed";
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* List all available (installed) browsers on the system.
|
|
238
|
+
*/
|
|
239
|
+
async function listBrowsers(client) {
|
|
240
|
+
return await client.listAvailableBrowsers();
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* List browser profiles for a given browser (defaults to Brave).
|
|
244
|
+
*/
|
|
245
|
+
async function listBrowserProfiles(client, browser) {
|
|
246
|
+
return await client.listBrowserProfiles(browser || "brave");
|
|
247
|
+
}
|
|
248
|
+
// ─── Cookie & Cache Management ─────────────────────────────────────────
|
|
249
|
+
/**
|
|
250
|
+
* Get all cookies for the current page URL.
|
|
251
|
+
*/
|
|
252
|
+
async function getCookies(client) {
|
|
253
|
+
const result = await client.sendCommand("Network.getCookies", {
|
|
254
|
+
urls: [
|
|
255
|
+
(await client.evaluate("window.location.href").catch(() => "*")) ||
|
|
256
|
+
"*",
|
|
257
|
+
],
|
|
258
|
+
});
|
|
259
|
+
return result.cookies || [];
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Set a single cookie on the current page.
|
|
263
|
+
*/
|
|
264
|
+
async function setCookie(client, params) {
|
|
265
|
+
const cookies = [
|
|
266
|
+
{
|
|
267
|
+
name: params.cookieName || params.name,
|
|
268
|
+
value: params.cookieValue || params.value,
|
|
269
|
+
url: params.url ||
|
|
270
|
+
(await client.evaluate("window.location.href").catch(() => undefined)),
|
|
271
|
+
domain: params.domain,
|
|
272
|
+
path: params.path || "/",
|
|
273
|
+
secure: params.secure || false,
|
|
274
|
+
httpOnly: params.httpOnly || false,
|
|
275
|
+
},
|
|
276
|
+
];
|
|
277
|
+
await client.sendCommand("Network.setCookies", { cookies });
|
|
278
|
+
return "Cookie set";
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Clear the browser cache and all cookies.
|
|
282
|
+
*/
|
|
283
|
+
async function clearCache(client) {
|
|
284
|
+
await client.sendCommand("Network.clearBrowserCache", {});
|
|
285
|
+
await client.sendCommand("Network.clearBrowserCookies", {});
|
|
286
|
+
return "Cache cleared";
|
|
287
|
+
}
|