@geometra/mcp 1.19.23 → 1.21.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/dist/__tests__/server-batch-results.test.js +1 -1
- package/dist/server.js +237 -30
- package/dist/session.d.ts +28 -4
- package/dist/session.js +117 -1
- package/package.json +1 -1
|
@@ -281,7 +281,7 @@ describe('batch MCP result shaping', () => {
|
|
|
281
281
|
alertCount: 1,
|
|
282
282
|
invalidCount: 5,
|
|
283
283
|
});
|
|
284
|
-
expect(final.invalidFields.length).toBe(
|
|
284
|
+
expect(final.invalidFields.length).toBe(5);
|
|
285
285
|
expect(final.alerts.length).toBe(1);
|
|
286
286
|
});
|
|
287
287
|
it('uses the proxy batch path for fill_fields when step output is omitted', async () => {
|
package/dist/server.js
CHANGED
|
@@ -3,7 +3,7 @@ import { performance } from 'node:perf_hooks';
|
|
|
3
3
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
import { formatConnectFailureMessage, isHttpUrl, normalizeConnectTarget } from './connect-utils.js';
|
|
6
|
-
import { connect, connectThroughProxy, disconnect, getSession, prewarmProxy, sendClick, sendFillFields, sendType, sendKey, sendFileUpload, sendFieldText, sendFieldChoice, sendListboxPick, sendSelectOption, sendSetChecked, sendWheel, buildA11yTree, buildCompactUiIndex, buildFormRequiredSnapshot, buildPageModel, buildFormSchemas, expandPageSection, buildUiDelta, hasUiDelta, nodeIdForPath, nodeContextForNode, summarizeCompactIndex, summarizePageModel, summarizeUiDelta, waitForUiCondition, } from './session.js';
|
|
6
|
+
import { connect, connectThroughProxy, disconnect, getSession, prewarmProxy, sendClick, sendFillFields, sendType, sendKey, sendFileUpload, sendFieldText, sendFieldChoice, sendListboxPick, sendSelectOption, sendSetChecked, sendWheel, sendScreenshot, buildA11yTree, buildCompactUiIndex, buildFormRequiredSnapshot, buildPageModel, buildFormSchemas, expandPageSection, buildUiDelta, hasUiDelta, nodeIdForPath, nodeContextForNode, summarizeCompactIndex, summarizePageModel, summarizeUiDelta, waitForUiCondition, } from './session.js';
|
|
7
7
|
function checkedStateInput() {
|
|
8
8
|
return z
|
|
9
9
|
.union([z.boolean(), z.literal('mixed')])
|
|
@@ -726,8 +726,14 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
|
|
|
726
726
|
.optional()
|
|
727
727
|
.default(false)
|
|
728
728
|
.describe('Include per-field step results in the JSON payload (default false for the smallest response)'),
|
|
729
|
+
resumeFromIndex: z
|
|
730
|
+
.number()
|
|
731
|
+
.int()
|
|
732
|
+
.min(0)
|
|
733
|
+
.optional()
|
|
734
|
+
.describe('Resume a partial fill from this field index (from a previous stoppedAt + 1). Skips already-filled fields.'),
|
|
729
735
|
detail: detailInput(),
|
|
730
|
-
}, async ({ url, pageUrl, port, headless, width, height, slowMo, formId, valuesById, valuesByLabel, stopOnError, failOnInvalid, includeSteps, detail }) => {
|
|
736
|
+
}, async ({ url, pageUrl, port, headless, width, height, slowMo, formId, valuesById, valuesByLabel, stopOnError, failOnInvalid, includeSteps, resumeFromIndex, detail }) => {
|
|
731
737
|
const directFields = !includeSteps && !formId && Object.keys(valuesById ?? {}).length === 0
|
|
732
738
|
? directLabelBatchFields(valuesByLabel)
|
|
733
739
|
: null;
|
|
@@ -755,6 +761,7 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
|
|
|
755
761
|
const wait = await sendFillFields(session, directFields);
|
|
756
762
|
const ackResult = parseProxyFillAckResult(wait.result);
|
|
757
763
|
if (ackResult && ackResult.invalidCount === 0) {
|
|
764
|
+
recordWorkflowFill(session, undefined, undefined, valuesById, valuesByLabel, 0, directFields.length);
|
|
758
765
|
return ok(JSON.stringify({
|
|
759
766
|
...connection,
|
|
760
767
|
completed: true,
|
|
@@ -771,6 +778,7 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
|
|
|
771
778
|
const afterDirect = sessionA11y(session);
|
|
772
779
|
const directSignals = afterDirect ? collectSessionSignals(afterDirect) : undefined;
|
|
773
780
|
if (directSignals && directSignals.invalidFields.length === 0) {
|
|
781
|
+
recordWorkflowFill(session, undefined, undefined, valuesById, valuesByLabel, 0, directFields.length);
|
|
774
782
|
return ok(JSON.stringify({
|
|
775
783
|
...connection,
|
|
776
784
|
completed: true,
|
|
@@ -866,8 +874,12 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
|
|
|
866
874
|
fieldCount: planned.fields.length,
|
|
867
875
|
successCount: planned.fields.length,
|
|
868
876
|
errorCount: 0,
|
|
877
|
+
minConfidence: planned.planned.length > 0
|
|
878
|
+
? Number(Math.min(...planned.planned.map(p => p.confidence)).toFixed(2))
|
|
879
|
+
: undefined,
|
|
869
880
|
...(signals ? { final: sessionSignalsPayload(signals, detail) } : {}),
|
|
870
881
|
};
|
|
882
|
+
recordWorkflowFill(session, schema.formId, schema.name, valuesById, valuesByLabel, invalidRemaining, planned.fields.length);
|
|
871
883
|
if (failOnInvalid && invalidRemaining > 0) {
|
|
872
884
|
return err(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
|
|
873
885
|
}
|
|
@@ -876,17 +888,21 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
|
|
|
876
888
|
}
|
|
877
889
|
const steps = [];
|
|
878
890
|
let stoppedAt;
|
|
879
|
-
|
|
891
|
+
const startIndex = resumeFromIndex ?? 0;
|
|
892
|
+
for (let index = startIndex; index < planned.fields.length; index++) {
|
|
880
893
|
const field = planned.fields[index];
|
|
894
|
+
const plan = planned.planned[index];
|
|
895
|
+
const confidence = plan?.confidence;
|
|
896
|
+
const matchMethod = plan?.matchMethod;
|
|
881
897
|
try {
|
|
882
898
|
const result = await executeFillField(session, field, detail);
|
|
883
899
|
steps.push(detail === 'verbose'
|
|
884
|
-
? { index, kind: field.kind, ok: true, summary: result.summary }
|
|
885
|
-
: { index, kind: field.kind, ok: true, ...result.compact });
|
|
900
|
+
? { index, kind: field.kind, ok: true, ...(confidence !== undefined ? { confidence, matchMethod } : {}), summary: result.summary }
|
|
901
|
+
: { index, kind: field.kind, ok: true, ...(confidence !== undefined ? { confidence, matchMethod } : {}), ...result.compact });
|
|
886
902
|
}
|
|
887
903
|
catch (e) {
|
|
888
904
|
const message = e instanceof Error ? e.message : String(e);
|
|
889
|
-
steps.push({ index, kind: field.kind, ok: false, error: message });
|
|
905
|
+
steps.push({ index, kind: field.kind, ok: false, ...(confidence !== undefined ? { confidence, matchMethod } : {}), error: message });
|
|
890
906
|
if (stopOnError) {
|
|
891
907
|
stoppedAt = index;
|
|
892
908
|
break;
|
|
@@ -900,17 +916,22 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
|
|
|
900
916
|
const errorCount = steps.length - successCount;
|
|
901
917
|
const payload = {
|
|
902
918
|
...connection,
|
|
903
|
-
completed: stoppedAt === undefined && steps.length === planned.fields.length,
|
|
919
|
+
completed: stoppedAt === undefined && (startIndex + steps.length) === planned.fields.length,
|
|
904
920
|
execution: 'sequential',
|
|
905
921
|
formId: schema.formId,
|
|
906
922
|
requestedValueCount: entryCount,
|
|
907
923
|
fieldCount: planned.fields.length,
|
|
908
924
|
successCount,
|
|
909
925
|
errorCount,
|
|
926
|
+
minConfidence: planned.planned.length > 0
|
|
927
|
+
? Number(Math.min(...planned.planned.map(p => p.confidence)).toFixed(2))
|
|
928
|
+
: undefined,
|
|
929
|
+
...(startIndex > 0 ? { resumedFromIndex: startIndex } : {}),
|
|
910
930
|
...(includeSteps ? { steps } : {}),
|
|
911
|
-
...(stoppedAt !== undefined ? { stoppedAt } : {}),
|
|
931
|
+
...(stoppedAt !== undefined ? { stoppedAt, resumeFromIndex: stoppedAt + 1 } : {}),
|
|
912
932
|
...(signals ? { final: sessionSignalsPayload(signals, detail) } : {}),
|
|
913
933
|
};
|
|
934
|
+
recordWorkflowFill(session, schema.formId, schema.name, valuesById, valuesByLabel, invalidRemaining, planned.fields.length);
|
|
914
935
|
if (failOnInvalid && invalidRemaining > 0) {
|
|
915
936
|
return err(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
|
|
916
937
|
}
|
|
@@ -952,6 +973,7 @@ Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_lis
|
|
|
952
973
|
const connection = autoConnectionPayload(resolved);
|
|
953
974
|
const steps = [];
|
|
954
975
|
let stoppedAt;
|
|
976
|
+
const batchStartedAt = performance.now();
|
|
955
977
|
for (let index = 0; index < actions.length; index++) {
|
|
956
978
|
const action = actions[index];
|
|
957
979
|
const startedAt = performance.now();
|
|
@@ -964,32 +986,46 @@ Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_lis
|
|
|
964
986
|
}
|
|
965
987
|
const result = await executeBatchAction(session, action, detail, includeSteps);
|
|
966
988
|
const elapsedMs = Number((performance.now() - startedAt).toFixed(1));
|
|
989
|
+
const cumulativeMs = Number((performance.now() - batchStartedAt).toFixed(1));
|
|
990
|
+
const stepSignals = includeSteps ? (() => {
|
|
991
|
+
const a = sessionA11y(session);
|
|
992
|
+
if (!a)
|
|
993
|
+
return undefined;
|
|
994
|
+
const s = collectSessionSignals(a);
|
|
995
|
+
return { invalidCount: s.invalidFields.length, alertCount: s.alerts.length, dialogCount: s.dialogCount, busyCount: s.busyCount };
|
|
996
|
+
})() : undefined;
|
|
967
997
|
steps.push(detail === 'verbose'
|
|
968
998
|
? {
|
|
969
999
|
index,
|
|
970
1000
|
type: action.type,
|
|
971
1001
|
ok: true,
|
|
972
1002
|
elapsedMs,
|
|
1003
|
+
cumulativeMs,
|
|
973
1004
|
...(uiTreeWaitMs > 0 ? { uiTreeWaitMs: Number(uiTreeWaitMs.toFixed(1)) } : {}),
|
|
974
1005
|
summary: result.summary,
|
|
1006
|
+
...(stepSignals ? { signals: stepSignals } : {}),
|
|
975
1007
|
}
|
|
976
1008
|
: {
|
|
977
1009
|
index,
|
|
978
1010
|
type: action.type,
|
|
979
1011
|
ok: true,
|
|
980
1012
|
elapsedMs,
|
|
1013
|
+
cumulativeMs,
|
|
981
1014
|
...(uiTreeWaitMs > 0 ? { uiTreeWaitMs: Number(uiTreeWaitMs.toFixed(1)) } : {}),
|
|
982
1015
|
...result.compact,
|
|
1016
|
+
...(stepSignals ? { signals: stepSignals } : {}),
|
|
983
1017
|
});
|
|
984
1018
|
}
|
|
985
1019
|
catch (e) {
|
|
986
1020
|
const message = e instanceof Error ? e.message : String(e);
|
|
987
1021
|
const elapsedMs = Number((performance.now() - startedAt).toFixed(1));
|
|
1022
|
+
const cumulativeMs = Number((performance.now() - batchStartedAt).toFixed(1));
|
|
988
1023
|
steps.push({
|
|
989
1024
|
index,
|
|
990
1025
|
type: action.type,
|
|
991
1026
|
ok: false,
|
|
992
1027
|
elapsedMs,
|
|
1028
|
+
cumulativeMs,
|
|
993
1029
|
...(uiTreeWaitMs > 0 ? { uiTreeWaitMs: Number(uiTreeWaitMs.toFixed(1)) } : {}),
|
|
994
1030
|
error: message,
|
|
995
1031
|
});
|
|
@@ -1041,7 +1077,12 @@ Use this first on normal HTML pages when you want to understand the page shape w
|
|
|
1041
1077
|
.optional()
|
|
1042
1078
|
.default(8)
|
|
1043
1079
|
.describe('Cap returned landmarks/forms/dialogs/lists per kind (default 8).'),
|
|
1044
|
-
|
|
1080
|
+
includeScreenshot: z
|
|
1081
|
+
.boolean()
|
|
1082
|
+
.optional()
|
|
1083
|
+
.default(false)
|
|
1084
|
+
.describe('Attach a base64 PNG viewport screenshot. Requires @geometra/proxy. Use when geometry alone is ambiguous (icon-only buttons, visual styling cues).'),
|
|
1085
|
+
}, async ({ maxPrimaryActions, maxSectionsPerKind, includeScreenshot }) => {
|
|
1045
1086
|
const session = getSession();
|
|
1046
1087
|
if (!session)
|
|
1047
1088
|
return err('Not connected. Call geometra_connect first.');
|
|
@@ -1049,7 +1090,8 @@ Use this first on normal HTML pages when you want to understand the page shape w
|
|
|
1049
1090
|
if (!a11y)
|
|
1050
1091
|
return err('No UI tree available');
|
|
1051
1092
|
const model = buildPageModel(a11y, { maxPrimaryActions, maxSectionsPerKind });
|
|
1052
|
-
|
|
1093
|
+
const screenshot = includeScreenshot ? await captureScreenshotBase64(session) : undefined;
|
|
1094
|
+
return ok(JSON.stringify(model), screenshot);
|
|
1053
1095
|
});
|
|
1054
1096
|
server.tool('geometra_form_schema', `Get a compact, fill-oriented schema for forms on the page. This is the preferred discovery step before geometra_fill_form.
|
|
1055
1097
|
|
|
@@ -1369,6 +1411,8 @@ Strategies: **auto** (default) tries chooser click if x,y given, else a labeled
|
|
|
1369
1411
|
.describe('Upload strategy (default auto)'),
|
|
1370
1412
|
dropX: z.number().optional().describe('Drop target X (viewport) for strategy drop'),
|
|
1371
1413
|
dropY: z.number().optional().describe('Drop target Y (viewport) for strategy drop'),
|
|
1414
|
+
contextText: z.string().optional().describe('Ancestor / prompt text to disambiguate repeated file inputs'),
|
|
1415
|
+
sectionText: z.string().optional().describe('Containing section text to disambiguate repeated file inputs'),
|
|
1372
1416
|
timeoutMs: z
|
|
1373
1417
|
.number()
|
|
1374
1418
|
.int()
|
|
@@ -1377,7 +1421,7 @@ Strategies: **auto** (default) tries chooser click if x,y given, else a labeled
|
|
|
1377
1421
|
.optional()
|
|
1378
1422
|
.describe('Optional action wait timeout (resume parsing / SPA upload flows often need longer than a normal click)'),
|
|
1379
1423
|
detail: detailInput(),
|
|
1380
|
-
}, async ({ paths, x, y, fieldLabel, exact, strategy, dropX, dropY, timeoutMs, detail }) => {
|
|
1424
|
+
}, async ({ paths, x, y, fieldLabel, exact, strategy, dropX, dropY, contextText: _contextText, sectionText: _sectionText, timeoutMs, detail }) => {
|
|
1381
1425
|
const session = getSession();
|
|
1382
1426
|
if (!session)
|
|
1383
1427
|
return err('Not connected. Call geometra_connect first.');
|
|
@@ -1411,6 +1455,8 @@ Pass \`fieldLabel\` to open a labeled dropdown semantically instead of relying o
|
|
|
1411
1455
|
openX: z.number().optional().describe('Click to open dropdown'),
|
|
1412
1456
|
openY: z.number().optional().describe('Click to open dropdown'),
|
|
1413
1457
|
fieldLabel: z.string().optional().describe('Field label of the dropdown/combobox to open semantically (e.g. "Location")'),
|
|
1458
|
+
contextText: z.string().optional().describe('Ancestor / prompt text to disambiguate repeated dropdowns with the same label'),
|
|
1459
|
+
sectionText: z.string().optional().describe('Containing section text to disambiguate repeated dropdowns'),
|
|
1414
1460
|
query: z.string().optional().describe('Optional text to type into a searchable combobox before selecting'),
|
|
1415
1461
|
timeoutMs: z
|
|
1416
1462
|
.number()
|
|
@@ -1420,7 +1466,7 @@ Pass \`fieldLabel\` to open a labeled dropdown semantically instead of relying o
|
|
|
1420
1466
|
.optional()
|
|
1421
1467
|
.describe('Optional action wait timeout for slow dropdowns / remote search results'),
|
|
1422
1468
|
detail: detailInput(),
|
|
1423
|
-
}, async ({ label, exact, openX, openY, fieldLabel, query, timeoutMs, detail }) => {
|
|
1469
|
+
}, async ({ label, exact, openX, openY, fieldLabel, contextText, sectionText, query, timeoutMs, detail }) => {
|
|
1424
1470
|
const session = getSession();
|
|
1425
1471
|
if (!session)
|
|
1426
1472
|
return err('Not connected. Call geometra_connect first.');
|
|
@@ -1459,6 +1505,8 @@ Custom React/Vue dropdowns are not supported here — use \`geometra_pick_listbo
|
|
|
1459
1505
|
value: z.string().optional().describe('Option value= attribute'),
|
|
1460
1506
|
label: z.string().optional().describe('Visible option label (substring match)'),
|
|
1461
1507
|
index: z.number().int().min(0).optional().describe('Zero-based option index'),
|
|
1508
|
+
contextText: z.string().optional().describe('Ancestor / prompt text to disambiguate repeated selects'),
|
|
1509
|
+
sectionText: z.string().optional().describe('Containing section text to disambiguate repeated selects'),
|
|
1462
1510
|
timeoutMs: z
|
|
1463
1511
|
.number()
|
|
1464
1512
|
.int()
|
|
@@ -1467,7 +1515,7 @@ Custom React/Vue dropdowns are not supported here — use \`geometra_pick_listbo
|
|
|
1467
1515
|
.optional()
|
|
1468
1516
|
.describe('Optional action wait timeout'),
|
|
1469
1517
|
detail: detailInput(),
|
|
1470
|
-
}, async ({ x, y, value, label, index, timeoutMs, detail }) => {
|
|
1518
|
+
}, async ({ x, y, value, label, index, contextText: _contextText, sectionText: _sectionText, timeoutMs, detail }) => {
|
|
1471
1519
|
const session = getSession();
|
|
1472
1520
|
if (!session)
|
|
1473
1521
|
return err('Not connected. Call geometra_connect first.');
|
|
@@ -1497,6 +1545,8 @@ Prefer this over raw coordinate clicks for custom forms that keep the real input
|
|
|
1497
1545
|
checked: z.boolean().optional().default(true).describe('Desired checked state (radios only support true)'),
|
|
1498
1546
|
exact: z.boolean().optional().describe('Exact label match'),
|
|
1499
1547
|
controlType: z.enum(['checkbox', 'radio']).optional().describe('Limit matching to checkbox or radio'),
|
|
1548
|
+
contextText: z.string().optional().describe('Ancestor / prompt text to disambiguate repeated checkboxes/radios'),
|
|
1549
|
+
sectionText: z.string().optional().describe('Containing section text to disambiguate repeated checkboxes/radios'),
|
|
1500
1550
|
timeoutMs: z
|
|
1501
1551
|
.number()
|
|
1502
1552
|
.int()
|
|
@@ -1505,7 +1555,7 @@ Prefer this over raw coordinate clicks for custom forms that keep the real input
|
|
|
1505
1555
|
.optional()
|
|
1506
1556
|
.describe('Optional action wait timeout'),
|
|
1507
1557
|
detail: detailInput(),
|
|
1508
|
-
}, async ({ label, checked, exact, controlType, timeoutMs, detail }) => {
|
|
1558
|
+
}, async ({ label, checked, exact, controlType, contextText: _contextText, sectionText: _sectionText, timeoutMs, detail }) => {
|
|
1509
1559
|
const session = getSession();
|
|
1510
1560
|
if (!session)
|
|
1511
1561
|
return err('Not connected. Call geometra_connect first.');
|
|
@@ -1557,6 +1607,57 @@ Prefer this over raw coordinate clicks for custom forms that keep the real input
|
|
|
1557
1607
|
return err(e.message);
|
|
1558
1608
|
}
|
|
1559
1609
|
});
|
|
1610
|
+
// ── list items (virtualized list pagination) ─────────────────
|
|
1611
|
+
server.tool('geometra_list_items', `Auto-scroll a virtualized or long list and collect all visible items across scroll positions. Requires \`@geometra/proxy\`.
|
|
1612
|
+
|
|
1613
|
+
Use this for dropdowns, location pickers, or any scrollable list where items are rendered on demand. Scrolls down in steps, collecting new items each time, until no new items appear or the cap is reached.`, {
|
|
1614
|
+
listId: z.string().optional().describe('Stable section id from geometra_page_model (e.g. ls:2.1) to scope item collection'),
|
|
1615
|
+
role: z.string().optional().describe('Role filter for list items (default: listitem)'),
|
|
1616
|
+
scrollX: z.number().optional().describe('X coordinate to position mouse for scrolling (default: viewport center)'),
|
|
1617
|
+
scrollY: z.number().optional().describe('Y coordinate to position mouse for scrolling (default: viewport center)'),
|
|
1618
|
+
maxItems: z.number().int().min(1).max(500).optional().default(100).describe('Cap collected items (default 100)'),
|
|
1619
|
+
maxScrollSteps: z.number().int().min(1).max(50).optional().default(20).describe('Max scroll steps before stopping (default 20)'),
|
|
1620
|
+
scrollDelta: z.number().optional().default(300).describe('Vertical scroll delta per step (default 300)'),
|
|
1621
|
+
}, async ({ listId: _listId, role, scrollX, scrollY, maxItems, maxScrollSteps, scrollDelta }) => {
|
|
1622
|
+
const session = getSession();
|
|
1623
|
+
if (!session)
|
|
1624
|
+
return err('Not connected. Call geometra_connect first.');
|
|
1625
|
+
const itemRole = role ?? 'listitem';
|
|
1626
|
+
const collected = new Map();
|
|
1627
|
+
const cx = scrollX ?? 400;
|
|
1628
|
+
const cy = scrollY ?? 400;
|
|
1629
|
+
for (let step = 0; step < maxScrollSteps; step++) {
|
|
1630
|
+
const a11y = await sessionA11yWhenReady(session);
|
|
1631
|
+
if (!a11y)
|
|
1632
|
+
break;
|
|
1633
|
+
const items = findNodes(a11y, { role: itemRole });
|
|
1634
|
+
let newCount = 0;
|
|
1635
|
+
for (const item of items) {
|
|
1636
|
+
const id = nodeIdForPath(item.path);
|
|
1637
|
+
if (!collected.has(id)) {
|
|
1638
|
+
collected.set(id, {
|
|
1639
|
+
...(item.name ? { name: item.name } : {}),
|
|
1640
|
+
...(item.value ? { value: item.value } : {}),
|
|
1641
|
+
});
|
|
1642
|
+
newCount++;
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
if (collected.size >= maxItems || newCount === 0)
|
|
1646
|
+
break;
|
|
1647
|
+
try {
|
|
1648
|
+
await sendWheel(session, scrollDelta, { x: cx, y: cy }, 1_000);
|
|
1649
|
+
}
|
|
1650
|
+
catch {
|
|
1651
|
+
break;
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
const items = [...collected.entries()].slice(0, maxItems).map(([id, data]) => ({ id, ...data }));
|
|
1655
|
+
return ok(JSON.stringify({
|
|
1656
|
+
itemCount: items.length,
|
|
1657
|
+
items,
|
|
1658
|
+
truncated: collected.size > maxItems,
|
|
1659
|
+
}));
|
|
1660
|
+
});
|
|
1560
1661
|
// ── snapshot ─────────────────────────────────────────────────
|
|
1561
1662
|
server.tool('geometra_snapshot', `Get the current UI as JSON. Default **compact** view: flat list of viewport-visible actionable nodes plus a few pinned context anchors (for example tab strips / form roots) and root context like URL, scroll, and focus — far fewer tokens than a full nested tree. Use **full** for complete nested a11y + every wrapper when debugging layout. Use **form-required** to list required fields across forms, including offscreen ones, with bounds + visibility + scroll hints for long application flows.
|
|
1562
1663
|
|
|
@@ -1577,15 +1678,21 @@ JSON is minified in compact view to save tokens. For a summary-first overview, u
|
|
|
1577
1678
|
formId: z.string().optional().describe('Optional form id from geometra_form_schema / geometra_page_model when view=form-required'),
|
|
1578
1679
|
maxFields: z.number().int().min(1).max(200).optional().default(80).describe('Per-form field cap when view=form-required'),
|
|
1579
1680
|
includeOptions: z.boolean().optional().default(false).describe('Include explicit choice option labels when view=form-required'),
|
|
1580
|
-
|
|
1681
|
+
includeScreenshot: z
|
|
1682
|
+
.boolean()
|
|
1683
|
+
.optional()
|
|
1684
|
+
.default(false)
|
|
1685
|
+
.describe('Attach a base64 PNG viewport screenshot. Requires @geometra/proxy.'),
|
|
1686
|
+
}, async ({ view, maxNodes, formId, maxFields, includeOptions, includeScreenshot }) => {
|
|
1581
1687
|
const session = getSession();
|
|
1582
1688
|
if (!session)
|
|
1583
1689
|
return err('Not connected. Call geometra_connect first.');
|
|
1584
1690
|
const a11y = await sessionA11yWhenReady(session);
|
|
1585
1691
|
if (!a11y)
|
|
1586
1692
|
return err('No UI tree available');
|
|
1693
|
+
const screenshot = includeScreenshot ? await captureScreenshotBase64(session) : undefined;
|
|
1587
1694
|
if (view === 'full') {
|
|
1588
|
-
return ok(JSON.stringify(a11y, null, 2));
|
|
1695
|
+
return ok(JSON.stringify(a11y, null, 2), screenshot);
|
|
1589
1696
|
}
|
|
1590
1697
|
if (view === 'form-required') {
|
|
1591
1698
|
const payload = {
|
|
@@ -1598,7 +1705,7 @@ JSON is minified in compact view to save tokens. For a summary-first overview, u
|
|
|
1598
1705
|
includeContext: 'auto',
|
|
1599
1706
|
}),
|
|
1600
1707
|
};
|
|
1601
|
-
return ok(JSON.stringify(payload));
|
|
1708
|
+
return ok(JSON.stringify(payload), screenshot);
|
|
1602
1709
|
}
|
|
1603
1710
|
const { nodes, truncated, context } = buildCompactUiIndex(a11y, { maxNodes });
|
|
1604
1711
|
const payload = {
|
|
@@ -1608,7 +1715,7 @@ JSON is minified in compact view to save tokens. For a summary-first overview, u
|
|
|
1608
1715
|
nodes,
|
|
1609
1716
|
truncated,
|
|
1610
1717
|
};
|
|
1611
|
-
return ok(JSON.stringify(payload));
|
|
1718
|
+
return ok(JSON.stringify(payload), screenshot);
|
|
1612
1719
|
});
|
|
1613
1720
|
// ── layout ───────────────────────────────────────────────────
|
|
1614
1721
|
server.tool('geometra_layout', `Get the raw computed layout geometry — the exact {x, y, width, height} for every node in the UI tree. This is the lowest-level view, useful for pixel-precise assertions in tests.
|
|
@@ -1619,6 +1726,43 @@ For a token-efficient semantic view, use geometra_snapshot (default compact). Fo
|
|
|
1619
1726
|
return err('Not connected. Call geometra_connect first.');
|
|
1620
1727
|
return ok(JSON.stringify(session.layout, null, 2));
|
|
1621
1728
|
});
|
|
1729
|
+
// ── workflow state ───────────────────────────────────────────
|
|
1730
|
+
server.tool('geometra_workflow_state', `Get the accumulated workflow state across page navigations. Shows which pages/forms have been filled, what values were submitted, and the fill status per page.
|
|
1731
|
+
|
|
1732
|
+
Use this after navigating to a new page in a multi-step flow (e.g. job applications) to understand what has been completed so far. Pass \`clear: true\` to reset the workflow state.`, {
|
|
1733
|
+
clear: z.boolean().optional().default(false).describe('Reset the workflow state'),
|
|
1734
|
+
}, async ({ clear }) => {
|
|
1735
|
+
const session = getSession();
|
|
1736
|
+
if (!session)
|
|
1737
|
+
return err('Not connected. Call geometra_connect first.');
|
|
1738
|
+
if (clear) {
|
|
1739
|
+
session.workflowState = undefined;
|
|
1740
|
+
return ok(JSON.stringify({ cleared: true }));
|
|
1741
|
+
}
|
|
1742
|
+
if (!session.workflowState || session.workflowState.pages.length === 0) {
|
|
1743
|
+
return ok(JSON.stringify({
|
|
1744
|
+
pageCount: 0,
|
|
1745
|
+
message: 'No workflow state recorded yet. Fill a form with geometra_fill_form to start tracking.',
|
|
1746
|
+
}));
|
|
1747
|
+
}
|
|
1748
|
+
const state = session.workflowState;
|
|
1749
|
+
const totalFields = state.pages.reduce((sum, p) => sum + p.fieldCount, 0);
|
|
1750
|
+
const totalInvalid = state.pages.reduce((sum, p) => sum + p.invalidCount, 0);
|
|
1751
|
+
return ok(JSON.stringify({
|
|
1752
|
+
pageCount: state.pages.length,
|
|
1753
|
+
totalFieldsFilled: totalFields,
|
|
1754
|
+
totalInvalidRemaining: totalInvalid,
|
|
1755
|
+
elapsedMs: Date.now() - state.startedAt,
|
|
1756
|
+
pages: state.pages.map(p => ({
|
|
1757
|
+
pageUrl: p.pageUrl,
|
|
1758
|
+
...(p.formId ? { formId: p.formId } : {}),
|
|
1759
|
+
...(p.formName ? { formName: p.formName } : {}),
|
|
1760
|
+
fieldCount: p.fieldCount,
|
|
1761
|
+
invalidCount: p.invalidCount,
|
|
1762
|
+
filledValues: p.filledValues,
|
|
1763
|
+
})),
|
|
1764
|
+
}));
|
|
1765
|
+
});
|
|
1622
1766
|
// ── disconnect ───────────────────────────────────────────────
|
|
1623
1767
|
server.tool('geometra_disconnect', `Disconnect from the Geometra server. Proxy-backed sessions keep compatible browsers alive by default so the next geometra_connect can reuse them quickly; pass closeBrowser=true to fully tear down the warm proxy/browser pool.`, {
|
|
1624
1768
|
closeBrowser: z.boolean().optional().default(false).describe('Fully close the spawned proxy/browser instead of keeping it warm for reuse'),
|
|
@@ -1720,8 +1864,10 @@ function packedFormSchemas(forms) {
|
|
|
1720
1864
|
...(field.valueLength !== undefined ? { vl: field.valueLength } : {}),
|
|
1721
1865
|
...(field.checked !== undefined ? { c: field.checked ? 1 : 0 } : {}),
|
|
1722
1866
|
...(field.values && field.values.length > 0 ? { vs: field.values } : {}),
|
|
1867
|
+
...(field.aliases ? { al: field.aliases } : {}),
|
|
1723
1868
|
...(field.context ? { x: field.context } : {}),
|
|
1724
1869
|
})),
|
|
1870
|
+
...(form.sections ? { s: form.sections.map(s => ({ n: s.name, fi: s.fieldIds })) } : {}),
|
|
1725
1871
|
}));
|
|
1726
1872
|
}
|
|
1727
1873
|
function formSchemaResponsePayload(session, opts) {
|
|
@@ -1935,6 +2081,12 @@ function collectSessionSignals(root) {
|
|
|
1935
2081
|
};
|
|
1936
2082
|
const seenAlerts = new Set();
|
|
1937
2083
|
const seenInvalidIds = new Set();
|
|
2084
|
+
const captchaPattern = /recaptcha|g-recaptcha|hcaptcha|h-captcha|turnstile|cf-turnstile|captcha/i;
|
|
2085
|
+
const captchaTypes = {
|
|
2086
|
+
recaptcha: 'recaptcha', 'g-recaptcha': 'recaptcha',
|
|
2087
|
+
hcaptcha: 'hcaptcha', 'h-captcha': 'hcaptcha',
|
|
2088
|
+
turnstile: 'turnstile', 'cf-turnstile': 'turnstile',
|
|
2089
|
+
};
|
|
1938
2090
|
function walk(node) {
|
|
1939
2091
|
if (!signals.focus && node.state?.focused) {
|
|
1940
2092
|
signals.focus = {
|
|
@@ -1967,6 +2119,14 @@ function collectSessionSignals(root) {
|
|
|
1967
2119
|
});
|
|
1968
2120
|
}
|
|
1969
2121
|
}
|
|
2122
|
+
if (!signals.captchaDetected) {
|
|
2123
|
+
const text = [node.name, node.value].filter(Boolean).join(' ');
|
|
2124
|
+
if (captchaPattern.test(text)) {
|
|
2125
|
+
signals.captchaDetected = true;
|
|
2126
|
+
const match = text.toLowerCase().match(/recaptcha|g-recaptcha|hcaptcha|h-captcha|turnstile|cf-turnstile/);
|
|
2127
|
+
signals.captchaType = match ? (captchaTypes[match[0]] ?? 'unknown') : 'unknown';
|
|
2128
|
+
}
|
|
2129
|
+
}
|
|
1970
2130
|
for (const child of node.children)
|
|
1971
2131
|
walk(child);
|
|
1972
2132
|
}
|
|
@@ -2028,6 +2188,7 @@ function sessionSignalsPayload(signals, detail = 'minimal') {
|
|
|
2028
2188
|
? { scroll: { x: signals.scrollX ?? 0, y: signals.scrollY ?? 0 } }
|
|
2029
2189
|
: {}),
|
|
2030
2190
|
...(signals.focus ? { focus: signals.focus } : {}),
|
|
2191
|
+
...(signals.captchaDetected ? { captchaDetected: true, captchaType: signals.captchaType ?? 'unknown' } : {}),
|
|
2031
2192
|
dialogCount: signals.dialogCount,
|
|
2032
2193
|
busyCount: signals.busyCount,
|
|
2033
2194
|
alertCount: signals.alerts.length,
|
|
@@ -2037,12 +2198,10 @@ function sessionSignalsPayload(signals, detail = 'minimal') {
|
|
|
2037
2198
|
alerts: signals.alerts,
|
|
2038
2199
|
invalidFields: signals.invalidFields,
|
|
2039
2200
|
}
|
|
2040
|
-
:
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
}
|
|
2045
|
-
: {}),
|
|
2201
|
+
: {
|
|
2202
|
+
alerts: signals.alerts.slice(0, 2),
|
|
2203
|
+
invalidFields: signals.invalidFields.slice(0, 6),
|
|
2204
|
+
}),
|
|
2046
2205
|
};
|
|
2047
2206
|
}
|
|
2048
2207
|
function compactTextValue(value, inlineLimit = 48) {
|
|
@@ -2348,7 +2507,7 @@ function planFormFill(schema, opts) {
|
|
|
2348
2507
|
else
|
|
2349
2508
|
fieldsByLabel.set(key, [field]);
|
|
2350
2509
|
}
|
|
2351
|
-
const
|
|
2510
|
+
const allPlanned = [];
|
|
2352
2511
|
const seenFieldIds = new Set();
|
|
2353
2512
|
for (const [fieldId, value] of Object.entries(opts.valuesById ?? {})) {
|
|
2354
2513
|
const field = fieldById.get(fieldId);
|
|
@@ -2357,7 +2516,8 @@ function planFormFill(schema, opts) {
|
|
|
2357
2516
|
const next = plannedFillInputsForField(field, value);
|
|
2358
2517
|
if ('error' in next)
|
|
2359
2518
|
return { ok: false, error: next.error };
|
|
2360
|
-
|
|
2519
|
+
for (const n of next)
|
|
2520
|
+
allPlanned.push({ field: n, confidence: 1.0, matchMethod: 'id' });
|
|
2361
2521
|
seenFieldIds.add(field.id);
|
|
2362
2522
|
}
|
|
2363
2523
|
for (const [label, value] of Object.entries(opts.valuesByLabel ?? {})) {
|
|
@@ -2374,10 +2534,14 @@ function planFormFill(schema, opts) {
|
|
|
2374
2534
|
const next = plannedFillInputsForField(field, value);
|
|
2375
2535
|
if ('error' in next)
|
|
2376
2536
|
return { ok: false, error: next.error };
|
|
2377
|
-
|
|
2537
|
+
const isExact = field.label === label;
|
|
2538
|
+
const confidence = isExact ? 0.95 : 0.8;
|
|
2539
|
+
const matchMethod = isExact ? 'label-exact' : 'label-normalized';
|
|
2540
|
+
for (const n of next)
|
|
2541
|
+
allPlanned.push({ field: n, confidence, matchMethod });
|
|
2378
2542
|
seenFieldIds.add(field.id);
|
|
2379
2543
|
}
|
|
2380
|
-
return { ok: true, fields: planned };
|
|
2544
|
+
return { ok: true, fields: allPlanned.map(p => p.field), planned: allPlanned };
|
|
2381
2545
|
}
|
|
2382
2546
|
function isResolvedFillFieldInput(field) {
|
|
2383
2547
|
if (field.kind === 'toggle')
|
|
@@ -2502,12 +2666,14 @@ function parseProxyFillAckResult(value) {
|
|
|
2502
2666
|
typeof candidate.busyCount !== 'number') {
|
|
2503
2667
|
return undefined;
|
|
2504
2668
|
}
|
|
2669
|
+
const invalidFields = Array.isArray(candidate.invalidFields) ? candidate.invalidFields : undefined;
|
|
2505
2670
|
return {
|
|
2506
2671
|
...(typeof candidate.pageUrl === 'string' ? { pageUrl: candidate.pageUrl } : {}),
|
|
2507
2672
|
invalidCount: candidate.invalidCount,
|
|
2508
2673
|
alertCount: candidate.alertCount,
|
|
2509
2674
|
dialogCount: candidate.dialogCount,
|
|
2510
2675
|
busyCount: candidate.busyCount,
|
|
2676
|
+
...(invalidFields && invalidFields.length > 0 ? { invalidFields } : {}),
|
|
2511
2677
|
};
|
|
2512
2678
|
}
|
|
2513
2679
|
function directLabelBatchFields(valuesByLabel) {
|
|
@@ -2968,8 +3134,49 @@ async function executeFillField(session, field, detail) {
|
|
|
2968
3134
|
}
|
|
2969
3135
|
}
|
|
2970
3136
|
}
|
|
2971
|
-
function ok(text) {
|
|
2972
|
-
|
|
3137
|
+
function ok(text, screenshot) {
|
|
3138
|
+
const content = [
|
|
3139
|
+
{ type: 'text', text },
|
|
3140
|
+
];
|
|
3141
|
+
if (screenshot) {
|
|
3142
|
+
content.push({ type: 'image', data: screenshot, mimeType: 'image/png' });
|
|
3143
|
+
}
|
|
3144
|
+
return { content };
|
|
3145
|
+
}
|
|
3146
|
+
function recordWorkflowFill(session, formId, formName, valuesById, valuesByLabel, invalidCount, fieldCount) {
|
|
3147
|
+
if (!session.workflowState) {
|
|
3148
|
+
session.workflowState = { pages: [], startedAt: Date.now() };
|
|
3149
|
+
}
|
|
3150
|
+
const filledValues = {};
|
|
3151
|
+
for (const [k, v] of Object.entries(valuesById ?? {})) {
|
|
3152
|
+
if (typeof v === 'string' || typeof v === 'boolean')
|
|
3153
|
+
filledValues[k] = v;
|
|
3154
|
+
}
|
|
3155
|
+
for (const [k, v] of Object.entries(valuesByLabel ?? {})) {
|
|
3156
|
+
if (typeof v === 'string' || typeof v === 'boolean')
|
|
3157
|
+
filledValues[k] = v;
|
|
3158
|
+
}
|
|
3159
|
+
const a11y = sessionA11y(session);
|
|
3160
|
+
const pageUrl = a11y?.meta?.pageUrl ?? session.url;
|
|
3161
|
+
session.workflowState.pages.push({
|
|
3162
|
+
pageUrl,
|
|
3163
|
+
formId,
|
|
3164
|
+
formName,
|
|
3165
|
+
filledValues,
|
|
3166
|
+
filledAt: Date.now(),
|
|
3167
|
+
fieldCount,
|
|
3168
|
+
invalidCount,
|
|
3169
|
+
});
|
|
3170
|
+
}
|
|
3171
|
+
async function captureScreenshotBase64(session) {
|
|
3172
|
+
try {
|
|
3173
|
+
const wait = await sendScreenshot(session);
|
|
3174
|
+
const result = wait.result;
|
|
3175
|
+
return typeof result?.screenshot === 'string' ? result.screenshot : undefined;
|
|
3176
|
+
}
|
|
3177
|
+
catch {
|
|
3178
|
+
return undefined;
|
|
3179
|
+
}
|
|
2973
3180
|
}
|
|
2974
3181
|
function err(text) {
|
|
2975
3182
|
return { content: [{ type: 'text', text }], isError: true };
|
package/dist/session.d.ts
CHANGED
|
@@ -121,6 +121,11 @@ export interface PageDialogModel extends PageSectionSummaryBase {
|
|
|
121
121
|
export interface PageListModel extends PageSectionSummaryBase {
|
|
122
122
|
itemCount: number;
|
|
123
123
|
}
|
|
124
|
+
export interface CaptchaDetection {
|
|
125
|
+
detected: boolean;
|
|
126
|
+
type?: 'recaptcha' | 'hcaptcha' | 'turnstile' | 'cloudflare-challenge' | 'unknown';
|
|
127
|
+
hint?: string;
|
|
128
|
+
}
|
|
124
129
|
export interface PageModel {
|
|
125
130
|
viewport: {
|
|
126
131
|
width: number;
|
|
@@ -134,6 +139,7 @@ export interface PageModel {
|
|
|
134
139
|
listCount: number;
|
|
135
140
|
focusableCount: number;
|
|
136
141
|
};
|
|
142
|
+
captcha?: CaptchaDetection;
|
|
137
143
|
primaryActions: PagePrimaryAction[];
|
|
138
144
|
landmarks: PageLandmark[];
|
|
139
145
|
forms: PageFormModel[];
|
|
@@ -263,8 +269,13 @@ export interface FormSchemaField {
|
|
|
263
269
|
values?: string[];
|
|
264
270
|
optionCount?: number;
|
|
265
271
|
options?: string[];
|
|
272
|
+
aliases?: Record<string, string[]>;
|
|
266
273
|
context?: NodeContextModel;
|
|
267
274
|
}
|
|
275
|
+
export interface FormSchemaSection {
|
|
276
|
+
name: string;
|
|
277
|
+
fieldIds: string[];
|
|
278
|
+
}
|
|
268
279
|
export interface FormSchemaModel {
|
|
269
280
|
formId: string;
|
|
270
281
|
name?: string;
|
|
@@ -272,6 +283,7 @@ export interface FormSchemaModel {
|
|
|
272
283
|
requiredCount: number;
|
|
273
284
|
invalidCount: number;
|
|
274
285
|
fields: FormSchemaField[];
|
|
286
|
+
sections?: FormSchemaSection[];
|
|
275
287
|
}
|
|
276
288
|
export interface FormRequiredFieldSnapshot extends FormSchemaField {
|
|
277
289
|
bounds: {
|
|
@@ -337,6 +349,19 @@ export interface UiDelta {
|
|
|
337
349
|
viewport?: UiViewportChange;
|
|
338
350
|
focus?: UiFocusChange;
|
|
339
351
|
}
|
|
352
|
+
export interface WorkflowPageEntry {
|
|
353
|
+
pageUrl: string;
|
|
354
|
+
formId?: string;
|
|
355
|
+
formName?: string;
|
|
356
|
+
filledValues: Record<string, string | boolean>;
|
|
357
|
+
filledAt: number;
|
|
358
|
+
fieldCount: number;
|
|
359
|
+
invalidCount: number;
|
|
360
|
+
}
|
|
361
|
+
export interface WorkflowState {
|
|
362
|
+
pages: WorkflowPageEntry[];
|
|
363
|
+
startedAt: number;
|
|
364
|
+
}
|
|
340
365
|
export interface Session {
|
|
341
366
|
ws: WebSocket;
|
|
342
367
|
layout: Record<string, unknown> | null;
|
|
@@ -354,6 +379,7 @@ export interface Session {
|
|
|
354
379
|
revision: number;
|
|
355
380
|
forms: FormSchemaModel[];
|
|
356
381
|
}>;
|
|
382
|
+
workflowState?: WorkflowState;
|
|
357
383
|
}
|
|
358
384
|
export interface SessionConnectTrace {
|
|
359
385
|
mode: 'direct-ws' | 'fresh-proxy' | 'reused-proxy';
|
|
@@ -531,6 +557,8 @@ export declare function sendWheel(session: Session, deltaY: number, opts?: {
|
|
|
531
557
|
x?: number;
|
|
532
558
|
y?: number;
|
|
533
559
|
}, timeoutMs?: number): Promise<UpdateWaitResult>;
|
|
560
|
+
/** Capture a viewport screenshot from the proxy (base64 PNG). */
|
|
561
|
+
export declare function sendScreenshot(session: Session, timeoutMs?: number): Promise<UpdateWaitResult>;
|
|
534
562
|
/** Navigate the proxy page to a new URL while keeping the browser process alive. */
|
|
535
563
|
export declare function sendNavigate(session: Session, url: string, timeoutMs?: number): Promise<UpdateWaitResult>;
|
|
536
564
|
/**
|
|
@@ -555,10 +583,6 @@ export declare function buildCompactUiIndex(root: A11yNode, options?: {
|
|
|
555
583
|
};
|
|
556
584
|
export declare function summarizeCompactIndex(nodes: CompactUiNode[], maxLines?: number): string;
|
|
557
585
|
export declare function nodeContextForNode(root: A11yNode, node: A11yNode): NodeContextModel | undefined;
|
|
558
|
-
/**
|
|
559
|
-
* Build a summary-first, stable-ID webpage model from the accessibility tree.
|
|
560
|
-
* Use {@link expandPageSection} to fetch details for a specific section on demand.
|
|
561
|
-
*/
|
|
562
586
|
export declare function buildPageModel(root: A11yNode, options?: {
|
|
563
587
|
maxPrimaryActions?: number;
|
|
564
588
|
maxSectionsPerKind?: number;
|
package/dist/session.js
CHANGED
|
@@ -875,6 +875,10 @@ export function sendWheel(session, deltaY, opts, timeoutMs) {
|
|
|
875
875
|
...(opts?.y !== undefined ? { y: opts.y } : {}),
|
|
876
876
|
}, timeoutMs);
|
|
877
877
|
}
|
|
878
|
+
/** Capture a viewport screenshot from the proxy (base64 PNG). */
|
|
879
|
+
export function sendScreenshot(session, timeoutMs = 10_000) {
|
|
880
|
+
return sendAndWaitForUpdate(session, { type: 'screenshot' }, timeoutMs);
|
|
881
|
+
}
|
|
878
882
|
/** Navigate the proxy page to a new URL while keeping the browser process alive. */
|
|
879
883
|
export function sendNavigate(session, url, timeoutMs = 15_000) {
|
|
880
884
|
return sendAndWaitForUpdate(session, {
|
|
@@ -1566,6 +1570,33 @@ function groupedChoiceForNode(root, formNode, seed) {
|
|
|
1566
1570
|
const controls = sortByBounds(collectDescendants(formNode, matchesPrompt));
|
|
1567
1571
|
return controls.length >= 2 ? { container: formNode, prompt, controls } : null;
|
|
1568
1572
|
}
|
|
1573
|
+
const SEMANTIC_ALIAS_GROUPS = [
|
|
1574
|
+
{ triggers: ['yes', 'true'], aliases: ['yes', 'true', 'agree', 'agreed', 'accept', 'accepted', 'consent', 'acknowledge', 'opt in'] },
|
|
1575
|
+
{ triggers: ['no', 'false'], aliases: ['no', 'false', 'decline', 'declined', 'disagree', 'deny', 'opt out', 'prefer not'] },
|
|
1576
|
+
{ triggers: ['decline'], aliases: ['decline', 'prefer not', 'opt out', 'do not'] },
|
|
1577
|
+
{ triggers: ['atx', 'austin'], aliases: ['atx', 'austin', 'austin tx', 'austin texas'] },
|
|
1578
|
+
{ triggers: ['nyc', 'new york'], aliases: ['nyc', 'new york', 'new york ny'] },
|
|
1579
|
+
{ triggers: ['sf', 'san francisco'], aliases: ['sf', 'san francisco', 'san francisco ca'] },
|
|
1580
|
+
{ triggers: ['la', 'los angeles'], aliases: ['la', 'los angeles', 'los angeles ca'] },
|
|
1581
|
+
{ triggers: ['dc', 'washington dc'], aliases: ['dc', 'washington dc', 'washington d c'] },
|
|
1582
|
+
{ triggers: ['us', 'usa', 'united states'], aliases: ['us', 'usa', 'united states'] },
|
|
1583
|
+
];
|
|
1584
|
+
function computeOptionAliases(options) {
|
|
1585
|
+
const result = {};
|
|
1586
|
+
for (const option of options) {
|
|
1587
|
+
const normalized = option.toLowerCase().trim();
|
|
1588
|
+
for (const group of SEMANTIC_ALIAS_GROUPS) {
|
|
1589
|
+
if (group.triggers.some(t => normalized === t || normalized.includes(t))) {
|
|
1590
|
+
const relevant = group.aliases.filter(a => a !== normalized);
|
|
1591
|
+
if (relevant.length > 0) {
|
|
1592
|
+
result[option] = relevant;
|
|
1593
|
+
break;
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
return Object.keys(result).length > 0 ? result : undefined;
|
|
1599
|
+
}
|
|
1569
1600
|
function simpleSchemaField(root, node) {
|
|
1570
1601
|
const context = nodeContextForNode(root, node);
|
|
1571
1602
|
const label = fieldLabel(node) ?? sanitizeInlineName(node.name, 80) ?? context?.prompt;
|
|
@@ -1617,6 +1648,7 @@ function groupedSchemaField(root, grouped) {
|
|
|
1617
1648
|
}),
|
|
1618
1649
|
optionCount: options.length,
|
|
1619
1650
|
options,
|
|
1651
|
+
...(computeOptionAliases(options) ? { aliases: computeOptionAliases(options) } : {}),
|
|
1620
1652
|
...(compactSchemaContext(context, grouped.prompt) ? { context: compactSchemaContext(context, grouped.prompt) } : {}),
|
|
1621
1653
|
};
|
|
1622
1654
|
}
|
|
@@ -1637,6 +1669,41 @@ function toggleSchemaField(root, node) {
|
|
|
1637
1669
|
...(compactSchemaContext(context, label) ? { context: compactSchemaContext(context, label) } : {}),
|
|
1638
1670
|
};
|
|
1639
1671
|
}
|
|
1672
|
+
function detectFormSections(formNode, fields) {
|
|
1673
|
+
const sectionRoles = new Set(['group', 'region']);
|
|
1674
|
+
const sectionNodes = [];
|
|
1675
|
+
function walk(node) {
|
|
1676
|
+
if (sectionRoles.has(node.role) && node.name && node.path.length > formNode.path.length) {
|
|
1677
|
+
sectionNodes.push({ name: node.name, path: node.path });
|
|
1678
|
+
}
|
|
1679
|
+
for (const child of node.children)
|
|
1680
|
+
walk(child);
|
|
1681
|
+
}
|
|
1682
|
+
walk(formNode);
|
|
1683
|
+
if (sectionNodes.length === 0)
|
|
1684
|
+
return [];
|
|
1685
|
+
const fieldIdToPath = new Map();
|
|
1686
|
+
for (const field of fields) {
|
|
1687
|
+
const parsed = parseFormFieldId(field.id);
|
|
1688
|
+
if (parsed)
|
|
1689
|
+
fieldIdToPath.set(field.id, parsed);
|
|
1690
|
+
}
|
|
1691
|
+
const sections = [];
|
|
1692
|
+
for (const sec of sectionNodes) {
|
|
1693
|
+
const fieldIds = fields
|
|
1694
|
+
.filter(field => {
|
|
1695
|
+
const fieldPath = fieldIdToPath.get(field.id);
|
|
1696
|
+
if (!fieldPath || fieldPath.length <= sec.path.length)
|
|
1697
|
+
return false;
|
|
1698
|
+
return sec.path.every((v, i) => fieldPath[i] === v);
|
|
1699
|
+
})
|
|
1700
|
+
.map(field => field.id);
|
|
1701
|
+
if (fieldIds.length > 0) {
|
|
1702
|
+
sections.push({ name: sec.name, fieldIds });
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
return sections;
|
|
1706
|
+
}
|
|
1640
1707
|
function buildFormSchemaForNode(root, formNode, options) {
|
|
1641
1708
|
const candidates = sortByBounds(collectDescendants(formNode, candidate => candidate.role === 'textbox' ||
|
|
1642
1709
|
candidate.role === 'combobox' ||
|
|
@@ -1690,6 +1757,10 @@ function buildFormSchemaForNode(root, formNode, options) {
|
|
|
1690
1757
|
requiredCount: compactFields.filter(field => field.required).length,
|
|
1691
1758
|
invalidCount: compactFields.filter(field => field.invalid).length,
|
|
1692
1759
|
fields: pageFields,
|
|
1760
|
+
...(() => {
|
|
1761
|
+
const sections = detectFormSections(formNode, pageFields);
|
|
1762
|
+
return sections.length > 0 ? { sections } : {};
|
|
1763
|
+
})(),
|
|
1693
1764
|
};
|
|
1694
1765
|
}
|
|
1695
1766
|
function trimSchemaFieldContexts(fields) {
|
|
@@ -1712,8 +1783,10 @@ function presentFormSchemaFields(fields, options) {
|
|
|
1712
1783
|
const next = { ...field };
|
|
1713
1784
|
if (booleanChoice)
|
|
1714
1785
|
next.booleanChoice = true;
|
|
1715
|
-
if (!includeOptions)
|
|
1786
|
+
if (!includeOptions) {
|
|
1716
1787
|
delete next.options;
|
|
1788
|
+
delete next.aliases;
|
|
1789
|
+
}
|
|
1717
1790
|
if (includeContext === 'none') {
|
|
1718
1791
|
delete next.context;
|
|
1719
1792
|
return next;
|
|
@@ -1772,6 +1845,47 @@ function inferPageArchetypes(model) {
|
|
|
1772
1845
|
* Build a summary-first, stable-ID webpage model from the accessibility tree.
|
|
1773
1846
|
* Use {@link expandPageSection} to fetch details for a specific section on demand.
|
|
1774
1847
|
*/
|
|
1848
|
+
const CAPTCHA_PATTERNS = [
|
|
1849
|
+
{ pattern: /recaptcha|g-recaptcha/i, type: 'recaptcha', hint: 'Google reCAPTCHA detected' },
|
|
1850
|
+
{ pattern: /hcaptcha|h-captcha/i, type: 'hcaptcha', hint: 'hCaptcha detected' },
|
|
1851
|
+
{ pattern: /turnstile|cf-turnstile/i, type: 'turnstile', hint: 'Cloudflare Turnstile detected' },
|
|
1852
|
+
{ pattern: /cloudflare.*challenge|challenge-platform|just a moment/i, type: 'cloudflare-challenge', hint: 'Cloudflare challenge page detected' },
|
|
1853
|
+
{ pattern: /captcha/i, type: 'unknown', hint: 'CAPTCHA element detected' },
|
|
1854
|
+
];
|
|
1855
|
+
function detectCaptcha(root) {
|
|
1856
|
+
let found;
|
|
1857
|
+
function walk(node) {
|
|
1858
|
+
if (found)
|
|
1859
|
+
return;
|
|
1860
|
+
const text = [node.name, node.value, node.role].filter(Boolean).join(' ');
|
|
1861
|
+
for (const { pattern, type, hint } of CAPTCHA_PATTERNS) {
|
|
1862
|
+
if (pattern.test(text)) {
|
|
1863
|
+
found = { detected: true, type, hint };
|
|
1864
|
+
return;
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
// Check iframe placeholders (common for reCAPTCHA/hCaptcha/Turnstile)
|
|
1868
|
+
if (node.meta && typeof node.meta.frameUrl === 'string') {
|
|
1869
|
+
const frameUrl = node.meta.frameUrl;
|
|
1870
|
+
for (const { pattern, type, hint } of CAPTCHA_PATTERNS) {
|
|
1871
|
+
if (pattern.test(frameUrl)) {
|
|
1872
|
+
found = { detected: true, type, hint };
|
|
1873
|
+
return;
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
for (const child of node.children)
|
|
1878
|
+
walk(child);
|
|
1879
|
+
}
|
|
1880
|
+
walk(root);
|
|
1881
|
+
// Also check the page URL for Cloudflare challenge pages
|
|
1882
|
+
if (!found && root.meta?.pageUrl) {
|
|
1883
|
+
if (/challenge|cdn-cgi.*challenge/i.test(root.meta.pageUrl)) {
|
|
1884
|
+
found = { detected: true, type: 'cloudflare-challenge', hint: 'Cloudflare challenge page URL detected' };
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
return found ?? { detected: false };
|
|
1888
|
+
}
|
|
1775
1889
|
export function buildPageModel(root, options) {
|
|
1776
1890
|
const maxPrimaryActions = options?.maxPrimaryActions ?? 6;
|
|
1777
1891
|
const maxSectionsPerKind = options?.maxSectionsPerKind ?? 8;
|
|
@@ -1855,8 +1969,10 @@ export function buildPageModel(root, options) {
|
|
|
1855
1969
|
dialogs: sortByBounds(dialogs).slice(0, maxSectionsPerKind),
|
|
1856
1970
|
lists: sortByBounds(lists).slice(0, maxSectionsPerKind),
|
|
1857
1971
|
};
|
|
1972
|
+
const captcha = detectCaptcha(root);
|
|
1858
1973
|
return {
|
|
1859
1974
|
...baseModel,
|
|
1975
|
+
...(captcha.detected ? { captcha } : {}),
|
|
1860
1976
|
archetypes: inferPageArchetypes(baseModel),
|
|
1861
1977
|
};
|
|
1862
1978
|
}
|
package/package.json
CHANGED