crawlforge-mcp-server 6.4.0 → 6.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +5 -5
- package/README.md +7 -6
- package/package.json +2 -1
- package/server.js +83 -15
- package/src/cli/commands/browser.js +77 -0
- package/src/cli/index.js +3 -1
- package/src/core/ActionExecutor.js +185 -7
- package/src/core/AuthManager.js +26 -0
- package/src/core/ChangeTracker.js +25 -8
- package/src/core/browser/SessionStore.js +331 -0
- package/src/core/browser/snapshot.js +346 -0
- package/src/core/llm/LLMManager.js +86 -6
- package/src/core/processing/PDFProcessor.js +3 -1
- package/src/server/fallbackHints.js +4 -0
- package/src/server/inlineThreshold.js +31 -1
- package/src/server/requestContext.js +26 -5
- package/src/server/toolFilter.js +2 -2
- package/src/server/transports/streamableHttp.js +38 -8
- package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +9 -2
- package/src/skills/agent-skills/crawlforge-batch-automation/references/actions.md +50 -4
- package/src/skills/agent-skills/crawlforge-browser-sessions/SKILL.md +178 -0
- package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +7 -4
- package/src/skills/agent-skills/crawlforge-getting-started/references/cli.md +6 -1
- package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +4 -0
- package/src/skills/installer.js +1 -1
- package/src/tools/advanced/BrowserSessionTool.js +476 -0
- package/src/tools/advanced/ScrapeWithActionsTool.js +10 -1
- package/src/tools/crawl/mapSite.js +81 -7
- package/src/tools/extract/extractEmbeddedState.js +18 -2
- package/src/tools/extract/extractStructured.js +4 -0
- package/src/tools/extract/processDocument.js +94 -1
- package/src/tools/scrape/_brandingExtractor.js +23 -5
- package/src/tools/scrape/unifiedScrape.js +8 -1
- package/src/tools/search/redditSearch.js +24 -17
- package/src/utils/hiddenContent.js +67 -2
- package/src/utils/redditHosts.js +123 -0
- package/src/utils/robotsGate.js +27 -3
|
@@ -9,6 +9,7 @@ import { EventEmitter } from 'events';
|
|
|
9
9
|
import { createHash } from 'node:crypto';
|
|
10
10
|
import { assertUrlAllowed } from '../utils/ssrfGuard.js';
|
|
11
11
|
import { browserPreflight } from '../utils/robotsGate.js';
|
|
12
|
+
import { isRef, resolveRef, captureSnapshot } from './browser/snapshot.js';
|
|
12
13
|
|
|
13
14
|
// executeJavaScript hardening limits (only relevant when the deploy-time flag
|
|
14
15
|
// ALLOW_JAVASCRIPT_EXECUTION=true is set; JS execution stays off by default).
|
|
@@ -148,6 +149,14 @@ const ExecuteJavaScriptActionSchema = BaseActionSchema.extend({
|
|
|
148
149
|
returnResult: z.boolean().default(true)
|
|
149
150
|
});
|
|
150
151
|
|
|
152
|
+
// camelCase (interactiveOnly/maxNodes) to match every other field in the same
|
|
153
|
+
// action object: clickCount, fullPage, toElement, captureAfter.
|
|
154
|
+
const SnapshotActionSchema = BaseActionSchema.extend({
|
|
155
|
+
type: z.literal('snapshot'),
|
|
156
|
+
interactiveOnly: z.boolean().default(true),
|
|
157
|
+
maxNodes: z.number().min(1).max(1000).optional()
|
|
158
|
+
});
|
|
159
|
+
|
|
151
160
|
const ActionSchema = z.union([
|
|
152
161
|
WaitActionSchema,
|
|
153
162
|
ClickActionSchema,
|
|
@@ -158,7 +167,8 @@ const ActionSchema = z.union([
|
|
|
158
167
|
HoverActionSchema,
|
|
159
168
|
NavigateActionSchema,
|
|
160
169
|
ScreenshotActionSchema,
|
|
161
|
-
ExecuteJavaScriptActionSchema
|
|
170
|
+
ExecuteJavaScriptActionSchema,
|
|
171
|
+
SnapshotActionSchema
|
|
162
172
|
]);
|
|
163
173
|
|
|
164
174
|
const ActionChainSchema = z.object({
|
|
@@ -314,7 +324,15 @@ export class ActionExecutor extends EventEmitter {
|
|
|
314
324
|
fullPage: true,
|
|
315
325
|
description: 'Error screenshot'
|
|
316
326
|
});
|
|
317
|
-
|
|
327
|
+
// An actionId is what lets the server publish the shot as a
|
|
328
|
+
// crawlforge://screenshot/{actionId} resource and drop the base64
|
|
329
|
+
// from the result; without one a failed chain shipped 1.7 MB of
|
|
330
|
+
// PNG inline (R21, 2026-09-09).
|
|
331
|
+
executionContext.screenshots.push({
|
|
332
|
+
...errorScreenshot,
|
|
333
|
+
actionId: this.generateActionId(),
|
|
334
|
+
error: true
|
|
335
|
+
});
|
|
318
336
|
} catch (screenshotError) {
|
|
319
337
|
this.log('warn', 'Failed to capture error screenshot: ' + screenshotError.message);
|
|
320
338
|
}
|
|
@@ -507,6 +525,123 @@ export class ActionExecutor extends EventEmitter {
|
|
|
507
525
|
throw lastError;
|
|
508
526
|
}
|
|
509
527
|
|
|
528
|
+
/**
|
|
529
|
+
* Run actions against a page this executor does NOT own.
|
|
530
|
+
*
|
|
531
|
+
* executeActionChain() is the one-shot path and owns its page: it creates it,
|
|
532
|
+
* closes it in `finally`, and re-navigates to the chain's starting URL before
|
|
533
|
+
* each retry. None of that is right for a browser session, where the page
|
|
534
|
+
* outlives the call and "the starting URL" is wherever the last call left it —
|
|
535
|
+
* re-navigating there would throw away the very state the session exists to
|
|
536
|
+
* keep. So this is the same per-action loop with the lifecycle removed: it
|
|
537
|
+
* opens nothing, closes nothing, never re-navigates, and deliberately does not
|
|
538
|
+
* register in `activeChains` (destroy() closes the pages of everything in
|
|
539
|
+
* there, which would take a live session's page out from under its store).
|
|
540
|
+
*
|
|
541
|
+
* Gating comes for free and must not be duplicated by callers: a `navigate`
|
|
542
|
+
* action goes through executeNavigateAction, which re-runs the SSRF guard and
|
|
543
|
+
* the blocklist/robots gate on every hop (see the comment at its definition).
|
|
544
|
+
* `browserOptions.respectRobots` is what reaches that gate.
|
|
545
|
+
*
|
|
546
|
+
* No `finalHtml` here, unlike the chain: the page is still open afterwards, so
|
|
547
|
+
* content is read from it when it is asked for rather than on every call.
|
|
548
|
+
*
|
|
549
|
+
* @param {Page} page - a live page owned by the caller
|
|
550
|
+
* @param {Array} actions - actions to run, in order
|
|
551
|
+
* @param {Object} [options]
|
|
552
|
+
* @param {boolean} [options.continueOnError=false] - keep going past a failed action
|
|
553
|
+
* @param {number} [options.timeout] - per-action deadline for actions that name none
|
|
554
|
+
* @param {Object} [options.browserOptions] - carries `respectRobots` to the navigate gate
|
|
555
|
+
* @returns {Promise<Object>} Same results/screenshots/capturedStates shape the chain returns
|
|
556
|
+
*/
|
|
557
|
+
async executeActionsOnPage(page, actions, { continueOnError = false, timeout, browserOptions = {} } = {}) {
|
|
558
|
+
const startTime = Date.now();
|
|
559
|
+
// executeActionInternal reads `id` for its events and `browserOptions` for
|
|
560
|
+
// the navigate action's robots gate; nothing else on a chain context is
|
|
561
|
+
// consulted from there.
|
|
562
|
+
const executionContext = {
|
|
563
|
+
id: this.generateChainId(),
|
|
564
|
+
url: page.url(),
|
|
565
|
+
browserOptions,
|
|
566
|
+
startTime,
|
|
567
|
+
results: [],
|
|
568
|
+
screenshots: [],
|
|
569
|
+
capturedStates: []
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
let success = true;
|
|
573
|
+
let error;
|
|
574
|
+
|
|
575
|
+
for (let i = 0; i < actions.length; i++) {
|
|
576
|
+
// actionTimeout() reads the deadline off the action itself, so a caller's
|
|
577
|
+
// per-call timeout is applied as the default for actions that set none.
|
|
578
|
+
const action = timeout && !actions[i].timeout ? { ...actions[i], timeout } : actions[i];
|
|
579
|
+
|
|
580
|
+
const actionResult = await this.executeActionInternal(page, action, executionContext);
|
|
581
|
+
executionContext.results.push(actionResult);
|
|
582
|
+
this.stats.totalActions++;
|
|
583
|
+
|
|
584
|
+
if (actionResult.success) {
|
|
585
|
+
this.stats.successfulActions++;
|
|
586
|
+
} else {
|
|
587
|
+
this.stats.failedActions++;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Collection kept in step with executeChainWithRetries — a screenshot
|
|
591
|
+
// action's payload and a captureAfter snapshot of the page reach the
|
|
592
|
+
// caller in the same fields either way.
|
|
593
|
+
if (actionResult.success && action.type === 'screenshot' && actionResult.result?.data) {
|
|
594
|
+
executionContext.screenshots.push({
|
|
595
|
+
actionId: actionResult.id,
|
|
596
|
+
data: actionResult.result.data,
|
|
597
|
+
format: actionResult.result.format,
|
|
598
|
+
fullPage: actionResult.result.fullPage,
|
|
599
|
+
timestamp: actionResult.timestamp
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (action.captureAfter) {
|
|
604
|
+
try {
|
|
605
|
+
const capturedHtml = await page.content();
|
|
606
|
+
executionContext.capturedStates.push({
|
|
607
|
+
afterActionIndex: i,
|
|
608
|
+
afterActionId: actionResult.id,
|
|
609
|
+
url: page.url(),
|
|
610
|
+
html: capturedHtml,
|
|
611
|
+
timestamp: Date.now()
|
|
612
|
+
});
|
|
613
|
+
} catch (captureErr) {
|
|
614
|
+
this.log('warn', 'Failed to capture intermediate state: ' + captureErr.message);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
if (!actionResult.success && !action.continueOnError && !continueOnError) {
|
|
619
|
+
success = false;
|
|
620
|
+
error = actionResult.error;
|
|
621
|
+
break;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (i < actions.length - 1 && this.actionDelay > 0) {
|
|
625
|
+
await this.delay(this.actionDelay);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
return {
|
|
630
|
+
success,
|
|
631
|
+
error,
|
|
632
|
+
finalUrl: page.url(),
|
|
633
|
+
executionTime: Date.now() - startTime,
|
|
634
|
+
results: executionContext.results,
|
|
635
|
+
screenshots: executionContext.screenshots,
|
|
636
|
+
capturedStates: executionContext.capturedStates,
|
|
637
|
+
stats: {
|
|
638
|
+
totalActions: executionContext.results.length,
|
|
639
|
+
successfulActions: executionContext.results.filter(r => r.success).length,
|
|
640
|
+
failedActions: executionContext.results.filter(r => !r.success).length
|
|
641
|
+
}
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
|
|
510
645
|
/**
|
|
511
646
|
* Execute individual action (original internal method)
|
|
512
647
|
* @param {Page} page - Playwright page
|
|
@@ -627,6 +762,23 @@ export class ActionExecutor extends EventEmitter {
|
|
|
627
762
|
return Math.min(this.actionTimeout(action), RECOVERY_TIMEOUT_MS);
|
|
628
763
|
}
|
|
629
764
|
|
|
765
|
+
/**
|
|
766
|
+
* Resolve a caller-supplied selector.
|
|
767
|
+
*
|
|
768
|
+
* A selector starting with `@` names a ref a prior snapshot action assigned,
|
|
769
|
+
* and resolves to the attribute selector that snapshot stamped on the
|
|
770
|
+
* element; anything else is already a CSS selector. Every selector a caller
|
|
771
|
+
* writes goes through here, so refs work in every action type without any
|
|
772
|
+
* schema change — and, because a ref resolves to an ordinary CSS selector,
|
|
773
|
+
* in the stealth human-behaviour paths too.
|
|
774
|
+
* @param {Page} page - Playwright page
|
|
775
|
+
* @param {string} selector - CSS selector, or an `@e1` snapshot ref
|
|
776
|
+
* @returns {string} CSS selector
|
|
777
|
+
*/
|
|
778
|
+
resolveSelector(page, selector) {
|
|
779
|
+
return isRef(selector) ? resolveRef(page, selector) : selector;
|
|
780
|
+
}
|
|
781
|
+
|
|
630
782
|
/**
|
|
631
783
|
* Locator for an action's selector.
|
|
632
784
|
*
|
|
@@ -635,11 +787,11 @@ export class ActionExecutor extends EventEmitter {
|
|
|
635
787
|
* selector matching more than one element, which would break action chains
|
|
636
788
|
* that work today.
|
|
637
789
|
* @param {Page} page - Playwright page
|
|
638
|
-
* @param {string} selector - CSS/text selector
|
|
790
|
+
* @param {string} selector - CSS/text selector, or an `@e1` snapshot ref
|
|
639
791
|
* @returns {Locator} Playwright locator
|
|
640
792
|
*/
|
|
641
793
|
elementLocator(page, selector) {
|
|
642
|
-
return page.locator(selector).first();
|
|
794
|
+
return page.locator(this.resolveSelector(page, selector)).first();
|
|
643
795
|
}
|
|
644
796
|
|
|
645
797
|
/**
|
|
@@ -725,6 +877,8 @@ export class ActionExecutor extends EventEmitter {
|
|
|
725
877
|
return await this.executeScreenshotAction(page, action);
|
|
726
878
|
case 'executeJavaScript':
|
|
727
879
|
return await this.executeJavaScriptAction(page, action);
|
|
880
|
+
case 'snapshot':
|
|
881
|
+
return await this.executeSnapshotAction(page, action);
|
|
728
882
|
default:
|
|
729
883
|
throw new Error('Unknown action type: ' + action.type);
|
|
730
884
|
}
|
|
@@ -784,7 +938,9 @@ export class ActionExecutor extends EventEmitter {
|
|
|
784
938
|
// be there before it starts (locator.click() would have waited for it).
|
|
785
939
|
await locator.waitFor({ state: 'visible', timeout });
|
|
786
940
|
// Use human-like clicking behavior
|
|
787
|
-
|
|
941
|
+
// The simulator takes a raw selector string rather than a locator, so
|
|
942
|
+
// it needs the resolved form — it never goes through elementLocator.
|
|
943
|
+
await humanBehaviorSimulator.simulateClick(page, this.resolveSelector(page, action.selector), {
|
|
788
944
|
button: action.button,
|
|
789
945
|
clickCount: action.clickCount,
|
|
790
946
|
delay: action.delay,
|
|
@@ -841,7 +997,7 @@ export class ActionExecutor extends EventEmitter {
|
|
|
841
997
|
// Same as click: the simulator works from the selector, so wait first.
|
|
842
998
|
await locator.waitFor({ state: 'visible', timeout });
|
|
843
999
|
// Use human-like typing behavior
|
|
844
|
-
await humanBehaviorSimulator.simulateTyping(page, action.selector, action.text);
|
|
1000
|
+
await humanBehaviorSimulator.simulateTyping(page, this.resolveSelector(page, action.selector), action.text);
|
|
845
1001
|
} else {
|
|
846
1002
|
// Standard typing behavior
|
|
847
1003
|
await locator.pressSequentially(action.text, { delay: action.delay, timeout });
|
|
@@ -899,7 +1055,7 @@ export class ActionExecutor extends EventEmitter {
|
|
|
899
1055
|
if (humanBehaviorSimulator) {
|
|
900
1056
|
// Use human-like scrolling to element
|
|
901
1057
|
await humanBehaviorSimulator.simulateScroll(page, {
|
|
902
|
-
target: action.toElement
|
|
1058
|
+
target: this.resolveSelector(page, action.toElement)
|
|
903
1059
|
});
|
|
904
1060
|
} else {
|
|
905
1061
|
// scrollIntoViewIfNeeded, not scrollIntoView — the latter is a DOM API
|
|
@@ -1122,6 +1278,21 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1122
1278
|
result: action.returnResult ? result : undefined
|
|
1123
1279
|
};
|
|
1124
1280
|
}
|
|
1281
|
+
|
|
1282
|
+
/**
|
|
1283
|
+
* Execute snapshot action - the page's interactive elements, each stamped
|
|
1284
|
+
* with a ref later actions can target instead of a guessed CSS selector.
|
|
1285
|
+
* @param {Page} page - Playwright page
|
|
1286
|
+
* @param {Object} action - Snapshot action
|
|
1287
|
+
* @returns {Promise<Object>} Snapshot tree with refs
|
|
1288
|
+
*/
|
|
1289
|
+
async executeSnapshotAction(page, action) {
|
|
1290
|
+
return await captureSnapshot(page, {
|
|
1291
|
+
interactiveOnly: action.interactiveOnly,
|
|
1292
|
+
maxNodes: action.maxNodes
|
|
1293
|
+
});
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1125
1296
|
/**
|
|
1126
1297
|
* Capture screenshot
|
|
1127
1298
|
* @param {Page} page - Playwright page
|
|
@@ -1313,6 +1484,13 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1313
1484
|
* @returns {Promise<Object>} Recovery result
|
|
1314
1485
|
*/
|
|
1315
1486
|
async attemptErrorRecovery(page, action, error, executionContext) {
|
|
1487
|
+
// A stale ref names an element that went away with the old document, so no
|
|
1488
|
+
// strategy here can find it again. Re-snapshotting is the caller's job:
|
|
1489
|
+
// fail the action now rather than spend the recovery budget on it.
|
|
1490
|
+
if (error?.name === 'StaleRefError') {
|
|
1491
|
+
return { success: false };
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1316
1494
|
const strategies = this.errorRecoveryStrategies.get(action.type) || [];
|
|
1317
1495
|
// `retries` caps how many strategies get a turn. Walking all of them
|
|
1318
1496
|
// unconditionally would add a second full round of timeouts to every action
|
package/src/core/AuthManager.js
CHANGED
|
@@ -593,6 +593,7 @@ class AuthManager {
|
|
|
593
593
|
analyze_content: 3,
|
|
594
594
|
extract_structured: 3,
|
|
595
595
|
extract_with_llm: 3,
|
|
596
|
+
browser_session: 3, // ceiling — `open`'s price; the schedule is per operation below
|
|
596
597
|
|
|
597
598
|
// 4 credits
|
|
598
599
|
summarize_content: 4,
|
|
@@ -635,6 +636,28 @@ class AuthManager {
|
|
|
635
636
|
if (bookkeepingOps.has(params?.operation)) return 1;
|
|
636
637
|
}
|
|
637
638
|
|
|
639
|
+
// browser_session bills per operation for the same reason: one session is
|
|
640
|
+
// many calls, and a flat price would charge the ceiling for every cheap
|
|
641
|
+
// one. `open` launches a browser and navigates, so it costs more than a
|
|
642
|
+
// `scrape` (2); `read` extracts content and is priced with `scrape`;
|
|
643
|
+
// `snapshot`, `act` and `screenshot` are one injected script or one action
|
|
644
|
+
// batch against a page that is already open. `close` and `list` are
|
|
645
|
+
// bookkeeping but still cost 1 — nothing here runs for free. An unknown or
|
|
646
|
+
// absent operation falls through to the flat 3: the published price is the
|
|
647
|
+
// ceiling, never the floor.
|
|
648
|
+
if (tool === 'browser_session') {
|
|
649
|
+
const operationCosts = new Map([
|
|
650
|
+
['open', 3],
|
|
651
|
+
['snapshot', 1],
|
|
652
|
+
['act', 1],
|
|
653
|
+
['read', 2],
|
|
654
|
+
['screenshot', 1],
|
|
655
|
+
['close', 1],
|
|
656
|
+
['list', 1]
|
|
657
|
+
]);
|
|
658
|
+
if (operationCosts.has(params?.operation)) return operationCosts.get(params.operation);
|
|
659
|
+
}
|
|
660
|
+
|
|
638
661
|
// localize_search with a query runs a real web search through the same
|
|
639
662
|
// adapter search_web uses, so it is priced as one rather than undercutting it.
|
|
640
663
|
if (tool === 'localization' && params?.operation === 'localize_search' && params?.searchParams?.query) {
|
|
@@ -719,6 +742,9 @@ class AuthManager {
|
|
|
719
742
|
? 'Bookkeeping operation — launches no browser.'
|
|
720
743
|
: 'Browser operation. configure/enable/disable/get_stats/cleanup cost 1 credit each.';
|
|
721
744
|
break;
|
|
745
|
+
case 'browser_session':
|
|
746
|
+
note = `Priced per operation: open 3, read 2, snapshot/act/screenshot/close/list 1. This call bills ${projected}.`;
|
|
747
|
+
break;
|
|
722
748
|
case 'serp_rank':
|
|
723
749
|
note = projected === 0
|
|
724
750
|
? 'DataForSEO not configured — no-op, no credits charged. Set DATAFORSEO_LOGIN/PASSWORD to enable.'
|
|
@@ -720,14 +720,16 @@ export class ChangeTracker extends EventEmitter {
|
|
|
720
720
|
significanceScore += Math.min(totalElements * 0.05, 1) *
|
|
721
721
|
(weights.additions + weights.removals + weights.modifications);
|
|
722
722
|
|
|
723
|
-
// Text changes impact
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
723
|
+
// Text changes impact. textChanges holds diff GROUPS — {type:'word_diff',
|
|
724
|
+
// changes:[{added, removed, value}]} — not flat parts, so reading
|
|
725
|
+
// `change.added.length` off a group was always 0 and this term never
|
|
726
|
+
// fired: a feed that grew by a whole record scored on similarity alone.
|
|
727
|
+
// The USGS all-hour earthquake feed gained an event (622 words, 84%
|
|
728
|
+
// similar) and compare reported hasChanges:false, "No significant
|
|
729
|
+
// changes detected" (R21, 2026-09-09).
|
|
730
|
+
const changedChars = this.changedTextChars(changeAnalysis.textChanges);
|
|
731
|
+
if (changedChars > 0) {
|
|
732
|
+
significanceScore += Math.min(changedChars / 1000, 1) * weights.textChanges;
|
|
731
733
|
}
|
|
732
734
|
|
|
733
735
|
// Determine significance level
|
|
@@ -1317,6 +1319,21 @@ export class ChangeTracker extends EventEmitter {
|
|
|
1317
1319
|
);
|
|
1318
1320
|
}
|
|
1319
1321
|
|
|
1322
|
+
/**
|
|
1323
|
+
* Characters added or removed at the text level, for significance scoring.
|
|
1324
|
+
* Same word-diff-then-line-diff choice as countTextChanges, and for the
|
|
1325
|
+
* same reason: the two describe one edit.
|
|
1326
|
+
*/
|
|
1327
|
+
changedTextChars(textChanges = []) {
|
|
1328
|
+
const diff = textChanges.find(c => c.type === 'word_diff')
|
|
1329
|
+
|| textChanges.find(c => c.type === 'line_diff');
|
|
1330
|
+
if (!diff) return 0;
|
|
1331
|
+
return diff.changes.reduce(
|
|
1332
|
+
(chars, part) => chars + ((part.added || part.removed) && typeof part.value === 'string' ? part.value.length : 0),
|
|
1333
|
+
0
|
|
1334
|
+
);
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1320
1337
|
generateChangeSummary(changeAnalysis, significance) {
|
|
1321
1338
|
const { addedElements, removedElements, modifiedElements, similarity } = changeAnalysis;
|
|
1322
1339
|
|