@wordbricks/playwright-mcp 0.1.25 → 0.1.26

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.
Files changed (82) hide show
  1. package/lib/browserContextFactory.js +399 -0
  2. package/lib/browserServerBackend.js +86 -0
  3. package/lib/config.js +300 -0
  4. package/lib/context.js +311 -0
  5. package/lib/extension/cdpRelay.js +352 -0
  6. package/lib/extension/extensionContextFactory.js +56 -0
  7. package/lib/frameworkPatterns.js +35 -0
  8. package/lib/hooks/antiBotDetectionHook.js +178 -0
  9. package/lib/hooks/core.js +145 -0
  10. package/lib/hooks/eventConsumer.js +52 -0
  11. package/lib/hooks/events.js +42 -0
  12. package/lib/hooks/formatToolCallEvent.js +12 -0
  13. package/lib/hooks/frameworkStateHook.js +182 -0
  14. package/lib/hooks/grouping.js +72 -0
  15. package/lib/hooks/jsonLdDetectionHook.js +182 -0
  16. package/lib/hooks/networkFilters.js +82 -0
  17. package/lib/hooks/networkSetup.js +61 -0
  18. package/lib/hooks/networkTrackingHook.js +67 -0
  19. package/lib/hooks/pageHeightHook.js +75 -0
  20. package/lib/hooks/registry.js +41 -0
  21. package/lib/hooks/requireTabHook.js +26 -0
  22. package/lib/hooks/schema.js +89 -0
  23. package/lib/hooks/waitHook.js +33 -0
  24. package/lib/index.js +41 -0
  25. package/lib/mcp/inProcessTransport.js +71 -0
  26. package/lib/mcp/proxyBackend.js +130 -0
  27. package/lib/mcp/server.js +91 -0
  28. package/lib/mcp/tool.js +44 -0
  29. package/lib/mcp/transport.js +188 -0
  30. package/lib/playwrightTransformer.js +520 -0
  31. package/lib/program.js +112 -0
  32. package/lib/response.js +192 -0
  33. package/lib/sessionLog.js +123 -0
  34. package/lib/tab.js +251 -0
  35. package/lib/tools/common.js +55 -0
  36. package/lib/tools/console.js +33 -0
  37. package/lib/tools/dialogs.js +50 -0
  38. package/lib/tools/evaluate.js +62 -0
  39. package/lib/tools/extractFrameworkState.js +225 -0
  40. package/lib/tools/files.js +48 -0
  41. package/lib/tools/form.js +66 -0
  42. package/lib/tools/getSnapshot.js +36 -0
  43. package/lib/tools/getVisibleHtml.js +68 -0
  44. package/lib/tools/install.js +51 -0
  45. package/lib/tools/keyboard.js +83 -0
  46. package/lib/tools/mouse.js +97 -0
  47. package/lib/tools/navigate.js +66 -0
  48. package/lib/tools/network.js +121 -0
  49. package/lib/tools/networkDetail.js +238 -0
  50. package/lib/tools/networkSearch/bodySearch.js +161 -0
  51. package/lib/tools/networkSearch/grouping.js +37 -0
  52. package/lib/tools/networkSearch/helpers.js +32 -0
  53. package/lib/tools/networkSearch/searchHtml.js +76 -0
  54. package/lib/tools/networkSearch/types.js +1 -0
  55. package/lib/tools/networkSearch/urlSearch.js +124 -0
  56. package/lib/tools/networkSearch.js +278 -0
  57. package/lib/tools/pdf.js +41 -0
  58. package/lib/tools/repl.js +414 -0
  59. package/lib/tools/screenshot.js +103 -0
  60. package/lib/tools/scroll.js +131 -0
  61. package/lib/tools/snapshot.js +161 -0
  62. package/lib/tools/tabs.js +62 -0
  63. package/lib/tools/tool.js +35 -0
  64. package/lib/tools/utils.js +78 -0
  65. package/lib/tools/wait.js +60 -0
  66. package/lib/tools.js +68 -0
  67. package/lib/utils/adBlockFilter.js +90 -0
  68. package/lib/utils/codegen.js +55 -0
  69. package/lib/utils/extensionPath.js +10 -0
  70. package/lib/utils/fileUtils.js +40 -0
  71. package/lib/utils/graphql.js +269 -0
  72. package/lib/utils/guid.js +22 -0
  73. package/lib/utils/httpServer.js +39 -0
  74. package/lib/utils/log.js +21 -0
  75. package/lib/utils/manualPromise.js +111 -0
  76. package/lib/utils/networkFormat.js +14 -0
  77. package/lib/utils/package.js +20 -0
  78. package/lib/utils/result.js +2 -0
  79. package/lib/utils/sanitizeHtml.js +130 -0
  80. package/lib/utils/truncate.js +103 -0
  81. package/lib/utils/withTimeout.js +7 -0
  82. package/package.json +11 -1
