web-agent-bridge 2.7.0 → 2.8.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/package.json +1 -1
- package/sdk/package.json +1 -1
- package/server/routes/runtime.js +303 -0
- package/server/services/lfd.js +616 -0
- package/server/services/vision.js +292 -0
package/package.json
CHANGED
package/sdk/package.json
CHANGED
package/server/routes/runtime.js
CHANGED
|
@@ -34,6 +34,8 @@ const metering = require('../services/metering');
|
|
|
34
34
|
const { marketplace } = require('../services/marketplace');
|
|
35
35
|
const { hostedRuntime } = require('../services/hosted-runtime');
|
|
36
36
|
const { sessionEngine } = require('../runtime/session-engine');
|
|
37
|
+
const vision = require('../services/vision');
|
|
38
|
+
const { lfdEngine } = require('../services/lfd');
|
|
37
39
|
|
|
38
40
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
39
41
|
// AUTH MIDDLEWARE
|
|
@@ -55,6 +57,12 @@ const PUBLIC_PATHS = [
|
|
|
55
57
|
'/registry/templates',
|
|
56
58
|
'/plans',
|
|
57
59
|
'/marketplace',
|
|
60
|
+
'/recipes',
|
|
61
|
+
'/vision/models',
|
|
62
|
+
'/vision/extraction-script',
|
|
63
|
+
'/recipes',
|
|
64
|
+
'/vision/models',
|
|
65
|
+
'/vision/extraction-script',
|
|
58
66
|
];
|
|
59
67
|
|
|
60
68
|
function authMiddleware(req, res, next) {
|
|
@@ -534,6 +542,7 @@ router.get('/observability/health', (req, res) => {
|
|
|
534
542
|
health.marketplace = marketplace.getStats();
|
|
535
543
|
health.hostedRuntime = hostedRuntime.getStats();
|
|
536
544
|
health.metering = metering.getStats();
|
|
545
|
+
health.lfd = lfdEngine.getStats();
|
|
537
546
|
res.json(health);
|
|
538
547
|
});
|
|
539
548
|
|
|
@@ -1443,4 +1452,298 @@ router.get('/hosted/stats', (req, res) => {
|
|
|
1443
1452
|
res.json(hostedRuntime.getStats());
|
|
1444
1453
|
});
|
|
1445
1454
|
|
|
1455
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1456
|
+
// LOCAL VISION ENGINE (Self-contained — no external API)
|
|
1457
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1458
|
+
|
|
1459
|
+
/**
|
|
1460
|
+
* Analyze page DOM locally (no external API calls)
|
|
1461
|
+
*/
|
|
1462
|
+
router.post('/vision/analyze-dom', async (req, res) => {
|
|
1463
|
+
try {
|
|
1464
|
+
const siteId = req.body.siteId || req.agentId || 'default';
|
|
1465
|
+
const result = await vision.analyzePageDOM(siteId, {
|
|
1466
|
+
domSnapshot: req.body.domSnapshot,
|
|
1467
|
+
url: req.body.url,
|
|
1468
|
+
});
|
|
1469
|
+
res.json(result);
|
|
1470
|
+
} catch (err) {
|
|
1471
|
+
res.status(400).json({ error: err.message });
|
|
1472
|
+
}
|
|
1473
|
+
});
|
|
1474
|
+
|
|
1475
|
+
/**
|
|
1476
|
+
* Get DOM extraction script to inject into pages
|
|
1477
|
+
*/
|
|
1478
|
+
router.get('/vision/extraction-script', (req, res) => {
|
|
1479
|
+
res.json({ script: vision.getDomExtractionScript() });
|
|
1480
|
+
});
|
|
1481
|
+
|
|
1482
|
+
/**
|
|
1483
|
+
* Find elements in cached vision data
|
|
1484
|
+
*/
|
|
1485
|
+
router.get('/vision/elements', (req, res) => {
|
|
1486
|
+
const siteId = req.query.siteId || req.agentId || 'default';
|
|
1487
|
+
const results = vision.findElement(siteId, req.query.url, {
|
|
1488
|
+
description: req.query.q,
|
|
1489
|
+
type: req.query.type,
|
|
1490
|
+
label: req.query.label,
|
|
1491
|
+
});
|
|
1492
|
+
res.json({ elements: results, total: results.length });
|
|
1493
|
+
});
|
|
1494
|
+
|
|
1495
|
+
/**
|
|
1496
|
+
* Vision history
|
|
1497
|
+
*/
|
|
1498
|
+
router.get('/vision/history', (req, res) => {
|
|
1499
|
+
const siteId = req.query.siteId || req.agentId || 'default';
|
|
1500
|
+
const history = vision.getVisionHistory(siteId, {
|
|
1501
|
+
limit: parseInt(req.query.limit) || 50,
|
|
1502
|
+
url: req.query.url,
|
|
1503
|
+
});
|
|
1504
|
+
res.json({ history, total: history.length });
|
|
1505
|
+
});
|
|
1506
|
+
|
|
1507
|
+
/**
|
|
1508
|
+
* Supported vision models
|
|
1509
|
+
*/
|
|
1510
|
+
router.get('/vision/models', (req, res) => {
|
|
1511
|
+
res.json({ models: vision.getSupportedModels() });
|
|
1512
|
+
});
|
|
1513
|
+
|
|
1514
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1515
|
+
// LEARNING FROM DEMONSTRATION (LfD)
|
|
1516
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1517
|
+
|
|
1518
|
+
/**
|
|
1519
|
+
* Start a recording session
|
|
1520
|
+
*/
|
|
1521
|
+
router.post('/lfd/record', (req, res) => {
|
|
1522
|
+
try {
|
|
1523
|
+
const session = lfdEngine.startRecording({
|
|
1524
|
+
name: req.body.name,
|
|
1525
|
+
description: req.body.description,
|
|
1526
|
+
agentId: req.agentId || req.body.agentId,
|
|
1527
|
+
startUrl: req.body.startUrl,
|
|
1528
|
+
tags: req.body.tags,
|
|
1529
|
+
});
|
|
1530
|
+
res.json(session);
|
|
1531
|
+
} catch (err) {
|
|
1532
|
+
res.status(400).json({ error: err.message });
|
|
1533
|
+
}
|
|
1534
|
+
});
|
|
1535
|
+
|
|
1536
|
+
/**
|
|
1537
|
+
* Record events into a session
|
|
1538
|
+
*/
|
|
1539
|
+
router.post('/lfd/:sessionId/events', (req, res) => {
|
|
1540
|
+
try {
|
|
1541
|
+
const events = req.body.events || [req.body];
|
|
1542
|
+
const results = events.map(evt => lfdEngine.recordEvent(req.params.sessionId, evt));
|
|
1543
|
+
res.json({ recorded: results.filter(Boolean).length });
|
|
1544
|
+
} catch (err) {
|
|
1545
|
+
res.status(400).json({ error: err.message });
|
|
1546
|
+
}
|
|
1547
|
+
});
|
|
1548
|
+
|
|
1549
|
+
/**
|
|
1550
|
+
* Record a DOM snapshot
|
|
1551
|
+
*/
|
|
1552
|
+
router.post('/lfd/:sessionId/snapshot', (req, res) => {
|
|
1553
|
+
try {
|
|
1554
|
+
lfdEngine.recordSnapshot(req.params.sessionId, req.body);
|
|
1555
|
+
res.json({ success: true });
|
|
1556
|
+
} catch (err) {
|
|
1557
|
+
res.status(400).json({ error: err.message });
|
|
1558
|
+
}
|
|
1559
|
+
});
|
|
1560
|
+
|
|
1561
|
+
/**
|
|
1562
|
+
* Pause recording
|
|
1563
|
+
*/
|
|
1564
|
+
router.post('/lfd/:sessionId/pause', (req, res) => {
|
|
1565
|
+
try { res.json(lfdEngine.pauseRecording(req.params.sessionId)); }
|
|
1566
|
+
catch (err) { res.status(400).json({ error: err.message }); }
|
|
1567
|
+
});
|
|
1568
|
+
|
|
1569
|
+
/**
|
|
1570
|
+
* Resume recording
|
|
1571
|
+
*/
|
|
1572
|
+
router.post('/lfd/:sessionId/resume', (req, res) => {
|
|
1573
|
+
try { res.json(lfdEngine.resumeRecording(req.params.sessionId)); }
|
|
1574
|
+
catch (err) { res.status(400).json({ error: err.message }); }
|
|
1575
|
+
});
|
|
1576
|
+
|
|
1577
|
+
/**
|
|
1578
|
+
* Stop recording and generate recipe
|
|
1579
|
+
*/
|
|
1580
|
+
router.post('/lfd/:sessionId/stop', (req, res) => {
|
|
1581
|
+
try { res.json(lfdEngine.stopRecording(req.params.sessionId)); }
|
|
1582
|
+
catch (err) { res.status(400).json({ error: err.message }); }
|
|
1583
|
+
});
|
|
1584
|
+
|
|
1585
|
+
/**
|
|
1586
|
+
* Cancel recording
|
|
1587
|
+
*/
|
|
1588
|
+
router.post('/lfd/:sessionId/cancel', (req, res) => {
|
|
1589
|
+
try { res.json(lfdEngine.cancelRecording(req.params.sessionId)); }
|
|
1590
|
+
catch (err) { res.status(400).json({ error: err.message }); }
|
|
1591
|
+
});
|
|
1592
|
+
|
|
1593
|
+
/**
|
|
1594
|
+
* Get recording details
|
|
1595
|
+
*/
|
|
1596
|
+
router.get('/lfd/:sessionId', (req, res) => {
|
|
1597
|
+
const recording = lfdEngine.getRecording(req.params.sessionId);
|
|
1598
|
+
if (!recording) return res.status(404).json({ error: 'Recording not found' });
|
|
1599
|
+
res.json(recording);
|
|
1600
|
+
});
|
|
1601
|
+
|
|
1602
|
+
/**
|
|
1603
|
+
* List recordings
|
|
1604
|
+
*/
|
|
1605
|
+
router.get('/lfd', (req, res) => {
|
|
1606
|
+
res.json({ recordings: lfdEngine.listRecordings(parseInt(req.query.limit) || 50) });
|
|
1607
|
+
});
|
|
1608
|
+
|
|
1609
|
+
/**
|
|
1610
|
+
* Get recording script to inject into pages
|
|
1611
|
+
*/
|
|
1612
|
+
router.get('/lfd/:sessionId/script', (req, res) => {
|
|
1613
|
+
const serverUrl = `${req.protocol}://${req.get('host')}`;
|
|
1614
|
+
res.json({ script: lfdEngine.getRecordingScript(req.params.sessionId, serverUrl) });
|
|
1615
|
+
});
|
|
1616
|
+
|
|
1617
|
+
// ── Recipes ──
|
|
1618
|
+
|
|
1619
|
+
/**
|
|
1620
|
+
* List recipes
|
|
1621
|
+
*/
|
|
1622
|
+
router.get('/recipes', (req, res) => {
|
|
1623
|
+
const recipes = lfdEngine.listRecipes({
|
|
1624
|
+
domain: req.query.domain,
|
|
1625
|
+
tag: req.query.tag,
|
|
1626
|
+
query: req.query.q,
|
|
1627
|
+
}, parseInt(req.query.limit) || 50);
|
|
1628
|
+
res.json({ recipes, total: recipes.length });
|
|
1629
|
+
});
|
|
1630
|
+
|
|
1631
|
+
/**
|
|
1632
|
+
* Get recipe
|
|
1633
|
+
*/
|
|
1634
|
+
router.get('/recipes/:recipeId', (req, res) => {
|
|
1635
|
+
const recipe = lfdEngine.getRecipe(req.params.recipeId);
|
|
1636
|
+
if (!recipe) return res.status(404).json({ error: 'Recipe not found' });
|
|
1637
|
+
res.json(recipe);
|
|
1638
|
+
});
|
|
1639
|
+
|
|
1640
|
+
/**
|
|
1641
|
+
* Save/import recipe manually
|
|
1642
|
+
*/
|
|
1643
|
+
router.post('/recipes', (req, res) => {
|
|
1644
|
+
try { res.json(lfdEngine.saveRecipe(req.body)); }
|
|
1645
|
+
catch (err) { res.status(400).json({ error: err.message }); }
|
|
1646
|
+
});
|
|
1647
|
+
|
|
1648
|
+
/**
|
|
1649
|
+
* Delete recipe
|
|
1650
|
+
*/
|
|
1651
|
+
router.delete('/recipes/:recipeId', (req, res) => {
|
|
1652
|
+
const deleted = lfdEngine.deleteRecipe(req.params.recipeId);
|
|
1653
|
+
res.json({ deleted });
|
|
1654
|
+
});
|
|
1655
|
+
|
|
1656
|
+
/**
|
|
1657
|
+
* Execute a recipe
|
|
1658
|
+
*/
|
|
1659
|
+
router.post('/recipes/:recipeId/execute', (req, res) => {
|
|
1660
|
+
try {
|
|
1661
|
+
const execution = lfdEngine.executeRecipe(req.params.recipeId, {
|
|
1662
|
+
variables: req.body.variables,
|
|
1663
|
+
speed: req.body.speed,
|
|
1664
|
+
stopOnError: req.body.stopOnError,
|
|
1665
|
+
skipWaits: req.body.skipWaits,
|
|
1666
|
+
humanInTheLoop: req.body.humanInTheLoop,
|
|
1667
|
+
});
|
|
1668
|
+
res.json(execution);
|
|
1669
|
+
} catch (err) {
|
|
1670
|
+
res.status(400).json({ error: err.message });
|
|
1671
|
+
}
|
|
1672
|
+
});
|
|
1673
|
+
|
|
1674
|
+
/**
|
|
1675
|
+
* Get next step in execution
|
|
1676
|
+
*/
|
|
1677
|
+
router.get('/executions/:executionId/next', (req, res) => {
|
|
1678
|
+
const step = lfdEngine.getNextStep(req.params.executionId);
|
|
1679
|
+
if (!step) {
|
|
1680
|
+
const exec = lfdEngine.getExecution(req.params.executionId);
|
|
1681
|
+
return res.json({ done: true, status: exec?.status || 'unknown' });
|
|
1682
|
+
}
|
|
1683
|
+
res.json(step);
|
|
1684
|
+
});
|
|
1685
|
+
|
|
1686
|
+
/**
|
|
1687
|
+
* Report step result
|
|
1688
|
+
*/
|
|
1689
|
+
router.post('/executions/:executionId/steps/:stepIndex', (req, res) => {
|
|
1690
|
+
const exec = lfdEngine.reportStep(
|
|
1691
|
+
req.params.executionId,
|
|
1692
|
+
parseInt(req.params.stepIndex),
|
|
1693
|
+
{ success: req.body.success, error: req.body.error, duration: req.body.duration, selectorUsed: req.body.selectorUsed }
|
|
1694
|
+
);
|
|
1695
|
+
if (!exec) return res.status(404).json({ error: 'Execution not found' });
|
|
1696
|
+
res.json({ status: exec.status, currentStep: exec.currentStep, totalSteps: exec.totalSteps });
|
|
1697
|
+
});
|
|
1698
|
+
|
|
1699
|
+
/**
|
|
1700
|
+
* Pause execution
|
|
1701
|
+
*/
|
|
1702
|
+
router.post('/executions/:executionId/pause', (req, res) => {
|
|
1703
|
+
const exec = lfdEngine.pauseExecution(req.params.executionId);
|
|
1704
|
+
if (!exec) return res.status(404).json({ error: 'Execution not found' });
|
|
1705
|
+
res.json({ status: exec.status });
|
|
1706
|
+
});
|
|
1707
|
+
|
|
1708
|
+
/**
|
|
1709
|
+
* Resume execution
|
|
1710
|
+
*/
|
|
1711
|
+
router.post('/executions/:executionId/resume', (req, res) => {
|
|
1712
|
+
const exec = lfdEngine.resumeExecution(req.params.executionId);
|
|
1713
|
+
if (!exec) return res.status(404).json({ error: 'Execution not found' });
|
|
1714
|
+
res.json({ status: exec.status });
|
|
1715
|
+
});
|
|
1716
|
+
|
|
1717
|
+
/**
|
|
1718
|
+
* Abort execution
|
|
1719
|
+
*/
|
|
1720
|
+
router.post('/executions/:executionId/abort', (req, res) => {
|
|
1721
|
+
const exec = lfdEngine.abortExecution(req.params.executionId);
|
|
1722
|
+
if (!exec) return res.status(404).json({ error: 'Execution not found' });
|
|
1723
|
+
res.json({ status: exec.status });
|
|
1724
|
+
});
|
|
1725
|
+
|
|
1726
|
+
/**
|
|
1727
|
+
* Get execution details
|
|
1728
|
+
*/
|
|
1729
|
+
router.get('/executions/:executionId', (req, res) => {
|
|
1730
|
+
const exec = lfdEngine.getExecution(req.params.executionId);
|
|
1731
|
+
if (!exec) return res.status(404).json({ error: 'Execution not found' });
|
|
1732
|
+
res.json(exec);
|
|
1733
|
+
});
|
|
1734
|
+
|
|
1735
|
+
/**
|
|
1736
|
+
* List executions
|
|
1737
|
+
*/
|
|
1738
|
+
router.get('/executions', (req, res) => {
|
|
1739
|
+
res.json({ executions: lfdEngine.listExecutions(parseInt(req.query.limit) || 50) });
|
|
1740
|
+
});
|
|
1741
|
+
|
|
1742
|
+
/**
|
|
1743
|
+
* LfD stats
|
|
1744
|
+
*/
|
|
1745
|
+
router.get('/lfd/stats', (req, res) => {
|
|
1746
|
+
res.json(lfdEngine.getStats());
|
|
1747
|
+
});
|
|
1748
|
+
|
|
1446
1749
|
module.exports = router;
|
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Learning from Demonstration (LfD) Engine
|
|
5
|
+
*
|
|
6
|
+
* Records user actions on web pages, converts them to replayable recipes,
|
|
7
|
+
* and enables agents to learn from human demonstrations.
|
|
8
|
+
*
|
|
9
|
+
* Flow:
|
|
10
|
+
* 1. User starts a recording session
|
|
11
|
+
* 2. Browser captures events (clicks, typing, navigation, scrolls)
|
|
12
|
+
* 3. Each event includes DOM snapshot + screenshot hash + element info
|
|
13
|
+
* 4. Session is saved as a "Recipe" (YAML/JSON task template)
|
|
14
|
+
* 5. Recipes can be replayed by agents on the same or similar sites
|
|
15
|
+
* 6. Recipes can be shared via the Marketplace
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const crypto = require('crypto');
|
|
19
|
+
|
|
20
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
21
|
+
// RECORDING SESSION
|
|
22
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
23
|
+
|
|
24
|
+
class RecordingSession {
|
|
25
|
+
constructor(config = {}) {
|
|
26
|
+
this.id = crypto.randomUUID();
|
|
27
|
+
this.name = config.name || 'Untitled Recording';
|
|
28
|
+
this.description = config.description || '';
|
|
29
|
+
this.agentId = config.agentId || null;
|
|
30
|
+
this.startUrl = config.startUrl || '';
|
|
31
|
+
this.status = 'recording'; // recording | paused | completed | cancelled
|
|
32
|
+
this.events = [];
|
|
33
|
+
this.snapshots = []; // DOM snapshots at key moments
|
|
34
|
+
this.metadata = {
|
|
35
|
+
startedAt: Date.now(),
|
|
36
|
+
completedAt: null,
|
|
37
|
+
duration: 0,
|
|
38
|
+
pageCount: 0,
|
|
39
|
+
actionCount: 0,
|
|
40
|
+
domain: '',
|
|
41
|
+
tags: config.tags || [],
|
|
42
|
+
};
|
|
43
|
+
try { this.metadata.domain = new URL(config.startUrl).hostname; } catch {}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Record a user action event
|
|
48
|
+
*/
|
|
49
|
+
addEvent(event) {
|
|
50
|
+
if (this.status !== 'recording') return null;
|
|
51
|
+
|
|
52
|
+
const recorded = {
|
|
53
|
+
id: `evt-${this.events.length}`,
|
|
54
|
+
seq: this.events.length,
|
|
55
|
+
timestamp: Date.now(),
|
|
56
|
+
relativeTime: Date.now() - this.metadata.startedAt,
|
|
57
|
+
type: event.type, // click | type | navigate | scroll | select | hover | wait | assert
|
|
58
|
+
target: {
|
|
59
|
+
selector: event.selector || '',
|
|
60
|
+
xpath: event.xpath || '',
|
|
61
|
+
text: (event.text || '').slice(0, 200),
|
|
62
|
+
tag: event.tag || '',
|
|
63
|
+
attributes: event.attributes || {},
|
|
64
|
+
rect: event.rect || {},
|
|
65
|
+
},
|
|
66
|
+
data: {}, // type-specific data
|
|
67
|
+
url: event.url || '',
|
|
68
|
+
pageTitle: event.pageTitle || '',
|
|
69
|
+
screenshot: event.screenshotHash || null,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Type-specific data
|
|
73
|
+
switch (event.type) {
|
|
74
|
+
case 'click':
|
|
75
|
+
recorded.data = { x: event.x, y: event.y, button: event.button || 'left', doubleClick: !!event.doubleClick };
|
|
76
|
+
break;
|
|
77
|
+
case 'type':
|
|
78
|
+
recorded.data = { value: event.value || '', key: event.key || '', clearFirst: !!event.clearFirst };
|
|
79
|
+
break;
|
|
80
|
+
case 'navigate':
|
|
81
|
+
recorded.data = { url: event.url || '', method: event.method || 'goto' };
|
|
82
|
+
this.metadata.pageCount++;
|
|
83
|
+
break;
|
|
84
|
+
case 'scroll':
|
|
85
|
+
recorded.data = { x: event.scrollX || 0, y: event.scrollY || 0, direction: event.direction || 'down' };
|
|
86
|
+
break;
|
|
87
|
+
case 'select':
|
|
88
|
+
recorded.data = { value: event.value || '', label: event.label || '', index: event.index };
|
|
89
|
+
break;
|
|
90
|
+
case 'hover':
|
|
91
|
+
recorded.data = { duration: event.duration || 0 };
|
|
92
|
+
break;
|
|
93
|
+
case 'wait':
|
|
94
|
+
recorded.data = { ms: event.ms || 1000, condition: event.condition || 'delay' };
|
|
95
|
+
break;
|
|
96
|
+
case 'assert':
|
|
97
|
+
recorded.data = { assertion: event.assertion || '', expected: event.expected };
|
|
98
|
+
break;
|
|
99
|
+
case 'keypress':
|
|
100
|
+
recorded.data = { key: event.key || '', modifiers: event.modifiers || [] };
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
this.events.push(recorded);
|
|
105
|
+
this.metadata.actionCount = this.events.length;
|
|
106
|
+
return recorded;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Add a DOM snapshot at a key moment
|
|
111
|
+
*/
|
|
112
|
+
addSnapshot(snapshot) {
|
|
113
|
+
if (this.status !== 'recording') return;
|
|
114
|
+
this.snapshots.push({
|
|
115
|
+
seq: this.snapshots.length,
|
|
116
|
+
timestamp: Date.now(),
|
|
117
|
+
url: snapshot.url || '',
|
|
118
|
+
title: snapshot.title || '',
|
|
119
|
+
domHash: snapshot.domHash || '',
|
|
120
|
+
elementCount: snapshot.elementCount || 0,
|
|
121
|
+
interactiveElements: snapshot.interactiveElements || [],
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
pause() { if (this.status === 'recording') this.status = 'paused'; }
|
|
126
|
+
resume() { if (this.status === 'paused') this.status = 'recording'; }
|
|
127
|
+
|
|
128
|
+
complete() {
|
|
129
|
+
this.status = 'completed';
|
|
130
|
+
this.metadata.completedAt = Date.now();
|
|
131
|
+
this.metadata.duration = this.metadata.completedAt - this.metadata.startedAt;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
cancel() {
|
|
135
|
+
this.status = 'cancelled';
|
|
136
|
+
this.metadata.completedAt = Date.now();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Convert recording to a replayable Recipe
|
|
141
|
+
*/
|
|
142
|
+
toRecipe() {
|
|
143
|
+
return {
|
|
144
|
+
id: crypto.randomUUID(),
|
|
145
|
+
name: this.name,
|
|
146
|
+
description: this.description,
|
|
147
|
+
version: '1.0.0',
|
|
148
|
+
sourceRecording: this.id,
|
|
149
|
+
domain: this.metadata.domain,
|
|
150
|
+
startUrl: this.startUrl,
|
|
151
|
+
tags: this.metadata.tags,
|
|
152
|
+
created: new Date().toISOString(),
|
|
153
|
+
steps: this.events.map(evt => this._eventToStep(evt)),
|
|
154
|
+
metadata: {
|
|
155
|
+
recordedBy: this.agentId,
|
|
156
|
+
duration: this.metadata.duration,
|
|
157
|
+
pageCount: this.metadata.pageCount,
|
|
158
|
+
actionCount: this.metadata.actionCount,
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
_eventToStep(evt) {
|
|
164
|
+
const step = {
|
|
165
|
+
seq: evt.seq,
|
|
166
|
+
action: evt.type,
|
|
167
|
+
selector: evt.target.selector,
|
|
168
|
+
description: this._describeStep(evt),
|
|
169
|
+
wait: { before: 0, after: 200 }, // Default delays
|
|
170
|
+
retry: { maxAttempts: 3, delay: 500 },
|
|
171
|
+
fallback: {},
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
// Add fallback selectors
|
|
175
|
+
if (evt.target.text) step.fallback.text = evt.target.text;
|
|
176
|
+
if (evt.target.xpath) step.fallback.xpath = evt.target.xpath;
|
|
177
|
+
if (evt.target.attributes?.['aria-label']) step.fallback.ariaLabel = evt.target.attributes['aria-label'];
|
|
178
|
+
|
|
179
|
+
switch (evt.type) {
|
|
180
|
+
case 'click':
|
|
181
|
+
step.options = { button: evt.data.button, doubleClick: evt.data.doubleClick };
|
|
182
|
+
break;
|
|
183
|
+
case 'type':
|
|
184
|
+
step.value = evt.data.value;
|
|
185
|
+
step.options = { clearFirst: evt.data.clearFirst };
|
|
186
|
+
break;
|
|
187
|
+
case 'navigate':
|
|
188
|
+
step.url = evt.data.url;
|
|
189
|
+
step.options = { method: evt.data.method };
|
|
190
|
+
break;
|
|
191
|
+
case 'scroll':
|
|
192
|
+
step.options = { x: evt.data.x, y: evt.data.y, direction: evt.data.direction };
|
|
193
|
+
break;
|
|
194
|
+
case 'select':
|
|
195
|
+
step.value = evt.data.value;
|
|
196
|
+
step.options = { label: evt.data.label };
|
|
197
|
+
break;
|
|
198
|
+
case 'wait':
|
|
199
|
+
step.options = { ms: evt.data.ms, condition: evt.data.condition };
|
|
200
|
+
break;
|
|
201
|
+
case 'assert':
|
|
202
|
+
step.options = { assertion: evt.data.assertion, expected: evt.data.expected };
|
|
203
|
+
break;
|
|
204
|
+
case 'keypress':
|
|
205
|
+
step.options = { key: evt.data.key, modifiers: evt.data.modifiers };
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return step;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
_describeStep(evt) {
|
|
213
|
+
const target = evt.target.text ? `"${evt.target.text.slice(0, 50)}"` : evt.target.selector;
|
|
214
|
+
switch (evt.type) {
|
|
215
|
+
case 'click': return `Click on ${target}`;
|
|
216
|
+
case 'type': return `Type "${(evt.data.value || '').slice(0, 30)}" into ${target}`;
|
|
217
|
+
case 'navigate': return `Navigate to ${evt.data.url}`;
|
|
218
|
+
case 'scroll': return `Scroll ${evt.data.direction}`;
|
|
219
|
+
case 'select': return `Select "${evt.data.label || evt.data.value}" in ${target}`;
|
|
220
|
+
case 'hover': return `Hover over ${target}`;
|
|
221
|
+
case 'wait': return `Wait ${evt.data.ms}ms`;
|
|
222
|
+
case 'assert': return `Assert ${evt.data.assertion}`;
|
|
223
|
+
case 'keypress': return `Press ${evt.data.modifiers?.length ? evt.data.modifiers.join('+') + '+' : ''}${evt.data.key}`;
|
|
224
|
+
default: return `${evt.type} on ${target}`;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
toJSON() {
|
|
229
|
+
return {
|
|
230
|
+
id: this.id,
|
|
231
|
+
name: this.name,
|
|
232
|
+
description: this.description,
|
|
233
|
+
agentId: this.agentId,
|
|
234
|
+
startUrl: this.startUrl,
|
|
235
|
+
status: this.status,
|
|
236
|
+
events: this.events,
|
|
237
|
+
snapshots: this.snapshots,
|
|
238
|
+
metadata: this.metadata,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
244
|
+
// RECIPE EXECUTOR — Replays recorded recipes
|
|
245
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
246
|
+
|
|
247
|
+
class RecipeExecutor {
|
|
248
|
+
constructor() {
|
|
249
|
+
this.executions = new Map();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Start executing a recipe
|
|
254
|
+
* Returns an execution plan that can be consumed step-by-step
|
|
255
|
+
*/
|
|
256
|
+
startExecution(recipe, options = {}) {
|
|
257
|
+
const execution = {
|
|
258
|
+
id: crypto.randomUUID(),
|
|
259
|
+
recipeId: recipe.id,
|
|
260
|
+
recipeName: recipe.name,
|
|
261
|
+
status: 'running', // running | paused | completed | failed | aborted
|
|
262
|
+
currentStep: 0,
|
|
263
|
+
totalSteps: recipe.steps.length,
|
|
264
|
+
startedAt: Date.now(),
|
|
265
|
+
completedAt: null,
|
|
266
|
+
results: [],
|
|
267
|
+
variables: options.variables || {},
|
|
268
|
+
config: {
|
|
269
|
+
speed: options.speed || 1.0, // Playback speed multiplier
|
|
270
|
+
stopOnError: options.stopOnError !== false,
|
|
271
|
+
skipWaits: !!options.skipWaits,
|
|
272
|
+
adaptiveSelectors: options.adaptiveSelectors !== false, // Try fallbacks
|
|
273
|
+
maxRetries: options.maxRetries || 3,
|
|
274
|
+
humanInTheLoop: !!options.humanInTheLoop, // Pause on sensitive actions
|
|
275
|
+
},
|
|
276
|
+
steps: recipe.steps.map(s => ({ ...s })), // Clone steps
|
|
277
|
+
errors: [],
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
// Variable substitution in steps
|
|
281
|
+
if (Object.keys(execution.variables).length > 0) {
|
|
282
|
+
for (const step of execution.steps) {
|
|
283
|
+
if (step.value) step.value = this._substituteVars(step.value, execution.variables);
|
|
284
|
+
if (step.url) step.url = this._substituteVars(step.url, execution.variables);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
this.executions.set(execution.id, execution);
|
|
289
|
+
return execution;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Get next step to execute
|
|
294
|
+
*/
|
|
295
|
+
getNextStep(executionId) {
|
|
296
|
+
const exec = this.executions.get(executionId);
|
|
297
|
+
if (!exec || exec.status !== 'running') return null;
|
|
298
|
+
if (exec.currentStep >= exec.totalSteps) {
|
|
299
|
+
exec.status = 'completed';
|
|
300
|
+
exec.completedAt = Date.now();
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const step = exec.steps[exec.currentStep];
|
|
305
|
+
const sensitiveActions = ['type']; // Actions that might need human approval
|
|
306
|
+
if (exec.config.humanInTheLoop && sensitiveActions.includes(step.action)) {
|
|
307
|
+
step._requiresApproval = true;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return { ...step, executionId, stepIndex: exec.currentStep };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Report step result
|
|
315
|
+
*/
|
|
316
|
+
reportStepResult(executionId, stepIndex, result) {
|
|
317
|
+
const exec = this.executions.get(executionId);
|
|
318
|
+
if (!exec) return null;
|
|
319
|
+
|
|
320
|
+
exec.results[stepIndex] = {
|
|
321
|
+
stepIndex,
|
|
322
|
+
action: exec.steps[stepIndex]?.action,
|
|
323
|
+
success: result.success,
|
|
324
|
+
error: result.error || null,
|
|
325
|
+
duration: result.duration || 0,
|
|
326
|
+
timestamp: Date.now(),
|
|
327
|
+
selectorUsed: result.selectorUsed || exec.steps[stepIndex]?.selector,
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
if (result.success) {
|
|
331
|
+
exec.currentStep = stepIndex + 1;
|
|
332
|
+
} else {
|
|
333
|
+
exec.errors.push({ stepIndex, error: result.error, timestamp: Date.now() });
|
|
334
|
+
if (exec.config.stopOnError) {
|
|
335
|
+
exec.status = 'failed';
|
|
336
|
+
exec.completedAt = Date.now();
|
|
337
|
+
} else {
|
|
338
|
+
exec.currentStep = stepIndex + 1;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Auto-complete if done
|
|
343
|
+
if (exec.currentStep >= exec.totalSteps && exec.status === 'running') {
|
|
344
|
+
exec.status = 'completed';
|
|
345
|
+
exec.completedAt = Date.now();
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return exec;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
pauseExecution(executionId) {
|
|
352
|
+
const exec = this.executions.get(executionId);
|
|
353
|
+
if (exec && exec.status === 'running') exec.status = 'paused';
|
|
354
|
+
return exec;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
resumeExecution(executionId) {
|
|
358
|
+
const exec = this.executions.get(executionId);
|
|
359
|
+
if (exec && exec.status === 'paused') exec.status = 'running';
|
|
360
|
+
return exec;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
abortExecution(executionId) {
|
|
364
|
+
const exec = this.executions.get(executionId);
|
|
365
|
+
if (exec) { exec.status = 'aborted'; exec.completedAt = Date.now(); }
|
|
366
|
+
return exec;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
getExecution(executionId) { return this.executions.get(executionId) || null; }
|
|
370
|
+
|
|
371
|
+
listExecutions(limit = 50) {
|
|
372
|
+
return [...this.executions.values()]
|
|
373
|
+
.sort((a, b) => b.startedAt - a.startedAt)
|
|
374
|
+
.slice(0, limit);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
_substituteVars(str, vars) {
|
|
378
|
+
if (typeof str !== 'string') return str;
|
|
379
|
+
return str.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] !== undefined ? String(vars[key]) : `{{${key}}}`);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
getStats() {
|
|
383
|
+
const execs = [...this.executions.values()];
|
|
384
|
+
return {
|
|
385
|
+
total: execs.length,
|
|
386
|
+
running: execs.filter(e => e.status === 'running').length,
|
|
387
|
+
completed: execs.filter(e => e.status === 'completed').length,
|
|
388
|
+
failed: execs.filter(e => e.status === 'failed').length,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
394
|
+
// LfD ENGINE — Manages recordings, recipes, and executions
|
|
395
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
396
|
+
|
|
397
|
+
class LfdEngine {
|
|
398
|
+
constructor() {
|
|
399
|
+
this.sessions = new Map(); // Active recording sessions
|
|
400
|
+
this.recipes = new Map(); // Saved recipes
|
|
401
|
+
this.executor = new RecipeExecutor();
|
|
402
|
+
this.stats = {
|
|
403
|
+
totalRecordings: 0,
|
|
404
|
+
totalRecipes: 0,
|
|
405
|
+
totalExecutions: 0,
|
|
406
|
+
totalEvents: 0,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ── Recording ──
|
|
411
|
+
|
|
412
|
+
startRecording(config) {
|
|
413
|
+
const session = new RecordingSession(config);
|
|
414
|
+
this.sessions.set(session.id, session);
|
|
415
|
+
this.stats.totalRecordings++;
|
|
416
|
+
return { id: session.id, status: session.status, startedAt: session.metadata.startedAt };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
recordEvent(sessionId, event) {
|
|
420
|
+
const session = this.sessions.get(sessionId);
|
|
421
|
+
if (!session) throw new Error('Recording session not found');
|
|
422
|
+
const recorded = session.addEvent(event);
|
|
423
|
+
if (recorded) this.stats.totalEvents++;
|
|
424
|
+
return recorded;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
recordSnapshot(sessionId, snapshot) {
|
|
428
|
+
const session = this.sessions.get(sessionId);
|
|
429
|
+
if (!session) throw new Error('Recording session not found');
|
|
430
|
+
session.addSnapshot(snapshot);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
pauseRecording(sessionId) {
|
|
434
|
+
const session = this.sessions.get(sessionId);
|
|
435
|
+
if (!session) throw new Error('Recording session not found');
|
|
436
|
+
session.pause();
|
|
437
|
+
return { id: sessionId, status: session.status };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
resumeRecording(sessionId) {
|
|
441
|
+
const session = this.sessions.get(sessionId);
|
|
442
|
+
if (!session) throw new Error('Recording session not found');
|
|
443
|
+
session.resume();
|
|
444
|
+
return { id: sessionId, status: session.status };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
stopRecording(sessionId) {
|
|
448
|
+
const session = this.sessions.get(sessionId);
|
|
449
|
+
if (!session) throw new Error('Recording session not found');
|
|
450
|
+
session.complete();
|
|
451
|
+
|
|
452
|
+
// Auto-convert to recipe
|
|
453
|
+
const recipe = session.toRecipe();
|
|
454
|
+
this.recipes.set(recipe.id, recipe);
|
|
455
|
+
this.stats.totalRecipes++;
|
|
456
|
+
|
|
457
|
+
return { recording: session.toJSON(), recipe };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
cancelRecording(sessionId) {
|
|
461
|
+
const session = this.sessions.get(sessionId);
|
|
462
|
+
if (!session) throw new Error('Recording session not found');
|
|
463
|
+
session.cancel();
|
|
464
|
+
return { id: sessionId, status: 'cancelled' };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
getRecording(sessionId) {
|
|
468
|
+
const session = this.sessions.get(sessionId);
|
|
469
|
+
if (!session) return null;
|
|
470
|
+
return session.toJSON();
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
listRecordings(limit = 50) {
|
|
474
|
+
return [...this.sessions.values()]
|
|
475
|
+
.map(s => ({
|
|
476
|
+
id: s.id, name: s.name, status: s.status, domain: s.metadata.domain,
|
|
477
|
+
actionCount: s.metadata.actionCount, duration: s.metadata.duration,
|
|
478
|
+
startedAt: s.metadata.startedAt,
|
|
479
|
+
}))
|
|
480
|
+
.sort((a, b) => b.startedAt - a.startedAt)
|
|
481
|
+
.slice(0, limit);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// ── Recipes ──
|
|
485
|
+
|
|
486
|
+
saveRecipe(recipe) {
|
|
487
|
+
if (!recipe.id) recipe.id = crypto.randomUUID();
|
|
488
|
+
if (!recipe.created) recipe.created = new Date().toISOString();
|
|
489
|
+
this.recipes.set(recipe.id, recipe);
|
|
490
|
+
this.stats.totalRecipes++;
|
|
491
|
+
return recipe;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
getRecipe(recipeId) { return this.recipes.get(recipeId) || null; }
|
|
495
|
+
|
|
496
|
+
listRecipes(filters = {}, limit = 50) {
|
|
497
|
+
let recipes = [...this.recipes.values()];
|
|
498
|
+
if (filters.domain) recipes = recipes.filter(r => r.domain === filters.domain);
|
|
499
|
+
if (filters.tag) recipes = recipes.filter(r => r.tags?.includes(filters.tag));
|
|
500
|
+
if (filters.query) {
|
|
501
|
+
const q = filters.query.toLowerCase();
|
|
502
|
+
recipes = recipes.filter(r =>
|
|
503
|
+
r.name.toLowerCase().includes(q) || (r.description || '').toLowerCase().includes(q)
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
return recipes.sort((a, b) => new Date(b.created) - new Date(a.created)).slice(0, limit);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
deleteRecipe(recipeId) {
|
|
510
|
+
return this.recipes.delete(recipeId);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// ── Execution ──
|
|
514
|
+
|
|
515
|
+
executeRecipe(recipeId, options = {}) {
|
|
516
|
+
const recipe = this.recipes.get(recipeId);
|
|
517
|
+
if (!recipe) throw new Error('Recipe not found');
|
|
518
|
+
this.stats.totalExecutions++;
|
|
519
|
+
return this.executor.startExecution(recipe, options);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
getNextStep(executionId) { return this.executor.getNextStep(executionId); }
|
|
523
|
+
reportStep(executionId, stepIndex, result) { return this.executor.reportStepResult(executionId, stepIndex, result); }
|
|
524
|
+
pauseExecution(executionId) { return this.executor.pauseExecution(executionId); }
|
|
525
|
+
resumeExecution(executionId) { return this.executor.resumeExecution(executionId); }
|
|
526
|
+
abortExecution(executionId) { return this.executor.abortExecution(executionId); }
|
|
527
|
+
getExecution(executionId) { return this.executor.getExecution(executionId); }
|
|
528
|
+
listExecutions(limit) { return this.executor.listExecutions(limit); }
|
|
529
|
+
|
|
530
|
+
// ── Stats ──
|
|
531
|
+
|
|
532
|
+
getStats() {
|
|
533
|
+
return {
|
|
534
|
+
...this.stats,
|
|
535
|
+
activeRecordings: [...this.sessions.values()].filter(s => s.status === 'recording').length,
|
|
536
|
+
savedRecipes: this.recipes.size,
|
|
537
|
+
executorStats: this.executor.getStats(),
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Client-side recording script — inject into pages to capture user actions
|
|
543
|
+
*/
|
|
544
|
+
getRecordingScript(sessionId, serverUrl) {
|
|
545
|
+
return `(function(){
|
|
546
|
+
var SID='${sessionId}',API='${serverUrl || ''}/api/os/lfd';
|
|
547
|
+
var q=[];var sending=false;
|
|
548
|
+
|
|
549
|
+
function send(evt){
|
|
550
|
+
evt.url=location.href;evt.pageTitle=document.title;
|
|
551
|
+
if(API){
|
|
552
|
+
q.push(evt);if(!sending){flush();}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function flush(){
|
|
557
|
+
if(q.length===0){sending=false;return;}
|
|
558
|
+
sending=true;var batch=q.splice(0,10);
|
|
559
|
+
fetch(API+'/'+SID+'/events',{method:'POST',headers:{'Content-Type':'application/json'},
|
|
560
|
+
body:JSON.stringify({events:batch})}).catch(function(){}).finally(function(){setTimeout(flush,100);});
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function sel(el){
|
|
564
|
+
if(!el||el===document)return'body';
|
|
565
|
+
if(el.id)return'#'+CSS.escape(el.id);
|
|
566
|
+
var t=el.tagName?.toLowerCase()||'';var c=el.className;
|
|
567
|
+
if(c&&typeof c==='string'){var cls=c.trim().split(/\\s+/).slice(0,2).map(function(x){return'.'+CSS.escape(x);}).join('');if(cls)t+=cls;}
|
|
568
|
+
return t||'unknown';
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function attrs(el){
|
|
572
|
+
var a={};if(!el||!el.attributes)return a;
|
|
573
|
+
['id','class','href','type','name','placeholder','role','aria-label','value','alt'].forEach(function(n){
|
|
574
|
+
if(el.hasAttribute(n))a[n]=el.getAttribute(n);
|
|
575
|
+
});return a;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function rect(el){if(!el)return{};var r=el.getBoundingClientRect();return{x:Math.round(r.x),y:Math.round(r.y),width:Math.round(r.width),height:Math.round(r.height)};}
|
|
579
|
+
|
|
580
|
+
document.addEventListener('click',function(e){
|
|
581
|
+
send({type:'click',selector:sel(e.target),tag:e.target.tagName?.toLowerCase(),text:(e.target.textContent||'').trim().substring(0,100),
|
|
582
|
+
attributes:attrs(e.target),rect:rect(e.target),x:e.clientX,y:e.clientY,button:e.button===0?'left':'right'});
|
|
583
|
+
},true);
|
|
584
|
+
|
|
585
|
+
document.addEventListener('input',function(e){
|
|
586
|
+
if(['INPUT','TEXTAREA','SELECT'].includes(e.target.tagName)){
|
|
587
|
+
send({type:e.target.tagName==='SELECT'?'select':'type',selector:sel(e.target),tag:e.target.tagName.toLowerCase(),
|
|
588
|
+
text:(e.target.labels?.[0]?.textContent||e.target.placeholder||'').substring(0,100),
|
|
589
|
+
attributes:attrs(e.target),rect:rect(e.target),value:e.target.value?.substring(0,200)});
|
|
590
|
+
}
|
|
591
|
+
},true);
|
|
592
|
+
|
|
593
|
+
document.addEventListener('keydown',function(e){
|
|
594
|
+
if(['Enter','Escape','Tab','Backspace','Delete'].includes(e.key)||e.ctrlKey||e.metaKey){
|
|
595
|
+
send({type:'keypress',key:e.key,modifiers:[e.ctrlKey&&'ctrl',e.shiftKey&&'shift',e.altKey&&'alt',e.metaKey&&'meta'].filter(Boolean),
|
|
596
|
+
selector:sel(e.target),tag:e.target.tagName?.toLowerCase(),attributes:attrs(e.target)});
|
|
597
|
+
}
|
|
598
|
+
},true);
|
|
599
|
+
|
|
600
|
+
var lastScroll=0;
|
|
601
|
+
window.addEventListener('scroll',function(){
|
|
602
|
+
var now=Date.now();if(now-lastScroll<500)return;lastScroll=now;
|
|
603
|
+
send({type:'scroll',scrollX:window.scrollX,scrollY:window.scrollY,direction:window.scrollY>0?'down':'up'});
|
|
604
|
+
},true);
|
|
605
|
+
|
|
606
|
+
// Navigation detection
|
|
607
|
+
var lastUrl=location.href;
|
|
608
|
+
setInterval(function(){if(location.href!==lastUrl){send({type:'navigate',url:location.href,method:'spa'});lastUrl=location.href;}},500);
|
|
609
|
+
|
|
610
|
+
console.log('[WAB LfD] Recording started — session '+SID);
|
|
611
|
+
})();`;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const lfdEngine = new LfdEngine();
|
|
616
|
+
module.exports = { lfdEngine, LfdEngine, RecordingSession, RecipeExecutor };
|
|
@@ -848,6 +848,295 @@ function getVisionHistory(siteId, { limit, url } = {}) {
|
|
|
848
848
|
});
|
|
849
849
|
}
|
|
850
850
|
|
|
851
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
852
|
+
// LOCAL VISION ENGINE — Self-contained, no external API needed
|
|
853
|
+
// DOM-based element detection, dark pattern analysis, ad detection,
|
|
854
|
+
// layout analysis, accessibility audit — all computed locally.
|
|
855
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
856
|
+
|
|
857
|
+
const DARK_PATTERN_SIGNATURES = {
|
|
858
|
+
confirmshaming: [
|
|
859
|
+
/no,? i (don'?t|do not) (want|like|need|care)/i,
|
|
860
|
+
/no thanks,? i (prefer|like|want) (to )?(pay|miss|stay|lose)/i,
|
|
861
|
+
/i('?d rather|'?ll pass)/i, /keep (paying|losing|missing)/i,
|
|
862
|
+
],
|
|
863
|
+
urgency: [
|
|
864
|
+
/only \d+ left/i, /limited (time|offer|stock|availability)/i,
|
|
865
|
+
/hurry|rush|act now|don'?t miss|last chance|expires? (soon|in|today)/i,
|
|
866
|
+
/\d+ (people|others|users) (are )?(viewing|watching|buying)/i,
|
|
867
|
+
/selling fast|almost gone/i,
|
|
868
|
+
],
|
|
869
|
+
hiddenCosts: [/service fee|handling fee|processing fee|convenience fee/i, /additional charge|extra charge|booking fee/i],
|
|
870
|
+
forcedContinuity: [/free trial.*(auto|automatic).*(renew|bill|charge)/i, /will be charged after/i, /cancel anytime.*(before|or)/i],
|
|
871
|
+
sneakIntoBasket: [/added to (your )?cart|included (in|with) (your )?(order|purchase)/i, /protection plan|warranty|insurance/i],
|
|
872
|
+
privacyZuckering: [/share (your )?(data|info|details|location|contacts)/i, /personalize/i],
|
|
873
|
+
};
|
|
874
|
+
|
|
875
|
+
const AD_CLASS_PATTERNS = [
|
|
876
|
+
/\bad[s]?\b/i, /\badvert/i, /\bsponsor/i, /\bpromo(tion|ted)?\b/i,
|
|
877
|
+
/\bbanner[\-_]?ad/i, /\bgoogle[\-_]?ad/i, /\bdfp[\-_]/i, /\badsense/i, /\btaboola/i, /\boutbrain/i,
|
|
878
|
+
];
|
|
879
|
+
|
|
880
|
+
const AD_SIZES = [
|
|
881
|
+
[728, 90], [300, 250], [336, 280], [160, 600], [320, 50], [970, 250], [300, 600],
|
|
882
|
+
];
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* Analyze a DOM snapshot locally — no external API calls.
|
|
886
|
+
* @param {Array} domNodes - Flattened DOM nodes from the extraction script
|
|
887
|
+
* @param {Object} viewport - { width, height }
|
|
888
|
+
* @returns {Object} Full analysis result
|
|
889
|
+
*/
|
|
890
|
+
function analyzeLocally(domNodes, viewport = { width: 1280, height: 720 }) {
|
|
891
|
+
const elements = [];
|
|
892
|
+
const darkPatterns = [];
|
|
893
|
+
const adElements = [];
|
|
894
|
+
const accessibilityIssues = [];
|
|
895
|
+
|
|
896
|
+
// Layout detection
|
|
897
|
+
const layoutRegions = [];
|
|
898
|
+
const LAYOUT_SELECTORS = {
|
|
899
|
+
header: ['header', '[role="banner"]'],
|
|
900
|
+
navigation: ['nav', '[role="navigation"]'],
|
|
901
|
+
main: ['main', '[role="main"]', 'article'],
|
|
902
|
+
sidebar: ['aside', '[role="complementary"]'],
|
|
903
|
+
footer: ['footer', '[role="contentinfo"]'],
|
|
904
|
+
};
|
|
905
|
+
|
|
906
|
+
for (const node of domNodes) {
|
|
907
|
+
const tag = (node.tag || '').toLowerCase();
|
|
908
|
+
const cls = (node.attributes?.class || '').toLowerCase();
|
|
909
|
+
const id = (node.attributes?.id || '').toLowerCase();
|
|
910
|
+
const role = (node.attributes?.role || '').toLowerCase();
|
|
911
|
+
const text = (node.text || '').trim();
|
|
912
|
+
const rect = node.rect || {};
|
|
913
|
+
|
|
914
|
+
// ── Element detection ──
|
|
915
|
+
let elType = null;
|
|
916
|
+
let confidence = 0;
|
|
917
|
+
|
|
918
|
+
if (tag === 'button' || role === 'button' || (tag === 'input' && ['submit', 'button'].includes(node.attributes?.type))) {
|
|
919
|
+
elType = 'button'; confidence = 0.95;
|
|
920
|
+
} else if (tag === 'a' && node.attributes?.href) {
|
|
921
|
+
elType = 'link'; confidence = 0.9;
|
|
922
|
+
} else if (['input', 'textarea'].includes(tag) || role === 'textbox') {
|
|
923
|
+
elType = 'input'; confidence = 0.95;
|
|
924
|
+
} else if (tag === 'select' || role === 'listbox' || role === 'combobox') {
|
|
925
|
+
elType = 'dropdown'; confidence = 0.9;
|
|
926
|
+
} else if (tag === 'form' || role === 'form') {
|
|
927
|
+
elType = 'form'; confidence = 0.85;
|
|
928
|
+
} else if (['img', 'picture', 'svg', 'video', 'canvas'].includes(tag) || role === 'img') {
|
|
929
|
+
elType = 'image'; confidence = 0.8;
|
|
930
|
+
} else if (['nav', 'header', 'footer'].includes(tag) || ['navigation', 'banner', 'contentinfo'].includes(role) || cls.includes('nav') || cls.includes('menu')) {
|
|
931
|
+
elType = 'nav'; confidence = 0.75;
|
|
932
|
+
} else if (cls.includes('btn') || cls.includes('button') || cls.includes('cta')) {
|
|
933
|
+
elType = 'button'; confidence = 0.7;
|
|
934
|
+
} else if (cls.includes('dropdown') || cls.includes('select')) {
|
|
935
|
+
elType = 'dropdown'; confidence = 0.65;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
if (elType) {
|
|
939
|
+
elements.push({
|
|
940
|
+
type: elType,
|
|
941
|
+
label: (text || node.attributes?.placeholder || node.attributes?.['aria-label'] || node.attributes?.alt || '').slice(0, 200),
|
|
942
|
+
description: `${tag} element${cls ? ' class=' + cls.slice(0, 80) : ''}`,
|
|
943
|
+
boundingBox: { x: rect.x || 0, y: rect.y || 0, width: rect.width || 0, height: rect.height || 0 },
|
|
944
|
+
suggestedSelector: node.selector || _buildFallbackSelector(node),
|
|
945
|
+
confidence,
|
|
946
|
+
interactable: ['button', 'link', 'input', 'dropdown', 'form'].includes(elType),
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// ── Dark pattern detection ──
|
|
951
|
+
if (text.length > 5) {
|
|
952
|
+
for (const [patternName, regexes] of Object.entries(DARK_PATTERN_SIGNATURES)) {
|
|
953
|
+
for (const rx of regexes) {
|
|
954
|
+
if (rx.test(text)) {
|
|
955
|
+
darkPatterns.push({ type: patternName, text: text.slice(0, 200), selector: node.selector || '', severity: patternName === 'urgency' ? 'medium' : 'high', confidence: 0.85 });
|
|
956
|
+
break;
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// Pre-checked upsell checkbox detection
|
|
963
|
+
if (tag === 'input' && node.attributes?.type === 'checkbox' && node.attributes?.checked != null) {
|
|
964
|
+
const lbl = text.toLowerCase();
|
|
965
|
+
if (/newsletter|marketing|promo|share|partner|third.party|sms|offer/i.test(lbl)) {
|
|
966
|
+
darkPatterns.push({ type: 'misdirection', text: `Pre-checked: "${text.slice(0, 100)}"`, selector: node.selector || '', severity: 'medium', confidence: 0.9 });
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// ── Ad detection ──
|
|
971
|
+
let isAd = false;
|
|
972
|
+
for (const rx of AD_CLASS_PATTERNS) {
|
|
973
|
+
if (rx.test(cls) || rx.test(id)) { isAd = true; break; }
|
|
974
|
+
}
|
|
975
|
+
if (!isAd && rect.width && rect.height) {
|
|
976
|
+
for (const [w, h] of AD_SIZES) {
|
|
977
|
+
if (Math.abs(rect.width - w) < 10 && Math.abs(rect.height - h) < 10) { isAd = true; break; }
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
if (!isAd && tag === 'iframe' && node.attributes?.src) {
|
|
981
|
+
if (/doubleclick|googlesyndication|adnxs|criteo|taboola|outbrain/i.test(node.attributes.src)) isAd = true;
|
|
982
|
+
}
|
|
983
|
+
if (isAd) adElements.push({ tag, selector: node.selector || '', rect, reason: `class/id/size match` });
|
|
984
|
+
|
|
985
|
+
// ── Accessibility ──
|
|
986
|
+
if (tag === 'img' && !node.attributes?.alt) {
|
|
987
|
+
accessibilityIssues.push({ type: 'missing-alt', severity: 'high', selector: node.selector || '' });
|
|
988
|
+
}
|
|
989
|
+
if (['button', 'a', 'input'].includes(tag) && rect.width > 0 && (rect.width < 44 || rect.height < 44)) {
|
|
990
|
+
accessibilityIssues.push({ type: 'small-tap-target', severity: 'medium', selector: node.selector || '', size: `${rect.width}x${rect.height}` });
|
|
991
|
+
}
|
|
992
|
+
if (['input', 'select', 'textarea'].includes(tag) && !node.attributes?.['aria-label'] && !node.attributes?.['aria-labelledby'] && !node.attributes?.id) {
|
|
993
|
+
accessibilityIssues.push({ type: 'missing-label', severity: 'high', selector: node.selector || '' });
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// ── Layout regions ──
|
|
997
|
+
for (const [regionName, selectors] of Object.entries(LAYOUT_SELECTORS)) {
|
|
998
|
+
if (selectors.some(s => {
|
|
999
|
+
if (s.startsWith('[role="')) return role === s.match(/\[role="(.+?)"\]/)?.[1];
|
|
1000
|
+
return tag === s;
|
|
1001
|
+
})) {
|
|
1002
|
+
layoutRegions.push({ type: regionName, tag, rect, selector: node.selector || '' });
|
|
1003
|
+
break;
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
// Build analysis text (human-readable summary for caching)
|
|
1009
|
+
const analysisText = JSON.stringify({
|
|
1010
|
+
summary: {
|
|
1011
|
+
totalElements: elements.length,
|
|
1012
|
+
buttons: elements.filter(e => e.type === 'button').length,
|
|
1013
|
+
links: elements.filter(e => e.type === 'link').length,
|
|
1014
|
+
inputs: elements.filter(e => e.type === 'input').length,
|
|
1015
|
+
forms: elements.filter(e => e.type === 'form').length,
|
|
1016
|
+
darkPatterns: darkPatterns.length,
|
|
1017
|
+
ads: adElements.length,
|
|
1018
|
+
accessibilityIssues: accessibilityIssues.length,
|
|
1019
|
+
},
|
|
1020
|
+
elements,
|
|
1021
|
+
darkPatterns,
|
|
1022
|
+
ads: adElements,
|
|
1023
|
+
accessibility: {
|
|
1024
|
+
issues: accessibilityIssues,
|
|
1025
|
+
score: Math.max(0, 100 - accessibilityIssues.length * 5),
|
|
1026
|
+
},
|
|
1027
|
+
layout: { regions: layoutRegions, columns: layoutRegions.filter(r => r.type === 'sidebar').length > 0 ? 2 : 1 },
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
return {
|
|
1031
|
+
text: analysisText,
|
|
1032
|
+
tokens: 0, // Local analysis — no tokens used
|
|
1033
|
+
elements,
|
|
1034
|
+
darkPatterns,
|
|
1035
|
+
ads: adElements,
|
|
1036
|
+
accessibility: { issues: accessibilityIssues, score: Math.max(0, 100 - accessibilityIssues.length * 5) },
|
|
1037
|
+
layout: { regions: layoutRegions },
|
|
1038
|
+
};
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
function _buildFallbackSelector(node) {
|
|
1042
|
+
const tag = node.tag || 'div';
|
|
1043
|
+
if (node.attributes?.id) return '#' + node.attributes.id;
|
|
1044
|
+
let s = tag;
|
|
1045
|
+
if (node.attributes?.class) {
|
|
1046
|
+
const cls = node.attributes.class.trim().split(/\s+/).slice(0, 2).join('.');
|
|
1047
|
+
if (cls) s += '.' + cls;
|
|
1048
|
+
}
|
|
1049
|
+
return s;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
/**
|
|
1053
|
+
* DOM Extraction Script — inject into pages to capture DOM for local analysis.
|
|
1054
|
+
* Returns minimal JSON with all interactive/layout elements + computed styles.
|
|
1055
|
+
*/
|
|
1056
|
+
function getDomExtractionScript() {
|
|
1057
|
+
return `(function(){
|
|
1058
|
+
var MAX_D=8,INT=new Set(['a','button','input','select','textarea','details','summary','label']),
|
|
1059
|
+
LAY=new Set(['header','nav','main','aside','footer','article','section','div','form']),
|
|
1060
|
+
SKIP=new Set(['script','style','noscript','meta','link','br','hr']);
|
|
1061
|
+
function ext(el,d){
|
|
1062
|
+
if(d>MAX_D)return null;var t=el.tagName;if(!t)return null;t=t.toLowerCase();
|
|
1063
|
+
if(SKIP.has(t))return null;var r=el.getBoundingClientRect();
|
|
1064
|
+
if(r.width===0&&r.height===0&&!LAY.has(t)&&!INT.has(t))return null;
|
|
1065
|
+
var cs=window.getComputedStyle(el);if(cs.display==='none'||cs.visibility==='hidden')return null;
|
|
1066
|
+
var n={tag:t,text:(el.textContent||'').trim().substring(0,200),selector:sel(el),attributes:{},
|
|
1067
|
+
rect:{x:Math.round(r.x),y:Math.round(r.y),width:Math.round(r.width),height:Math.round(r.height)},
|
|
1068
|
+
visible:r.width>0&&r.height>0&&cs.opacity!=='0'};
|
|
1069
|
+
['id','class','href','src','alt','type','name','value','placeholder','role','aria-label',
|
|
1070
|
+
'aria-labelledby','aria-checked','data-action','checked','disabled'].forEach(function(a){
|
|
1071
|
+
if(el.hasAttribute(a))n.attributes[a]=el.getAttribute(a);
|
|
1072
|
+
});if(el.checked)n.attributes.checked='checked';
|
|
1073
|
+
if(LAY.has(t)||INT.has(t)){n.children=[];for(var c of el.children){var cn=ext(c,d+1);if(cn)n.children.push(cn);}}
|
|
1074
|
+
return n;
|
|
1075
|
+
}
|
|
1076
|
+
function sel(el){if(el.id)return'#'+CSS.escape(el.id);var p=[];var c=el;
|
|
1077
|
+
for(var i=0;i<4&&c&&c!==document.body;i++){var s=c.tagName.toLowerCase();
|
|
1078
|
+
if(c.id){p.unshift('#'+CSS.escape(c.id));break;}
|
|
1079
|
+
if(c.className&&typeof c.className==='string'){var cl=c.className.trim().split(/\\s+/).slice(0,2).map(function(x){return'.'+CSS.escape(x);}).join('');if(cl)s+=cl;}
|
|
1080
|
+
p.unshift(s);c=c.parentElement;}return p.join(' > ');}
|
|
1081
|
+
function flat(n,r){if(!n)return;var ch=n.children;delete n.children;r.push(n);if(ch)ch.forEach(function(c){flat(c,r);});}
|
|
1082
|
+
var root=ext(document.body,0);var f=[];flat(root,f);
|
|
1083
|
+
return JSON.stringify({url:location.href,title:document.title,viewport:{width:innerWidth,height:innerHeight},dom:f,
|
|
1084
|
+
meta:{lang:document.documentElement.lang||'',charset:document.characterSet}});
|
|
1085
|
+
})();`;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
1089
|
+
// Enhanced analyzeScreenshot — use local engine when provider is 'local'
|
|
1090
|
+
// and DOM data is provided (no external API call needed)
|
|
1091
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
1092
|
+
|
|
1093
|
+
async function analyzePageDOM(siteId, { domSnapshot, url } = {}) {
|
|
1094
|
+
if (!domSnapshot || !domSnapshot.dom) throw new Error('domSnapshot with dom array is required');
|
|
1095
|
+
|
|
1096
|
+
const dataStr = JSON.stringify(domSnapshot.dom).slice(0, 2000);
|
|
1097
|
+
const screenshotHash = crypto.createHash('sha256').update(dataStr).digest('hex');
|
|
1098
|
+
|
|
1099
|
+
// Check cache
|
|
1100
|
+
const cached = stmts.getCacheByHash.get(siteId, screenshotHash);
|
|
1101
|
+
if (cached) {
|
|
1102
|
+
let elements = [];
|
|
1103
|
+
try { elements = JSON.parse(cached.elements_found || '[]'); } catch {}
|
|
1104
|
+
return { analysis: cached.analysis, elements, cached: true, latency_ms: cached.latency_ms, tokens_used: 0, cache_id: cached.id };
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
const startTime = Date.now();
|
|
1108
|
+
const result = analyzeLocally(domSnapshot.dom || [], domSnapshot.viewport);
|
|
1109
|
+
const latencyMs = Date.now() - startTime;
|
|
1110
|
+
|
|
1111
|
+
const cacheId = uuidv4();
|
|
1112
|
+
const config = stmts.getConfig.get(siteId);
|
|
1113
|
+
const cacheTtl = config?.cache_ttl || 300;
|
|
1114
|
+
const expiresAt = new Date(Date.now() + cacheTtl * 1000).toISOString();
|
|
1115
|
+
|
|
1116
|
+
stmts.insertCache.run(cacheId, siteId, url || domSnapshot.url || null, screenshotHash, result.text, JSON.stringify(result.elements), 'local', 'dom-engine', 0, latencyMs, expiresAt);
|
|
1117
|
+
|
|
1118
|
+
const insertElements = db.transaction((elems) => {
|
|
1119
|
+
for (const el of elems) {
|
|
1120
|
+
stmts.insertElement.run(uuidv4(), cacheId, siteId, el.type, el.label, el.description, JSON.stringify(el.boundingBox), el.suggestedSelector, el.confidence, el.interactable ? 1 : 0);
|
|
1121
|
+
}
|
|
1122
|
+
});
|
|
1123
|
+
insertElements(result.elements);
|
|
1124
|
+
|
|
1125
|
+
return {
|
|
1126
|
+
analysis: result.text,
|
|
1127
|
+
elements: result.elements,
|
|
1128
|
+
darkPatterns: result.darkPatterns,
|
|
1129
|
+
ads: result.ads,
|
|
1130
|
+
accessibility: result.accessibility,
|
|
1131
|
+
layout: result.layout,
|
|
1132
|
+
cached: false,
|
|
1133
|
+
latency_ms: latencyMs,
|
|
1134
|
+
tokens_used: 0,
|
|
1135
|
+
cache_id: cacheId,
|
|
1136
|
+
engine: 'local-dom',
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
|
|
851
1140
|
// ═══════════════════════════════════════════════════════════════════════
|
|
852
1141
|
// Exports
|
|
853
1142
|
// ═══════════════════════════════════════════════════════════════════════
|
|
@@ -856,6 +1145,9 @@ module.exports = {
|
|
|
856
1145
|
configureVision,
|
|
857
1146
|
getVisionConfig,
|
|
858
1147
|
analyzeScreenshot,
|
|
1148
|
+
analyzePageDOM,
|
|
1149
|
+
analyzeLocally,
|
|
1150
|
+
getDomExtractionScript,
|
|
859
1151
|
buildVisionPrompt,
|
|
860
1152
|
parseVisionResponse,
|
|
861
1153
|
extractElementsFromAnalysis,
|