chromex-mcp 1.1.0 → 1.2.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/package.json +1 -1
- package/plugins/chromex/skills/chromex/scripts/chromex.mjs +3 -1
- package/plugins/chromex/skills/chromex/scripts/lib/commands/snapshot.mjs +58 -3
- package/plugins/chromex/skills/chromex/scripts/lib/daemon.mjs +43 -5
- package/plugins/chromex/skills/chromex/scripts/mcp-server.mjs +31 -14
package/package.json
CHANGED
|
@@ -244,7 +244,8 @@ async function main() {
|
|
|
244
244
|
|
|
245
245
|
const conn = await getOrStartTabDaemon(targetId, config);
|
|
246
246
|
|
|
247
|
-
const
|
|
247
|
+
const noSnap = args.includes('--no-snap');
|
|
248
|
+
const cmdArgs = args.slice(1).filter(a => a !== '--no-snap');
|
|
248
249
|
|
|
249
250
|
// Juntar argumentos para comandos que aceitam texto livre
|
|
250
251
|
if (cmd === 'eval') {
|
|
@@ -278,6 +279,7 @@ async function main() {
|
|
|
278
279
|
process.exit(1);
|
|
279
280
|
}
|
|
280
281
|
|
|
282
|
+
if (noSnap) cmdArgs.push('--no-snap');
|
|
281
283
|
const response = await sendCommand(conn, { cmd, args: cmdArgs });
|
|
282
284
|
|
|
283
285
|
if (response.ok) {
|
|
@@ -38,7 +38,7 @@ function truncate(str, max = MAX_NAME_LENGTH) {
|
|
|
38
38
|
return str.slice(0, max) + '...';
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
function formatAxNode(node, depth, refIndex, refs) {
|
|
41
|
+
function formatAxNode(node, depth, refIndex, refs, isNew = false) {
|
|
42
42
|
const role = node.role?.value || '';
|
|
43
43
|
const name = truncate(node.name?.value ?? '');
|
|
44
44
|
const value = node.value?.value;
|
|
@@ -50,7 +50,8 @@ function formatAxNode(node, depth, refIndex, refs) {
|
|
|
50
50
|
refIndex.value++;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
const newTag = isNew ? '*' : '';
|
|
54
|
+
let line = `${indent}${newTag}${refTag}[${role}]`;
|
|
54
55
|
if (name !== '') line += ` ${name}`;
|
|
55
56
|
if (!(value === '' || value == null)) line += ` = ${JSON.stringify(truncate(String(value)))}`;
|
|
56
57
|
return line;
|
|
@@ -91,6 +92,54 @@ function buildFingerprints(nodes, nodesById, childrenByParent, compact) {
|
|
|
91
92
|
return fingerprints;
|
|
92
93
|
}
|
|
93
94
|
|
|
95
|
+
// Detect scrollable containers. Returns human-readable summary lines.
|
|
96
|
+
// Only reports containers with overflow:auto|scroll and >50px hidden content.
|
|
97
|
+
async function detectScrollables(cdp, sid) {
|
|
98
|
+
try {
|
|
99
|
+
const { result } = await cdp.send('Runtime.evaluate', {
|
|
100
|
+
expression: `(() => {
|
|
101
|
+
const MIN = 50;
|
|
102
|
+
const out = [];
|
|
103
|
+
const walk = (el, path) => {
|
|
104
|
+
if (el.nodeType !== 1 || out.length >= 10) return;
|
|
105
|
+
const sh = el.scrollHeight, sw = el.scrollWidth;
|
|
106
|
+
const ch = el.clientHeight, cw = el.clientWidth;
|
|
107
|
+
const isRoot = el === document.documentElement || el === document.body;
|
|
108
|
+
const style = isRoot ? null : getComputedStyle(el);
|
|
109
|
+
const oy = isRoot ? 'auto' : style.overflowY;
|
|
110
|
+
const ox = isRoot ? 'auto' : style.overflowX;
|
|
111
|
+
const scrollableY = isRoot || oy === 'auto' || oy === 'scroll';
|
|
112
|
+
const scrollableX = isRoot || ox === 'auto' || ox === 'scroll';
|
|
113
|
+
if (((scrollableY && sh > ch + MIN) || (scrollableX && sw > cw + MIN)) && ch > 0) {
|
|
114
|
+
const dirs = [];
|
|
115
|
+
if (scrollableY && sh > ch + MIN) {
|
|
116
|
+
const down = sh - ch - el.scrollTop;
|
|
117
|
+
const up = el.scrollTop;
|
|
118
|
+
if (up > MIN) dirs.push('up:' + Math.round(up) + 'px');
|
|
119
|
+
if (down > MIN) dirs.push('down:' + Math.round(down) + 'px');
|
|
120
|
+
}
|
|
121
|
+
if (scrollableX && sw > cw + MIN) {
|
|
122
|
+
const right = sw - cw - el.scrollLeft;
|
|
123
|
+
const left = el.scrollLeft;
|
|
124
|
+
if (left > MIN) dirs.push('left:' + Math.round(left) + 'px');
|
|
125
|
+
if (right > MIN) dirs.push('right:' + Math.round(right) + 'px');
|
|
126
|
+
}
|
|
127
|
+
if (dirs.length) {
|
|
128
|
+
const label = isRoot ? 'page' : (el.getAttribute('aria-label') || el.getAttribute('role') || el.id || el.tagName.toLowerCase());
|
|
129
|
+
out.push(label + ': ' + dirs.join(', '));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
for (const c of el.children) walk(c);
|
|
133
|
+
};
|
|
134
|
+
walk(document.documentElement);
|
|
135
|
+
return out;
|
|
136
|
+
})()`,
|
|
137
|
+
returnByValue: true,
|
|
138
|
+
}, sid);
|
|
139
|
+
return result?.value || [];
|
|
140
|
+
} catch { return []; }
|
|
141
|
+
}
|
|
142
|
+
|
|
94
143
|
// refMap is populated when refs=true: { refNumber -> { backendNodeId, role, name } }
|
|
95
144
|
// The caller (daemon) stores this map for later ref resolution.
|
|
96
145
|
// previousFingerprints: Map from prior snapshot for incremental diff.
|
|
@@ -108,6 +157,7 @@ export async function snapshotStr(cdp, sid, compact = true, refs = false, previo
|
|
|
108
157
|
|
|
109
158
|
const currentFingerprints = buildFingerprints(nodes, nodesById, childrenByParent, compact);
|
|
110
159
|
const isDiff = previousFingerprints !== null && previousFingerprints.size > 0;
|
|
160
|
+
const scrollables = await detectScrollables(cdp, sid);
|
|
111
161
|
|
|
112
162
|
const refIndex = { value: 1 };
|
|
113
163
|
const refMap = new Map();
|
|
@@ -157,8 +207,9 @@ export async function snapshotStr(cdp, sid, compact = true, refs = false, previo
|
|
|
157
207
|
|
|
158
208
|
const role = node.role?.value || '';
|
|
159
209
|
const currentRef = refIndex.value;
|
|
210
|
+
const isNew = isDiff && !previousFingerprints.has(node.nodeId);
|
|
160
211
|
|
|
161
|
-
lines.push(formatAxNode(node, depth, refIndex, refs));
|
|
212
|
+
lines.push(formatAxNode(node, depth, refIndex, refs, isNew));
|
|
162
213
|
|
|
163
214
|
if (refs && refIndex.value > currentRef) {
|
|
164
215
|
refMap.set(currentRef, {
|
|
@@ -214,6 +265,10 @@ export async function snapshotStr(cdp, sid, compact = true, refs = false, previo
|
|
|
214
265
|
const changedCount = totalVisible - unchangedCount;
|
|
215
266
|
text = `[incremental: ${changedCount} changed, ${unchangedCount} unchanged]\n${text}`;
|
|
216
267
|
}
|
|
268
|
+
// Append scroll info footer when scrollable containers exist
|
|
269
|
+
if (scrollables.length > 0) {
|
|
270
|
+
text += `\n[scroll: ${scrollables.join(' | ')}]`;
|
|
271
|
+
}
|
|
217
272
|
|
|
218
273
|
return { text, refMap, fingerprints: currentFingerprints };
|
|
219
274
|
}
|
|
@@ -46,6 +46,15 @@ import { touchStr } from './commands/touch.mjs';
|
|
|
46
46
|
import { domsnapshotStr } from './commands/domsnapshot.mjs';
|
|
47
47
|
import { parseRef, clickRefStr, hoverRefStr, fillRefStr } from './commands/refs.mjs';
|
|
48
48
|
import { highlightStr } from './commands/highlight.mjs';
|
|
49
|
+
import { sleep } from './utils.mjs';
|
|
50
|
+
|
|
51
|
+
// Commands that modify visible DOM and should trigger automatic post-action snapshot.
|
|
52
|
+
// After these commands, an incremental snapshot with refs is appended to the result,
|
|
53
|
+
// so the AI agent sees the page state without needing a separate snapshot call.
|
|
54
|
+
const AUTO_SNAP_CMDS = new Set([
|
|
55
|
+
'click', 'clickxy', 'type', 'fill', 'clear', 'select', 'check', 'form',
|
|
56
|
+
'nav', 'navigate', 'dialog', 'loadall', 'drag', 'touch', 'upload',
|
|
57
|
+
]);
|
|
49
58
|
|
|
50
59
|
export function getOrCreateToken(config) {
|
|
51
60
|
if (!config.socketAuth) return null;
|
|
@@ -114,10 +123,18 @@ export async function runDaemon(targetId, config) {
|
|
|
114
123
|
resetIdle();
|
|
115
124
|
const auditResult = { ok: true };
|
|
116
125
|
try {
|
|
126
|
+
// Strip --no-snap before dispatch so it doesn't contaminate command args
|
|
127
|
+
// (e.g. fill would type "--no-snap" into the input field).
|
|
128
|
+
const noSnap = args.includes('--no-snap');
|
|
129
|
+
if (noSnap) args = args.filter(a => a !== '--no-snap');
|
|
130
|
+
|
|
131
|
+
let result;
|
|
132
|
+
let isRefCmd = false;
|
|
133
|
+
|
|
117
134
|
// Ref-based dispatch: click @e5, fill @e3 "value", hover @e12
|
|
118
135
|
if (args[0] && parseRef(args[0]) !== null) {
|
|
136
|
+
isRefCmd = true;
|
|
119
137
|
const refNum = parseRef(args[0]);
|
|
120
|
-
let result;
|
|
121
138
|
if (cmd === 'click') {
|
|
122
139
|
result = await clickRefStr(cdp, sessionId, currentRefMap, refNum);
|
|
123
140
|
} else if (cmd === 'fill') {
|
|
@@ -127,12 +144,9 @@ export async function runDaemon(targetId, config) {
|
|
|
127
144
|
} else {
|
|
128
145
|
throw new Error(`Ref @e${refNum} not supported for command "${cmd}". Use with: click, fill, hover.`);
|
|
129
146
|
}
|
|
130
|
-
audit(cmd, targetId, args, auditResult, config);
|
|
131
|
-
return { ok: true, result };
|
|
132
147
|
}
|
|
133
148
|
|
|
134
|
-
|
|
135
|
-
switch (cmd) {
|
|
149
|
+
if (!isRefCmd) switch (cmd) {
|
|
136
150
|
// --- Comandos originais ---
|
|
137
151
|
case 'list': {
|
|
138
152
|
const pages = await getPages(cdp);
|
|
@@ -172,6 +186,7 @@ export async function runDaemon(targetId, config) {
|
|
|
172
186
|
break;
|
|
173
187
|
case 'nav': case 'navigate':
|
|
174
188
|
result = await navStr(cdp, sessionId, args[0], config);
|
|
189
|
+
previousFingerprints = null; // Reset: new page needs full snapshot
|
|
175
190
|
break;
|
|
176
191
|
case 'net': case 'network':
|
|
177
192
|
result = await netStr(cdp, sessionId);
|
|
@@ -322,6 +337,29 @@ export async function runDaemon(targetId, config) {
|
|
|
322
337
|
}
|
|
323
338
|
}
|
|
324
339
|
audit(cmd, targetId, args, auditResult, config);
|
|
340
|
+
|
|
341
|
+
// Auto-snapshot: append incremental snapshot with refs after DOM-modifying actions.
|
|
342
|
+
// This lets the AI agent see the resulting page state in a single round-trip.
|
|
343
|
+
// Opt-out with --no-snap for scripts doing rapid sequential actions.
|
|
344
|
+
const shouldSnap = isRefCmd
|
|
345
|
+
? (cmd === 'click' || cmd === 'fill') // hover doesn't change DOM
|
|
346
|
+
: AUTO_SNAP_CMDS.has(cmd);
|
|
347
|
+
|
|
348
|
+
if (shouldSnap && !noSnap) {
|
|
349
|
+
try {
|
|
350
|
+
// Navigate already waits for load+readyState; shorter settle for it.
|
|
351
|
+
// Other actions (click, fill) need more time for SPA re-renders.
|
|
352
|
+
const settleMs = (cmd === 'nav' || cmd === 'navigate') ? 100 : 300;
|
|
353
|
+
await sleep(settleMs);
|
|
354
|
+
const snapResult = await snapshotStr(cdp, sessionId, true, true, previousFingerprints);
|
|
355
|
+
previousFingerprints = snapResult.fingerprints;
|
|
356
|
+
if (snapResult.refMap.size > 0) {
|
|
357
|
+
currentRefMap = snapResult.refMap;
|
|
358
|
+
}
|
|
359
|
+
result = (result ?? '') + '\n\n' + snapResult.text;
|
|
360
|
+
} catch (e) { process.stderr.write(`[auto-snap] ${e.message}\n`); }
|
|
361
|
+
}
|
|
362
|
+
|
|
325
363
|
return { ok: true, result: result ?? '' };
|
|
326
364
|
} catch (e) {
|
|
327
365
|
auditResult.ok = false;
|
|
@@ -54,6 +54,7 @@ function tool(name, description, properties, required, annotations) {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
const P_TARGET = { type: 'string', description: 'Target ID prefix from chromex_list' };
|
|
57
|
+
const P_NO_SNAP = { type: 'boolean', description: 'Skip auto-snapshot after action' };
|
|
57
58
|
|
|
58
59
|
// ---- Tool definitions (52 tools) ----
|
|
59
60
|
|
|
@@ -167,10 +168,11 @@ const TOOLS = [
|
|
|
167
168
|
|
|
168
169
|
// == NAVIGATE ==
|
|
169
170
|
tool('chromex_navigate',
|
|
170
|
-
'Navigate to URL and wait for page load.',
|
|
171
|
+
'Navigate to URL and wait for page load. Returns full snapshot with refs of the new page.',
|
|
171
172
|
{
|
|
172
173
|
target: P_TARGET,
|
|
173
174
|
url: { type: 'string', description: 'URL to navigate to' },
|
|
175
|
+
noSnap: P_NO_SNAP,
|
|
174
176
|
}, ['target', 'url'], RW),
|
|
175
177
|
|
|
176
178
|
tool('chromex_waitfor',
|
|
@@ -199,25 +201,28 @@ const TOOLS = [
|
|
|
199
201
|
|
|
200
202
|
// == INTERACT ==
|
|
201
203
|
tool('chromex_click',
|
|
202
|
-
'Click element by CSS selector or @eN ref from snapshot.',
|
|
204
|
+
'Click element by CSS selector or @eN ref from snapshot. Returns auto-snapshot with updated refs.',
|
|
203
205
|
{
|
|
204
206
|
target: P_TARGET,
|
|
205
207
|
selector: { type: 'string', description: 'CSS selector or @eN ref' },
|
|
208
|
+
noSnap: P_NO_SNAP,
|
|
206
209
|
}, ['target', 'selector'], RW),
|
|
207
210
|
|
|
208
211
|
tool('chromex_clickxy',
|
|
209
|
-
'Click at CSS pixel coordinates.',
|
|
212
|
+
'Click at CSS pixel coordinates. Returns auto-snapshot with updated refs.',
|
|
210
213
|
{
|
|
211
214
|
target: P_TARGET,
|
|
212
215
|
x: { type: 'number', description: 'X in CSS pixels' },
|
|
213
216
|
y: { type: 'number', description: 'Y in CSS pixels' },
|
|
217
|
+
noSnap: P_NO_SNAP,
|
|
214
218
|
}, ['target', 'x', 'y'], RW),
|
|
215
219
|
|
|
216
220
|
tool('chromex_type',
|
|
217
|
-
'Type text at currently focused element.',
|
|
221
|
+
'Type text at currently focused element. Returns auto-snapshot with updated refs.',
|
|
218
222
|
{
|
|
219
223
|
target: P_TARGET,
|
|
220
224
|
text: { type: 'string', description: 'Text to type' },
|
|
225
|
+
noSnap: P_NO_SNAP,
|
|
221
226
|
}, ['target', 'text'], RW),
|
|
222
227
|
|
|
223
228
|
tool('chromex_hover',
|
|
@@ -228,82 +233,92 @@ const TOOLS = [
|
|
|
228
233
|
}, ['target', 'ref'], RW),
|
|
229
234
|
|
|
230
235
|
tool('chromex_drag',
|
|
231
|
-
'Drag and drop between selectors or coordinate pairs (x1,y1 x2,y2).',
|
|
236
|
+
'Drag and drop between selectors or coordinate pairs (x1,y1 x2,y2). Returns auto-snapshot with updated refs.',
|
|
232
237
|
{
|
|
233
238
|
target: P_TARGET,
|
|
234
239
|
from: { type: 'string', description: 'Source selector or x,y' },
|
|
235
240
|
to: { type: 'string', description: 'Destination selector or x,y' },
|
|
241
|
+
noSnap: P_NO_SNAP,
|
|
236
242
|
}, ['target', 'from', 'to'], RW),
|
|
237
243
|
|
|
238
244
|
tool('chromex_touch',
|
|
239
|
-
'Touch gesture: tap, swipe, pinch, longpress.',
|
|
245
|
+
'Touch gesture: tap, swipe, pinch, longpress. Returns auto-snapshot with updated refs.',
|
|
240
246
|
{
|
|
241
247
|
target: P_TARGET,
|
|
242
248
|
gesture: { type: 'string', enum: ['tap', 'swipe', 'pinch', 'longpress'], description: 'Gesture type' },
|
|
243
249
|
args: { type: 'array', items: { type: 'string' }, description: 'Gesture args: tap(x,y), swipe(x1,y1,x2,y2), pinch(x,y,scale), longpress(x,y,[ms])' },
|
|
250
|
+
noSnap: P_NO_SNAP,
|
|
244
251
|
}, ['target', 'gesture'], RW),
|
|
245
252
|
|
|
246
253
|
tool('chromex_dialog',
|
|
247
|
-
'Handle JS dialogs (alert/confirm/prompt). Use "auto" to auto-accept all.',
|
|
254
|
+
'Handle JS dialogs (alert/confirm/prompt). Use "auto" to auto-accept all. Returns auto-snapshot with updated refs.',
|
|
248
255
|
{
|
|
249
256
|
target: P_TARGET,
|
|
250
257
|
action: { type: 'string', enum: ['accept', 'dismiss', 'auto'], description: 'Dialog action' },
|
|
251
258
|
text: { type: 'string', description: 'Text for prompt (only with accept)' },
|
|
259
|
+
noSnap: P_NO_SNAP,
|
|
252
260
|
}, ['target', 'action'], RW),
|
|
253
261
|
|
|
254
262
|
tool('chromex_loadall',
|
|
255
|
-
'Click "load more" button repeatedly until it disappears.',
|
|
263
|
+
'Click "load more" button repeatedly until it disappears. Returns auto-snapshot with updated refs.',
|
|
256
264
|
{
|
|
257
265
|
target: P_TARGET,
|
|
258
266
|
selector: { type: 'string', description: 'CSS selector of load-more button' },
|
|
259
267
|
interval: { type: 'number', description: 'Interval between clicks in ms (default: 1500)' },
|
|
268
|
+
noSnap: P_NO_SNAP,
|
|
260
269
|
}, ['target', 'selector'], RW),
|
|
261
270
|
|
|
262
271
|
// == FORMS ==
|
|
263
272
|
tool('chromex_fill',
|
|
264
|
-
'Fill input/textarea. Handles React/Vue/Angular controlled inputs. Accepts @eN ref.',
|
|
273
|
+
'Fill input/textarea. Handles React/Vue/Angular controlled inputs. Accepts @eN ref. Returns auto-snapshot with updated refs.',
|
|
265
274
|
{
|
|
266
275
|
target: P_TARGET,
|
|
267
276
|
selector: { type: 'string', description: 'CSS selector or @eN ref' },
|
|
268
277
|
value: { type: 'string', description: 'Value to fill' },
|
|
278
|
+
noSnap: P_NO_SNAP,
|
|
269
279
|
}, ['target', 'selector', 'value'], RW),
|
|
270
280
|
|
|
271
281
|
tool('chromex_clear',
|
|
272
|
-
'Clear input field.',
|
|
282
|
+
'Clear input field. Returns auto-snapshot with updated refs.',
|
|
273
283
|
{
|
|
274
284
|
target: P_TARGET,
|
|
275
285
|
selector: { type: 'string', description: 'CSS selector' },
|
|
286
|
+
noSnap: P_NO_SNAP,
|
|
276
287
|
}, ['target', 'selector'], RW),
|
|
277
288
|
|
|
278
289
|
tool('chromex_select',
|
|
279
|
-
'Select option in dropdown.',
|
|
290
|
+
'Select option in dropdown. Returns auto-snapshot with updated refs.',
|
|
280
291
|
{
|
|
281
292
|
target: P_TARGET,
|
|
282
293
|
selector: { type: 'string', description: 'CSS selector of select element' },
|
|
283
294
|
value: { type: 'string', description: 'Option value or visible text' },
|
|
295
|
+
noSnap: P_NO_SNAP,
|
|
284
296
|
}, ['target', 'selector', 'value'], RW),
|
|
285
297
|
|
|
286
298
|
tool('chromex_check',
|
|
287
|
-
'Toggle checkbox or radio button.',
|
|
299
|
+
'Toggle checkbox or radio button. Returns auto-snapshot with updated refs.',
|
|
288
300
|
{
|
|
289
301
|
target: P_TARGET,
|
|
290
302
|
selector: { type: 'string', description: 'CSS selector' },
|
|
291
303
|
checked: { type: 'boolean', description: 'Desired state (default: true)', default: true },
|
|
304
|
+
noSnap: P_NO_SNAP,
|
|
292
305
|
}, ['target', 'selector'], RW),
|
|
293
306
|
|
|
294
307
|
tool('chromex_form',
|
|
295
|
-
'Batch fill form. JSON maps selectors to values. Booleans toggle checkboxes.',
|
|
308
|
+
'Batch fill form. JSON maps selectors to values. Booleans toggle checkboxes. Returns auto-snapshot with updated refs.',
|
|
296
309
|
{
|
|
297
310
|
target: P_TARGET,
|
|
298
311
|
fields: { type: 'string', description: 'JSON: {"#email":"user@test.com","#terms":true}' },
|
|
312
|
+
noSnap: P_NO_SNAP,
|
|
299
313
|
}, ['target', 'fields'], RW),
|
|
300
314
|
|
|
301
315
|
tool('chromex_upload',
|
|
302
|
-
'Upload file(s) to input[type=file].',
|
|
316
|
+
'Upload file(s) to input[type=file]. Returns auto-snapshot with updated refs.',
|
|
303
317
|
{
|
|
304
318
|
target: P_TARGET,
|
|
305
319
|
selector: { type: 'string', description: 'CSS selector of file input' },
|
|
306
320
|
files: { type: 'array', items: { type: 'string' }, description: 'File path(s)' },
|
|
321
|
+
noSnap: P_NO_SNAP,
|
|
307
322
|
}, ['target', 'selector', 'files'], RW),
|
|
308
323
|
|
|
309
324
|
// == DATA ==
|
|
@@ -631,6 +646,8 @@ async function executeTool(name, params) {
|
|
|
631
646
|
const mapped = toolToCmd(name, params);
|
|
632
647
|
if (!mapped) return fail(`Unknown tool: ${name}`);
|
|
633
648
|
|
|
649
|
+
if (params.noSnap) mapped.args.push('--no-snap');
|
|
650
|
+
|
|
634
651
|
const conn = await getOrStartTabDaemon(targetId, config);
|
|
635
652
|
const response = await sendCommand(conn, { cmd: mapped.cmd, args: mapped.args });
|
|
636
653
|
|