@@ -0,0 +1,145 @@
1
+ import { reduce } from "@fxts/core";
2
+ import { consumeEvents } from "./eventConsumer.js";
3
+ import { getEventStore, trackEvent } from "./events.js";
4
+ import { toolNameSchema } from "./schema.js";
5
+ export { Err, Ok } from "../utils/result.js";
6
+ export const runHook = async (hook, ctx) => {
7
+ const result = await hook.handler(ctx);
8
+ if (!result.ok)
9
+ throw result.error;
10
+ return ctx;
11
+ };
12
+ export const hookRegistryMap = new WeakMap();
13
+ export const createHookRegistry = () => ({
14
+ tools: new Map(),
15
+ });
16
+ export const setToolHooks = (registry, toolHooks) => ({
17
+ tools: new Map([...registry.tools, [toolHooks.toolName, toolHooks]]),
18
+ });
19
+ export const getToolHooks = (registry, toolName) => {
20
+ return registry.tools.get(toolName);
21
+ };
22
+ export const wrapToolWithHooks = (tool, registry) => {
23
+ const parsedName = toolNameSchema.safeParse(tool.schema.name);
24
+ if (!parsedName.success)
25
+ return tool; // Tool name not in our schema, don't apply hooks
26
+ // NOTE: This means tool call events won't be tracked for tools not in toolNameSchema.
27
+ // All tools exposed by this MCP server should be added to the schema.
28
+ const toolName = parsedName.data;
29
+ const toolHooks = getToolHooks(registry, toolName);
30
+ // Even if no hooks configured, we still need to consume events and track tool calls
31
+ if (!toolHooks ||
32
+ (toolHooks.preHooks.length === 0 && toolHooks.postHooks.length === 0)) {
33
+ return {
34
+ ...tool,
35
+ handle: async (context, params, response) => {
36
+ const eventStore = getEventStore(context);
37
+ // Consume pre-tool events
38
+ consumeEvents(context, eventStore, response);
39
+ // Track tool execution
40
+ const startTime = Date.now();
41
+ let success = true;
42
+ try {
43
+ await tool.handle(context, params, response);
44
+ }
45
+ catch (error) {
46
+ success = false;
47
+ throw error;
48
+ }
49
+ finally {
50
+ // Record tool call completion
51
+ const executionTime = Date.now() - startTime;
52
+ trackEvent(context, {
53
+ type: "tool-call",
54
+ data: {
55
+ toolName,
56
+ params: params,
57
+ executionTime,
58
+ success,
59
+ },
60
+ timestamp: startTime,
61
+ });
62
+ // Consume post-tool events
63
+ consumeEvents(context, eventStore, response);
64
+ }
65
+ },
66
+ };
67
+ }
68
+ return {
69
+ ...tool,
70
+ handle: async (context, params, response) => {
71
+ const eventStore = getEventStore(context);
72
+ // Consume pre-tool events
73
+ consumeEvents(context, eventStore, response);
74
+ // Track tool execution
75
+ const startTime = Date.now();
76
+ // Run pre-hooks
77
+ const hookContext = {
78
+ context,
79
+ tab: context.currentTab(),
80
+ params,
81
+ toolName,
82
+ response,
83
+ eventStore,
84
+ };
85
+ try {
86
+ await reduce(async (ctx, hook) => runHook(hook, await ctx), Promise.resolve(hookContext), toolHooks.preHooks);
87
+ }
88
+ catch (error) {
89
+ // Pre-hook already handled messaging (e.g., require-tab pre-hook sets tabs section)
90
+ // Avoid adding a duplicate error line in the Result section.
91
+ return;
92
+ }
93
+ // Run original tool
94
+ let success = true;
95
+ try {
96
+ await tool.handle(context, params, response);
97
+ }
98
+ catch (error) {
99
+ success = false;
100
+ throw error;
101
+ }
102
+ finally {
103
+ // Record tool call completion
104
+ const executionTime = Date.now() - startTime;
105
+ trackEvent(context, {
106
+ type: "tool-call",
107
+ data: {
108
+ toolName,
109
+ params: params,
110
+ executionTime,
111
+ success,
112
+ },
113
+ timestamp: startTime,
114
+ });
115
+ }
116
+ // Run post-hooks
117
+ const postHookContext = {
118
+ context,
119
+ tab: context.currentTab(),
120
+ params,
121
+ toolName,
122
+ response,
123
+ eventStore: getEventStore(context),
124
+ };
125
+ try {
126
+ await reduce(async (ctx, hook) => runHook(hook, await ctx), Promise.resolve(postHookContext), toolHooks.postHooks);
127
+ }
128
+ catch (error) {
129
+ response.addError(error instanceof Error ? error.message : "Post-hook failed");
130
+ }
131
+ // Consume post-tool events
132
+ consumeEvents(context, eventStore, response);
133
+ },
134
+ };
135
+ };
136
+ export const getHookRegistry = (context) => {
137
+ const registry = hookRegistryMap.get(context);
138
+ return registry || createHookRegistry();
139
+ };
140
+ export const applyHooksToTools = (tools, context) => {
141
+ const registry = getHookRegistry(context);
142
+ if (registry.tools.size === 0)
143
+ return tools;
144
+ return tools.map((tool) => wrapToolWithHooks(tool, registry));
145
+ };
@@ -0,0 +1,52 @@
1
+ import { formatAntiBotEvent, getAntiBotProviderConfigs, } from "./antiBotDetectionHook.js";
2
+ import { getEventsAfter, isEventType, updateLastSeenId } from "./events.js";
3
+ import { formatToolCallEvent } from "./formatToolCallEvent.js";
4
+ import { formatFrameworkStateEvent } from "./frameworkStateHook.js";
5
+ import { planGroupedMessages } from "./grouping.js";
6
+ import { formatJsonLdEvent } from "./jsonLdDetectionHook.js";
7
+ import { isAntiBotUrl } from "./networkFilters.js";
8
+ import { formatNetworkEvent } from "./networkTrackingHook.js";
9
+ import { formatPageHeightEvent } from "./pageHeightHook.js";
10
+ import { formatWaitEvent } from "./waitHook.js";
11
+ const eventFormatters = {
12
+ wait: formatWaitEvent,
13
+ "page-height-change": formatPageHeightEvent,
14
+ "network-request": formatNetworkEvent,
15
+ "tool-call": formatToolCallEvent,
16
+ "framework-state": formatFrameworkStateEvent,
17
+ "json-ld": formatJsonLdEvent,
18
+ "anti-bot": formatAntiBotEvent,
19
+ };
20
+ const formatEvent = (event) => {
21
+ const formatter = eventFormatters[event.type];
22
+ return formatter(event);
23
+ };
24
+ const consumeEvent = (event, response, plan) => {
25
+ if (plan.skipIds.has(event.id))
26
+ return;
27
+ const replacement = plan.replacementById.get(event.id);
28
+ const formattedMessage = replacement ?? formatEvent(event);
29
+ response.addEvent(`[${event.id}] ${formattedMessage}`);
30
+ };
31
+ const shouldHideEvent = (event) => {
32
+ const isNetworkRequest = isEventType("network-request");
33
+ if (!isNetworkRequest(event))
34
+ return false;
35
+ if (isAntiBotUrl(event.data.url))
36
+ return true;
37
+ const configs = getAntiBotProviderConfigs().filter((config) => config.provider === "cloudflare-turnstile");
38
+ return configs.some((config) => config.match(event));
39
+ };
40
+ export const consumeEvents = (context, eventStore, response) => {
41
+ const unconsumedEvents = getEventsAfter(eventStore, eventStore.lastSeenEventId);
42
+ if (unconsumedEvents.length === 0)
43
+ return;
44
+ const visibleEvents = unconsumedEvents.filter((event) => !shouldHideEvent(event));
45
+ const plan = planGroupedMessages(visibleEvents);
46
+ // Consume all events in chronological order
47
+ for (const event of visibleEvents)
48
+ consumeEvent(event, response, plan);
49
+ // Update last seen event ID
50
+ const latestEvent = unconsumedEvents[unconsumedEvents.length - 1];
51
+ updateLastSeenId(context, latestEvent.id);
52
+ };
@@ -0,0 +1,42 @@
1
+ import { filter, pipe, toArray } from "@fxts/core";
2
+ export const isEventType = (type) => (event) => event.type === type;
3
+ export const createEventStore = () => ({
4
+ events: new Map(),
5
+ lastSeenEventId: undefined,
6
+ nextEventId: 1,
7
+ });
8
+ export const trackEvent = (context, params) => {
9
+ const store = getEventStore(context);
10
+ const eventId = store.nextEventId++;
11
+ const event = {
12
+ id: eventId,
13
+ type: params.type,
14
+ data: params.data,
15
+ timestamp: params.timestamp ?? Date.now(),
16
+ };
17
+ store.events.set(eventId, event);
18
+ return eventId;
19
+ };
20
+ export const updateLastSeenId = (context, eventId) => {
21
+ const store = getEventStore(context);
22
+ store.lastSeenEventId = eventId;
23
+ return context;
24
+ };
25
+ export const getEventsAfter = (store, afterEventId) => {
26
+ if (!afterEventId) {
27
+ return pipe(store.events.values(), toArray);
28
+ }
29
+ return pipe(store.events.values(), filter((event) => event.id > afterEventId), toArray);
30
+ };
31
+ const eventStoreMap = new WeakMap();
32
+ export const getEventStore = (context) => {
33
+ let store = eventStoreMap.get(context);
34
+ if (!store) {
35
+ store = createEventStore();
36
+ eventStoreMap.set(context, store);
37
+ }
38
+ return store;
39
+ };
40
+ export const setEventStore = (context, store) => {
41
+ eventStoreMap.set(context, store);
42
+ };
@@ -0,0 +1,12 @@
1
+ export const formatToolCallEvent = (event) => {
2
+ const { toolName, params, executionTime, success } = event.data;
3
+ // Format parameters (truncate if too long)
4
+ const paramStr = params && Object.keys(params).length > 0
5
+ ? ` with params: ${JSON.stringify(params, null, 0).slice(0, 100)}`
6
+ : "";
7
+ // Format execution time if available
8
+ const timeStr = executionTime !== undefined ? ` (${executionTime}ms)` : "";
9
+ // Format success status if available
10
+ const statusStr = success !== undefined ? (success ? " ✓" : " ✗") : "";
11
+ return `Tool ${toolName}${paramStr}${timeStr}${statusStr}`;
12
+ };
@@ -0,0 +1,182 @@
1
+ import { FRAMEWORK_STATE_PATTERNS, MAX_DISPLAY_ITEMS, } from "../frameworkPatterns.js";
2
+ import { Ok } from "../utils/result.js";
3
+ import { trackEvent } from "./events.js";
4
+ import { hookNameSchema } from "./schema.js";
5
+ const pageFrameworkStates = new WeakMap();
6
+ const seenFrameworkKeysByContext = new WeakMap();
7
+ const getSeenFrameworkKeys = (context) => {
8
+ let set = seenFrameworkKeysByContext.get(context);
9
+ if (!set) {
10
+ set = new Set();
11
+ seenFrameworkKeysByContext.set(context, set);
12
+ }
13
+ return set;
14
+ };
15
+ export const frameworkStatePreHook = {
16
+ name: hookNameSchema.enum["framework-state-pre"],
17
+ handler: async (context) => {
18
+ const frameworkState = await detectFrameworkState(context);
19
+ if (frameworkState) {
20
+ // Store the initial state
21
+ if (context.tab?.page)
22
+ pageFrameworkStates.set(context.tab.page, frameworkState);
23
+ // Track event for newly detected framework state
24
+ const newKeys = Object.keys(frameworkState).filter((key) => !getSeenFrameworkKeys(context.context).has(key));
25
+ if (newKeys.length > 0) {
26
+ trackEvent(context.context, {
27
+ type: "framework-state",
28
+ data: {
29
+ state: frameworkState,
30
+ action: "detected",
31
+ },
32
+ });
33
+ // Mark keys as seen
34
+ newKeys.forEach((key) => getSeenFrameworkKeys(context.context).add(key));
35
+ }
36
+ }
37
+ return Ok(undefined);
38
+ },
39
+ };
40
+ export const frameworkStatePostHook = {
41
+ name: hookNameSchema.enum["framework-state-post"],
42
+ handler: async (context) => {
43
+ const newFrameworkState = await detectFrameworkState(context);
44
+ const initialState = context.tab?.page
45
+ ? pageFrameworkStates.get(context.tab.page)
46
+ : undefined;
47
+ if (newFrameworkState) {
48
+ const changes = [];
49
+ if (initialState) {
50
+ // Compare states
51
+ const allKeys = new Set([
52
+ ...Object.keys(initialState),
53
+ ...Object.keys(newFrameworkState),
54
+ ]);
55
+ for (const key of allKeys) {
56
+ const initVal = initialState[key];
57
+ const currVal = newFrameworkState[key];
58
+ if (!initVal && currVal)
59
+ changes.push(`+ ${key}: ${formatValue(currVal)}`);
60
+ else if (initVal && !currVal)
61
+ changes.push(`- ${key}`);
62
+ else if (initVal &&
63
+ currVal &&
64
+ JSON.stringify(initVal) !== JSON.stringify(currVal))
65
+ changes.push(`~ ${key}: changed`);
66
+ }
67
+ if (changes.length > 0) {
68
+ trackEvent(context.context, {
69
+ type: "framework-state",
70
+ data: {
71
+ state: newFrameworkState,
72
+ changes,
73
+ action: "changed",
74
+ },
75
+ });
76
+ }
77
+ }
78
+ else {
79
+ // No initial state, but we have state now
80
+ const newKeys = Object.keys(newFrameworkState).filter((key) => !getSeenFrameworkKeys(context.context).has(key));
81
+ if (newKeys.length > 0) {
82
+ trackEvent(context.context, {
83
+ type: "framework-state",
84
+ data: {
85
+ state: newFrameworkState,
86
+ action: "detected",
87
+ },
88
+ });
89
+ newKeys.forEach((key) => getSeenFrameworkKeys(context.context).add(key));
90
+ }
91
+ }
92
+ // Update stored state
93
+ if (context.tab?.page)
94
+ pageFrameworkStates.set(context.tab.page, newFrameworkState);
95
+ }
96
+ return Ok(undefined);
97
+ },
98
+ };
99
+ async function detectFrameworkState(context) {
100
+ if (!context.tab?.page)
101
+ return null;
102
+ const result = await context.tab.page.evaluate((patterns) => {
103
+ const state = {};
104
+ const MAX_ITEMS = 5;
105
+ // Scan window object for these patterns
106
+ for (const pattern of patterns) {
107
+ if (pattern in window) {
108
+ try {
109
+ const value = window[pattern];
110
+ // Only capture if it's a non-empty object or has meaningful content
111
+ if (value &&
112
+ (typeof value === "object" || typeof value === "string")) {
113
+ state[pattern] =
114
+ typeof value === "object"
115
+ ? {
116
+ type: "object",
117
+ keys: Object.keys(value).slice(0, MAX_ITEMS * 2),
118
+ }
119
+ : {
120
+ type: typeof value,
121
+ preview: String(value).slice(0, 200),
122
+ };
123
+ }
124
+ }
125
+ catch (e) {
126
+ // Skip inaccessible properties
127
+ }
128
+ }
129
+ }
130
+ // Also check for React Fiber internals
131
+ const reactRootSelectors = [
132
+ "#__next",
133
+ "#root",
134
+ "#app",
135
+ "[data-reactroot]",
136
+ ];
137
+ for (const selector of reactRootSelectors) {
138
+ const element = document.querySelector(selector);
139
+ if (element) {
140
+ const fiberKey = Object.keys(element).find((key) => key.startsWith("__reactInternalInstance") ||
141
+ key.startsWith("__reactFiber") ||
142
+ key.startsWith("_reactRootContainer"));
143
+ if (fiberKey) {
144
+ state["React Fiber Root"] = { selector, fiberKey };
145
+ break;
146
+ }
147
+ }
148
+ }
149
+ return Object.keys(state).length > 0 ? state : null;
150
+ }, FRAMEWORK_STATE_PATTERNS);
151
+ return result;
152
+ }
153
+ function formatValue(value) {
154
+ if (typeof value === "object" && value !== null && "type" in value) {
155
+ if (value.type === "object" && "keys" in value && Array.isArray(value.keys))
156
+ return `{${value.keys.join(", ")}${value.keys.length >= MAX_DISPLAY_ITEMS * 2 ? ", ..." : ""}}`;
157
+ else if ("preview" in value && typeof value.preview === "string")
158
+ return `"${value.preview}${value.preview.length >= 200 ? "..." : ""}"`;
159
+ }
160
+ return JSON.stringify(value);
161
+ }
162
+ export const formatFrameworkStateEvent = (event) => {
163
+ const { state, changes, action } = event.data;
164
+ const messages = [];
165
+ if (action === "detected") {
166
+ messages.push("Framework state detected:");
167
+ const keys = Object.keys(state);
168
+ for (const key of keys) {
169
+ const value = state[key];
170
+ messages.push(` ${key}: ${formatValue(value)}`);
171
+ }
172
+ }
173
+ else if (action === "changed" && changes) {
174
+ messages.push("Framework state changed:");
175
+ messages.push(...changes.map((change) => ` ${change}`));
176
+ }
177
+ return messages.join("\n");
178
+ };
179
+ export const frameworkStateHooks = {
180
+ pre: frameworkStatePreHook,
181
+ post: frameworkStatePostHook,
182
+ };
@@ -0,0 +1,72 @@
1
+ const rules = new Map();
2
+ // Helper to define a rule with typed callbacks without using type assertions in callers
3
+ export const defineGroupingRule = (spec) => {
4
+ const { match } = spec;
5
+ return {
6
+ match,
7
+ keyOf: (e) => {
8
+ // Planner guarantees keyOf is only called when match(e) is true
9
+ if (!match(e))
10
+ return "";
11
+ return spec.keyOf(e);
12
+ },
13
+ summaryOf: (first, run) => {
14
+ // Build a typed run defensively using the provided type guard
15
+ const typedRun = [];
16
+ for (const ev of run) {
17
+ if (match(ev))
18
+ typedRun.push(ev);
19
+ }
20
+ const typedFirst = match(first) ? first : typedRun[0];
21
+ if (!typedFirst)
22
+ return "";
23
+ return spec.summaryOf(typedFirst, typedRun);
24
+ },
25
+ };
26
+ };
27
+ export const registerGroupingRule = (type, rule) => {
28
+ rules.set(type, rule);
29
+ };
30
+ const getGroupingRule = (type) => {
31
+ return rules.get(type);
32
+ };
33
+ export const planGroupedMessages = (events) => {
34
+ const replacementById = new Map();
35
+ const skipIds = new Set();
36
+ let currentType;
37
+ let currentKey;
38
+ let run = [];
39
+ const flush = () => {
40
+ if (currentType && run.length > 1) {
41
+ const rule = getGroupingRule(currentType);
42
+ if (rule) {
43
+ const first = run[0];
44
+ const summary = rule.summaryOf(first, run);
45
+ replacementById.set(first.id, summary);
46
+ for (let i = 1; i < run.length; i++)
47
+ skipIds.add(run[i].id);
48
+ }
49
+ }
50
+ currentType = undefined;
51
+ currentKey = undefined;
52
+ run = [];
53
+ };
54
+ for (const ev of events) {
55
+ const rule = getGroupingRule(ev.type);
56
+ if (!rule || !rule.match(ev)) {
57
+ flush();
58
+ continue;
59
+ }
60
+ const key = rule.keyOf(ev);
61
+ if (currentType === ev.type && currentKey === key) {
62
+ run.push(ev);
63
+ continue;
64
+ }
65
+ flush();
66
+ currentType = ev.type;
67
+ currentKey = key;
68
+ run = [ev];
69
+ }
70
+ flush();
71
+ return { replacementById, skipIds };
72
+ };
@@ -0,0 +1,182 @@
1
+ import { Ok } from "../utils/result.js";
2
+ import { trackEvent } from "./events.js";
3
+ import { hookNameSchema } from "./schema.js";
4
+ const MAX_DISPLAY_ITEMS = 5;
5
+ const pageJsonLdStates = new WeakMap();
6
+ const seenJsonLdTypesByContext = new WeakMap();
7
+ const getSeenJsonLdTypes = (context) => {
8
+ let set = seenJsonLdTypesByContext.get(context);
9
+ if (!set) {
10
+ set = new Set();
11
+ seenJsonLdTypesByContext.set(context, set);
12
+ }
13
+ return set;
14
+ };
15
+ export const jsonLdDetectionPreHook = {
16
+ name: hookNameSchema.enum["json-ld-detection-pre"],
17
+ handler: async (context) => {
18
+ const jsonLdState = await detectJsonLdState(context);
19
+ if (jsonLdState) {
20
+ // Store the initial state
21
+ if (context.tab?.page)
22
+ pageJsonLdStates.set(context.tab.page, jsonLdState);
23
+ // Track event for newly detected JSON-LD types
24
+ const newTypes = Object.keys(jsonLdState).filter((type) => !getSeenJsonLdTypes(context.context).has(type));
25
+ if (newTypes.length > 0) {
26
+ trackEvent(context.context, {
27
+ type: "json-ld",
28
+ data: {
29
+ state: jsonLdState,
30
+ action: "detected",
31
+ },
32
+ });
33
+ // Mark types as seen
34
+ newTypes.forEach((type) => getSeenJsonLdTypes(context.context).add(type));
35
+ }
36
+ }
37
+ return Ok(undefined);
38
+ },
39
+ };
40
+ export const jsonLdDetectionPostHook = {
41
+ name: hookNameSchema.enum["json-ld-detection-post"],
42
+ handler: async (context) => {
43
+ const newJsonLdState = await detectJsonLdState(context);
44
+ const initialState = context.tab?.page
45
+ ? pageJsonLdStates.get(context.tab.page)
46
+ : undefined;
47
+ if (newJsonLdState || initialState) {
48
+ const changes = [];
49
+ if (initialState && newJsonLdState) {
50
+ // Compare states
51
+ const allTypes = new Set([
52
+ ...Object.keys(initialState),
53
+ ...Object.keys(newJsonLdState),
54
+ ]);
55
+ for (const type of allTypes) {
56
+ const initInfo = initialState[type];
57
+ const currInfo = newJsonLdState[type];
58
+ if (!initInfo && currInfo)
59
+ changes.push(`+ ${type}${currInfo.count > 1 ? ` (${currInfo.count} instances)` : ""}`);
60
+ else if (initInfo && !currInfo)
61
+ changes.push(`- ${type}${initInfo.count > 1 ? ` (${initInfo.count} instances)` : ""}`);
62
+ else if (initInfo && currInfo && initInfo.count !== currInfo.count)
63
+ changes.push(`~ ${type}: ${initInfo.count} → ${currInfo.count} instances`);
64
+ }
65
+ if (changes.length > 0) {
66
+ trackEvent(context.context, {
67
+ type: "json-ld",
68
+ data: {
69
+ state: newJsonLdState,
70
+ changes,
71
+ action: "changed",
72
+ },
73
+ });
74
+ }
75
+ }
76
+ else if (newJsonLdState && !initialState) {
77
+ // No initial state, but we have state now
78
+ const newTypes = Object.keys(newJsonLdState).filter((type) => !getSeenJsonLdTypes(context.context).has(type));
79
+ if (newTypes.length > 0) {
80
+ trackEvent(context.context, {
81
+ type: "json-ld",
82
+ data: {
83
+ state: newJsonLdState,
84
+ action: "detected",
85
+ },
86
+ });
87
+ newTypes.forEach((type) => getSeenJsonLdTypes(context.context).add(type));
88
+ }
89
+ }
90
+ // Update stored state
91
+ if (context.tab?.page && newJsonLdState)
92
+ pageJsonLdStates.set(context.tab.page, newJsonLdState);
93
+ }
94
+ return Ok(undefined);
95
+ },
96
+ };
97
+ async function detectJsonLdState(context) {
98
+ if (!context.tab?.page)
99
+ return null;
100
+ const result = await context.tab.page.evaluate(() => {
101
+ const state = {};
102
+ // Find all JSON-LD scripts
103
+ const scripts = document.querySelectorAll('script[type="application/ld+json"]');
104
+ scripts.forEach((script, index) => {
105
+ try {
106
+ // Parse JSON
107
+ const data = JSON.parse(script.textContent || "{}");
108
+ // Extract @type - handle both single and array types
109
+ let types = [];
110
+ if (data["@type"]) {
111
+ types = Array.isArray(data["@type"])
112
+ ? data["@type"]
113
+ : [data["@type"]];
114
+ }
115
+ else if (data["@graph"] && Array.isArray(data["@graph"])) {
116
+ // Handle @graph structures
117
+ data["@graph"].forEach((item) => {
118
+ if (item["@type"]) {
119
+ const itemTypes = Array.isArray(item["@type"])
120
+ ? item["@type"]
121
+ : [item["@type"]];
122
+ types.push(...itemTypes);
123
+ }
124
+ });
125
+ }
126
+ // Count occurrences of each type
127
+ types.forEach((type) => {
128
+ if (!state[type])
129
+ state[type] = { count: 0, indices: [] };
130
+ state[type].count++;
131
+ state[type].indices.push(index);
132
+ });
133
+ }
134
+ catch (e) {
135
+ state["InvalidJSON-LD"] = state["InvalidJSON-LD"] || {
136
+ count: 0,
137
+ indices: [],
138
+ };
139
+ state["InvalidJSON-LD"].count++;
140
+ state["InvalidJSON-LD"].indices.push(index);
141
+ }
142
+ });
143
+ return Object.keys(state).length > 0 ? state : null;
144
+ });
145
+ return result;
146
+ }
147
+ function buildStateMessages(state, types) {
148
+ const msgs = [];
149
+ const targetTypes = types ?? Object.keys(state);
150
+ // Sort by count (descending) and take top MAX_DISPLAY_ITEMS
151
+ const sortedTypes = targetTypes
152
+ .sort((a, b) => (state[b].count || 0) - (state[a].count || 0))
153
+ .slice(0, MAX_DISPLAY_ITEMS);
154
+ for (const type of sortedTypes) {
155
+ const info = state[type];
156
+ if (info.count === 1)
157
+ msgs.push(` ${type}`);
158
+ else
159
+ msgs.push(` ${type} (${info.count} instances)`);
160
+ }
161
+ // Add indicator if there are more types
162
+ if (targetTypes.length > MAX_DISPLAY_ITEMS)
163
+ msgs.push(` ... and ${targetTypes.length - MAX_DISPLAY_ITEMS} more type(s)`);
164
+ return msgs;
165
+ }
166
+ export const formatJsonLdEvent = (event) => {
167
+ const { state, changes, action } = event.data;
168
+ const messages = [];
169
+ if (action === "detected") {
170
+ messages.push("New JSON-LD types detected:");
171
+ messages.push(...buildStateMessages(state));
172
+ }
173
+ else if (action === "changed" && changes) {
174
+ messages.push("JSON-LD changes after action:");
175
+ messages.push(...changes.map((change) => ` ${change}`));
176
+ }
177
+ return messages.join("\n");
178
+ };
179
+ export const jsonLdDetectionHooks = {
180
+ pre: jsonLdDetectionPreHook,
181
+ post: jsonLdDetectionPostHook,
182
+ };