@flrande/bak-extension 0.6.0 → 0.6.2
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/.bak-e2e-build-stamp +1 -1
- package/dist/background.global.js +692 -266
- package/dist/content.global.js +83 -22
- package/dist/manifest.json +1 -1
- package/package.json +2 -2
- package/public/manifest.json +1 -1
- package/src/background.ts +728 -202
- package/src/content.ts +93 -22
- package/src/network-debugger.ts +1 -1
- package/src/session-binding-storage.ts +50 -68
- package/src/{workspace.ts → session-binding.ts} +228 -213
package/src/background.ts
CHANGED
|
@@ -6,7 +6,9 @@ import type {
|
|
|
6
6
|
PageExecutionScope,
|
|
7
7
|
PageFetchResponse,
|
|
8
8
|
PageFrameResult,
|
|
9
|
-
PageFreshnessResult
|
|
9
|
+
PageFreshnessResult,
|
|
10
|
+
TableHandle,
|
|
11
|
+
TableSchema
|
|
10
12
|
} from '@flrande/bak-protocol';
|
|
11
13
|
import {
|
|
12
14
|
clearNetworkEntries,
|
|
@@ -22,21 +24,16 @@ import {
|
|
|
22
24
|
} from './network-debugger.js';
|
|
23
25
|
import { isSupportedAutomationUrl } from './url-policy.js';
|
|
24
26
|
import { computeReconnectDelayMs } from './reconnect.js';
|
|
25
|
-
import {
|
|
26
|
-
LEGACY_STORAGE_KEY_WORKSPACE,
|
|
27
|
-
LEGACY_STORAGE_KEY_WORKSPACES,
|
|
28
|
-
resolveSessionBindingStateMap,
|
|
29
|
-
STORAGE_KEY_SESSION_BINDINGS
|
|
30
|
-
} from './session-binding-storage.js';
|
|
27
|
+
import { resolveSessionBindingStateMap, STORAGE_KEY_SESSION_BINDINGS } from './session-binding-storage.js';
|
|
31
28
|
import { containsRedactionMarker } from './privacy.js';
|
|
32
29
|
import {
|
|
33
|
-
type
|
|
34
|
-
type
|
|
35
|
-
type
|
|
36
|
-
type
|
|
37
|
-
type
|
|
38
|
-
|
|
39
|
-
} from './
|
|
30
|
+
type SessionBindingBrowser,
|
|
31
|
+
type SessionBindingColor,
|
|
32
|
+
type SessionBindingRecord,
|
|
33
|
+
type SessionBindingTab,
|
|
34
|
+
type SessionBindingWindow,
|
|
35
|
+
SessionBindingManager
|
|
36
|
+
} from './session-binding.js';
|
|
40
37
|
|
|
41
38
|
interface CliRequest {
|
|
42
39
|
id: string;
|
|
@@ -74,6 +71,30 @@ const STORAGE_KEY_DEBUG_RICH_TEXT = 'debugRichText';
|
|
|
74
71
|
const DEFAULT_TAB_LOAD_TIMEOUT_MS = 40_000;
|
|
75
72
|
const textEncoder = new TextEncoder();
|
|
76
73
|
const textDecoder = new TextDecoder();
|
|
74
|
+
const DATA_TIMESTAMP_CONTEXT_PATTERN =
|
|
75
|
+
/\b(updated|update|updatedat|asof|timestamp|generated|generatedat|refresh|freshness|latest|last|quote|trade|price|flow|market|time|snapshot|signal)\b/i;
|
|
76
|
+
const CONTRACT_TIMESTAMP_CONTEXT_PATTERN =
|
|
77
|
+
/\b(expiry|expiration|expires|option|contract|strike|maturity|dte|call|put|exercise)\b/i;
|
|
78
|
+
const EVENT_TIMESTAMP_CONTEXT_PATTERN = /\b(earnings|event|report|dividend|split|meeting|fomc|release|filing)\b/i;
|
|
79
|
+
|
|
80
|
+
interface TimestampEvidenceCandidate {
|
|
81
|
+
value: string;
|
|
82
|
+
source: 'visible' | 'inline' | 'page-data' | 'network';
|
|
83
|
+
context?: string;
|
|
84
|
+
path?: string;
|
|
85
|
+
category?: 'data' | 'contract' | 'event' | 'unknown';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface PageDataCandidateProbe {
|
|
89
|
+
name: string;
|
|
90
|
+
resolver: 'globalThis' | 'lexical';
|
|
91
|
+
sample: unknown;
|
|
92
|
+
timestamps: Array<{
|
|
93
|
+
path: string;
|
|
94
|
+
value: string;
|
|
95
|
+
category: 'data' | 'contract' | 'event' | 'unknown';
|
|
96
|
+
}>;
|
|
97
|
+
}
|
|
77
98
|
const REPLAY_FORBIDDEN_HEADER_NAMES = new Set([
|
|
78
99
|
'accept-encoding',
|
|
79
100
|
'authorization',
|
|
@@ -87,12 +108,13 @@ const REPLAY_FORBIDDEN_HEADER_NAMES = new Set([
|
|
|
87
108
|
'set-cookie'
|
|
88
109
|
]);
|
|
89
110
|
|
|
90
|
-
let ws: WebSocket | null = null;
|
|
91
|
-
let reconnectTimer: number | null = null;
|
|
92
|
-
let nextReconnectInMs: number | null = null;
|
|
93
|
-
let reconnectAttempt = 0;
|
|
94
|
-
let lastError: RuntimeErrorDetails | null = null;
|
|
95
|
-
let manualDisconnect = false;
|
|
111
|
+
let ws: WebSocket | null = null;
|
|
112
|
+
let reconnectTimer: number | null = null;
|
|
113
|
+
let nextReconnectInMs: number | null = null;
|
|
114
|
+
let reconnectAttempt = 0;
|
|
115
|
+
let lastError: RuntimeErrorDetails | null = null;
|
|
116
|
+
let manualDisconnect = false;
|
|
117
|
+
let sessionBindingStateMutationQueue: Promise<void> = Promise.resolve();
|
|
96
118
|
|
|
97
119
|
async function getConfig(): Promise<ExtensionConfig> {
|
|
98
120
|
const stored = await chrome.storage.local.get([STORAGE_KEY_TOKEN, STORAGE_KEY_PORT, STORAGE_KEY_DEBUG_RICH_TEXT]);
|
|
@@ -156,12 +178,12 @@ function normalizeUnhandledError(error: unknown): CliResponse['error'] {
|
|
|
156
178
|
if (lower.includes('no tab with id') || lower.includes('no window with id')) {
|
|
157
179
|
return toError('E_NOT_FOUND', message);
|
|
158
180
|
}
|
|
159
|
-
if (lower.includes('
|
|
160
|
-
return toError('E_NOT_FOUND', message);
|
|
161
|
-
}
|
|
162
|
-
if (lower.includes('does not belong to
|
|
163
|
-
return toError('E_NOT_FOUND', message);
|
|
164
|
-
}
|
|
181
|
+
if (lower.includes('binding') && lower.includes('does not exist')) {
|
|
182
|
+
return toError('E_NOT_FOUND', message);
|
|
183
|
+
}
|
|
184
|
+
if (lower.includes('does not belong to binding') || lower.includes('is missing from binding')) {
|
|
185
|
+
return toError('E_NOT_FOUND', message);
|
|
186
|
+
}
|
|
165
187
|
if (lower.includes('invalid url') || lower.includes('url is invalid')) {
|
|
166
188
|
return toError('E_INVALID_PARAMS', message);
|
|
167
189
|
}
|
|
@@ -186,43 +208,64 @@ function toTabInfo(tab: chrome.tabs.Tab): SessionBindingTab {
|
|
|
186
208
|
};
|
|
187
209
|
}
|
|
188
210
|
|
|
189
|
-
async function
|
|
190
|
-
const stored = await chrome.storage.local.get([
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
211
|
+
async function readSessionBindingStateMap(): Promise<Record<string, SessionBindingRecord>> {
|
|
212
|
+
const stored = await chrome.storage.local.get([STORAGE_KEY_SESSION_BINDINGS]);
|
|
213
|
+
return resolveSessionBindingStateMap(stored);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function flushSessionBindingStateMap(stateMap: Record<string, SessionBindingRecord>): Promise<void> {
|
|
217
|
+
if (Object.keys(stateMap).length === 0) {
|
|
218
|
+
await chrome.storage.local.remove([STORAGE_KEY_SESSION_BINDINGS]);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
await chrome.storage.local.set({ [STORAGE_KEY_SESSION_BINDINGS]: stateMap });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function runSessionBindingStateMutation<T>(operation: () => Promise<T>): Promise<T> {
|
|
225
|
+
const run = sessionBindingStateMutationQueue.then(operation, operation);
|
|
226
|
+
sessionBindingStateMutationQueue = run.then(
|
|
227
|
+
() => undefined,
|
|
228
|
+
() => undefined
|
|
229
|
+
);
|
|
230
|
+
return run;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function mutateSessionBindingStateMap<T>(mutator: (stateMap: Record<string, SessionBindingRecord>) => Promise<T> | T): Promise<T> {
|
|
234
|
+
return await runSessionBindingStateMutation(async () => {
|
|
235
|
+
const stateMap = await readSessionBindingStateMap();
|
|
236
|
+
const result = await mutator(stateMap);
|
|
237
|
+
await flushSessionBindingStateMap(stateMap);
|
|
238
|
+
return result;
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function loadSessionBindingStateMap(): Promise<Record<string, SessionBindingRecord>> {
|
|
243
|
+
await sessionBindingStateMutationQueue;
|
|
244
|
+
return await readSessionBindingStateMap();
|
|
245
|
+
}
|
|
202
246
|
|
|
203
|
-
async function
|
|
204
|
-
|
|
247
|
+
async function loadSessionBindingState(bindingId: string): Promise<SessionBindingRecord | null> {
|
|
248
|
+
const stateMap = await loadSessionBindingStateMap();
|
|
249
|
+
return stateMap[bindingId] ?? null;
|
|
205
250
|
}
|
|
206
251
|
|
|
207
|
-
async function
|
|
208
|
-
|
|
209
|
-
stateMap[state.id] = state;
|
|
210
|
-
await chrome.storage.local.set({ [STORAGE_KEY_SESSION_BINDINGS]: stateMap });
|
|
211
|
-
await chrome.storage.local.remove([LEGACY_STORAGE_KEY_WORKSPACES, LEGACY_STORAGE_KEY_WORKSPACE]);
|
|
252
|
+
async function listSessionBindingStates(): Promise<SessionBindingRecord[]> {
|
|
253
|
+
return Object.values(await loadSessionBindingStateMap());
|
|
212
254
|
}
|
|
213
255
|
|
|
214
|
-
async function
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
await
|
|
222
|
-
|
|
223
|
-
}
|
|
256
|
+
async function saveSessionBindingState(state: SessionBindingRecord): Promise<void> {
|
|
257
|
+
await mutateSessionBindingStateMap((stateMap) => {
|
|
258
|
+
stateMap[state.id] = state;
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function deleteSessionBindingState(bindingId: string): Promise<void> {
|
|
263
|
+
await mutateSessionBindingStateMap((stateMap) => {
|
|
264
|
+
delete stateMap[bindingId];
|
|
265
|
+
});
|
|
266
|
+
}
|
|
224
267
|
|
|
225
|
-
const
|
|
268
|
+
const sessionBindingBrowser: SessionBindingBrowser = {
|
|
226
269
|
async getTab(tabId) {
|
|
227
270
|
try {
|
|
228
271
|
return toTabInfo(await chrome.tabs.get(tabId));
|
|
@@ -361,14 +404,14 @@ const workspaceBrowser: SessionBindingBrowser = {
|
|
|
361
404
|
}
|
|
362
405
|
};
|
|
363
406
|
|
|
364
|
-
const bindingManager = new SessionBindingManager(
|
|
365
|
-
{
|
|
366
|
-
load:
|
|
367
|
-
save:
|
|
368
|
-
delete:
|
|
369
|
-
list:
|
|
370
|
-
},
|
|
371
|
-
|
|
407
|
+
const bindingManager = new SessionBindingManager(
|
|
408
|
+
{
|
|
409
|
+
load: loadSessionBindingState,
|
|
410
|
+
save: saveSessionBindingState,
|
|
411
|
+
delete: deleteSessionBindingState,
|
|
412
|
+
list: listSessionBindingStates
|
|
413
|
+
},
|
|
414
|
+
sessionBindingBrowser
|
|
372
415
|
);
|
|
373
416
|
|
|
374
417
|
async function waitForTabComplete(tabId: number, timeoutMs = DEFAULT_TAB_LOAD_TIMEOUT_MS): Promise<void> {
|
|
@@ -475,10 +518,10 @@ function normalizeComparableTabUrl(url: string): string {
|
|
|
475
518
|
}
|
|
476
519
|
}
|
|
477
520
|
|
|
478
|
-
async function
|
|
479
|
-
opened: Awaited<ReturnType<SessionBindingManager['openTab']>>,
|
|
480
|
-
expectedUrl?: string
|
|
481
|
-
): Promise<Awaited<ReturnType<SessionBindingManager['openTab']>>> {
|
|
521
|
+
async function finalizeOpenedSessionBindingTab(
|
|
522
|
+
opened: Awaited<ReturnType<SessionBindingManager['openTab']>>,
|
|
523
|
+
expectedUrl?: string
|
|
524
|
+
): Promise<Awaited<ReturnType<SessionBindingManager['openTab']>>> {
|
|
482
525
|
if (expectedUrl && expectedUrl !== 'about:blank') {
|
|
483
526
|
await waitForTabUrl(opened.tab.id, expectedUrl).catch(() => undefined);
|
|
484
527
|
}
|
|
@@ -498,24 +541,24 @@ async function finalizeOpenedWorkspaceTab(
|
|
|
498
541
|
url: effectiveUrl
|
|
499
542
|
};
|
|
500
543
|
} catch {
|
|
501
|
-
refreshedTab = (await
|
|
502
|
-
}
|
|
503
|
-
const
|
|
504
|
-
...opened.
|
|
505
|
-
tabs: opened.
|
|
544
|
+
refreshedTab = (await sessionBindingBrowser.getTab(opened.tab.id)) ?? opened.tab;
|
|
545
|
+
}
|
|
546
|
+
const refreshedBinding = (await bindingManager.getBindingInfo(opened.binding.id)) ?? {
|
|
547
|
+
...opened.binding,
|
|
548
|
+
tabs: opened.binding.tabs.map((tab) => (tab.id === refreshedTab.id ? refreshedTab : tab))
|
|
506
549
|
};
|
|
507
550
|
|
|
508
551
|
return {
|
|
509
|
-
|
|
552
|
+
binding: refreshedBinding,
|
|
510
553
|
tab: refreshedTab
|
|
511
554
|
};
|
|
512
555
|
}
|
|
513
556
|
|
|
514
|
-
interface WithTabOptions {
|
|
515
|
-
requireSupportedAutomationUrl?: boolean;
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
async function withTab(target: { tabId?: number;
|
|
557
|
+
interface WithTabOptions {
|
|
558
|
+
requireSupportedAutomationUrl?: boolean;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
async function withTab(target: { tabId?: number; bindingId?: string } = {}, options: WithTabOptions = {}): Promise<chrome.tabs.Tab> {
|
|
519
562
|
const requireSupportedAutomationUrl = options.requireSupportedAutomationUrl !== false;
|
|
520
563
|
const validate = (tab: chrome.tabs.Tab): chrome.tabs.Tab => {
|
|
521
564
|
if (!tab.id) {
|
|
@@ -535,11 +578,11 @@ async function withTab(target: { tabId?: number; workspaceId?: string } = {}, op
|
|
|
535
578
|
return validate(tab);
|
|
536
579
|
}
|
|
537
580
|
|
|
538
|
-
const resolved = await bindingManager.resolveTarget({
|
|
539
|
-
tabId: target.tabId,
|
|
540
|
-
|
|
541
|
-
createIfMissing: false
|
|
542
|
-
});
|
|
581
|
+
const resolved = await bindingManager.resolveTarget({
|
|
582
|
+
tabId: target.tabId,
|
|
583
|
+
bindingId: typeof target.bindingId === 'string' ? target.bindingId : undefined,
|
|
584
|
+
createIfMissing: false
|
|
585
|
+
});
|
|
543
586
|
const tab = await chrome.tabs.get(resolved.tab.id);
|
|
544
587
|
return validate(tab);
|
|
545
588
|
}
|
|
@@ -719,6 +762,7 @@ async function executePageWorld<T>(
|
|
|
719
762
|
framePath,
|
|
720
763
|
expr: typeof params.expr === 'string' ? params.expr : '',
|
|
721
764
|
path: typeof params.path === 'string' ? params.path : '',
|
|
765
|
+
resolver: typeof params.resolver === 'string' ? params.resolver : undefined,
|
|
722
766
|
url: typeof params.url === 'string' ? params.url : '',
|
|
723
767
|
method: typeof params.method === 'string' ? params.method : 'GET',
|
|
724
768
|
headers: typeof params.headers === 'object' && params.headers !== null ? params.headers : undefined,
|
|
@@ -804,6 +848,64 @@ async function executePageWorld<T>(
|
|
|
804
848
|
return currentWindow;
|
|
805
849
|
};
|
|
806
850
|
|
|
851
|
+
const buildPathExpression = (path: string): string =>
|
|
852
|
+
parsePath(path)
|
|
853
|
+
.map((segment, index) => {
|
|
854
|
+
if (typeof segment === 'number') {
|
|
855
|
+
return `[${segment}]`;
|
|
856
|
+
}
|
|
857
|
+
if (index === 0) {
|
|
858
|
+
return segment;
|
|
859
|
+
}
|
|
860
|
+
return `.${segment}`;
|
|
861
|
+
})
|
|
862
|
+
.join('');
|
|
863
|
+
|
|
864
|
+
const readPath = (targetWindow: Window, path: string): unknown => {
|
|
865
|
+
const segments = parsePath(path);
|
|
866
|
+
let current: unknown = targetWindow;
|
|
867
|
+
for (const segment of segments) {
|
|
868
|
+
if (current === null || current === undefined || !(segment in (current as Record<string | number, unknown>))) {
|
|
869
|
+
throw { code: 'E_NOT_FOUND', message: `path not found: ${path}` };
|
|
870
|
+
}
|
|
871
|
+
current = (current as Record<string | number, unknown>)[segment];
|
|
872
|
+
}
|
|
873
|
+
return current;
|
|
874
|
+
};
|
|
875
|
+
|
|
876
|
+
const resolveExtractValue = (
|
|
877
|
+
targetWindow: Window & { eval: (expr: string) => unknown },
|
|
878
|
+
path: string,
|
|
879
|
+
resolver: unknown
|
|
880
|
+
): { resolver: 'globalThis' | 'lexical'; value: unknown } => {
|
|
881
|
+
const strategy = resolver === 'globalThis' || resolver === 'lexical' ? resolver : 'auto';
|
|
882
|
+
const lexicalExpression = buildPathExpression(path);
|
|
883
|
+
const readLexical = (): unknown => {
|
|
884
|
+
try {
|
|
885
|
+
return targetWindow.eval(lexicalExpression);
|
|
886
|
+
} catch (error) {
|
|
887
|
+
if (error instanceof ReferenceError) {
|
|
888
|
+
throw { code: 'E_NOT_FOUND', message: `path not found: ${path}` };
|
|
889
|
+
}
|
|
890
|
+
throw error;
|
|
891
|
+
}
|
|
892
|
+
};
|
|
893
|
+
if (strategy === 'globalThis') {
|
|
894
|
+
return { resolver: 'globalThis', value: readPath(targetWindow, path) };
|
|
895
|
+
}
|
|
896
|
+
if (strategy === 'lexical') {
|
|
897
|
+
return { resolver: 'lexical', value: readLexical() };
|
|
898
|
+
}
|
|
899
|
+
try {
|
|
900
|
+
return { resolver: 'globalThis', value: readPath(targetWindow, path) };
|
|
901
|
+
} catch (error) {
|
|
902
|
+
if (typeof error !== 'object' || error === null || (error as { code?: string }).code !== 'E_NOT_FOUND') {
|
|
903
|
+
throw error;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
return { resolver: 'lexical', value: readLexical() };
|
|
907
|
+
};
|
|
908
|
+
|
|
807
909
|
try {
|
|
808
910
|
const targetWindow = payload.scope === 'main' ? window : payload.scope === 'current' ? resolveFrameWindow(payload.framePath ?? []) : window;
|
|
809
911
|
if (payload.action === 'eval') {
|
|
@@ -812,16 +914,15 @@ async function executePageWorld<T>(
|
|
|
812
914
|
return { url: targetWindow.location.href, framePath: payload.scope === 'current' ? payload.framePath ?? [] : [], value: serialized.value, bytes: serialized.bytes };
|
|
813
915
|
}
|
|
814
916
|
if (payload.action === 'extract') {
|
|
815
|
-
const
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
return { url: targetWindow.location.href, framePath: payload.scope === 'current' ? payload.framePath ?? [] : [], value: serialized.value, bytes: serialized.bytes };
|
|
917
|
+
const extracted = resolveExtractValue(targetWindow as Window & { eval: (expr: string) => unknown }, payload.path, payload.resolver);
|
|
918
|
+
const serialized = serializeValue(extracted.value, payload.maxBytes);
|
|
919
|
+
return {
|
|
920
|
+
url: targetWindow.location.href,
|
|
921
|
+
framePath: payload.scope === 'current' ? payload.framePath ?? [] : [],
|
|
922
|
+
value: serialized.value,
|
|
923
|
+
bytes: serialized.bytes,
|
|
924
|
+
resolver: extracted.resolver
|
|
925
|
+
};
|
|
825
926
|
}
|
|
826
927
|
if (payload.action === 'fetch') {
|
|
827
928
|
const headers = { ...(payload.headers ?? {}) } as Record<string, string>;
|
|
@@ -988,6 +1089,34 @@ function replayHeadersFromEntry(entry: NetworkEntry): Record<string, string> | u
|
|
|
988
1089
|
return Object.keys(headers).length > 0 ? headers : undefined;
|
|
989
1090
|
}
|
|
990
1091
|
|
|
1092
|
+
function collectTimestampMatchesFromText(text: string, source: TimestampEvidenceCandidate['source'], patterns?: string[]): TimestampEvidenceCandidate[] {
|
|
1093
|
+
const regexes = (
|
|
1094
|
+
patterns ?? [
|
|
1095
|
+
String.raw`\b20\d{2}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?\b`,
|
|
1096
|
+
String.raw`\b20\d{2}-\d{2}-\d{2}\b`,
|
|
1097
|
+
String.raw`\b20\d{2}\/\d{2}\/\d{2}\b`
|
|
1098
|
+
]
|
|
1099
|
+
).map((pattern) => new RegExp(pattern, 'gi'));
|
|
1100
|
+
const collected = new Map<string, TimestampEvidenceCandidate>();
|
|
1101
|
+
for (const regex of regexes) {
|
|
1102
|
+
for (const match of text.matchAll(regex)) {
|
|
1103
|
+
const value = match[0];
|
|
1104
|
+
if (!value) {
|
|
1105
|
+
continue;
|
|
1106
|
+
}
|
|
1107
|
+
const index = match.index ?? text.indexOf(value);
|
|
1108
|
+
const start = Math.max(0, index - 28);
|
|
1109
|
+
const end = Math.min(text.length, index + value.length + 28);
|
|
1110
|
+
const context = text.slice(start, end).replace(/\s+/g, ' ').trim();
|
|
1111
|
+
const key = `${value}::${context}`;
|
|
1112
|
+
if (!collected.has(key)) {
|
|
1113
|
+
collected.set(key, { value, source, context });
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
return [...collected.values()];
|
|
1118
|
+
}
|
|
1119
|
+
|
|
991
1120
|
function parseTimestampCandidate(value: string, now = Date.now()): number | null {
|
|
992
1121
|
const normalized = value.trim().toLowerCase();
|
|
993
1122
|
if (!normalized) {
|
|
@@ -1003,13 +1132,71 @@ function parseTimestampCandidate(value: string, now = Date.now()): number | null
|
|
|
1003
1132
|
return Number.isNaN(parsed) ? null : parsed;
|
|
1004
1133
|
}
|
|
1005
1134
|
|
|
1006
|
-
function
|
|
1007
|
-
|
|
1135
|
+
function nearestPatternDistance(text: string, anchor: string, pattern: RegExp): number | null {
|
|
1136
|
+
const normalizedText = text.toLowerCase();
|
|
1137
|
+
const normalizedAnchor = anchor.toLowerCase();
|
|
1138
|
+
const anchorIndex = normalizedText.indexOf(normalizedAnchor);
|
|
1139
|
+
if (anchorIndex < 0) {
|
|
1008
1140
|
return null;
|
|
1009
1141
|
}
|
|
1142
|
+
const regex = new RegExp(pattern.source, 'gi');
|
|
1143
|
+
let match: RegExpExecArray | null;
|
|
1144
|
+
let best: number | null = null;
|
|
1145
|
+
while ((match = regex.exec(normalizedText)) !== null) {
|
|
1146
|
+
best = best === null ? Math.abs(anchorIndex - match.index) : Math.min(best, Math.abs(anchorIndex - match.index));
|
|
1147
|
+
}
|
|
1148
|
+
return best;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
function classifyTimestampCandidate(candidate: TimestampEvidenceCandidate, now = Date.now()): PageFreshnessResult['evidence']['classifiedTimestamps'][number]['category'] {
|
|
1152
|
+
const normalizedPath = (candidate.path ?? '').toLowerCase();
|
|
1153
|
+
if (DATA_TIMESTAMP_CONTEXT_PATTERN.test(normalizedPath)) {
|
|
1154
|
+
return 'data';
|
|
1155
|
+
}
|
|
1156
|
+
if (CONTRACT_TIMESTAMP_CONTEXT_PATTERN.test(normalizedPath)) {
|
|
1157
|
+
return 'contract';
|
|
1158
|
+
}
|
|
1159
|
+
if (EVENT_TIMESTAMP_CONTEXT_PATTERN.test(normalizedPath)) {
|
|
1160
|
+
return 'event';
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
const context = candidate.context ?? '';
|
|
1164
|
+
const distances = [
|
|
1165
|
+
{ category: 'data' as const, distance: nearestPatternDistance(context, candidate.value, DATA_TIMESTAMP_CONTEXT_PATTERN) },
|
|
1166
|
+
{ category: 'contract' as const, distance: nearestPatternDistance(context, candidate.value, CONTRACT_TIMESTAMP_CONTEXT_PATTERN) },
|
|
1167
|
+
{ category: 'event' as const, distance: nearestPatternDistance(context, candidate.value, EVENT_TIMESTAMP_CONTEXT_PATTERN) }
|
|
1168
|
+
].filter((entry): entry is { category: 'data' | 'contract' | 'event'; distance: number } => typeof entry.distance === 'number');
|
|
1169
|
+
if (distances.length > 0) {
|
|
1170
|
+
distances.sort((left, right) => left.distance - right.distance);
|
|
1171
|
+
return distances[0]!.category;
|
|
1172
|
+
}
|
|
1173
|
+
const parsed = parseTimestampCandidate(candidate.value, now);
|
|
1174
|
+
return typeof parsed === 'number' && parsed > now + 36 * 60 * 60 * 1000 ? 'contract' : 'unknown';
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
function normalizeTimestampCandidates(
|
|
1178
|
+
candidates: TimestampEvidenceCandidate[],
|
|
1179
|
+
now = Date.now()
|
|
1180
|
+
): PageFreshnessResult['evidence']['classifiedTimestamps'] {
|
|
1181
|
+
return candidates.map((candidate) => ({
|
|
1182
|
+
value: candidate.value,
|
|
1183
|
+
source: candidate.source,
|
|
1184
|
+
category: candidate.category ?? classifyTimestampCandidate(candidate, now),
|
|
1185
|
+
context: candidate.context,
|
|
1186
|
+
path: candidate.path
|
|
1187
|
+
}));
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
function latestTimestampFromCandidates(
|
|
1191
|
+
candidates: Array<{ value: string; category: PageFreshnessResult['evidence']['classifiedTimestamps'][number]['category'] }>,
|
|
1192
|
+
now = Date.now()
|
|
1193
|
+
): number | null {
|
|
1010
1194
|
let latest: number | null = null;
|
|
1011
|
-
for (const
|
|
1012
|
-
|
|
1195
|
+
for (const candidate of candidates) {
|
|
1196
|
+
if (candidate.category === 'contract' || candidate.category === 'event') {
|
|
1197
|
+
continue;
|
|
1198
|
+
}
|
|
1199
|
+
const parsed = parseTimestampCandidate(candidate.value, now);
|
|
1013
1200
|
if (parsed === null) {
|
|
1014
1201
|
continue;
|
|
1015
1202
|
}
|
|
@@ -1020,6 +1207,8 @@ function extractLatestTimestamp(values: string[] | undefined, now = Date.now()):
|
|
|
1020
1207
|
|
|
1021
1208
|
function computeFreshnessAssessment(input: {
|
|
1022
1209
|
latestInlineDataTimestamp: number | null;
|
|
1210
|
+
latestPageDataTimestamp: number | null;
|
|
1211
|
+
latestNetworkDataTimestamp: number | null;
|
|
1023
1212
|
latestNetworkTimestamp: number | null;
|
|
1024
1213
|
domVisibleTimestamp: number | null;
|
|
1025
1214
|
lastMutationAt: number | null;
|
|
@@ -1027,19 +1216,29 @@ function computeFreshnessAssessment(input: {
|
|
|
1027
1216
|
staleWindowMs: number;
|
|
1028
1217
|
}): PageFreshnessResult['assessment'] {
|
|
1029
1218
|
const now = Date.now();
|
|
1030
|
-
const
|
|
1219
|
+
const latestPageVisibleTimestamp = [input.latestPageDataTimestamp, input.latestInlineDataTimestamp, input.domVisibleTimestamp]
|
|
1031
1220
|
.filter((value): value is number => typeof value === 'number')
|
|
1032
1221
|
.sort((left, right) => right - left)[0] ?? null;
|
|
1033
|
-
if (
|
|
1222
|
+
if (latestPageVisibleTimestamp !== null && now - latestPageVisibleTimestamp <= input.freshWindowMs) {
|
|
1034
1223
|
return 'fresh';
|
|
1035
1224
|
}
|
|
1225
|
+
const networkHasFreshData =
|
|
1226
|
+
typeof input.latestNetworkDataTimestamp === 'number' && now - input.latestNetworkDataTimestamp <= input.freshWindowMs;
|
|
1227
|
+
if (networkHasFreshData) {
|
|
1228
|
+
return 'lagged';
|
|
1229
|
+
}
|
|
1036
1230
|
const recentSignals = [input.latestNetworkTimestamp, input.lastMutationAt]
|
|
1037
1231
|
.filter((value): value is number => typeof value === 'number')
|
|
1038
1232
|
.some((value) => now - value <= input.freshWindowMs);
|
|
1039
|
-
if (recentSignals &&
|
|
1233
|
+
if (recentSignals && latestPageVisibleTimestamp !== null && now - latestPageVisibleTimestamp > input.freshWindowMs) {
|
|
1040
1234
|
return 'lagged';
|
|
1041
1235
|
}
|
|
1042
|
-
const staleSignals = [
|
|
1236
|
+
const staleSignals = [
|
|
1237
|
+
input.latestNetworkTimestamp,
|
|
1238
|
+
input.lastMutationAt,
|
|
1239
|
+
latestPageVisibleTimestamp,
|
|
1240
|
+
input.latestNetworkDataTimestamp
|
|
1241
|
+
]
|
|
1043
1242
|
.filter((value): value is number => typeof value === 'number');
|
|
1044
1243
|
if (staleSignals.length > 0 && staleSignals.every((value) => now - value > input.staleWindowMs)) {
|
|
1045
1244
|
return 'stale';
|
|
@@ -1051,25 +1250,192 @@ async function collectPageInspection(tabId: number, params: Record<string, unkno
|
|
|
1051
1250
|
return (await forwardContentRpc(tabId, 'bak.internal.inspectState', params)) as Record<string, unknown>;
|
|
1052
1251
|
}
|
|
1053
1252
|
|
|
1253
|
+
async function probePageDataCandidatesForTab(tabId: number, inspection: Record<string, unknown>): Promise<PageDataCandidateProbe[]> {
|
|
1254
|
+
const candidateNames = [
|
|
1255
|
+
...(Array.isArray(inspection.suspiciousGlobals) ? inspection.suspiciousGlobals.map(String) : []),
|
|
1256
|
+
...(Array.isArray(inspection.globalsPreview) ? inspection.globalsPreview.map(String) : [])
|
|
1257
|
+
]
|
|
1258
|
+
.filter((name, index, array) => /^[A-Za-z_$][\w$]*$/.test(name) && array.indexOf(name) === index)
|
|
1259
|
+
.slice(0, 16);
|
|
1260
|
+
if (candidateNames.length === 0) {
|
|
1261
|
+
return [];
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
const expr = `(() => {
|
|
1265
|
+
const candidates = ${JSON.stringify(candidateNames)};
|
|
1266
|
+
const dataPattern = /\\b(updated|update|updatedat|asof|timestamp|generated|generatedat|refresh|latest|last|quote|trade|price|flow|market|time|snapshot|signal)\\b/i;
|
|
1267
|
+
const contractPattern = /\\b(expiry|expiration|expires|option|contract|strike|maturity|dte|call|put|exercise)\\b/i;
|
|
1268
|
+
const eventPattern = /\\b(earnings|event|report|dividend|split|meeting|fomc|release|filing)\\b/i;
|
|
1269
|
+
const isTimestampString = (value) => typeof value === 'string' && value.trim().length > 0 && !Number.isNaN(Date.parse(value.trim()));
|
|
1270
|
+
const classify = (path, value) => {
|
|
1271
|
+
const normalized = String(path || '').toLowerCase();
|
|
1272
|
+
if (dataPattern.test(normalized)) return 'data';
|
|
1273
|
+
if (contractPattern.test(normalized)) return 'contract';
|
|
1274
|
+
if (eventPattern.test(normalized)) return 'event';
|
|
1275
|
+
const parsed = Date.parse(String(value || '').trim());
|
|
1276
|
+
return Number.isFinite(parsed) && parsed > Date.now() + 36 * 60 * 60 * 1000 ? 'contract' : 'unknown';
|
|
1277
|
+
};
|
|
1278
|
+
const sampleValue = (value, depth = 0) => {
|
|
1279
|
+
if (depth >= 2 || value == null || typeof value !== 'object') {
|
|
1280
|
+
if (typeof value === 'string') {
|
|
1281
|
+
return value.length > 160 ? value.slice(0, 160) : value;
|
|
1282
|
+
}
|
|
1283
|
+
if (typeof value === 'function') {
|
|
1284
|
+
return '[Function]';
|
|
1285
|
+
}
|
|
1286
|
+
return value;
|
|
1287
|
+
}
|
|
1288
|
+
if (Array.isArray(value)) {
|
|
1289
|
+
return value.slice(0, 3).map((item) => sampleValue(item, depth + 1));
|
|
1290
|
+
}
|
|
1291
|
+
const sampled = {};
|
|
1292
|
+
for (const key of Object.keys(value).slice(0, 8)) {
|
|
1293
|
+
try {
|
|
1294
|
+
sampled[key] = sampleValue(value[key], depth + 1);
|
|
1295
|
+
} catch {
|
|
1296
|
+
sampled[key] = '[Unreadable]';
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
return sampled;
|
|
1300
|
+
};
|
|
1301
|
+
const collectTimestamps = (value, path, depth, collected) => {
|
|
1302
|
+
if (collected.length >= 16) return;
|
|
1303
|
+
if (isTimestampString(value)) {
|
|
1304
|
+
collected.push({ path, value: String(value), category: classify(path, value) });
|
|
1305
|
+
return;
|
|
1306
|
+
}
|
|
1307
|
+
if (depth >= 3) return;
|
|
1308
|
+
if (Array.isArray(value)) {
|
|
1309
|
+
value.slice(0, 3).forEach((item, index) => collectTimestamps(item, path + '[' + index + ']', depth + 1, collected));
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
if (value && typeof value === 'object') {
|
|
1313
|
+
Object.keys(value)
|
|
1314
|
+
.slice(0, 8)
|
|
1315
|
+
.forEach((key) => {
|
|
1316
|
+
try {
|
|
1317
|
+
collectTimestamps(value[key], path ? path + '.' + key : key, depth + 1, collected);
|
|
1318
|
+
} catch {
|
|
1319
|
+
// Ignore unreadable nested properties.
|
|
1320
|
+
}
|
|
1321
|
+
});
|
|
1322
|
+
}
|
|
1323
|
+
};
|
|
1324
|
+
const readCandidate = (name) => {
|
|
1325
|
+
if (name in globalThis) {
|
|
1326
|
+
return { resolver: 'globalThis', value: globalThis[name] };
|
|
1327
|
+
}
|
|
1328
|
+
return { resolver: 'lexical', value: globalThis.eval(name) };
|
|
1329
|
+
};
|
|
1330
|
+
const results = [];
|
|
1331
|
+
for (const name of candidates) {
|
|
1332
|
+
try {
|
|
1333
|
+
const resolved = readCandidate(name);
|
|
1334
|
+
const timestamps = [];
|
|
1335
|
+
collectTimestamps(resolved.value, name, 0, timestamps);
|
|
1336
|
+
results.push({
|
|
1337
|
+
name,
|
|
1338
|
+
resolver: resolved.resolver,
|
|
1339
|
+
sample: sampleValue(resolved.value),
|
|
1340
|
+
timestamps
|
|
1341
|
+
});
|
|
1342
|
+
} catch {
|
|
1343
|
+
// Ignore inaccessible candidates.
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
return results;
|
|
1347
|
+
})()`;
|
|
1348
|
+
|
|
1349
|
+
try {
|
|
1350
|
+
const evaluated = await executePageWorld<PageDataCandidateProbe[]>(tabId, 'eval', {
|
|
1351
|
+
expr,
|
|
1352
|
+
scope: 'current',
|
|
1353
|
+
maxBytes: 64 * 1024
|
|
1354
|
+
});
|
|
1355
|
+
const frameResult = evaluated.result ?? evaluated.results?.find((candidate) => candidate.value || candidate.error);
|
|
1356
|
+
return Array.isArray(frameResult?.value) ? frameResult.value : [];
|
|
1357
|
+
} catch {
|
|
1358
|
+
return [];
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1054
1362
|
async function buildFreshnessForTab(tabId: number, params: Record<string, unknown> = {}): Promise<PageFreshnessResult> {
|
|
1055
1363
|
const inspection = await collectPageInspection(tabId, params);
|
|
1056
|
-
const
|
|
1057
|
-
const inlineTimestamps = Array.isArray(inspection.inlineTimestamps) ? inspection.inlineTimestamps.map(String) : [];
|
|
1364
|
+
const probedPageDataCandidates = await probePageDataCandidatesForTab(tabId, inspection);
|
|
1058
1365
|
const now = Date.now();
|
|
1059
1366
|
const freshWindowMs = typeof params.freshWindowMs === 'number' ? Math.max(1, Math.floor(params.freshWindowMs)) : 15 * 60 * 1000;
|
|
1060
1367
|
const staleWindowMs = typeof params.staleWindowMs === 'number' ? Math.max(freshWindowMs, Math.floor(params.staleWindowMs)) : 24 * 60 * 60 * 1000;
|
|
1061
|
-
const
|
|
1062
|
-
|
|
1368
|
+
const visibleCandidates = normalizeTimestampCandidates(
|
|
1369
|
+
Array.isArray(inspection.visibleTimestampCandidates)
|
|
1370
|
+
? inspection.visibleTimestampCandidates
|
|
1371
|
+
.filter((candidate): candidate is Record<string, unknown> => typeof candidate === 'object' && candidate !== null)
|
|
1372
|
+
.map((candidate) => ({
|
|
1373
|
+
value: String(candidate.value ?? ''),
|
|
1374
|
+
context: typeof candidate.context === 'string' ? candidate.context : undefined,
|
|
1375
|
+
source: 'visible' as const
|
|
1376
|
+
}))
|
|
1377
|
+
: Array.isArray(inspection.visibleTimestamps)
|
|
1378
|
+
? inspection.visibleTimestamps.map((value) => ({ value: String(value), source: 'visible' as const }))
|
|
1379
|
+
: [],
|
|
1380
|
+
now
|
|
1381
|
+
);
|
|
1382
|
+
const inlineCandidates = normalizeTimestampCandidates(
|
|
1383
|
+
Array.isArray(inspection.inlineTimestampCandidates)
|
|
1384
|
+
? inspection.inlineTimestampCandidates
|
|
1385
|
+
.filter((candidate): candidate is Record<string, unknown> => typeof candidate === 'object' && candidate !== null)
|
|
1386
|
+
.map((candidate) => ({
|
|
1387
|
+
value: String(candidate.value ?? ''),
|
|
1388
|
+
context: typeof candidate.context === 'string' ? candidate.context : undefined,
|
|
1389
|
+
source: 'inline' as const
|
|
1390
|
+
}))
|
|
1391
|
+
: Array.isArray(inspection.inlineTimestamps)
|
|
1392
|
+
? inspection.inlineTimestamps.map((value) => ({ value: String(value), source: 'inline' as const }))
|
|
1393
|
+
: [],
|
|
1394
|
+
now
|
|
1395
|
+
);
|
|
1396
|
+
const pageDataCandidates = probedPageDataCandidates.flatMap((candidate) =>
|
|
1397
|
+
Array.isArray(candidate.timestamps)
|
|
1398
|
+
? candidate.timestamps.map((timestamp) => ({
|
|
1399
|
+
value: String(timestamp.value ?? ''),
|
|
1400
|
+
source: 'page-data' as const,
|
|
1401
|
+
path: typeof timestamp.path === 'string' ? timestamp.path : candidate.name,
|
|
1402
|
+
category:
|
|
1403
|
+
timestamp.category === 'data' ||
|
|
1404
|
+
timestamp.category === 'contract' ||
|
|
1405
|
+
timestamp.category === 'event' ||
|
|
1406
|
+
timestamp.category === 'unknown'
|
|
1407
|
+
? timestamp.category
|
|
1408
|
+
: 'unknown'
|
|
1409
|
+
}))
|
|
1410
|
+
: []
|
|
1411
|
+
);
|
|
1412
|
+
const networkEntries = listNetworkEntries(tabId, { limit: 25 });
|
|
1413
|
+
const networkCandidates = normalizeTimestampCandidates(
|
|
1414
|
+
networkEntries.flatMap((entry) => {
|
|
1415
|
+
const previews = [entry.responseBodyPreview, entry.requestBodyPreview].filter((value): value is string => typeof value === 'string');
|
|
1416
|
+
return previews.flatMap((preview) => collectTimestampMatchesFromText(preview, 'network', Array.isArray(params.patterns) ? params.patterns.map(String) : undefined));
|
|
1417
|
+
}),
|
|
1418
|
+
now
|
|
1419
|
+
);
|
|
1420
|
+
const latestInlineDataTimestamp = latestTimestampFromCandidates(inlineCandidates, now);
|
|
1421
|
+
const latestPageDataTimestamp = latestTimestampFromCandidates(pageDataCandidates, now);
|
|
1422
|
+
const latestNetworkDataTimestamp = latestTimestampFromCandidates(networkCandidates, now);
|
|
1423
|
+
const domVisibleTimestamp = latestTimestampFromCandidates(visibleCandidates, now);
|
|
1063
1424
|
const latestNetworkTs = latestNetworkTimestamp(tabId);
|
|
1064
1425
|
const lastMutationAt = typeof inspection.lastMutationAt === 'number' ? inspection.lastMutationAt : null;
|
|
1426
|
+
const allCandidates = [...visibleCandidates, ...inlineCandidates, ...pageDataCandidates, ...networkCandidates];
|
|
1065
1427
|
return {
|
|
1066
1428
|
pageLoadedAt: typeof inspection.pageLoadedAt === 'number' ? inspection.pageLoadedAt : null,
|
|
1067
1429
|
lastMutationAt,
|
|
1068
1430
|
latestNetworkTimestamp: latestNetworkTs,
|
|
1069
1431
|
latestInlineDataTimestamp,
|
|
1432
|
+
latestPageDataTimestamp,
|
|
1433
|
+
latestNetworkDataTimestamp,
|
|
1070
1434
|
domVisibleTimestamp,
|
|
1071
1435
|
assessment: computeFreshnessAssessment({
|
|
1072
1436
|
latestInlineDataTimestamp,
|
|
1437
|
+
latestPageDataTimestamp,
|
|
1438
|
+
latestNetworkDataTimestamp,
|
|
1073
1439
|
latestNetworkTimestamp: latestNetworkTs,
|
|
1074
1440
|
domVisibleTimestamp,
|
|
1075
1441
|
lastMutationAt,
|
|
@@ -1077,19 +1443,142 @@ async function buildFreshnessForTab(tabId: number, params: Record<string, unknow
|
|
|
1077
1443
|
staleWindowMs
|
|
1078
1444
|
}),
|
|
1079
1445
|
evidence: {
|
|
1080
|
-
visibleTimestamps,
|
|
1081
|
-
inlineTimestamps,
|
|
1446
|
+
visibleTimestamps: visibleCandidates.map((candidate) => candidate.value),
|
|
1447
|
+
inlineTimestamps: inlineCandidates.map((candidate) => candidate.value),
|
|
1448
|
+
pageDataTimestamps: pageDataCandidates.map((candidate) => candidate.value),
|
|
1449
|
+
networkDataTimestamps: networkCandidates.map((candidate) => candidate.value),
|
|
1450
|
+
classifiedTimestamps: allCandidates,
|
|
1082
1451
|
networkSampleIds: recentNetworkSampleIds(tabId)
|
|
1083
1452
|
}
|
|
1084
1453
|
};
|
|
1085
1454
|
}
|
|
1455
|
+
|
|
1456
|
+
function summarizeNetworkCadence(entries: NetworkEntry[]): Record<string, unknown> {
|
|
1457
|
+
const relevant = entries
|
|
1458
|
+
.filter((entry) => entry.kind === 'fetch' || entry.kind === 'xhr')
|
|
1459
|
+
.slice()
|
|
1460
|
+
.sort((left, right) => left.ts - right.ts);
|
|
1461
|
+
if (relevant.length === 0) {
|
|
1462
|
+
return {
|
|
1463
|
+
sampleCount: 0,
|
|
1464
|
+
classification: 'none',
|
|
1465
|
+
averageIntervalMs: null,
|
|
1466
|
+
medianIntervalMs: null,
|
|
1467
|
+
latestGapMs: null,
|
|
1468
|
+
endpoints: []
|
|
1469
|
+
};
|
|
1470
|
+
}
|
|
1471
|
+
const intervals: number[] = [];
|
|
1472
|
+
for (let index = 1; index < relevant.length; index += 1) {
|
|
1473
|
+
intervals.push(Math.max(0, relevant[index]!.ts - relevant[index - 1]!.ts));
|
|
1474
|
+
}
|
|
1475
|
+
const sortedIntervals = intervals.slice().sort((left, right) => left - right);
|
|
1476
|
+
const averageIntervalMs =
|
|
1477
|
+
intervals.length > 0 ? Math.round(intervals.reduce((sum, value) => sum + value, 0) / intervals.length) : null;
|
|
1478
|
+
const medianIntervalMs =
|
|
1479
|
+
sortedIntervals.length > 0 ? sortedIntervals[Math.floor(sortedIntervals.length / 2)] ?? null : null;
|
|
1480
|
+
const latestGapMs = Math.max(0, Date.now() - relevant[relevant.length - 1]!.ts);
|
|
1481
|
+
const classification =
|
|
1482
|
+
relevant.length >= 3 && medianIntervalMs !== null && medianIntervalMs <= 30_000
|
|
1483
|
+
? 'polling'
|
|
1484
|
+
: relevant.length >= 2
|
|
1485
|
+
? 'bursty'
|
|
1486
|
+
: 'single-request';
|
|
1487
|
+
return {
|
|
1488
|
+
sampleCount: relevant.length,
|
|
1489
|
+
classification,
|
|
1490
|
+
averageIntervalMs,
|
|
1491
|
+
medianIntervalMs,
|
|
1492
|
+
latestGapMs,
|
|
1493
|
+
endpoints: [...new Set(relevant.slice(-5).map((entry) => entry.url))].slice(0, 5)
|
|
1494
|
+
};
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
function extractReplayRowsCandidate(json: unknown): { rows: unknown[]; source: string } | null {
|
|
1498
|
+
if (Array.isArray(json)) {
|
|
1499
|
+
return { rows: json, source: '$' };
|
|
1500
|
+
}
|
|
1501
|
+
if (typeof json !== 'object' || json === null) {
|
|
1502
|
+
return null;
|
|
1503
|
+
}
|
|
1504
|
+
const record = json as Record<string, unknown>;
|
|
1505
|
+
const preferredKeys = ['data', 'rows', 'results', 'items'];
|
|
1506
|
+
for (const key of preferredKeys) {
|
|
1507
|
+
if (Array.isArray(record[key])) {
|
|
1508
|
+
return { rows: record[key] as unknown[], source: `$.${key}` };
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
return null;
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
async function enrichReplayWithSchema(tabId: number, response: PageFetchResponse): Promise<PageFetchResponse> {
|
|
1515
|
+
const candidate = extractReplayRowsCandidate(response.json);
|
|
1516
|
+
if (!candidate || candidate.rows.length === 0) {
|
|
1517
|
+
return response;
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
const firstRow = candidate.rows[0];
|
|
1521
|
+
const tablesResult = (await forwardContentRpc(tabId, 'table.list', {})) as { tables?: TableHandle[] };
|
|
1522
|
+
const tables = Array.isArray(tablesResult.tables) ? tablesResult.tables : [];
|
|
1523
|
+
if (tables.length === 0) {
|
|
1524
|
+
return response;
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
const schemas: Array<{ table: TableHandle; schema: TableSchema }> = [];
|
|
1528
|
+
for (const table of tables) {
|
|
1529
|
+
const schemaResult = (await forwardContentRpc(tabId, 'table.schema', { table: table.id })) as {
|
|
1530
|
+
table?: TableHandle;
|
|
1531
|
+
schema?: TableSchema;
|
|
1532
|
+
};
|
|
1533
|
+
if (schemaResult.schema && Array.isArray(schemaResult.schema.columns)) {
|
|
1534
|
+
schemas.push({ table: schemaResult.table ?? table, schema: schemaResult.schema });
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
if (schemas.length === 0) {
|
|
1539
|
+
return response;
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
if (Array.isArray(firstRow)) {
|
|
1543
|
+
const matchingSchema = schemas.find(({ schema }) => schema.columns.length === firstRow.length) ?? schemas[0];
|
|
1544
|
+
if (!matchingSchema) {
|
|
1545
|
+
return response;
|
|
1546
|
+
}
|
|
1547
|
+
const mappedRows = candidate.rows
|
|
1548
|
+
.filter((row): row is unknown[] => Array.isArray(row))
|
|
1549
|
+
.map((row) => {
|
|
1550
|
+
const mapped: Record<string, unknown> = {};
|
|
1551
|
+
matchingSchema.schema.columns.forEach((column, index) => {
|
|
1552
|
+
mapped[column.label] = row[index];
|
|
1553
|
+
});
|
|
1554
|
+
return mapped;
|
|
1555
|
+
});
|
|
1556
|
+
return {
|
|
1557
|
+
...response,
|
|
1558
|
+
table: matchingSchema.table,
|
|
1559
|
+
schema: matchingSchema.schema,
|
|
1560
|
+
mappedRows,
|
|
1561
|
+
mappingSource: candidate.source
|
|
1562
|
+
};
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
if (typeof firstRow === 'object' && firstRow !== null) {
|
|
1566
|
+
return {
|
|
1567
|
+
...response,
|
|
1568
|
+
mappedRows: candidate.rows.filter((row): row is Record<string, unknown> => typeof row === 'object' && row !== null),
|
|
1569
|
+
mappingSource: candidate.source
|
|
1570
|
+
};
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
return response;
|
|
1574
|
+
}
|
|
1086
1575
|
|
|
1087
1576
|
async function handleRequest(request: CliRequest): Promise<unknown> {
|
|
1088
1577
|
const params = request.params ?? {};
|
|
1089
|
-
const target = {
|
|
1090
|
-
tabId: typeof params.tabId === 'number' ? params.tabId : undefined,
|
|
1091
|
-
|
|
1092
|
-
};
|
|
1578
|
+
const target = {
|
|
1579
|
+
tabId: typeof params.tabId === 'number' ? params.tabId : undefined,
|
|
1580
|
+
bindingId: typeof params.bindingId === 'string' ? params.bindingId : undefined
|
|
1581
|
+
};
|
|
1093
1582
|
|
|
1094
1583
|
const rpcForwardMethods = new Set([
|
|
1095
1584
|
'page.title',
|
|
@@ -1191,64 +1680,93 @@ async function handleRequest(request: CliRequest): Promise<unknown> {
|
|
|
1191
1680
|
await chrome.tabs.remove(tabId);
|
|
1192
1681
|
return { ok: true };
|
|
1193
1682
|
}
|
|
1194
|
-
case '
|
|
1683
|
+
case 'sessionBinding.ensure': {
|
|
1195
1684
|
return preserveHumanFocus(params.focus !== true, async () => {
|
|
1196
|
-
const result = await bindingManager.
|
|
1197
|
-
|
|
1685
|
+
const result = await bindingManager.ensureBinding({
|
|
1686
|
+
bindingId: String(params.bindingId ?? ''),
|
|
1198
1687
|
focus: params.focus === true,
|
|
1199
1688
|
initialUrl: typeof params.url === 'string' ? params.url : undefined
|
|
1200
1689
|
});
|
|
1201
|
-
for (const tab of result.
|
|
1690
|
+
for (const tab of result.binding.tabs) {
|
|
1202
1691
|
void ensureNetworkDebugger(tab.id).catch(() => undefined);
|
|
1203
1692
|
}
|
|
1204
|
-
return
|
|
1693
|
+
return {
|
|
1694
|
+
browser: result.binding,
|
|
1695
|
+
created: result.created,
|
|
1696
|
+
repaired: result.repaired,
|
|
1697
|
+
repairActions: result.repairActions
|
|
1698
|
+
};
|
|
1205
1699
|
});
|
|
1206
1700
|
}
|
|
1207
|
-
case '
|
|
1208
|
-
return {
|
|
1209
|
-
|
|
1210
|
-
};
|
|
1211
|
-
}
|
|
1212
|
-
case '
|
|
1701
|
+
case 'sessionBinding.info': {
|
|
1702
|
+
return {
|
|
1703
|
+
browser: await bindingManager.getBindingInfo(String(params.bindingId ?? ''))
|
|
1704
|
+
};
|
|
1705
|
+
}
|
|
1706
|
+
case 'sessionBinding.openTab': {
|
|
1213
1707
|
const expectedUrl = typeof params.url === 'string' ? params.url : undefined;
|
|
1214
1708
|
const opened = await preserveHumanFocus(params.focus !== true, async () => {
|
|
1215
1709
|
return await bindingManager.openTab({
|
|
1216
|
-
|
|
1217
|
-
url: expectedUrl,
|
|
1218
|
-
active: params.active === true,
|
|
1710
|
+
bindingId: String(params.bindingId ?? ''),
|
|
1711
|
+
url: expectedUrl,
|
|
1712
|
+
active: params.active === true,
|
|
1219
1713
|
focus: params.focus === true
|
|
1220
1714
|
});
|
|
1221
1715
|
});
|
|
1222
|
-
const finalized = await
|
|
1716
|
+
const finalized = await finalizeOpenedSessionBindingTab(opened, expectedUrl);
|
|
1223
1717
|
void ensureNetworkDebugger(finalized.tab.id).catch(() => undefined);
|
|
1224
|
-
return
|
|
1718
|
+
return {
|
|
1719
|
+
browser: finalized.binding,
|
|
1720
|
+
tab: finalized.tab
|
|
1721
|
+
};
|
|
1225
1722
|
}
|
|
1226
|
-
case '
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1723
|
+
case 'sessionBinding.listTabs': {
|
|
1724
|
+
const listed = await bindingManager.listTabs(String(params.bindingId ?? ''));
|
|
1725
|
+
return {
|
|
1726
|
+
browser: listed.binding,
|
|
1727
|
+
tabs: listed.tabs
|
|
1728
|
+
};
|
|
1729
|
+
}
|
|
1730
|
+
case 'sessionBinding.getActiveTab': {
|
|
1731
|
+
const active = await bindingManager.getActiveTab(String(params.bindingId ?? ''));
|
|
1732
|
+
return {
|
|
1733
|
+
browser: active.binding,
|
|
1734
|
+
tab: active.tab
|
|
1735
|
+
};
|
|
1736
|
+
}
|
|
1737
|
+
case 'sessionBinding.setActiveTab': {
|
|
1738
|
+
const result = await bindingManager.setActiveTab(Number(params.tabId), String(params.bindingId ?? ''));
|
|
1234
1739
|
void ensureNetworkDebugger(result.tab.id).catch(() => undefined);
|
|
1235
|
-
return
|
|
1740
|
+
return {
|
|
1741
|
+
browser: result.binding,
|
|
1742
|
+
tab: result.tab
|
|
1743
|
+
};
|
|
1744
|
+
}
|
|
1745
|
+
case 'sessionBinding.focus': {
|
|
1746
|
+
const result = await bindingManager.focus(String(params.bindingId ?? ''));
|
|
1747
|
+
return {
|
|
1748
|
+
ok: true,
|
|
1749
|
+
browser: result.binding
|
|
1750
|
+
};
|
|
1751
|
+
}
|
|
1752
|
+
case 'sessionBinding.reset': {
|
|
1753
|
+
return await preserveHumanFocus(params.focus !== true, async () => {
|
|
1754
|
+
const result = await bindingManager.reset({
|
|
1755
|
+
bindingId: String(params.bindingId ?? ''),
|
|
1756
|
+
focus: params.focus === true,
|
|
1757
|
+
initialUrl: typeof params.url === 'string' ? params.url : undefined
|
|
1758
|
+
});
|
|
1759
|
+
return {
|
|
1760
|
+
browser: result.binding,
|
|
1761
|
+
created: result.created,
|
|
1762
|
+
repaired: result.repaired,
|
|
1763
|
+
repairActions: result.repairActions
|
|
1764
|
+
};
|
|
1765
|
+
});
|
|
1766
|
+
}
|
|
1767
|
+
case 'sessionBinding.close': {
|
|
1768
|
+
return await bindingManager.close(String(params.bindingId ?? ''));
|
|
1236
1769
|
}
|
|
1237
|
-
case 'workspace.focus': {
|
|
1238
|
-
return await bindingManager.focus(String(params.workspaceId ?? ''));
|
|
1239
|
-
}
|
|
1240
|
-
case 'workspace.reset': {
|
|
1241
|
-
return await preserveHumanFocus(params.focus !== true, async () => {
|
|
1242
|
-
return await bindingManager.reset({
|
|
1243
|
-
workspaceId: String(params.workspaceId ?? ''),
|
|
1244
|
-
focus: params.focus === true,
|
|
1245
|
-
initialUrl: typeof params.url === 'string' ? params.url : undefined
|
|
1246
|
-
});
|
|
1247
|
-
});
|
|
1248
|
-
}
|
|
1249
|
-
case 'workspace.close': {
|
|
1250
|
-
return await bindingManager.close(String(params.workspaceId ?? ''));
|
|
1251
|
-
}
|
|
1252
1770
|
case 'page.goto': {
|
|
1253
1771
|
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
1254
1772
|
const tab = await withTab(target, {
|
|
@@ -1556,7 +2074,7 @@ async function handleRequest(request: CliRequest): Promise<unknown> {
|
|
|
1556
2074
|
if (!first) {
|
|
1557
2075
|
throw toError('E_EXECUTION', 'network replay returned no response payload');
|
|
1558
2076
|
}
|
|
1559
|
-
return first;
|
|
2077
|
+
return params.withSchema === 'auto' && params.mode === 'json' ? await enrichReplayWithSchema(tab.id!, first) : first;
|
|
1560
2078
|
});
|
|
1561
2079
|
}
|
|
1562
2080
|
case 'page.freshness': {
|
|
@@ -1632,15 +2150,17 @@ async function handleRequest(request: CliRequest): Promise<unknown> {
|
|
|
1632
2150
|
const tab = await withTab(target);
|
|
1633
2151
|
await ensureNetworkDebugger(tab.id!).catch(() => undefined);
|
|
1634
2152
|
const inspection = await collectPageInspection(tab.id!, params);
|
|
2153
|
+
const pageDataCandidates = await probePageDataCandidatesForTab(tab.id!, inspection);
|
|
1635
2154
|
const network = listNetworkEntries(tab.id!, { limit: 10 });
|
|
1636
2155
|
return {
|
|
1637
2156
|
suspiciousGlobals: inspection.suspiciousGlobals ?? [],
|
|
1638
2157
|
tables: inspection.tables ?? [],
|
|
1639
2158
|
visibleTimestamps: inspection.visibleTimestamps ?? [],
|
|
1640
2159
|
inlineTimestamps: inspection.inlineTimestamps ?? [],
|
|
2160
|
+
pageDataCandidates,
|
|
1641
2161
|
recentNetwork: network,
|
|
1642
2162
|
recommendedNextSteps: [
|
|
1643
|
-
'bak page extract --path table_data',
|
|
2163
|
+
'bak page extract --path table_data --resolver auto',
|
|
1644
2164
|
'bak network search --pattern table_data',
|
|
1645
2165
|
'bak page freshness'
|
|
1646
2166
|
]
|
|
@@ -1657,6 +2177,7 @@ async function handleRequest(request: CliRequest): Promise<unknown> {
|
|
|
1657
2177
|
lastMutationAt: inspection.lastMutationAt ?? null,
|
|
1658
2178
|
timers: inspection.timers ?? { timeouts: 0, intervals: 0 },
|
|
1659
2179
|
networkCount: network.length,
|
|
2180
|
+
networkCadence: summarizeNetworkCadence(network),
|
|
1660
2181
|
recentNetwork: network.slice(0, 10)
|
|
1661
2182
|
};
|
|
1662
2183
|
});
|
|
@@ -1668,8 +2189,13 @@ async function handleRequest(request: CliRequest): Promise<unknown> {
|
|
|
1668
2189
|
return {
|
|
1669
2190
|
...freshness,
|
|
1670
2191
|
lagMs:
|
|
1671
|
-
typeof freshness.latestNetworkTimestamp === 'number' &&
|
|
1672
|
-
|
|
2192
|
+
typeof freshness.latestNetworkTimestamp === 'number' &&
|
|
2193
|
+
typeof (freshness.latestPageDataTimestamp ?? freshness.latestInlineDataTimestamp) === 'number'
|
|
2194
|
+
? Math.max(
|
|
2195
|
+
0,
|
|
2196
|
+
freshness.latestNetworkTimestamp -
|
|
2197
|
+
(freshness.latestPageDataTimestamp ?? freshness.latestInlineDataTimestamp ?? freshness.latestNetworkTimestamp)
|
|
2198
|
+
)
|
|
1673
2199
|
: null
|
|
1674
2200
|
};
|
|
1675
2201
|
});
|
|
@@ -1782,7 +2308,7 @@ async function connectWebSocket(): Promise<void> {
|
|
|
1782
2308
|
ws?.send(JSON.stringify({
|
|
1783
2309
|
type: 'hello',
|
|
1784
2310
|
role: 'extension',
|
|
1785
|
-
version: '0.6.
|
|
2311
|
+
version: '0.6.1',
|
|
1786
2312
|
ts: Date.now()
|
|
1787
2313
|
}));
|
|
1788
2314
|
});
|
|
@@ -1824,53 +2350,53 @@ async function connectWebSocket(): Promise<void> {
|
|
|
1824
2350
|
|
|
1825
2351
|
chrome.tabs.onRemoved.addListener((tabId) => {
|
|
1826
2352
|
dropNetworkCapture(tabId);
|
|
1827
|
-
void
|
|
1828
|
-
for (const state of
|
|
2353
|
+
void mutateSessionBindingStateMap((stateMap) => {
|
|
2354
|
+
for (const [bindingId, state] of Object.entries(stateMap)) {
|
|
1829
2355
|
if (!state.tabIds.includes(tabId)) {
|
|
1830
2356
|
continue;
|
|
1831
2357
|
}
|
|
1832
|
-
const nextTabIds = state.tabIds.filter((id) => id !== tabId);
|
|
1833
|
-
|
|
1834
|
-
...state,
|
|
1835
|
-
tabIds: nextTabIds,
|
|
1836
|
-
activeTabId: state.activeTabId === tabId ? null : state.activeTabId,
|
|
1837
|
-
primaryTabId: state.primaryTabId === tabId ? null : state.primaryTabId
|
|
1838
|
-
}
|
|
1839
|
-
}
|
|
1840
|
-
});
|
|
1841
|
-
});
|
|
1842
|
-
|
|
1843
|
-
chrome.tabs.onActivated.addListener((activeInfo) => {
|
|
1844
|
-
void
|
|
1845
|
-
for (const state of
|
|
1846
|
-
if (state.windowId !== activeInfo.windowId || !state.tabIds.includes(activeInfo.tabId)) {
|
|
1847
|
-
continue;
|
|
1848
|
-
}
|
|
1849
|
-
|
|
1850
|
-
...state,
|
|
1851
|
-
activeTabId: activeInfo.tabId
|
|
1852
|
-
}
|
|
1853
|
-
}
|
|
1854
|
-
});
|
|
1855
|
-
});
|
|
1856
|
-
|
|
1857
|
-
chrome.windows.onRemoved.addListener((windowId) => {
|
|
1858
|
-
void
|
|
1859
|
-
for (const state of
|
|
1860
|
-
if (state.windowId !== windowId) {
|
|
1861
|
-
continue;
|
|
1862
|
-
}
|
|
1863
|
-
|
|
1864
|
-
...state,
|
|
1865
|
-
windowId: null,
|
|
1866
|
-
groupId: null,
|
|
1867
|
-
tabIds: [],
|
|
1868
|
-
activeTabId: null,
|
|
1869
|
-
primaryTabId: null
|
|
1870
|
-
}
|
|
1871
|
-
}
|
|
1872
|
-
});
|
|
1873
|
-
});
|
|
2358
|
+
const nextTabIds = state.tabIds.filter((id) => id !== tabId);
|
|
2359
|
+
stateMap[bindingId] = {
|
|
2360
|
+
...state,
|
|
2361
|
+
tabIds: nextTabIds,
|
|
2362
|
+
activeTabId: state.activeTabId === tabId ? null : state.activeTabId,
|
|
2363
|
+
primaryTabId: state.primaryTabId === tabId ? null : state.primaryTabId
|
|
2364
|
+
};
|
|
2365
|
+
}
|
|
2366
|
+
});
|
|
2367
|
+
});
|
|
2368
|
+
|
|
2369
|
+
chrome.tabs.onActivated.addListener((activeInfo) => {
|
|
2370
|
+
void mutateSessionBindingStateMap((stateMap) => {
|
|
2371
|
+
for (const [bindingId, state] of Object.entries(stateMap)) {
|
|
2372
|
+
if (state.windowId !== activeInfo.windowId || !state.tabIds.includes(activeInfo.tabId)) {
|
|
2373
|
+
continue;
|
|
2374
|
+
}
|
|
2375
|
+
stateMap[bindingId] = {
|
|
2376
|
+
...state,
|
|
2377
|
+
activeTabId: activeInfo.tabId
|
|
2378
|
+
};
|
|
2379
|
+
}
|
|
2380
|
+
});
|
|
2381
|
+
});
|
|
2382
|
+
|
|
2383
|
+
chrome.windows.onRemoved.addListener((windowId) => {
|
|
2384
|
+
void mutateSessionBindingStateMap((stateMap) => {
|
|
2385
|
+
for (const [bindingId, state] of Object.entries(stateMap)) {
|
|
2386
|
+
if (state.windowId !== windowId) {
|
|
2387
|
+
continue;
|
|
2388
|
+
}
|
|
2389
|
+
stateMap[bindingId] = {
|
|
2390
|
+
...state,
|
|
2391
|
+
windowId: null,
|
|
2392
|
+
groupId: null,
|
|
2393
|
+
tabIds: [],
|
|
2394
|
+
activeTabId: null,
|
|
2395
|
+
primaryTabId: null
|
|
2396
|
+
};
|
|
2397
|
+
}
|
|
2398
|
+
});
|
|
2399
|
+
});
|
|
1874
2400
|
|
|
1875
2401
|
chrome.runtime.onInstalled.addListener(() => {
|
|
1876
2402
|
void setConfig({ port: DEFAULT_PORT, debugRichText: false });
|