unbrowse 2.12.2 → 2.12.7
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 +8 -44
- package/dist/cli.js +514 -20723
- package/package.json +4 -10
- package/runtime-src/api/routes.ts +15 -801
- package/runtime-src/auth/index.ts +32 -142
- package/runtime-src/capture/index.ts +101 -436
- package/runtime-src/cli.ts +371 -956
- package/runtime-src/client/index.ts +29 -622
- package/runtime-src/execution/index.ts +85 -345
- package/runtime-src/graph/index.ts +10 -128
- package/runtime-src/intent-match.ts +27 -27
- package/runtime-src/kuri/client.ts +82 -543
- package/runtime-src/orchestrator/index.ts +462 -2246
- package/runtime-src/reverse-engineer/index.ts +22 -220
- package/runtime-src/runtime/local-server.ts +16 -149
- package/runtime-src/runtime/paths.ts +5 -9
- package/runtime-src/runtime/setup.ts +1 -52
- package/runtime-src/server.ts +11 -6
- package/runtime-src/transform/schema-hints.ts +358 -0
- package/runtime-src/types/skill.ts +2 -49
- package/runtime-src/verification/index.ts +0 -15
- package/runtime-src/version.ts +13 -13
- package/vendor/kuri/darwin-arm64/kuri +0 -0
- package/vendor/kuri/darwin-x64/kuri +0 -0
- package/vendor/kuri/linux-arm64/kuri +0 -0
- package/vendor/kuri/linux-x64/kuri +0 -0
- package/bin/unbrowse-wrapper.mjs +0 -39
- package/bin/unbrowse.js +0 -38
- package/runtime-src/analytics-session.ts +0 -33
- package/runtime-src/api/browse-index.ts +0 -254
- package/runtime-src/api/browse-session.ts +0 -179
- package/runtime-src/api/browse-submit.ts +0 -455
- package/runtime-src/auth/runtime.ts +0 -116
- package/runtime-src/browser/index.ts +0 -635
- package/runtime-src/browser/types.ts +0 -41
- package/runtime-src/capture/prefetch.ts +0 -122
- package/runtime-src/capture/rsc.ts +0 -45
- package/runtime-src/cli/shortcuts.ts +0 -273
- package/runtime-src/client/graph-client.ts +0 -99
- package/runtime-src/execution/robots.ts +0 -167
- package/runtime-src/execution/search-forms.ts +0 -188
- package/runtime-src/graph/planner.ts +0 -411
- package/runtime-src/graph/session.ts +0 -294
- package/runtime-src/graph/trace-store.ts +0 -136
- package/runtime-src/indexer/index.ts +0 -480
- package/runtime-src/orchestrator/browser-agent.ts +0 -374
- package/runtime-src/orchestrator/dag-advisor.ts +0 -59
- package/runtime-src/orchestrator/dag-feedback.ts +0 -256
- package/runtime-src/orchestrator/first-pass-action.ts +0 -362
- package/runtime-src/orchestrator/passive-publish.ts +0 -152
- package/runtime-src/orchestrator/timing-economics.ts +0 -80
- package/runtime-src/payments/cascade.ts +0 -137
- package/runtime-src/payments/index.ts +0 -268
- package/runtime-src/payments/wallet.ts +0 -33
- package/runtime-src/reverse-engineer/description-prompt.ts +0 -132
- package/runtime-src/router.ts +0 -17
- package/runtime-src/runtime/browser-access.ts +0 -11
- package/runtime-src/runtime/browser-host.ts +0 -48
- package/runtime-src/runtime/lifecycle.ts +0 -17
- package/runtime-src/runtime/supervisor.ts +0 -69
- package/runtime-src/single-binary.ts +0 -141
- package/runtime-src/telemetry.ts +0 -253
- package/runtime-src/verification/matrix.ts +0 -30
- package/scripts/postinstall.mjs +0 -81
|
@@ -6,11 +6,10 @@ import { publishSkill, mergeEndpoints } from "../marketplace/index.js";
|
|
|
6
6
|
import { updateEndpointScore } from "../marketplace/index.js";
|
|
7
7
|
import { getCredential, storeCredential, deleteCredential } from "../vault/index.js";
|
|
8
8
|
import { getStoredAuth, getAuthCookies, refreshAuthFromBrowser } from "../auth/index.js";
|
|
9
|
-
import { resolvePreExecutionAuth } from "../auth/dependency-runtime.js";
|
|
10
|
-
import { authRuntime } from "../auth/runtime.js";
|
|
11
9
|
import { applyProjection, inferSchema } from "../transform/index.js";
|
|
12
10
|
import { detectSchemaDrift } from "../transform/drift.js";
|
|
13
|
-
import {
|
|
11
|
+
import { generateExtractionHints } from "../transform/schema-hints.js";
|
|
12
|
+
import { recordExecution, cachePublishedSkill, findExistingSkillForDomain, updateEndpointSchema } from "../client/index.js";
|
|
14
13
|
import { validateManifest } from "../client/index.js";
|
|
15
14
|
import { withRetry, isRetryableStatus } from "./retry.js";
|
|
16
15
|
import type { EndpointDescriptor, ExecutionOptions, ExecutionTrace, ProjectionOptions, SkillManifest } from "../types/index.js";
|
|
@@ -23,21 +22,7 @@ import { log } from "../logger.js";
|
|
|
23
22
|
import { TRACE_VERSION } from "../version.js";
|
|
24
23
|
import { buildQueryBindingMap, extractTemplateQueryBindings, mergeContextTemplateParams } from "../template-params.js";
|
|
25
24
|
import { assessIntentResult, projectIntentData } from "../intent-match.js";
|
|
26
|
-
|
|
27
|
-
import { attributeLifecycle, type LifecycleEvent, type LifecyclePhase } from "../runtime/lifecycle.js";
|
|
28
|
-
import { queueBackgroundIndex } from "../indexer/index.js";
|
|
29
|
-
import {
|
|
30
|
-
writeSkillSnapshot,
|
|
31
|
-
domainSkillCache,
|
|
32
|
-
persistDomainCache,
|
|
33
|
-
getDomainReuseKey,
|
|
34
|
-
scopedCacheKey,
|
|
35
|
-
buildResolveCacheKey,
|
|
36
|
-
snapshotPathForCacheKey,
|
|
37
|
-
generateLocalDescription,
|
|
38
|
-
} from "../orchestrator/index.js";
|
|
39
|
-
import { checkPaymentRequirement } from "../payments/index.js";
|
|
40
|
-
import { isAllowedByRobots } from "./robots.js";
|
|
25
|
+
|
|
41
26
|
/** Stamp every trace with the code version hash for telemetry tracking */
|
|
42
27
|
function stampTrace(trace: ExecutionTrace): ExecutionTrace {
|
|
43
28
|
trace.trace_version = TRACE_VERSION;
|
|
@@ -192,6 +177,10 @@ export interface ExecutionResult {
|
|
|
192
177
|
trace: ExecutionTrace;
|
|
193
178
|
result: unknown;
|
|
194
179
|
learned_skill?: SkillManifest;
|
|
180
|
+
/** Inferred JSON schema of the endpoint's response, for agent-side extraction */
|
|
181
|
+
response_schema?: import("../types/index.js").ResponseSchema;
|
|
182
|
+
/** Ready-to-use extraction hints derived from response_schema */
|
|
183
|
+
extraction_hints?: import("../transform/schema-hints.js").ExtractionHint;
|
|
195
184
|
}
|
|
196
185
|
|
|
197
186
|
export function projectResultForIntent(data: unknown, intent?: string): unknown {
|
|
@@ -217,9 +206,7 @@ function sanitizeNavigationQueryParams(url: URL): URL {
|
|
|
217
206
|
}
|
|
218
207
|
|
|
219
208
|
function restoreTemplatePlaceholderEncoding(url: string): string {
|
|
220
|
-
|
|
221
|
-
// Template placeholders: %7Bword_chars%7D (no spaces, no quotes, no colons inside)
|
|
222
|
-
return url.replace(/%7B(\w+)%7D/gi, "{$1}");
|
|
209
|
+
return url.replace(/%7B/gi, "{").replace(/%7D/gi, "}");
|
|
223
210
|
}
|
|
224
211
|
|
|
225
212
|
function compactSchemaSample(value: unknown, depth = 0): unknown {
|
|
@@ -575,21 +562,6 @@ export function buildStructuredReplayHeaders(
|
|
|
575
562
|
return headers;
|
|
576
563
|
}
|
|
577
564
|
|
|
578
|
-
function normalizeReplayHeaders(
|
|
579
|
-
...bags: Array<Record<string, string> | undefined>
|
|
580
|
-
): Record<string, string> {
|
|
581
|
-
const normalized: Record<string, string> = {};
|
|
582
|
-
for (const bag of bags) {
|
|
583
|
-
for (const [key, value] of Object.entries(bag ?? {})) {
|
|
584
|
-
if (typeof value !== "string") continue;
|
|
585
|
-
const trimmed = value.trim();
|
|
586
|
-
if (!trimmed) continue;
|
|
587
|
-
normalized[key.toLowerCase()] = trimmed;
|
|
588
|
-
}
|
|
589
|
-
}
|
|
590
|
-
return normalized;
|
|
591
|
-
}
|
|
592
|
-
|
|
593
565
|
function shouldFallbackToBrowserReplay(
|
|
594
566
|
data: unknown,
|
|
595
567
|
endpoint: EndpointDescriptor,
|
|
@@ -620,7 +592,6 @@ export function buildPageArtifactCapture(
|
|
|
620
592
|
endpoint?: EndpointDescriptor;
|
|
621
593
|
result?: { data: unknown; _extraction: Record<string, unknown> };
|
|
622
594
|
quality_note?: string;
|
|
623
|
-
search_form?: SearchFormSpec;
|
|
624
595
|
} {
|
|
625
596
|
const extracted = extractFromDOM(html, intent);
|
|
626
597
|
if (!extracted.data || extracted.confidence <= 0.2) return {};
|
|
@@ -632,11 +603,6 @@ export function buildPageArtifactCapture(
|
|
|
632
603
|
if (semanticAssessment.verdict === "fail") {
|
|
633
604
|
return { quality_note: semanticAssessment.reason };
|
|
634
605
|
}
|
|
635
|
-
|
|
636
|
-
// Detect structured search forms from the captured HTML
|
|
637
|
-
const searchForms = detectSearchForms(html);
|
|
638
|
-
const validSearchForm = searchForms.find((spec: SearchFormSpec) => isStructuredSearchForm(spec));
|
|
639
|
-
|
|
640
606
|
const response_schema = inferSchema([extracted.data]);
|
|
641
607
|
const endpoint: EndpointDescriptor = {
|
|
642
608
|
endpoint_id: nanoid(),
|
|
@@ -645,15 +611,12 @@ export function buildPageArtifactCapture(
|
|
|
645
611
|
idempotency: "safe" as const,
|
|
646
612
|
verification_status: "verified" as const,
|
|
647
613
|
reliability_score: extracted.confidence,
|
|
648
|
-
description:
|
|
649
|
-
? `Captured search form artifact for ${intent}`
|
|
650
|
-
: `Captured page artifact for ${intent}`,
|
|
614
|
+
description: `Captured page artifact for ${intent}`,
|
|
651
615
|
response_schema,
|
|
652
616
|
dom_extraction: {
|
|
653
617
|
extraction_method: extracted.extraction_method,
|
|
654
618
|
confidence: extracted.confidence,
|
|
655
619
|
...(extracted.selector ? { selector: extracted.selector } : {}),
|
|
656
|
-
...(validSearchForm ? { search_form: validSearchForm } : {}),
|
|
657
620
|
},
|
|
658
621
|
trigger_url: url,
|
|
659
622
|
};
|
|
@@ -666,14 +629,6 @@ export function buildPageArtifactCapture(
|
|
|
666
629
|
}),
|
|
667
630
|
...(authRequired ? { auth_required: true } : {}),
|
|
668
631
|
};
|
|
669
|
-
if (validSearchForm && endpoint.semantic) {
|
|
670
|
-
endpoint.semantic.action_kind = "search";
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
if (validSearchForm) {
|
|
674
|
-
log("execution", `detected structured search form: ${validSearchForm.form_selector} with ${validSearchForm.fields.length} fields`);
|
|
675
|
-
}
|
|
676
|
-
|
|
677
632
|
return {
|
|
678
633
|
endpoint,
|
|
679
634
|
result: {
|
|
@@ -682,10 +637,8 @@ export function buildPageArtifactCapture(
|
|
|
682
637
|
method: extracted.extraction_method,
|
|
683
638
|
confidence: extracted.confidence,
|
|
684
639
|
source: "dom-fallback",
|
|
685
|
-
...(validSearchForm ? { search_form_detected: true } : {}),
|
|
686
640
|
},
|
|
687
641
|
},
|
|
688
|
-
...(validSearchForm ? { search_form: validSearchForm } : {}),
|
|
689
642
|
};
|
|
690
643
|
}
|
|
691
644
|
|
|
@@ -960,35 +913,16 @@ export async function executeSkill(
|
|
|
960
913
|
options?: ExecutionOptions
|
|
961
914
|
): Promise<ExecutionResult> {
|
|
962
915
|
if (skill.execution_type === "browser-capture") {
|
|
963
|
-
return executeBrowserCapture(skill, params
|
|
916
|
+
return executeBrowserCapture(skill, params);
|
|
964
917
|
}
|
|
965
918
|
|
|
966
|
-
// Allow targeting a specific endpoint by ID
|
|
919
|
+
// Allow targeting a specific endpoint by ID
|
|
967
920
|
if (params.endpoint_id) {
|
|
968
921
|
const target = skill.endpoints.find((e) => e.endpoint_id === params.endpoint_id);
|
|
969
922
|
if (target) {
|
|
970
923
|
const { endpoint_id: _, ...cleanParams } = params;
|
|
971
924
|
return executeEndpoint(skill, target, cleanParams, projection, options);
|
|
972
925
|
}
|
|
973
|
-
// Agent explicitly chose this endpoint — don't silently swap to a different one
|
|
974
|
-
log("exec", `endpoint ${params.endpoint_id} not found in skill ${skill.skill_id} (${skill.endpoints.length} endpoints: ${skill.endpoints.map(e => e.endpoint_id).join(", ")})`);
|
|
975
|
-
const trace: ExecutionTrace = {
|
|
976
|
-
trace_id: nanoid(),
|
|
977
|
-
skill_id: skill.skill_id,
|
|
978
|
-
endpoint_id: String(params.endpoint_id),
|
|
979
|
-
started_at: new Date().toISOString(),
|
|
980
|
-
completed_at: new Date().toISOString(),
|
|
981
|
-
success: false,
|
|
982
|
-
error: `endpoint_not_found: ${params.endpoint_id} not in skill ${skill.skill_id}`,
|
|
983
|
-
};
|
|
984
|
-
return {
|
|
985
|
-
trace,
|
|
986
|
-
result: {
|
|
987
|
-
error: "endpoint_not_found",
|
|
988
|
-
message: `Endpoint ${params.endpoint_id} not found in skill ${skill.skill_id}. Available: ${skill.endpoints.map(e => `${e.endpoint_id} (${e.description?.slice(0, 50)})`).join(", ")}`,
|
|
989
|
-
available_endpoints: skill.endpoints.map(e => ({ endpoint_id: e.endpoint_id, description: e.description })),
|
|
990
|
-
},
|
|
991
|
-
};
|
|
992
926
|
}
|
|
993
927
|
|
|
994
928
|
// Use the caller's intent for ranking when available, fall back to skill's original intent
|
|
@@ -998,8 +932,7 @@ export async function executeSkill(
|
|
|
998
932
|
|
|
999
933
|
async function executeBrowserCapture(
|
|
1000
934
|
skill: SkillManifest,
|
|
1001
|
-
params: Record<string, unknown
|
|
1002
|
-
options?: ExecutionOptions,
|
|
935
|
+
params: Record<string, unknown>
|
|
1003
936
|
): Promise<ExecutionResult> {
|
|
1004
937
|
const fallbackUrl =
|
|
1005
938
|
(typeof params.context_url === "string" && params.context_url) ||
|
|
@@ -1019,9 +952,9 @@ async function executeBrowserCapture(
|
|
|
1019
952
|
let cookies = params.cookies as Array<{ name: string; value: string; domain: string }> | undefined;
|
|
1020
953
|
let usedStoredAuth = !!(cookies && cookies.length > 0) || !!(authHeaders && Object.keys(authHeaders).length > 0);
|
|
1021
954
|
|
|
1022
|
-
//
|
|
955
|
+
// Bird-style: auto-resolve cookies from vault → browser fallback
|
|
1023
956
|
if (!cookies || cookies.length === 0) {
|
|
1024
|
-
const resolved = await getAuthCookies(targetDomain, { autoExtract:
|
|
957
|
+
const resolved = await getAuthCookies(targetDomain, { autoExtract: false });
|
|
1025
958
|
if (resolved && resolved.length > 0) {
|
|
1026
959
|
cookies = resolved;
|
|
1027
960
|
usedStoredAuth = true;
|
|
@@ -1108,22 +1041,6 @@ async function executeBrowserCapture(
|
|
|
1108
1041
|
|
|
1109
1042
|
const endpoints = extractEndpoints(captured.requests, captured.ws_messages, { pageUrl: url, finalUrl: captured.final_url, intent });
|
|
1110
1043
|
|
|
1111
|
-
// Detect structured search forms from captured HTML and attach to search-like endpoints
|
|
1112
|
-
if (captured.html) {
|
|
1113
|
-
const detectedForms = detectSearchForms(captured.html);
|
|
1114
|
-
if (detectedForms.length > 0) {
|
|
1115
|
-
for (const ep of endpoints) {
|
|
1116
|
-
if (!ep.search_form && ep.method === "GET") {
|
|
1117
|
-
const matchingForm = detectedForms.find((f) => isStructuredSearchForm(f));
|
|
1118
|
-
if (matchingForm) {
|
|
1119
|
-
ep.search_form = matchingForm;
|
|
1120
|
-
break; // attach the best form to the first search-like GET endpoint
|
|
1121
|
-
}
|
|
1122
|
-
}
|
|
1123
|
-
}
|
|
1124
|
-
}
|
|
1125
|
-
}
|
|
1126
|
-
|
|
1127
1044
|
// JS bundle scanning: discover API routes not seen in network traffic
|
|
1128
1045
|
if (captured.js_bundles && captured.js_bundles.size > 0) {
|
|
1129
1046
|
const pageOrigin = new URL(url).origin;
|
|
@@ -1414,60 +1331,32 @@ async function executeBrowserCapture(
|
|
|
1414
1331
|
description: `API skill for ${domain}`,
|
|
1415
1332
|
owner_type: "agent" as const,
|
|
1416
1333
|
endpoints: localEndpoints,
|
|
1334
|
+
operation_graph: buildSkillOperationGraph(localEndpoints),
|
|
1417
1335
|
intents: Array.from(new Set([...(existingSkill?.intents ?? []), intent])),
|
|
1418
1336
|
...(auth_profile_ref ? { auth_profile_ref } : {}),
|
|
1419
1337
|
};
|
|
1420
|
-
// Generate local descriptions immediately so BM25 ranking works on first cache hit
|
|
1421
|
-
for (const ep of localDraft.endpoints) {
|
|
1422
|
-
if (!ep.description) {
|
|
1423
|
-
ep.description = generateLocalDescription(ep);
|
|
1424
|
-
}
|
|
1425
|
-
}
|
|
1426
|
-
|
|
1427
|
-
// PHASE 2: Write local cache IMMEDIATELY (~1ms) — populates cache before auto-exec
|
|
1428
|
-
const bgCacheKey = buildResolveCacheKey(domain, intent, url);
|
|
1429
|
-
const bgScopedKey = scopedCacheKey(options?.client_scope ?? "global", bgCacheKey);
|
|
1430
|
-
writeSkillSnapshot(bgScopedKey, localDraft);
|
|
1431
|
-
const bgDomainKey = getDomainReuseKey(url ?? domain);
|
|
1432
|
-
if (bgDomainKey) {
|
|
1433
|
-
domainSkillCache.set(bgDomainKey, {
|
|
1434
|
-
skillId: localDraft.skill_id,
|
|
1435
|
-
localSkillPath: snapshotPathForCacheKey(bgScopedKey),
|
|
1436
|
-
ts: Date.now(),
|
|
1437
|
-
});
|
|
1438
|
-
persistDomainCache();
|
|
1439
|
-
}
|
|
1440
|
-
|
|
1441
|
-
// PHASE 2: Queue heavy work for background (graph + validate + publish)
|
|
1442
|
-
queueBackgroundIndex({
|
|
1443
|
-
skill: { ...localDraft },
|
|
1444
|
-
domain,
|
|
1445
|
-
intent,
|
|
1446
|
-
contextUrl: url,
|
|
1447
|
-
clientScope: options?.client_scope,
|
|
1448
|
-
cacheKey: bgCacheKey,
|
|
1449
|
-
});
|
|
1450
|
-
|
|
1451
|
-
// Return the local draft as learned_skill — no blocking on marketplace publish
|
|
1452
1338
|
let learned: SkillManifest = localDraft;
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1339
|
+
if (publishableEndpoints.length > 0) {
|
|
1340
|
+
const { operation_graph: _graph, ...publishBase } = localDraft;
|
|
1341
|
+
const publishDraft: SkillManifest = { ...publishBase, endpoints: publishableEndpoints };
|
|
1342
|
+
const validation = await validateManifest({ ...publishDraft, skill_id: "__validate__" });
|
|
1343
|
+
if (!validation.valid) throw new Error(`Skill validation failed: ${validation.hardErrors.join("; ")}`);
|
|
1344
|
+
const published = await publishSkill(publishDraft);
|
|
1345
|
+
learned = {
|
|
1346
|
+
...published,
|
|
1347
|
+
endpoints: localEndpoints,
|
|
1348
|
+
operation_graph: localDraft.operation_graph,
|
|
1349
|
+
...(auth_profile_ref ? { auth_profile_ref } : {}),
|
|
1350
|
+
};
|
|
1351
|
+
}
|
|
1352
|
+
try { cachePublishedSkill(learned, options?.client_scope); } catch { /* local cache best-effort */ }
|
|
1464
1353
|
|
|
1465
1354
|
const trace: ExecutionTrace = stampTrace({
|
|
1466
1355
|
trace_id: traceId,
|
|
1467
1356
|
skill_id: learned.skill_id,
|
|
1468
1357
|
endpoint_id: "browser-capture",
|
|
1469
1358
|
started_at: startedAt,
|
|
1470
|
-
completed_at:
|
|
1359
|
+
completed_at: new Date().toISOString(),
|
|
1471
1360
|
success: true,
|
|
1472
1361
|
result: { learned_skill_id: learned.skill_id, endpoints_discovered: cleanEndpoints.length },
|
|
1473
1362
|
});
|
|
@@ -1527,16 +1416,6 @@ async function tryHttpFetch(
|
|
|
1527
1416
|
}
|
|
1528
1417
|
}
|
|
1529
1418
|
|
|
1530
|
-
/** When extraction returns "multiple" candidates, pick the best one's data to avoid duplicates */
|
|
1531
|
-
function flattenExtracted(data: unknown): unknown {
|
|
1532
|
-
if (!Array.isArray(data)) return data;
|
|
1533
|
-
const first = data[0];
|
|
1534
|
-
if (first && typeof first === "object" && "type" in first && "data" in first && "relevance_score" in first) {
|
|
1535
|
-
return data.reduce((best: any, cur: any) => (cur.relevance_score ?? 0) > (best.relevance_score ?? 0) ? cur : best).data;
|
|
1536
|
-
}
|
|
1537
|
-
return data;
|
|
1538
|
-
}
|
|
1539
|
-
|
|
1540
1419
|
async function executeDomExtractionEndpoint(
|
|
1541
1420
|
endpoint: EndpointDescriptor,
|
|
1542
1421
|
url: string,
|
|
@@ -1555,7 +1434,16 @@ async function executeDomExtractionEndpoint(
|
|
|
1555
1434
|
if (ssrSemantic.verdict !== "fail") {
|
|
1556
1435
|
console.log(`[ssr-fast] hit — extracted via HTTP fetch`);
|
|
1557
1436
|
return {
|
|
1558
|
-
data:
|
|
1437
|
+
data: {
|
|
1438
|
+
data: ssrExtracted.data,
|
|
1439
|
+
_extraction: {
|
|
1440
|
+
method: ssrExtracted.extraction_method,
|
|
1441
|
+
confidence: ssrExtracted.confidence,
|
|
1442
|
+
source: "ssr-fast",
|
|
1443
|
+
final_url: ssrResult.final_url,
|
|
1444
|
+
...(ssrExtracted.selector ? { selector: ssrExtracted.selector } : {}),
|
|
1445
|
+
},
|
|
1446
|
+
},
|
|
1559
1447
|
status: 200,
|
|
1560
1448
|
trace_id: nanoid(),
|
|
1561
1449
|
};
|
|
@@ -1567,33 +1455,8 @@ async function executeDomExtractionEndpoint(
|
|
|
1567
1455
|
console.log(`[ssr-fast] miss, falling back to browser`);
|
|
1568
1456
|
}
|
|
1569
1457
|
|
|
1570
|
-
// Browser fallback
|
|
1458
|
+
// Browser fallback
|
|
1571
1459
|
const captured = await captureSession(url, authHeaders, cookies, intent);
|
|
1572
|
-
|
|
1573
|
-
// Check intercepted requests first — if the site's JS made API calls,
|
|
1574
|
-
// those have the actual filtered data (not the initial HTML page load)
|
|
1575
|
-
if (captured.requests.length > 0) {
|
|
1576
|
-
const { extractEndpoints: extractEps } = await import("../reverse-engineer/index.js");
|
|
1577
|
-
const apiEndpoints = extractEps(captured.requests, undefined, { pageUrl: url, finalUrl: captured.final_url });
|
|
1578
|
-
const jsonEndpoints = apiEndpoints.filter(ep => ep.response_schema && !ep.dom_extraction);
|
|
1579
|
-
if (jsonEndpoints.length > 0) {
|
|
1580
|
-
// Found real API responses — return the best one's data
|
|
1581
|
-
const best = jsonEndpoints[0];
|
|
1582
|
-
const matchingReq = captured.requests.find(r =>
|
|
1583
|
-
r.url.includes(best.url_template.split("?")[0].split("{")[0]) &&
|
|
1584
|
-
r.response_body && r.response_status >= 200 && r.response_status < 400
|
|
1585
|
-
);
|
|
1586
|
-
if (matchingReq?.response_body) {
|
|
1587
|
-
try {
|
|
1588
|
-
const data = JSON.parse(matchingReq.response_body);
|
|
1589
|
-
console.log(`[dom-exec] found API response from browser capture: ${matchingReq.url.substring(0, 80)}`);
|
|
1590
|
-
return { data, status: matchingReq.response_status, trace_id: nanoid() };
|
|
1591
|
-
} catch { /* not JSON, fall through to DOM extraction */ }
|
|
1592
|
-
}
|
|
1593
|
-
}
|
|
1594
|
-
}
|
|
1595
|
-
|
|
1596
|
-
// Fall back to DOM extraction from rendered HTML
|
|
1597
1460
|
const html = captured.html ?? "";
|
|
1598
1461
|
const extracted = extractFromDOMWithHint(html, intent, endpoint.dom_extraction);
|
|
1599
1462
|
if (extracted.data) {
|
|
@@ -1620,7 +1483,16 @@ async function executeDomExtractionEndpoint(
|
|
|
1620
1483
|
};
|
|
1621
1484
|
}
|
|
1622
1485
|
return {
|
|
1623
|
-
data:
|
|
1486
|
+
data: {
|
|
1487
|
+
data: extracted.data,
|
|
1488
|
+
_extraction: {
|
|
1489
|
+
method: extracted.extraction_method,
|
|
1490
|
+
confidence: extracted.confidence,
|
|
1491
|
+
source: "rendered-dom",
|
|
1492
|
+
final_url: captured.final_url,
|
|
1493
|
+
...(extracted.selector ? { selector: extracted.selector } : {}),
|
|
1494
|
+
},
|
|
1495
|
+
},
|
|
1624
1496
|
status: 200,
|
|
1625
1497
|
trace_id: nanoid(),
|
|
1626
1498
|
};
|
|
@@ -1670,6 +1542,8 @@ export async function executeEndpoint(
|
|
|
1670
1542
|
}
|
|
1671
1543
|
return {
|
|
1672
1544
|
trace, result: resultData,
|
|
1545
|
+
...(endpoint.response_schema ? { response_schema: endpoint.response_schema } : {}),
|
|
1546
|
+
...(endpoint.response_schema ? { extraction_hints: generateExtractionHints(endpoint.response_schema, skill.intent_signature) ?? undefined } : {}),
|
|
1673
1547
|
};
|
|
1674
1548
|
} catch (err) {
|
|
1675
1549
|
const trace: ExecutionTrace = stampTrace({
|
|
@@ -1681,38 +1555,6 @@ export async function executeEndpoint(
|
|
|
1681
1555
|
}
|
|
1682
1556
|
}
|
|
1683
1557
|
|
|
1684
|
-
// Payment gate — check if marketplace skill requires payment before executing
|
|
1685
|
-
if (!skill.skill_id.startsWith("local:") && skill.execution_type === "http" && skill.owner_type !== "agent") {
|
|
1686
|
-
const wallet = getLocalWalletContext();
|
|
1687
|
-
const gate = await checkPaymentRequirement(skill.skill_id, endpoint.endpoint_id, {
|
|
1688
|
-
wallet_configured: !!wallet.wallet_address,
|
|
1689
|
-
});
|
|
1690
|
-
if (gate.status === "payment_required" || gate.status === "wallet_not_configured" || gate.status === "insufficient_balance") {
|
|
1691
|
-
const trace: ExecutionTrace = stampTrace({
|
|
1692
|
-
trace_id: nanoid(),
|
|
1693
|
-
skill_id: skill.skill_id,
|
|
1694
|
-
endpoint_id: endpoint.endpoint_id,
|
|
1695
|
-
started_at: new Date().toISOString(),
|
|
1696
|
-
completed_at: new Date().toISOString(),
|
|
1697
|
-
success: false,
|
|
1698
|
-
status_code: 402,
|
|
1699
|
-
error: "payment_required",
|
|
1700
|
-
});
|
|
1701
|
-
return {
|
|
1702
|
-
trace,
|
|
1703
|
-
result: {
|
|
1704
|
-
error: "payment_required",
|
|
1705
|
-
price_usd: gate.requirement?.amount,
|
|
1706
|
-
payment_status: gate.status,
|
|
1707
|
-
message: gate.message,
|
|
1708
|
-
wallet_provider: wallet.wallet_provider ?? "lobster.cash",
|
|
1709
|
-
wallet_address: wallet.wallet_address,
|
|
1710
|
-
indexing_fallback_available: true,
|
|
1711
|
-
},
|
|
1712
|
-
};
|
|
1713
|
-
}
|
|
1714
|
-
}
|
|
1715
|
-
|
|
1716
1558
|
// Mutation safety gate
|
|
1717
1559
|
if (endpoint.method !== "GET" && endpoint.idempotency === "unsafe") {
|
|
1718
1560
|
if (options?.dry_run) {
|
|
@@ -1899,45 +1741,18 @@ export async function executeEndpoint(
|
|
|
1899
1741
|
}
|
|
1900
1742
|
}
|
|
1901
1743
|
|
|
1902
|
-
|
|
1903
|
-
// robots.txt compliance gate — block disallowed paths before any network call.
|
|
1904
|
-
if (!options?.skip_robots_check) {
|
|
1905
|
-
const allowed = await isAllowedByRobots(url);
|
|
1906
|
-
if (!allowed) {
|
|
1907
|
-
const traceId = nanoid();
|
|
1908
|
-
log("exec", `robots.txt blocked ${url}`);
|
|
1909
|
-
return {
|
|
1910
|
-
trace: stampTrace({
|
|
1911
|
-
trace_id: traceId,
|
|
1912
|
-
skill_id: skill.skill_id,
|
|
1913
|
-
endpoint_id: endpoint.endpoint_id,
|
|
1914
|
-
started_at: startedAt,
|
|
1915
|
-
completed_at: new Date().toISOString(),
|
|
1916
|
-
success: false,
|
|
1917
|
-
error: "robots_txt_disallowed",
|
|
1918
|
-
}),
|
|
1919
|
-
result: {
|
|
1920
|
-
error: "robots_txt_disallowed",
|
|
1921
|
-
message: `robots.txt disallows access to ${url} for the Unbrowse user-agent.`,
|
|
1922
|
-
},
|
|
1923
|
-
};
|
|
1924
|
-
}
|
|
1925
|
-
}
|
|
1926
1744
|
const structuredReplayUrl = isSafe ? deriveStructuredDataReplayUrl(url) : url;
|
|
1927
1745
|
const hasStructuredReplay = structuredReplayUrl !== url;
|
|
1928
1746
|
|
|
1929
1747
|
const serverFetch = async (): Promise<{ data: unknown; status: number; trace_id: string }> => {
|
|
1930
|
-
const endpointHeaders = normalizeReplayHeaders(endpoint.headers_template);
|
|
1931
|
-
const sessionHeaders = normalizeReplayHeaders(authHeaders);
|
|
1932
|
-
|
|
1933
1748
|
// Default accept to JSON, but never overwrite the endpoint's own accept header
|
|
1934
1749
|
// (e.g. LinkedIn uses "application/vnd.linkedin.normalized+json+2.1")
|
|
1935
|
-
const defaultAccept: Record<string, string> = (!endpoint.dom_extraction && !
|
|
1750
|
+
const defaultAccept: Record<string, string> = (!endpoint.dom_extraction && !endpoint.headers_template?.["accept"])
|
|
1936
1751
|
? { "accept": "application/json" } : {};
|
|
1937
1752
|
const headers: Record<string, string> = {
|
|
1938
1753
|
...defaultAccept,
|
|
1939
|
-
...
|
|
1940
|
-
...
|
|
1754
|
+
...endpoint.headers_template,
|
|
1755
|
+
...authHeaders,
|
|
1941
1756
|
};
|
|
1942
1757
|
// Strip browser-only headers that cause issues server-side
|
|
1943
1758
|
delete headers["sec-ch-ua"];
|
|
@@ -1956,15 +1771,15 @@ export async function executeEndpoint(
|
|
|
1956
1771
|
headers["cookie"] = cookieStr;
|
|
1957
1772
|
|
|
1958
1773
|
// CSRF token auto-detection (bird pattern): many sites require CSRF tokens
|
|
1959
|
-
// as both a cookie AND a header.
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1774
|
+
// as both a cookie AND a header. Detect common patterns and replay them.
|
|
1775
|
+
if (!headers["x-csrf-token"] && !headers["x-xsrf-token"]) {
|
|
1776
|
+
const csrfCookie = cookies.find((c) =>
|
|
1777
|
+
/^(ct0|csrf_token|_csrf|csrftoken|XSRF-TOKEN|_xsrf)$/i.test(c.name)
|
|
1778
|
+
);
|
|
1779
|
+
if (csrfCookie) {
|
|
1780
|
+
const v = csrfCookie.value.startsWith('"') && csrfCookie.value.endsWith('"') ? csrfCookie.value.slice(1, -1) : csrfCookie.value;
|
|
1781
|
+
headers["x-csrf-token"] = v;
|
|
1782
|
+
}
|
|
1968
1783
|
}
|
|
1969
1784
|
}
|
|
1970
1785
|
|
|
@@ -1975,7 +1790,7 @@ export async function executeEndpoint(
|
|
|
1975
1790
|
if (csrfCookie) {
|
|
1976
1791
|
const v = csrfCookie.value.startsWith('"') && csrfCookie.value.endsWith('"') ? csrfCookie.value.slice(1, -1) : csrfCookie.value;
|
|
1977
1792
|
if (endpoint.csrf_plan.source === "cookie" || endpoint.csrf_plan.source === "header") {
|
|
1978
|
-
headers[endpoint.csrf_plan.param_name.toLowerCase()]
|
|
1793
|
+
headers[endpoint.csrf_plan.param_name.toLowerCase()] ??= v;
|
|
1979
1794
|
} else if (endpoint.csrf_plan.source === "form" && body && typeof body === "object" && !Array.isArray(body)) {
|
|
1980
1795
|
(body as Record<string, unknown>)[endpoint.csrf_plan.param_name] ??= v;
|
|
1981
1796
|
}
|
|
@@ -1987,7 +1802,6 @@ export async function executeEndpoint(
|
|
|
1987
1802
|
|
|
1988
1803
|
for (const replayUrl of replayUrls) {
|
|
1989
1804
|
const replayHeaders = buildStructuredReplayHeaders(url, replayUrl, headers);
|
|
1990
|
-
log("exec", `server-fetch: ${endpoint.method} ${replayUrl.substring(0, 80)} csrf=${replayHeaders["x-csrf-token"]?.substring(0, 10)}... cookies=${(replayHeaders["cookie"]?.length ?? 0)}chars`);
|
|
1991
1805
|
const res = await fetch(replayUrl, {
|
|
1992
1806
|
method: endpoint.method,
|
|
1993
1807
|
headers: replayHeaders,
|
|
@@ -2056,20 +1870,7 @@ export async function executeEndpoint(
|
|
|
2056
1870
|
if (result.status >= 200 && result.status < 400 && !shouldFallbackToBrowserReplay(result.data, endpoint, options?.intent ?? skill.intent_signature, options?.contextUrl)) {
|
|
2057
1871
|
strategy = "server";
|
|
2058
1872
|
} else if (endpoint.trigger_url && isSafe) {
|
|
2059
|
-
|
|
2060
|
-
let triggerUrl = endpoint.trigger_url;
|
|
2061
|
-
if (Object.keys(mergedParams).length > 0) {
|
|
2062
|
-
try {
|
|
2063
|
-
const tu = new URL(endpoint.trigger_url);
|
|
2064
|
-
for (const [k, v] of Object.entries(mergedParams)) {
|
|
2065
|
-
if (v != null && !reservedMetaParams.has(k)) {
|
|
2066
|
-
tu.searchParams.set(k, String(v));
|
|
2067
|
-
}
|
|
2068
|
-
}
|
|
2069
|
-
triggerUrl = tu.toString();
|
|
2070
|
-
} catch { /* keep original trigger_url */ }
|
|
2071
|
-
}
|
|
2072
|
-
result = await triggerAndIntercept(triggerUrl, endpoint.url_template, cookies, authHeaders);
|
|
1873
|
+
result = await triggerAndIntercept(endpoint.trigger_url, endpoint.url_template, cookies, authHeaders);
|
|
2073
1874
|
strategy = "trigger-intercept";
|
|
2074
1875
|
} else {
|
|
2075
1876
|
result = await withRetry(browserCall, (r) => isRetryableStatus(r.status));
|
|
@@ -2086,18 +1887,8 @@ export async function executeEndpoint(
|
|
|
2086
1887
|
}
|
|
2087
1888
|
} else if (endpointStrategy === "trigger-intercept" && endpoint.trigger_url && isSafe) {
|
|
2088
1889
|
// Proven: this endpoint needs trigger-intercept
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
try {
|
|
2092
|
-
const tu = new URL(endpoint.trigger_url);
|
|
2093
|
-
for (const [k, v] of Object.entries(mergedParams)) {
|
|
2094
|
-
if (v != null && !reservedMetaParams.has(k)) tu.searchParams.set(k, String(v));
|
|
2095
|
-
}
|
|
2096
|
-
triggerUrl = tu.toString();
|
|
2097
|
-
} catch { /* keep original */ }
|
|
2098
|
-
}
|
|
2099
|
-
log("exec", `using learned strategy trigger-intercept via ${triggerUrl}`);
|
|
2100
|
-
result = await triggerAndIntercept(triggerUrl, endpoint.url_template, cookies, authHeaders);
|
|
1890
|
+
log("exec", `using learned strategy trigger-intercept via ${endpoint.trigger_url}`);
|
|
1891
|
+
result = await triggerAndIntercept(endpoint.trigger_url, endpoint.url_template, cookies, authHeaders);
|
|
2101
1892
|
strategy = "trigger-intercept";
|
|
2102
1893
|
} else if (endpointStrategy === "browser") {
|
|
2103
1894
|
if (shouldIgnoreLearnedBrowserStrategy(endpoint, url)) {
|
|
@@ -2120,9 +1911,7 @@ export async function executeEndpoint(
|
|
|
2120
1911
|
try {
|
|
2121
1912
|
result = await serverFetch();
|
|
2122
1913
|
if (result.status >= 200 && result.status < 400) {
|
|
2123
|
-
|
|
2124
|
-
const isApiEndpoint = /\/(api|graphql)\b/i.test(endpoint.url_template) || /\.(json)(\?|$)/.test(endpoint.url_template);
|
|
2125
|
-
if (!isApiEndpoint && shouldFallbackToBrowserReplay(result.data, endpoint, options?.intent ?? skill.intent_signature, options?.contextUrl)) {
|
|
1914
|
+
if (shouldFallbackToBrowserReplay(result.data, endpoint, options?.intent ?? skill.intent_signature, options?.contextUrl)) {
|
|
2126
1915
|
result = await withRetry(browserCall, (r) => isRetryableStatus(r.status));
|
|
2127
1916
|
strategy = "browser";
|
|
2128
1917
|
} else {
|
|
@@ -2131,17 +1920,7 @@ export async function executeEndpoint(
|
|
|
2131
1920
|
} else {
|
|
2132
1921
|
log("exec", `server fetch returned ${result.status}, falling back`);
|
|
2133
1922
|
if (endpoint.trigger_url && isSafe) {
|
|
2134
|
-
|
|
2135
|
-
if (Object.keys(mergedParams).length > 0) {
|
|
2136
|
-
try {
|
|
2137
|
-
const tu = new URL(endpoint.trigger_url);
|
|
2138
|
-
for (const [k, v] of Object.entries(mergedParams)) {
|
|
2139
|
-
if (v != null && !reservedMetaParams.has(k)) tu.searchParams.set(k, String(v));
|
|
2140
|
-
}
|
|
2141
|
-
triggerUrl = tu.toString();
|
|
2142
|
-
} catch { /* keep original */ }
|
|
2143
|
-
}
|
|
2144
|
-
result = await triggerAndIntercept(triggerUrl, endpoint.url_template, cookies, authHeaders);
|
|
1923
|
+
result = await triggerAndIntercept(endpoint.trigger_url, endpoint.url_template, cookies, authHeaders);
|
|
2145
1924
|
strategy = "trigger-intercept";
|
|
2146
1925
|
} else {
|
|
2147
1926
|
result = await withRetry(browserCall, (r) => isRetryableStatus(r.status));
|
|
@@ -2199,41 +1978,15 @@ export async function executeEndpoint(
|
|
|
2199
1978
|
trace.result = data;
|
|
2200
1979
|
}
|
|
2201
1980
|
|
|
2202
|
-
// Stale credential detection: on 401/403,
|
|
2203
|
-
//
|
|
2204
|
-
// → authRuntime.loginIfNeeded (full interactive login)
|
|
1981
|
+
// Stale credential detection: on 401/403, try refreshing from browser (bird pattern)
|
|
1982
|
+
// instead of just deleting. Next request will use fresh cookies.
|
|
2205
1983
|
if (status === 401 || status === 403) {
|
|
2206
|
-
let authRecovered = false;
|
|
2207
1984
|
try {
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
log("auth", `session refreshed via authRuntime for ${epDomain} — retry should succeed`);
|
|
2212
|
-
authRecovered = true;
|
|
2213
|
-
}
|
|
2214
|
-
|
|
2215
|
-
// 2. Re-extract cookies from browser SQLite (bird pattern)
|
|
2216
|
-
if (!authRecovered) {
|
|
2217
|
-
const browserRefreshed = await refreshAuthFromBrowser(epDomain);
|
|
2218
|
-
if (browserRefreshed) {
|
|
2219
|
-
log("auth", `credentials refreshed from browser for ${epDomain}`);
|
|
2220
|
-
authRecovered = true;
|
|
2221
|
-
}
|
|
2222
|
-
}
|
|
2223
|
-
|
|
2224
|
-
// 3. Full login flow via authRuntime as last resort
|
|
2225
|
-
if (!authRecovered) {
|
|
2226
|
-
const loginResult = await authRuntime.loginIfNeeded(epDomain);
|
|
2227
|
-
if (loginResult) {
|
|
2228
|
-
log("auth", `loginIfNeeded succeeded for ${epDomain}`);
|
|
2229
|
-
authRecovered = true;
|
|
2230
|
-
}
|
|
2231
|
-
}
|
|
2232
|
-
|
|
2233
|
-
if (authRecovered) {
|
|
2234
|
-
trace.error = `${trace.error} (credentials refreshed — retry should succeed)`;
|
|
1985
|
+
const refreshed = await refreshAuthFromBrowser(epDomain);
|
|
1986
|
+
if (refreshed) {
|
|
1987
|
+
trace.error = `${trace.error} (credentials refreshed from browser — retry should succeed)`;
|
|
2235
1988
|
} else {
|
|
2236
|
-
// No
|
|
1989
|
+
// No fresh cookies available — delete stale ones
|
|
2237
1990
|
if (skill.auth_profile_ref) {
|
|
2238
1991
|
await deleteCredential(skill.auth_profile_ref);
|
|
2239
1992
|
}
|
|
@@ -2326,27 +2079,8 @@ export async function executeEndpoint(
|
|
|
2326
2079
|
}
|
|
2327
2080
|
|
|
2328
2081
|
// Record execution for reliability scoring (fire-and-forget — don't block response)
|
|
2329
|
-
recordExecution(skill.skill_id, endpoint.endpoint_id, trace
|
|
2082
|
+
recordExecution(skill.skill_id, endpoint.endpoint_id, trace).catch(() => {});
|
|
2330
2083
|
|
|
2331
|
-
// Record transaction if this was a paid execution (fire-and-forget)
|
|
2332
|
-
if (trace.success && options?.payment_verified === true && skill.base_price_usd && skill.base_price_usd > 0) {
|
|
2333
|
-
const consumerConfig = (() => {
|
|
2334
|
-
try { return JSON.parse(require("fs").readFileSync(require("os").homedir() + "/.unbrowse/config.json", "utf-8")); }
|
|
2335
|
-
catch { return {}; }
|
|
2336
|
-
})();
|
|
2337
|
-
if (consumerConfig.agent_id) {
|
|
2338
|
-
const wallet = getLocalWalletContext();
|
|
2339
|
-
recordTransaction({
|
|
2340
|
-
transaction_id: trace.trace_id,
|
|
2341
|
-
consumer_id: consumerConfig.agent_id,
|
|
2342
|
-
creator_id: skill.indexer_id,
|
|
2343
|
-
skill_id: skill.skill_id,
|
|
2344
|
-
endpoint_id: endpoint.endpoint_id,
|
|
2345
|
-
price_usd: skill.base_price_usd,
|
|
2346
|
-
payment_proof: wallet.wallet_address ? `wallet:${wallet.wallet_address}` : undefined,
|
|
2347
|
-
}).catch(() => {});
|
|
2348
|
-
}
|
|
2349
|
-
}
|
|
2350
2084
|
// Apply field projection
|
|
2351
2085
|
let resultData = data;
|
|
2352
2086
|
if (projection?.raw) {
|
|
@@ -2357,8 +2091,14 @@ export async function executeEndpoint(
|
|
|
2357
2091
|
resultData = projectResultForIntent(data, effectiveIntent);
|
|
2358
2092
|
}
|
|
2359
2093
|
|
|
2094
|
+
const rawResultShape = resultData === data;
|
|
2095
|
+
|
|
2360
2096
|
return {
|
|
2361
2097
|
trace, result: resultData,
|
|
2098
|
+
...(endpoint.response_schema && rawResultShape ? { response_schema: endpoint.response_schema } : {}),
|
|
2099
|
+
...(endpoint.response_schema && rawResultShape
|
|
2100
|
+
? { extraction_hints: generateExtractionHints(endpoint.response_schema, effectiveIntent) ?? undefined }
|
|
2101
|
+
: {}),
|
|
2362
2102
|
};
|
|
2363
2103
|
}
|
|
2364
2104
|
|
|
@@ -2367,7 +2107,7 @@ export async function executeEndpoint(
|
|
|
2367
2107
|
* e.g. /search?q=books&page=1 → /search?q={q}&page={page}
|
|
2368
2108
|
* Path stays untouched — only query string is templatized.
|
|
2369
2109
|
*/
|
|
2370
|
-
|
|
2110
|
+
function templatizeQueryParams(url: string): string {
|
|
2371
2111
|
try {
|
|
2372
2112
|
const u = sanitizeNavigationQueryParams(new URL(url));
|
|
2373
2113
|
if (u.search.length <= 1) return url; // no query params
|