auto-webmcp 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  **Automatically make any HTML form WebMCP-ready — zero explicit coding required.**
4
4
 
5
+ [Read the article on dev.to](https://dev.to/prasannagyde/every-web-form-should-be-callable-by-ai-agents-and-yours-can-be-today-228)  ·  [Live demo](https://autowebmcp.dev/demo)  ·  [Platform guides](https://autowebmcp.dev/platforms)
6
+
5
7
  Drop in one script tag (or one `import`) and every `<form>` on your page is
6
8
  instantly registered as a structured tool that in-browser AI agents can
7
9
  discover and use via Chrome's
@@ -7,6 +7,8 @@ export interface ToolMetadata {
7
7
  name: string;
8
8
  description: string;
9
9
  inputSchema: JsonSchema;
10
+ /** Key → DOM element for fields not addressable by name (id-keyed or ARIA-role controls). */
11
+ fieldElements?: Map<string, Element>;
10
12
  }
11
13
  /** Reset form index counter (useful in tests) */
12
14
  export declare function resetFormIndex(): void;
@@ -1 +1 @@
1
- {"version":3,"file":"analyzer.d.ts","sourceRoot":"","sources":["../src/analyzer.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,UAAU,EAA8E,MAAM,aAAa,CAAC;AACrH,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAE3C,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,UAAU,CAAC;CACzB;AAKD,iDAAiD;AACjD,wBAAgB,cAAc,IAAI,IAAI,CAErC;AAED,gDAAgD;AAChD,wBAAgB,WAAW,CAAC,IAAI,EAAE,eAAe,EAAE,QAAQ,CAAC,EAAE,YAAY,GAAG,YAAY,CAMxF"}
1
+ {"version":3,"file":"analyzer.d.ts","sourceRoot":"","sources":["../src/analyzer.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,UAAU,EAA8H,MAAM,aAAa,CAAC;AACrK,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAE3C,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,UAAU,CAAC;IACxB,6FAA6F;IAC7F,aAAa,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACtC;AAKD,iDAAiD;AACjD,wBAAgB,cAAc,IAAI,IAAI,CAErC;AAED,gDAAgD;AAChD,wBAAgB,WAAW,CAAC,IAAI,EAAE,eAAe,EAAE,QAAQ,CAAC,EAAE,YAAY,GAAG,YAAY,CAMxF"}
@@ -110,6 +110,16 @@ function resolveConfig(userConfig) {
110
110
  }
111
111
 
112
112
  // src/schema.ts
113
+ var ARIA_ROLES_TO_SCAN = [
114
+ "textbox",
115
+ "combobox",
116
+ "checkbox",
117
+ "radio",
118
+ "switch",
119
+ "spinbutton",
120
+ "searchbox",
121
+ "slider"
122
+ ];
113
123
  function inputTypeToSchema(input) {
114
124
  if (input instanceof HTMLInputElement) {
115
125
  return mapInputElement(input);
@@ -205,6 +215,49 @@ function collectRadioOneOf(form, name) {
205
215
  return { const: r.value, title: title || r.value };
206
216
  });
207
217
  }
218
+ function ariaRoleToSchema(el, role) {
219
+ switch (role) {
220
+ case "checkbox":
221
+ case "switch":
222
+ return { type: "boolean" };
223
+ case "spinbutton":
224
+ case "slider": {
225
+ const prop = { type: "number" };
226
+ const min = el.getAttribute("aria-valuemin");
227
+ const max = el.getAttribute("aria-valuemax");
228
+ if (min !== null)
229
+ prop.minimum = parseFloat(min);
230
+ if (max !== null)
231
+ prop.maximum = parseFloat(max);
232
+ return prop;
233
+ }
234
+ case "combobox": {
235
+ const ownedId = el.getAttribute("aria-owns") ?? el.getAttribute("aria-controls");
236
+ if (ownedId) {
237
+ const listbox = document.getElementById(ownedId);
238
+ if (listbox) {
239
+ const options = Array.from(listbox.querySelectorAll('[role="option"]')).filter(
240
+ (o) => o.getAttribute("aria-disabled") !== "true"
241
+ );
242
+ if (options.length > 0) {
243
+ const enumValues = options.map((o) => (o.getAttribute("data-value") ?? o.textContent ?? "").trim()).filter(Boolean);
244
+ const oneOf = options.map((o) => ({
245
+ const: (o.getAttribute("data-value") ?? o.textContent ?? "").trim(),
246
+ title: (o.textContent ?? "").trim()
247
+ }));
248
+ return { type: "string", enum: enumValues, oneOf };
249
+ }
250
+ }
251
+ }
252
+ return { type: "string" };
253
+ }
254
+ case "textbox":
255
+ case "searchbox":
256
+ case "radio":
257
+ default:
258
+ return { type: "string" };
259
+ }
260
+ }
208
261
  function getRadioLabelText(radio) {
209
262
  const parent = radio.closest("label");
210
263
  if (parent) {
@@ -230,8 +283,8 @@ var formIndex = 0;
230
283
  function analyzeForm(form, override) {
231
284
  const name = override?.name ?? inferToolName(form);
232
285
  const description = override?.description ?? inferToolDescription(form);
233
- const inputSchema = buildSchema(form);
234
- return { name, description, inputSchema };
286
+ const { schema: inputSchema, fieldElements } = buildSchema(form);
287
+ return { name, description, inputSchema, fieldElements };
235
288
  }
236
289
  function inferToolName(form) {
237
290
  const nativeName = form.getAttribute("toolname");
@@ -331,6 +384,7 @@ function inferToolDescription(form) {
331
384
  function buildSchema(form) {
332
385
  const properties = {};
333
386
  const required = [];
387
+ const fieldElements = /* @__PURE__ */ new Map();
334
388
  const processedRadioGroups = /* @__PURE__ */ new Set();
335
389
  const controls = Array.from(
336
390
  form.querySelectorAll(
@@ -339,12 +393,13 @@ function buildSchema(form) {
339
393
  );
340
394
  for (const control of controls) {
341
395
  const name = control.name;
342
- if (!name)
396
+ const fieldKey = name || resolveNativeControlFallbackKey(control);
397
+ if (!fieldKey)
343
398
  continue;
344
399
  if (control instanceof HTMLInputElement && control.type === "radio") {
345
- if (processedRadioGroups.has(name))
400
+ if (processedRadioGroups.has(fieldKey))
346
401
  continue;
347
- processedRadioGroups.add(name);
402
+ processedRadioGroups.add(fieldKey);
348
403
  }
349
404
  const schemaProp = inputTypeToSchema(control);
350
405
  if (!schemaProp)
@@ -354,17 +409,123 @@ function buildSchema(form) {
354
409
  if (desc)
355
410
  schemaProp.description = desc;
356
411
  if (control instanceof HTMLInputElement && control.type === "radio") {
357
- schemaProp.enum = collectRadioEnum(form, name);
358
- const radioOneOf = collectRadioOneOf(form, name);
412
+ schemaProp.enum = collectRadioEnum(form, fieldKey);
413
+ const radioOneOf = collectRadioOneOf(form, fieldKey);
359
414
  if (radioOneOf.length > 0)
360
415
  schemaProp.oneOf = radioOneOf;
361
416
  }
362
- properties[name] = schemaProp;
417
+ properties[fieldKey] = schemaProp;
418
+ if (!name) {
419
+ fieldElements.set(fieldKey, control);
420
+ }
363
421
  if (control.required) {
364
- required.push(name);
422
+ required.push(fieldKey);
423
+ }
424
+ }
425
+ const ariaControls = collectAriaControls(form);
426
+ const processedAriaRadioGroups = /* @__PURE__ */ new Set();
427
+ for (const { el, role, key } of ariaControls) {
428
+ if (properties[key])
429
+ continue;
430
+ if (role === "radio") {
431
+ if (processedAriaRadioGroups.has(key))
432
+ continue;
433
+ processedAriaRadioGroups.add(key);
434
+ }
435
+ const schemaProp = ariaRoleToSchema(el, role);
436
+ schemaProp.title = inferAriaFieldTitle(el);
437
+ const desc = inferAriaFieldDescription(el);
438
+ if (desc)
439
+ schemaProp.description = desc;
440
+ properties[key] = schemaProp;
441
+ fieldElements.set(key, el);
442
+ if (el.getAttribute("aria-required") === "true") {
443
+ required.push(key);
365
444
  }
366
445
  }
367
- return { type: "object", properties, required };
446
+ return { schema: { type: "object", properties, required }, fieldElements };
447
+ }
448
+ function resolveNativeControlFallbackKey(control) {
449
+ const el = control;
450
+ if (el.dataset["webmcpName"])
451
+ return sanitizeName(el.dataset["webmcpName"]);
452
+ if (control.id)
453
+ return sanitizeName(control.id);
454
+ const label = control.getAttribute("aria-label");
455
+ if (label)
456
+ return sanitizeName(label);
457
+ return null;
458
+ }
459
+ function collectAriaControls(form) {
460
+ const selector = ARIA_ROLES_TO_SCAN.map((r) => `[role="${r}"]`).join(", ");
461
+ const results = [];
462
+ for (const el of Array.from(form.querySelectorAll(selector))) {
463
+ if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement)
464
+ continue;
465
+ if (el.getAttribute("aria-hidden") === "true" || el.hidden)
466
+ continue;
467
+ const role = el.getAttribute("role");
468
+ const key = resolveAriaFieldKey(el);
469
+ if (!key)
470
+ continue;
471
+ results.push({ el, role, key });
472
+ }
473
+ return results;
474
+ }
475
+ function resolveAriaFieldKey(el) {
476
+ const htmlEl = el;
477
+ if (htmlEl.dataset?.["webmcpName"])
478
+ return sanitizeName(htmlEl.dataset["webmcpName"]);
479
+ if (el.id)
480
+ return sanitizeName(el.id);
481
+ const label = el.getAttribute("aria-label");
482
+ if (label)
483
+ return sanitizeName(label);
484
+ const labelledById = el.getAttribute("aria-labelledby");
485
+ if (labelledById) {
486
+ const text = document.getElementById(labelledById)?.textContent?.trim();
487
+ if (text)
488
+ return sanitizeName(text);
489
+ }
490
+ return null;
491
+ }
492
+ function inferAriaFieldTitle(el) {
493
+ const htmlEl = el;
494
+ if (htmlEl.dataset?.["webmcpTitle"])
495
+ return htmlEl.dataset["webmcpTitle"];
496
+ const label = el.getAttribute("aria-label");
497
+ if (label)
498
+ return label.trim();
499
+ const labelledById = el.getAttribute("aria-labelledby");
500
+ if (labelledById) {
501
+ const text = document.getElementById(labelledById)?.textContent?.trim();
502
+ if (text)
503
+ return text;
504
+ }
505
+ if (el.id)
506
+ return humanizeName(el.id);
507
+ return "";
508
+ }
509
+ function inferAriaFieldDescription(el) {
510
+ const nativeParamDesc = el.getAttribute("toolparamdescription");
511
+ if (nativeParamDesc)
512
+ return nativeParamDesc.trim();
513
+ const htmlEl = el;
514
+ if (htmlEl.dataset?.["webmcpDescription"])
515
+ return htmlEl.dataset["webmcpDescription"];
516
+ const ariaDesc = el.getAttribute("aria-description");
517
+ if (ariaDesc)
518
+ return ariaDesc;
519
+ const describedById = el.getAttribute("aria-describedby");
520
+ if (describedById) {
521
+ const text = document.getElementById(describedById)?.textContent?.trim();
522
+ if (text)
523
+ return text;
524
+ }
525
+ const placeholder = el.getAttribute("placeholder") ?? el.dataset?.["placeholder"];
526
+ if (placeholder)
527
+ return placeholder.trim();
528
+ return "";
368
529
  }
369
530
  function inferFieldTitle(control) {
370
531
  if ("dataset" in control && control.dataset["webmcpTitle"]) {
@@ -433,7 +594,15 @@ init_registry();
433
594
 
434
595
  // src/interceptor.ts
435
596
  var pendingExecutions = /* @__PURE__ */ new WeakMap();
436
- function buildExecuteHandler(form, config, toolName) {
597
+ var lastParams = /* @__PURE__ */ new WeakMap();
598
+ var formFieldElements = /* @__PURE__ */ new WeakMap();
599
+ var _inputValueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
600
+ var _textareaValueSetter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set;
601
+ var _checkedSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "checked")?.set;
602
+ function buildExecuteHandler(form, config, toolName, metadata) {
603
+ if (metadata?.fieldElements) {
604
+ formFieldElements.set(form, metadata.fieldElements);
605
+ }
437
606
  attachSubmitInterceptor(form, toolName);
438
607
  return async (params) => {
439
608
  fillFormFields(form, params);
@@ -456,8 +625,8 @@ function attachSubmitInterceptor(form, toolName) {
456
625
  return;
457
626
  const { resolve } = pending;
458
627
  pendingExecutions.delete(form);
459
- const formData = serializeFormData(form);
460
- const text = JSON.stringify(formData);
628
+ const formData = serializeFormData(form, lastParams.get(form), formFieldElements.get(form));
629
+ const text = `Form submitted. Fields: ${JSON.stringify(formData)}`;
461
630
  const result = { content: [{ type: "text", text }] };
462
631
  if (e.agentInvoked && typeof e.respondWith === "function") {
463
632
  e.preventDefault();
@@ -469,52 +638,112 @@ function attachSubmitInterceptor(form, toolName) {
469
638
  window.dispatchEvent(new CustomEvent("toolcancel", { detail: { toolName } }));
470
639
  });
471
640
  }
641
+ function setReactValue(el, v) {
642
+ el.focus();
643
+ el.select?.();
644
+ if (document.execCommand("insertText", false, v)) {
645
+ return;
646
+ }
647
+ const setter = el instanceof HTMLTextAreaElement ? _textareaValueSetter : _inputValueSetter;
648
+ if (setter) {
649
+ setter.call(el, v);
650
+ } else {
651
+ el.value = v;
652
+ }
653
+ el.dispatchEvent(new InputEvent("input", { bubbles: true, cancelable: true, inputType: "insertText", data: v }));
654
+ el.dispatchEvent(new Event("change", { bubbles: true }));
655
+ }
656
+ function setReactChecked(el, checked) {
657
+ if (_checkedSetter) {
658
+ _checkedSetter.call(el, checked);
659
+ } else {
660
+ el.checked = checked;
661
+ }
662
+ el.dispatchEvent(new Event("change", { bubbles: true }));
663
+ }
664
+ function findNativeField(form, key) {
665
+ const esc = CSS.escape(key);
666
+ return form.querySelector(`[name="${esc}"]`) ?? form.querySelector(
667
+ `input#${esc}, textarea#${esc}, select#${esc}`
668
+ );
669
+ }
472
670
  function fillFormFields(form, params) {
473
- for (const [name, value] of Object.entries(params)) {
474
- const escapedName = CSS.escape(name);
475
- const input = form.querySelector(
476
- `[name="${escapedName}"]`
477
- );
478
- if (!input)
671
+ lastParams.set(form, params);
672
+ const fieldEls = formFieldElements.get(form);
673
+ for (const [key, value] of Object.entries(params)) {
674
+ const input = findNativeField(form, key);
675
+ if (input) {
676
+ if (input instanceof HTMLInputElement) {
677
+ fillInput(input, form, key, value);
678
+ } else if (input instanceof HTMLTextAreaElement) {
679
+ setReactValue(input, String(value ?? ""));
680
+ } else if (input instanceof HTMLSelectElement) {
681
+ input.value = String(value ?? "");
682
+ input.dispatchEvent(new Event("change", { bubbles: true }));
683
+ }
479
684
  continue;
480
- if (input instanceof HTMLInputElement) {
481
- fillInput(input, form, name, value);
482
- } else if (input instanceof HTMLTextAreaElement) {
483
- input.value = String(value ?? "");
484
- input.dispatchEvent(new Event("input", { bubbles: true }));
485
- input.dispatchEvent(new Event("change", { bubbles: true }));
486
- } else if (input instanceof HTMLSelectElement) {
487
- input.value = String(value ?? "");
488
- input.dispatchEvent(new Event("change", { bubbles: true }));
685
+ }
686
+ const ariaEl = fieldEls?.get(key);
687
+ if (ariaEl) {
688
+ if (ariaEl instanceof HTMLInputElement) {
689
+ fillInput(ariaEl, form, key, value);
690
+ } else if (ariaEl instanceof HTMLTextAreaElement) {
691
+ setReactValue(ariaEl, String(value ?? ""));
692
+ } else if (ariaEl instanceof HTMLSelectElement) {
693
+ ariaEl.value = String(value ?? "");
694
+ ariaEl.dispatchEvent(new Event("change", { bubbles: true }));
695
+ } else {
696
+ fillAriaField(ariaEl, value);
697
+ }
489
698
  }
490
699
  }
491
700
  }
492
- function fillInput(input, form, name, value) {
701
+ function fillInput(input, form, key, value) {
493
702
  const type = input.type.toLowerCase();
494
703
  if (type === "checkbox") {
495
- input.checked = Boolean(value);
496
- input.dispatchEvent(new Event("change", { bubbles: true }));
704
+ setReactChecked(input, Boolean(value));
497
705
  return;
498
706
  }
499
707
  if (type === "radio") {
500
- const escapedName = CSS.escape(name);
708
+ const esc = CSS.escape(key);
501
709
  const radios = form.querySelectorAll(
502
- `input[type="radio"][name="${escapedName}"]`
710
+ `input[type="radio"][name="${esc}"]`
503
711
  );
504
712
  for (const radio of radios) {
505
713
  if (radio.value === String(value)) {
506
- radio.checked = true;
714
+ if (_checkedSetter) {
715
+ _checkedSetter.call(radio, true);
716
+ } else {
717
+ radio.checked = true;
718
+ }
507
719
  radio.dispatchEvent(new Event("change", { bubbles: true }));
508
720
  break;
509
721
  }
510
722
  }
511
723
  return;
512
724
  }
513
- input.value = String(value ?? "");
514
- input.dispatchEvent(new Event("input", { bubbles: true }));
515
- input.dispatchEvent(new Event("change", { bubbles: true }));
725
+ setReactValue(input, String(value ?? ""));
726
+ }
727
+ function fillAriaField(el, value) {
728
+ const role = el.getAttribute("role");
729
+ if (role === "checkbox" || role === "switch") {
730
+ el.setAttribute("aria-checked", String(Boolean(value)));
731
+ el.dispatchEvent(new MouseEvent("click", { bubbles: true }));
732
+ return;
733
+ }
734
+ if (role === "radio") {
735
+ el.setAttribute("aria-checked", "true");
736
+ el.dispatchEvent(new MouseEvent("click", { bubbles: true }));
737
+ return;
738
+ }
739
+ const htmlEl = el;
740
+ if (htmlEl.isContentEditable) {
741
+ htmlEl.textContent = String(value ?? "");
742
+ }
743
+ el.dispatchEvent(new Event("input", { bubbles: true }));
744
+ el.dispatchEvent(new Event("change", { bubbles: true }));
516
745
  }
517
- function serializeFormData(form) {
746
+ function serializeFormData(form, params, fieldEls) {
518
747
  const result = {};
519
748
  const data = new FormData(form);
520
749
  for (const [key, val] of data.entries()) {
@@ -529,6 +758,27 @@ function serializeFormData(form) {
529
758
  result[key] = val;
530
759
  }
531
760
  }
761
+ if (params) {
762
+ for (const key of Object.keys(params)) {
763
+ if (key in result)
764
+ continue;
765
+ const el = findNativeField(form, key) ?? fieldEls?.get(key) ?? null;
766
+ if (!el)
767
+ continue;
768
+ if (el instanceof HTMLInputElement && el.type === "checkbox") {
769
+ result[key] = el.checked;
770
+ } else if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement) {
771
+ result[key] = el.value;
772
+ } else {
773
+ const role = el.getAttribute("role");
774
+ if (role === "checkbox" || role === "switch") {
775
+ result[key] = el.getAttribute("aria-checked") === "true";
776
+ } else {
777
+ result[key] = el.textContent?.trim() ?? "";
778
+ }
779
+ }
780
+ }
781
+ }
532
782
  return result;
533
783
  }
534
784
 
@@ -640,8 +890,9 @@ async function registerForm(form, config) {
640
890
  if (config.debug) {
641
891
  warnToolQuality(metadata.name, metadata.description);
642
892
  }
643
- const execute = buildExecuteHandler(form, config, metadata.name);
893
+ const execute = buildExecuteHandler(form, config, metadata.name, metadata);
644
894
  await registerFormTool(form, metadata, execute);
895
+ registeredForms.add(form);
645
896
  if (config.debug) {
646
897
  console.debug(`[auto-webmcp] Registered: ${metadata.name}`, metadata);
647
898
  }
@@ -653,12 +904,43 @@ async function unregisterForm(form, config) {
653
904
  if (!name)
654
905
  return;
655
906
  await unregisterFormTool(form);
907
+ registeredForms.delete(form);
656
908
  if (config.debug) {
657
909
  console.debug(`[auto-webmcp] Unregistered: ${name}`);
658
910
  }
659
911
  emit("form:unregistered", form, name);
660
912
  }
661
913
  var observer = null;
914
+ var registeredForms = /* @__PURE__ */ new WeakSet();
915
+ var reAnalysisTimers = /* @__PURE__ */ new Map();
916
+ var RE_ANALYSIS_DEBOUNCE_MS = 300;
917
+ function isInterestingNode(node) {
918
+ const tag = node.tagName.toLowerCase();
919
+ if (tag === "input" || tag === "textarea" || tag === "select")
920
+ return true;
921
+ const role = node.getAttribute("role");
922
+ if (role && ARIA_ROLES_TO_SCAN.includes(role))
923
+ return true;
924
+ if (node.querySelector("input, textarea, select"))
925
+ return true;
926
+ for (const r of ARIA_ROLES_TO_SCAN) {
927
+ if (node.querySelector(`[role="${r}"]`))
928
+ return true;
929
+ }
930
+ return false;
931
+ }
932
+ function scheduleReAnalysis(form, config) {
933
+ const existing = reAnalysisTimers.get(form);
934
+ if (existing)
935
+ clearTimeout(existing);
936
+ reAnalysisTimers.set(
937
+ form,
938
+ setTimeout(() => {
939
+ reAnalysisTimers.delete(form);
940
+ void registerForm(form, config);
941
+ }, RE_ANALYSIS_DEBOUNCE_MS)
942
+ );
943
+ }
662
944
  function startObserver(config) {
663
945
  if (observer)
664
946
  return;
@@ -667,8 +949,15 @@ function startObserver(config) {
667
949
  for (const node of mutation.addedNodes) {
668
950
  if (!(node instanceof Element))
669
951
  continue;
670
- const forms = node instanceof HTMLFormElement ? [node] : Array.from(node.querySelectorAll("form"));
671
- for (const form of forms) {
952
+ if (node instanceof HTMLFormElement) {
953
+ void registerForm(node, config);
954
+ continue;
955
+ }
956
+ const parentForm = node.closest("form");
957
+ if (parentForm instanceof HTMLFormElement && registeredForms.has(parentForm) && isInterestingNode(node)) {
958
+ scheduleReAnalysis(parentForm, config);
959
+ }
960
+ for (const form of Array.from(node.querySelectorAll("form"))) {
672
961
  void registerForm(form, config);
673
962
  }
674
963
  }