@reclaimprotocol/agent 0.1.10 → 0.1.11
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/lib/authoring/tools.js +67 -0
- package/lib/mcp/tools/agent/find.js +41 -16
- package/lib/mcp/tools/agent/propose.js +4 -4
- package/lib/old/client.d.ts +0 -5
- package/lib/old/client.js +0 -10
- package/lib/old/to-register.d.ts +2 -0
- package/lib/old/to-register.js +9 -5
- package/lib/old/tools/build.js +0 -2
- package/lib/old/tools/get-provider-info.d.ts +10 -7
- package/lib/old/tools/get-provider-info.js +21 -15
- package/lib/old/tools/publish.js +64 -121
- package/lib/provider/dom-xpath.js +178 -25
- package/lib/provider/draft.d.ts +6 -0
- package/lib/provider/draft.js +54 -5
- package/lib/provider/matcher.d.ts +4 -0
- package/lib/provider/matcher.js +6 -0
- package/lib/provider/to-version.d.ts +13 -0
- package/package.json +1 -1
- package/src/authoring/tools.ts +76 -0
- package/src/mcp/tools/agent/find.ts +44 -16
- package/src/mcp/tools/agent/propose.ts +4 -4
- package/src/old/client.ts +0 -18
- package/src/old/to-register.ts +11 -5
- package/src/old/tools/build.ts +0 -2
- package/src/old/tools/get-provider-info.ts +21 -15
- package/src/old/tools/publish.ts +70 -154
- package/src/provider/dom-xpath.ts +227 -27
- package/src/provider/draft.ts +59 -5
- package/src/provider/matcher.ts +7 -0
- package/src/provider/to-version.ts +13 -0
- package/src/old/tools/get-version-config.ts +0 -56
package/src/old/tools/build.ts
CHANGED
|
@@ -3,7 +3,6 @@ import type { ReclaimOldClient } from '../client.ts'
|
|
|
3
3
|
import type { ReclaimSdkClient } from '../sdk-client.ts'
|
|
4
4
|
import { authenticateTool } from './authenticate.ts'
|
|
5
5
|
import { getProviderInfoTool } from './get-provider-info.ts'
|
|
6
|
-
import { getProviderVersionConfigTool } from './get-version-config.ts'
|
|
7
6
|
import { getMeProvidersTool } from './me-providers.ts'
|
|
8
7
|
import { createVersionFromCaptureTool } from './publish.ts'
|
|
9
8
|
import { sessionAnalyticsLogsTool } from './session-analytics-logs.ts'
|
|
@@ -27,7 +26,6 @@ export function buildOldBackendTools(
|
|
|
27
26
|
authenticateTool(client),
|
|
28
27
|
createVersionFromCaptureTool(client),
|
|
29
28
|
getMeProvidersTool(client),
|
|
30
|
-
getProviderVersionConfigTool(client),
|
|
31
29
|
getProviderInfoTool(sdkClient),
|
|
32
30
|
updateProviderMetadataTool(client),
|
|
33
31
|
sessionAnalyticsLogsTool(client),
|
|
@@ -18,14 +18,17 @@ async function attempt(
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
*
|
|
22
|
-
* (`api.reclaimprotocol.org`) — the service the
|
|
23
|
-
* itself calls at runtime
|
|
24
|
-
*
|
|
25
|
-
* auth). Both underlying reads are unauthenticated.
|
|
21
|
+
* The single provider-read tool for old-devtools mode. Reads from the PUBLIC
|
|
22
|
+
* production SDK backend (`api.reclaimprotocol.org`) — the service the
|
|
23
|
+
* verification SDK/attestor itself calls at runtime. No auth required, works
|
|
24
|
+
* for ANY provider.
|
|
26
25
|
*
|
|
27
|
-
* Fetches BOTH shapes in one call, since they're complementary
|
|
28
|
-
*
|
|
26
|
+
* Fetches BOTH shapes in one call, since they're complementary and together
|
|
27
|
+
* cover everything a caller needs before re-publishing a new version:
|
|
28
|
+
* - `provider`: the full provider + providerConfig — `customInjection`,
|
|
29
|
+
* `userAgent`, `allowedInjectedRequestData`, `requestData`, everything.
|
|
30
|
+
* This is what you read before create_provider_version_from_capture so
|
|
31
|
+
* you can pass the whole config back (that tool carries nothing forward).
|
|
29
32
|
* - `configs`: the trimmed runtime recipe the attestor actually consumes —
|
|
30
33
|
* `customInjection` deliberately omitted, `isScriptRequestingClaim`
|
|
31
34
|
* computed instead.
|
|
@@ -38,15 +41,18 @@ export function getProviderInfoTool(client: ReclaimSdkClient): RegisteredTool {
|
|
|
38
41
|
'Read a PUBLISHED provider from the public production SDK backend ' +
|
|
39
42
|
'(api.reclaimprotocol.org — what the verification SDK/attestor ' +
|
|
40
43
|
'itself fetches at runtime), for ANY provider, no auth required. ' +
|
|
41
|
-
'
|
|
42
|
-
'devtools DASHBOARD backend and requires auth. \n\n' +
|
|
44
|
+
'This is the provider-read tool for old-devtools mode. \n\n' +
|
|
43
45
|
'Returns two complementary reads for the given version (latest ' +
|
|
44
|
-
'when `versionNumber` is omitted): `provider` (the
|
|
45
|
-
'+ providerConfig
|
|
46
|
-
'
|
|
47
|
-
'
|
|
48
|
-
'`
|
|
49
|
-
'
|
|
46
|
+
'when `versionNumber` is omitted): `provider` (the FULL provider ' +
|
|
47
|
+
'+ providerConfig — `customInjection`, `userAgent`, ' +
|
|
48
|
+
'`allowedInjectedRequestData`, `requestData`, everything) and ' +
|
|
49
|
+
'`configs` (the trimmed recipe the attestor actually consumes — ' +
|
|
50
|
+
'`customInjection` is deliberately OMITTED there, replaced by a ' +
|
|
51
|
+
'computed `isScriptRequestingClaim` flag). Read `provider` here ' +
|
|
52
|
+
'BEFORE create_provider_version_from_capture so you can pass the ' +
|
|
53
|
+
'whole existing config back — that tool always creates a new ' +
|
|
54
|
+
'version and carries nothing forward. One endpoint failing (e.g. ' +
|
|
55
|
+
'an invalid `versionNumber`) is reported under `errors` without ' +
|
|
50
56
|
'hiding a result the other endpoint did return.',
|
|
51
57
|
inputSchema: {
|
|
52
58
|
type: 'object',
|
package/src/old/tools/publish.ts
CHANGED
|
@@ -12,7 +12,6 @@ import {
|
|
|
12
12
|
bumpPatchString,
|
|
13
13
|
extractVersions,
|
|
14
14
|
injectedRequestDataFrom,
|
|
15
|
-
type OldInjectedRequestData,
|
|
16
15
|
type OldProviderOpts,
|
|
17
16
|
providerToConfigBody,
|
|
18
17
|
providerToRegisterBody,
|
|
@@ -20,66 +19,6 @@ import {
|
|
|
20
19
|
|
|
21
20
|
const DEFAULT_VERSION_INFO = 'Updated from MCP browser capture'
|
|
22
21
|
|
|
23
|
-
/** Fields with no partial-patch equivalent on this backend: a version always
|
|
24
|
-
* replaces the WHOLE `providerConfig`, so omitting one of these on
|
|
25
|
-
* add-version must carry forward the current value rather than publish it
|
|
26
|
-
* blank. `allowedInjectedRequestData` is handled alongside these (further
|
|
27
|
-
* down, inline) since it needs translation when freshly authored, unlike
|
|
28
|
-
* these — which pass through unchanged either way. */
|
|
29
|
-
const PRESERVABLE_KEYS = [
|
|
30
|
-
'customInjection',
|
|
31
|
-
'userAgent',
|
|
32
|
-
'pageTitle',
|
|
33
|
-
'stepsToFollow',
|
|
34
|
-
'useIncognitoWebview',
|
|
35
|
-
'extensionConfig',
|
|
36
|
-
] as const
|
|
37
|
-
|
|
38
|
-
/** Pull `providerConfig` out of a `get_provider_version_config` /
|
|
39
|
-
* `getProviderConfig` response, tolerant of a missing/malformed shape. */
|
|
40
|
-
function extractProviderConfig(
|
|
41
|
-
resp: unknown,
|
|
42
|
-
): Record<string, unknown> | undefined {
|
|
43
|
-
if(resp && typeof resp === 'object') {
|
|
44
|
-
const cfg = (resp as { providerConfig?: unknown }).providerConfig
|
|
45
|
-
if(cfg && typeof cfg === 'object') {
|
|
46
|
-
return cfg as Record<string, unknown>
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
return undefined
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** Prefer the caller's explicit value; otherwise fall back to `current`'s
|
|
54
|
-
* value (if any) and record `fieldName` as carried forward.
|
|
55
|
-
*
|
|
56
|
-
* `current === null` is treated the SAME as `undefined` (nothing to carry),
|
|
57
|
-
* not as a real value to preserve. These are all optional fields with no
|
|
58
|
-
* Prisma default — a version where one was never set reads back as
|
|
59
|
-
* `null`, not a missing key, because Prisma normalizes an absent optional
|
|
60
|
-
* field to `null` on read. Carrying that `null` "forward" would falsely
|
|
61
|
-
* claim the field was preserved when really nothing was ever there — it
|
|
62
|
-
* most likely means an EARLIER version already lost it (e.g. published
|
|
63
|
-
* before this field had carry-forward support at all), not that this call
|
|
64
|
-
* is doing anything wrong. */
|
|
65
|
-
function resolveField<T>(
|
|
66
|
-
explicit: T | undefined,
|
|
67
|
-
current: unknown,
|
|
68
|
-
fieldName: string,
|
|
69
|
-
carried: string[],
|
|
70
|
-
): T | undefined {
|
|
71
|
-
if(explicit !== undefined) {
|
|
72
|
-
return explicit
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
if(current !== undefined && current !== null) {
|
|
76
|
-
carried.push(fieldName)
|
|
77
|
-
return current as T
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
return undefined
|
|
81
|
-
}
|
|
82
|
-
|
|
83
22
|
/**
|
|
84
23
|
* The old backend lets you add a version only to a provider YOU own. A
|
|
85
24
|
* `providerId` for a public provider (or anyone else's) comes back as
|
|
@@ -166,23 +105,19 @@ export function createVersionFromCaptureTool(
|
|
|
166
105
|
'For data gated behind login/navigation, pass `customInjection` — a ' +
|
|
167
106
|
'page-injection script (old-devtools\' name for the builder\'s ' +
|
|
168
107
|
'jsUserScripts) that drives the page to where the request fires. \n\n' +
|
|
169
|
-
'
|
|
170
|
-
'
|
|
171
|
-
'
|
|
172
|
-
'
|
|
173
|
-
'
|
|
174
|
-
'
|
|
175
|
-
'
|
|
176
|
-
'
|
|
177
|
-
'
|
|
178
|
-
'
|
|
179
|
-
'
|
|
180
|
-
'
|
|
181
|
-
'
|
|
182
|
-
'explicit value (`""`, `[]`, `false`, ...) to intentionally clear ' +
|
|
183
|
-
'one of them instead, or call get_provider_version_config FIRST ' +
|
|
184
|
-
'to see exactly what is live today, rather than assuming this ' +
|
|
185
|
-
'call recovered something that may already be gone. ' +
|
|
108
|
+
'BY DESIGN, this backend only ever CREATES A NEW VERSION (POST) — ' +
|
|
109
|
+
'there is no partial-patch/edit endpoint, and this tool does NOT ' +
|
|
110
|
+
'read or carry anything forward from the previous version. Every ' +
|
|
111
|
+
'new version replaces the WHOLE providerConfig with exactly what ' +
|
|
112
|
+
'you pass in THIS call. Therefore any field you want present in the ' +
|
|
113
|
+
'new version MUST be supplied here — `customInjection`, `userAgent`, ' +
|
|
114
|
+
'`pageTitle`, `allowedInjectedRequestData`, `stepsToFollow`, ' +
|
|
115
|
+
'`useIncognitoWebview`, `extensionConfig`, and `requestData` (via ' +
|
|
116
|
+
'`provider`/`providers`). Anything you OMIT is published blank/' +
|
|
117
|
+
'default in the new version, NOT preserved. To change one field ' +
|
|
118
|
+
'while keeping the rest, first read the current version with ' +
|
|
119
|
+
'get_provider_info, then pass the full desired config (the existing ' +
|
|
120
|
+
'values plus your change) back into this call. ' +
|
|
186
121
|
'`allowedInjectedRequestData`/`useIncognitoWebview`/' +
|
|
187
122
|
'`extensionConfig` only take effect on add-version — the register ' +
|
|
188
123
|
'endpoint (no `providerId`) ignores them. \n\n' +
|
|
@@ -316,6 +251,28 @@ export function createVersionFromCaptureTool(
|
|
|
316
251
|
description:
|
|
317
252
|
'Fail validation if none match. Default true.',
|
|
318
253
|
},
|
|
254
|
+
templateParams: {
|
|
255
|
+
type: 'array',
|
|
256
|
+
items: { type: 'string' },
|
|
257
|
+
description:
|
|
258
|
+
'Names of `${var}` placeholders used in this ' +
|
|
259
|
+
'template\'s `responseMatches`/`responseRedactions` ' +
|
|
260
|
+
'that should be substituted at verify time from the ' +
|
|
261
|
+
'submitted proof\'s own witness params. Omit (or ' +
|
|
262
|
+
'leave empty) if the template has no such ' +
|
|
263
|
+
'placeholders.',
|
|
264
|
+
},
|
|
265
|
+
templateParamsMode: {
|
|
266
|
+
type: 'string',
|
|
267
|
+
enum: ['separate', 'merge'],
|
|
268
|
+
description:
|
|
269
|
+
'How multiple values for `templateParams` expand ' +
|
|
270
|
+
'into request specs. `separate` (default): one ' +
|
|
271
|
+
'independent spec per value, each matching its own ' +
|
|
272
|
+
'proof. `merge`: fold all values into ONE spec with ' +
|
|
273
|
+
'one match/redaction entry per value, matching a ' +
|
|
274
|
+
'single proof that bundles them all into one claim.',
|
|
275
|
+
},
|
|
319
276
|
},
|
|
320
277
|
required: ['url', 'method'],
|
|
321
278
|
},
|
|
@@ -356,25 +313,23 @@ export function createVersionFromCaptureTool(
|
|
|
356
313
|
|
|
357
314
|
const providers = hasMany ? args.providers! : [args.provider!]
|
|
358
315
|
|
|
359
|
-
const opts: OldProviderOpts = {
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
if(args.customInjection !== undefined) {
|
|
377
|
-
opts.customInjection = args.customInjection
|
|
316
|
+
const opts: OldProviderOpts = {
|
|
317
|
+
initialUrl: args.initialUrl,
|
|
318
|
+
description: args.description,
|
|
319
|
+
geoLocation: args.geoLocation,
|
|
320
|
+
useProxy: args.useProxy,
|
|
321
|
+
providerType: args.providerType,
|
|
322
|
+
customInjection: args.customInjection,
|
|
323
|
+
userAgent: args.userAgent,
|
|
324
|
+
pageTitle: args.pageTitle,
|
|
325
|
+
stepsToFollow: args.stepsToFollow,
|
|
326
|
+
useIncognitoWebview: args.useIncognitoWebview,
|
|
327
|
+
extensionConfig: args.extensionConfig,
|
|
328
|
+
allowedInjectedRequestData: args.allowedInjectedRequestData
|
|
329
|
+
? injectedRequestDataFrom(
|
|
330
|
+
args.allowedInjectedRequestData,
|
|
331
|
+
)
|
|
332
|
+
: undefined
|
|
378
333
|
}
|
|
379
334
|
|
|
380
335
|
// Non-blocking advisory per request — never alters or blocks the
|
|
@@ -414,27 +369,15 @@ export function createVersionFromCaptureTool(
|
|
|
414
369
|
.filter((k) => args[k] !== undefined)
|
|
415
370
|
if(ignoredOnRegister.length) {
|
|
416
371
|
notes.push(
|
|
417
|
-
`${ignoredOnRegister.join(', ')}
|
|
418
|
-
|
|
419
|
-
|
|
372
|
+
`${ignoredOnRegister.join(', ')} ` +
|
|
373
|
+
`${ignoredOnRegister.length > 1 ? 'are' : 'is'} ` +
|
|
374
|
+
'ignored on register (no providerId) — the old backend only ' +
|
|
420
375
|
'reads them when adding a version. Publish once to create ' +
|
|
421
376
|
'the provider, then call this again WITH the returned ' +
|
|
422
377
|
'providerId to set them.',
|
|
423
378
|
)
|
|
424
379
|
}
|
|
425
380
|
|
|
426
|
-
if(args.userAgent !== undefined) {
|
|
427
|
-
opts.userAgent = args.userAgent
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
if(args.pageTitle !== undefined) {
|
|
431
|
-
opts.pageTitle = args.pageTitle
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
if(args.stepsToFollow !== undefined) {
|
|
435
|
-
opts.stepsToFollow = args.stepsToFollow
|
|
436
|
-
}
|
|
437
|
-
|
|
438
381
|
const body = providerToRegisterBody(providers, opts)
|
|
439
382
|
return withNotes(await client.registerProvider(body))
|
|
440
383
|
}
|
|
@@ -445,48 +388,12 @@ export function createVersionFromCaptureTool(
|
|
|
445
388
|
// workaround instead of a bare permission error.
|
|
446
389
|
const providerId = args.providerId
|
|
447
390
|
try {
|
|
448
|
-
//
|
|
449
|
-
//
|
|
450
|
-
//
|
|
451
|
-
//
|
|
452
|
-
//
|
|
453
|
-
|
|
454
|
-
|| args.allowedInjectedRequestData === undefined
|
|
455
|
-
const current = needsCurrent
|
|
456
|
-
? extractProviderConfig(await client.getProviderConfig(providerId))
|
|
457
|
-
: undefined
|
|
458
|
-
|
|
459
|
-
const carried: string[] = []
|
|
460
|
-
for(const key of PRESERVABLE_KEYS) {
|
|
461
|
-
const resolved = resolveField(args[key], current?.[key], key, carried)
|
|
462
|
-
if(resolved !== undefined) {
|
|
463
|
-
(opts as unknown as Record<string, unknown>)[key] = resolved
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
const freshInjected = args.allowedInjectedRequestData !== undefined
|
|
468
|
-
? injectedRequestDataFrom(args.allowedInjectedRequestData)
|
|
469
|
-
: undefined
|
|
470
|
-
const resolvedInjected = resolveField<OldInjectedRequestData[]>(
|
|
471
|
-
freshInjected,
|
|
472
|
-
current?.allowedInjectedRequestData,
|
|
473
|
-
'allowedInjectedRequestData',
|
|
474
|
-
carried,
|
|
475
|
-
)
|
|
476
|
-
if(resolvedInjected !== undefined) {
|
|
477
|
-
opts.allowedInjectedRequestData = resolvedInjected
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
if(carried.length) {
|
|
481
|
-
notes.push(
|
|
482
|
-
'Not passed, carried forward unchanged from the current ' +
|
|
483
|
-
`published version: ${carried.join(', ')}. Pass an explicit ` +
|
|
484
|
-
'value ("", [], false, ...) to override any of them, or ' +
|
|
485
|
-
'call get_provider_version_config first to see what is ' +
|
|
486
|
-
'live today.',
|
|
487
|
-
)
|
|
488
|
-
}
|
|
489
|
-
|
|
391
|
+
// By design this always POSTs a brand-new version and publishes
|
|
392
|
+
// EXACTLY the config assembled above — nothing is read back or
|
|
393
|
+
// carried forward. Any field the caller omitted is already absent
|
|
394
|
+
// from `opts` and will be blank/default in the new version. To
|
|
395
|
+
// preserve existing fields, the caller must have read them via
|
|
396
|
+
// get_provider_info and passed them back in.
|
|
490
397
|
if(args.customInjection !== undefined && opts.customInjection?.trim()) {
|
|
491
398
|
notes.push(
|
|
492
399
|
'customInjection will run in the verification client before ' +
|
|
@@ -495,6 +402,15 @@ export function createVersionFromCaptureTool(
|
|
|
495
402
|
)
|
|
496
403
|
}
|
|
497
404
|
|
|
405
|
+
notes.push(
|
|
406
|
+
'A NEW version was created with only the fields passed in this ' +
|
|
407
|
+
'call — nothing was carried forward from the previous version. ' +
|
|
408
|
+
'If you intended to keep other fields (customInjection, ' +
|
|
409
|
+
'userAgent, allowedInjectedRequestData, ...), read the prior ' +
|
|
410
|
+
'version with get_provider_info and re-publish with them ' +
|
|
411
|
+
'included.',
|
|
412
|
+
)
|
|
413
|
+
|
|
498
414
|
const versionsResp = await client.listVersions(providerId)
|
|
499
415
|
const version = bumpPatchString(extractVersions(versionsResp))
|
|
500
416
|
const versionInfo =
|
|
@@ -18,6 +18,104 @@ import '../proof/patch-domhandler.ts'
|
|
|
18
18
|
const IDENT_ATTRS =
|
|
19
19
|
['name', 'property', 'itemprop', 'data-testid', 'rel', 'for']
|
|
20
20
|
|
|
21
|
+
/** What an xPath candidate anchors on — used to weight its base stability. */
|
|
22
|
+
type AnchorKind = 'ident-attr' | 'id' | 'class' | 'positional'
|
|
23
|
+
|
|
24
|
+
/** One verified xPath option for the target element, tagged with what it
|
|
25
|
+
* anchors on so it can be scored for FUTURE stability (see
|
|
26
|
+
* {@link anchorStability}) rather than just "first that resolves". */
|
|
27
|
+
interface Candidate {
|
|
28
|
+
xPath: string
|
|
29
|
+
kind: AnchorKind
|
|
30
|
+
/** The identifier the anchor keys on (an id / attr / class value); undefined
|
|
31
|
+
* for a purely positional path. */
|
|
32
|
+
token?: string
|
|
33
|
+
/** Ancestor distance from the value element (0 = the element itself or a
|
|
34
|
+
* direct-child anchor); larger = higher up. Tie-break nudge only. */
|
|
35
|
+
depth: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Base weight by anchor kind: purpose-built test hooks and ids beat classes,
|
|
39
|
+
* which beat a positional path. Modulated by {@link tokenQuality}. */
|
|
40
|
+
const TYPE_WEIGHT: Record<AnchorKind, number> = {
|
|
41
|
+
'ident-attr': 0.95,
|
|
42
|
+
id: 0.9,
|
|
43
|
+
class: 0.6,
|
|
44
|
+
positional: 0.3,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const UUID_RE =
|
|
48
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* How likely an identifier token is to survive a future render, by its SHAPE —
|
|
52
|
+
* framework-agnostic, so it catches generated ids/classes from any tool (this
|
|
53
|
+
* year's or next) without naming them. Returns `[0.02, 1]`.
|
|
54
|
+
*
|
|
55
|
+
* Volatile shapes (machine-generated): a `:` (React `useId` `:r1:`, Radix), a
|
|
56
|
+
* UUID, digit-heavy or trailing-digit tokens (`ember123`, `tab-2`), hex/hash
|
|
57
|
+
* runs (`css-1a2b3c`), or opaque wordless strings. Durable shapes (human
|
|
58
|
+
* authored): real word segments, kebab/snake of words (`settings-header`,
|
|
59
|
+
* `primary_email_select`).
|
|
60
|
+
*/
|
|
61
|
+
function tokenQuality(token: string): number {
|
|
62
|
+
const t = token.trim()
|
|
63
|
+
if(!t) {
|
|
64
|
+
return 0.3
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Kill signals — these tokens are (almost) always per-render generated.
|
|
68
|
+
if(t.includes(':') || UUID_RE.test(t)) {
|
|
69
|
+
return 0.05
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let q = 1
|
|
73
|
+
const len = t.length
|
|
74
|
+
const digits = (t.match(/\d/g) ?? []).length
|
|
75
|
+
const digitRatio = digits / len
|
|
76
|
+
// A trailing digit run after letters is the classic generated-id tail.
|
|
77
|
+
if(/[A-Za-z][-_]?\d+$/.test(t)) {
|
|
78
|
+
q *= 0.3
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if(digitRatio > 0.3) {
|
|
82
|
+
q *= 0.4
|
|
83
|
+
} else if(digitRatio > 0.15) {
|
|
84
|
+
q *= 0.7
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// A pure hex/short-hash run (`1a2b3c`, `deadbeef`) — no semantic content.
|
|
88
|
+
if(/^[0-9a-f]{6,}$/i.test(t)) {
|
|
89
|
+
q *= 0.15
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// A real word — an alpha run ≥3 that ITSELF contains a vowel — is the
|
|
93
|
+
// strongest durability signal; its absence means the token is opaque /
|
|
94
|
+
// hash-like (`css-1a2b3c`, `sc-bdfBwQ`). Checking per-segment matters: a
|
|
95
|
+
// stray vowel elsewhere in a hash must not count as "has a word".
|
|
96
|
+
const hasWord = (t.match(/[A-Za-z]+/g) ?? [])
|
|
97
|
+
.some((run) => run.length >= 3 && /[aeiou]/i.test(run))
|
|
98
|
+
if(!hasWord) {
|
|
99
|
+
q *= 0.3
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if(len > 40) {
|
|
103
|
+
q *= 0.6
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return Math.max(0.02, Math.min(1, q))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Stability score for a candidate: base weight for its kind × the shape
|
|
110
|
+
* quality of its identifier, minus a small depth nudge so a nearer/shorter
|
|
111
|
+
* anchor wins an otherwise-tie. Positional paths score on kind alone. */
|
|
112
|
+
function anchorStability(c: Candidate): number {
|
|
113
|
+
const base = TYPE_WEIGHT[c.kind]
|
|
114
|
+
const quality = c.kind === 'positional' ? 1 : tokenQuality(c.token ?? '')
|
|
115
|
+
const depthNudge = 1 - Math.min(c.depth, 8) * 0.01
|
|
116
|
+
return base * quality * depthNudge
|
|
117
|
+
}
|
|
118
|
+
|
|
21
119
|
export interface DomAnchor {
|
|
22
120
|
xPath: string
|
|
23
121
|
/** Byte range of the element in the source html (parse5 source locations),
|
|
@@ -61,15 +159,25 @@ export function elementAnchor(
|
|
|
61
159
|
return undefined
|
|
62
160
|
}
|
|
63
161
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}
|
|
162
|
+
// Score EVERY candidate that provably resolves (first-match, uniquely) to the
|
|
163
|
+
// target, and take the most durable — not merely the first that resolves.
|
|
164
|
+
// Because each survivor already matches uniquely NOW, the choice only trades
|
|
165
|
+
// on FUTURE stability; it can never pick a wrong/non-resolving node.
|
|
166
|
+
const verified = [...candidateXPaths(el)]
|
|
167
|
+
.map((c, i) => ({ c, i }))
|
|
168
|
+
.filter(({ c }) => matchesUniquely(doc, c.xPath, el))
|
|
169
|
+
if(!verified.length) {
|
|
170
|
+
return undefined
|
|
70
171
|
}
|
|
71
172
|
|
|
72
|
-
|
|
173
|
+
verified.sort((a, b) => {
|
|
174
|
+
return anchorStability(b.c) - anchorStability(a.c) ||
|
|
175
|
+
a.c.depth - b.c.depth ||
|
|
176
|
+
a.i - b.i
|
|
177
|
+
})
|
|
178
|
+
// `[startIndex, endIndex)` is exactly the slice the attestor reveals for this
|
|
179
|
+
// xPath (its getNodeRange uses these same parse5 source locations).
|
|
180
|
+
return { xPath: verified[0].c.xPath, start: startIndex, end: endIndex }
|
|
73
181
|
}
|
|
74
182
|
|
|
75
183
|
/** The tightest element that DIRECTLY holds a target. Prefers a COMPLETE-value
|
|
@@ -121,34 +229,29 @@ function smallestElement(els: Element[]): Element | undefined {
|
|
|
121
229
|
.at(0)
|
|
122
230
|
}
|
|
123
231
|
|
|
124
|
-
/**
|
|
125
|
-
|
|
232
|
+
/** Every candidate xPath for `el`, each tagged with what it anchors on. The
|
|
233
|
+
* caller verifies + scores them ({@link anchorStability}) rather than trusting
|
|
234
|
+
* emission order, so the DURABILITY winner is chosen, not the first that
|
|
235
|
+
* resolves. Order here is only the final tie-break. */
|
|
236
|
+
function* candidateXPaths(el: Element): Generator<Candidate> {
|
|
126
237
|
const tag = el.name
|
|
127
238
|
const a = el.attribs ?? {}
|
|
128
239
|
if(a.id) {
|
|
129
|
-
yield `//${tag}[@id=${xpathLiteral(a.id)}]
|
|
240
|
+
yield { xPath: `//${tag}[@id=${xpathLiteral(a.id)}]`, kind: 'id',
|
|
241
|
+
token: a.id, depth: 0 }
|
|
130
242
|
}
|
|
131
243
|
|
|
132
244
|
for(const name of IDENT_ATTRS) {
|
|
133
245
|
if(a[name]) {
|
|
134
|
-
yield `//${tag}[@${name}=${xpathLiteral(a[name])}]
|
|
135
|
-
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
if(a.class) {
|
|
139
|
-
const distinctive = a.class
|
|
140
|
-
.split(/\s+/)
|
|
141
|
-
.filter(Boolean)
|
|
142
|
-
.sort((x, y) => y.length - x.length)[0]
|
|
143
|
-
if(distinctive) {
|
|
144
|
-
yield `//${tag}[contains(@class, ${xpathLiteral(distinctive)})]`
|
|
246
|
+
yield { xPath: `//${tag}[@${name}=${xpathLiteral(a[name])}]`,
|
|
247
|
+
kind: 'ident-attr', token: a[name], depth: 0 }
|
|
145
248
|
}
|
|
146
249
|
}
|
|
147
250
|
|
|
148
251
|
// A uniquely-identified DIRECT child pins the parent without a positional
|
|
149
|
-
// path (`//span[span[@id="labels"]]`). Self-verification
|
|
150
|
-
//
|
|
151
|
-
//
|
|
252
|
+
// path (`//span[span[@id="labels"]]`). Self-verification discards it when the
|
|
253
|
+
// child id/attr isn't unique — e.g. GitHub reuses `id="labels"` per email row
|
|
254
|
+
// — so it never silently matches the wrong node.
|
|
152
255
|
for(const child of el.children) {
|
|
153
256
|
if(!isTag(child)) {
|
|
154
257
|
continue
|
|
@@ -156,17 +259,114 @@ function* candidateXPaths(el: Element): Generator<string> {
|
|
|
156
259
|
|
|
157
260
|
const ca = child.attribs ?? {}
|
|
158
261
|
if(ca.id) {
|
|
159
|
-
yield `//${tag}[${child.name}[@id=${xpathLiteral(ca.id)}]]
|
|
262
|
+
yield { xPath: `//${tag}[${child.name}[@id=${xpathLiteral(ca.id)}]]`,
|
|
263
|
+
kind: 'id', token: ca.id, depth: 0 }
|
|
160
264
|
}
|
|
161
265
|
|
|
162
266
|
for(const name of IDENT_ATTRS) {
|
|
163
267
|
if(ca[name]) {
|
|
164
|
-
yield
|
|
268
|
+
yield {
|
|
269
|
+
xPath: `//${tag}[${child.name}[@${name}=${xpathLiteral(ca[name])}]]`,
|
|
270
|
+
kind: 'ident-attr', token: ca[name], depth: 0,
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// The value element's own distinctive class.
|
|
277
|
+
const distinctive = distinctiveClass(a.class)
|
|
278
|
+
if(distinctive) {
|
|
279
|
+
yield { xPath: `//${tag}[contains(@class, ${xpathLiteral(distinctive)})]`,
|
|
280
|
+
kind: 'class', token: distinctive, depth: 0 }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Ancestor-anchored (id/attr, then class), descending positionally to `el`.
|
|
284
|
+
yield* ancestorAnchoredCandidates(el)
|
|
285
|
+
|
|
286
|
+
yield { xPath: absolutePath(el), kind: 'positional', depth: 99 }
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** The longest whitespace-delimited token of a class attribute (the most
|
|
290
|
+
* distinctive one), or undefined when there's no class. */
|
|
291
|
+
function distinctiveClass(cls: string | undefined): string | undefined {
|
|
292
|
+
return (cls ?? '')
|
|
293
|
+
.split(/\s+/)
|
|
294
|
+
.filter(Boolean)
|
|
295
|
+
.sort((x, y) => y.length - x.length)[0]
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Positional path from `anc` (exclusive) down to `el` (inclusive), e.g.
|
|
299
|
+
* `/ul[1]/li[3]/a[1]`; empty string when `anc` is not an ancestor of `el`. */
|
|
300
|
+
function relativeSteps(anc: Element, el: Element): string {
|
|
301
|
+
const parts: string[] = []
|
|
302
|
+
let node: Node | null = el
|
|
303
|
+
while(node && isTag(node) && node !== anc) {
|
|
304
|
+
const tag = node.name
|
|
305
|
+
let index = 1
|
|
306
|
+
for(let sib = node.prev; sib; sib = sib.prev) {
|
|
307
|
+
if(isTag(sib) && sib.name === tag) {
|
|
308
|
+
index++
|
|
165
309
|
}
|
|
166
310
|
}
|
|
311
|
+
|
|
312
|
+
parts.unshift(`/${tag}[${index}]`)
|
|
313
|
+
node = node.parent
|
|
167
314
|
}
|
|
168
315
|
|
|
169
|
-
|
|
316
|
+
return node === anc ? parts.join('') : ''
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Ancestors of `el` (nearest first) with their depth, skipping
|
|
320
|
+
* `html`/`body`/`head` (≈ the absolute path) and non-ancestors. Capped depth
|
|
321
|
+
* keeps paths short. */
|
|
322
|
+
function* anchorAncestors(el: Element): Generator<{
|
|
323
|
+
tag: string
|
|
324
|
+
attribs: Record<string, string>
|
|
325
|
+
rel: string
|
|
326
|
+
depth: number
|
|
327
|
+
}> {
|
|
328
|
+
let depth = 1
|
|
329
|
+
for(
|
|
330
|
+
let anc: Node | null = el.parent;
|
|
331
|
+
anc && isTag(anc) && depth <= 10;
|
|
332
|
+
anc = anc.parent, depth++
|
|
333
|
+
) {
|
|
334
|
+
const tag = anc.name
|
|
335
|
+
if(tag === 'html' || tag === 'body' || tag === 'head') {
|
|
336
|
+
continue
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const rel = relativeSteps(anc, el)
|
|
340
|
+
if(rel) {
|
|
341
|
+
yield { tag, attribs: anc.attribs ?? {}, rel, depth }
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Ancestor-anchored candidates, descending positionally to `el`
|
|
347
|
+
* (`//div[@id="nav"]/ul[1]/li[1]/a[1]`): an id / identifying-attr or a
|
|
348
|
+
* distinctive-class anchor per ancestor, tagged with its kind + depth so the
|
|
349
|
+
* scorer weighs a stable ancestor id above the value's own utility class. */
|
|
350
|
+
function* ancestorAnchoredCandidates(el: Element): Generator<Candidate> {
|
|
351
|
+
for(const { tag, attribs, rel, depth } of anchorAncestors(el)) {
|
|
352
|
+
if(attribs.id) {
|
|
353
|
+
yield { xPath: `//${tag}[@id=${xpathLiteral(attribs.id)}]${rel}`,
|
|
354
|
+
kind: 'id', token: attribs.id, depth }
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
for(const name of IDENT_ATTRS) {
|
|
358
|
+
if(attribs[name]) {
|
|
359
|
+
const xp = `//${tag}[@${name}=${xpathLiteral(attribs[name])}]${rel}`
|
|
360
|
+
yield { xPath: xp, kind: 'ident-attr', token: attribs[name], depth }
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const dc = distinctiveClass(attribs.class)
|
|
365
|
+
if(dc) {
|
|
366
|
+
yield { xPath: `//${tag}[contains(@class, ${xpathLiteral(dc)})]${rel}`,
|
|
367
|
+
kind: 'class', token: dc, depth }
|
|
368
|
+
}
|
|
369
|
+
}
|
|
170
370
|
}
|
|
171
371
|
|
|
172
372
|
/** Chrome "full XPath"-style absolute path: `/html[1]/body[1]/…/tag[n]`, where
|