web-agent-bridge 3.4.0 → 3.8.1

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 (310) hide show
  1. package/LICENSE +84 -84
  2. package/README.ar.md +1563 -1304
  3. package/README.md +137 -298
  4. package/bin/agent-runner.js +474 -474
  5. package/bin/cli.js +237 -237
  6. package/bin/wab-init.js +244 -223
  7. package/bin/wab.js +80 -80
  8. package/examples/azure-dns-wab.js +83 -83
  9. package/examples/bidi-agent.js +119 -119
  10. package/examples/cloudflare-wab-dns.js +121 -121
  11. package/examples/cpanel-wab-dns.js +114 -114
  12. package/examples/cross-site-agent.js +91 -91
  13. package/examples/dns-discovery-agent.js +166 -166
  14. package/examples/gcp-dns-wab.js +76 -76
  15. package/examples/governance-agent.js +169 -169
  16. package/examples/mcp-agent.js +94 -94
  17. package/examples/next-app-router/README.md +44 -44
  18. package/examples/plesk-wab-dns.js +103 -103
  19. package/examples/puppeteer-agent.js +108 -108
  20. package/examples/route53-wab-dns.js +144 -144
  21. package/examples/saas-dashboard/README.md +55 -55
  22. package/examples/safe-mode-agent.js +96 -96
  23. package/examples/self-discovery.js +106 -0
  24. package/examples/shopify-hydrogen/README.md +74 -74
  25. package/examples/vision-agent.js +171 -171
  26. package/examples/wab-sign.js +74 -74
  27. package/examples/wab-verify.js +60 -60
  28. package/examples/wordpress-elementor/README.md +77 -77
  29. package/package.json +93 -93
  30. package/public/.well-known/agent-tools.json +180 -180
  31. package/public/.well-known/ai-assets.json +59 -59
  32. package/public/.well-known/security.txt +8 -8
  33. package/public/.well-known/wab.json +28 -28
  34. package/public/activate.html +448 -368
  35. package/public/adopt.html +236 -0
  36. package/public/adoption-metrics.html +188 -188
  37. package/public/agent-workspace.html +359 -349
  38. package/public/ai.html +198 -198
  39. package/public/api.html +397 -413
  40. package/public/azure-dns-integration.html +289 -289
  41. package/public/browser.html +486 -486
  42. package/public/cloudflare-integration.html +380 -380
  43. package/public/commander-dashboard.html +243 -243
  44. package/public/cookies.html +210 -210
  45. package/public/cpanel-integration.html +398 -398
  46. package/public/css/agent-workspace.css +1713 -1713
  47. package/public/css/premium.css +317 -317
  48. package/public/css/styles.css +1401 -1263
  49. package/public/dashboard-shieldlink.html +295 -0
  50. package/public/dashboard.html +711 -707
  51. package/public/dns.html +436 -436
  52. package/public/docs.html +588 -588
  53. package/public/enterprise-mesh.ar.html +80 -0
  54. package/public/enterprise-mesh.html +81 -0
  55. package/public/feed.xml +89 -89
  56. package/public/gcp-dns-integration.html +318 -318
  57. package/public/governance.ar.html +70 -0
  58. package/public/governance.html +69 -0
  59. package/public/growth.html +465 -465
  60. package/public/index.html +1372 -1266
  61. package/public/integrations.html +556 -556
  62. package/public/js/activate.js +449 -145
  63. package/public/js/agent-workspace.js +1740 -1740
  64. package/public/js/auth-nav.js +117 -65
  65. package/public/js/auth-redirect.js +12 -12
  66. package/public/js/cookie-consent.js +56 -56
  67. package/public/js/dns.js +438 -438
  68. package/public/js/wab-demo-page.js +721 -721
  69. package/public/js/ws-client.js +74 -74
  70. package/public/l-preview.html +242 -0
  71. package/public/llms-full.txt +360 -360
  72. package/public/llms.txt +125 -125
  73. package/public/login.html +85 -85
  74. package/public/mesh-dashboard.html +328 -328
  75. package/public/milestones.html +346 -0
  76. package/public/one-click.html +779 -0
  77. package/public/openapi.json +669 -669
  78. package/public/partners.ar.html +145 -0
  79. package/public/partners.html +143 -0
  80. package/public/phone-shield.html +281 -281
  81. package/public/plesk-integration.html +375 -375
  82. package/public/premium-dashboard.html +2489 -2489
  83. package/public/premium.html +793 -793
  84. package/public/privacy.html +297 -297
  85. package/public/provider-onboarding.html +172 -172
  86. package/public/provider-sandbox.html +134 -134
  87. package/public/providers.html +359 -359
  88. package/public/refusals.html +172 -0
  89. package/public/register.html +105 -105
  90. package/public/registrar-integrations.html +141 -141
  91. package/public/ring4.html +292 -0
  92. package/public/robots.txt +99 -99
  93. package/public/route53-integration.html +531 -531
  94. package/public/score.html +263 -0
  95. package/public/script/wab-consent.d.ts +36 -36
  96. package/public/script/wab-consent.js +104 -104
  97. package/public/script/wab-schema.js +131 -131
  98. package/public/script/wab.d.ts +108 -108
  99. package/public/script/wab.min.js +580 -580
  100. package/public/security.txt +8 -8
  101. package/public/shieldlink.html +244 -0
  102. package/public/shieldqr.html +231 -231
  103. package/public/sitemap.xml +13 -1
  104. package/public/terms.html +256 -256
  105. package/public/trust-graph-api.ar.html +92 -0
  106. package/public/trust-graph-api.html +91 -0
  107. package/public/wab-features.html +560 -0
  108. package/public/wab-trust.html +200 -200
  109. package/public/wab-truth.html +375 -0
  110. package/public/wab-vs-protocols.html +210 -210
  111. package/public/whitepaper.html +449 -449
  112. package/script/ai-agent-bridge.js +1754 -1754
  113. package/sdk/README.md +99 -99
  114. package/sdk/agent-mesh.js +449 -449
  115. package/sdk/auto-discovery.js +301 -288
  116. package/sdk/commander.js +262 -262
  117. package/sdk/governance.js +262 -262
  118. package/sdk/index.d.ts +464 -464
  119. package/sdk/index.js +649 -649
  120. package/sdk/multi-agent.js +318 -318
  121. package/sdk/safe-mode.js +221 -221
  122. package/sdk/safety-shield.js +219 -219
  123. package/sdk/schema-discovery.js +83 -83
  124. package/server/adapters/index.js +520 -520
  125. package/server/config/plans.js +412 -367
  126. package/server/config/secrets.js +102 -102
  127. package/server/control-plane/index.js +301 -301
  128. package/server/data-plane/index.js +354 -354
  129. package/server/index.js +790 -670
  130. package/server/llm/index.js +404 -404
  131. package/server/middleware/adminAuth.js +35 -35
  132. package/server/middleware/api-tier.js +170 -0
  133. package/server/middleware/auth.js +50 -50
  134. package/server/middleware/featureGate.js +88 -88
  135. package/server/middleware/rateLimits.js +100 -100
  136. package/server/middleware/sensitiveAction.js +157 -157
  137. package/server/middleware/wab-trust.js +141 -0
  138. package/server/migrations/001_add_analytics_indexes.sql +7 -7
  139. package/server/migrations/002_premium_features.sql +418 -418
  140. package/server/migrations/003_ads_integer_cents.sql +33 -33
  141. package/server/migrations/004_agent_os.sql +158 -158
  142. package/server/migrations/005_marketplace_metering.sql +126 -126
  143. package/server/migrations/006_growth_suite.sql +138 -0
  144. package/server/migrations/007_governance.sql +106 -106
  145. package/server/migrations/008_plans.sql +144 -144
  146. package/server/migrations/009_shieldqr.sql +30 -30
  147. package/server/migrations/010_extended_trust.sql +33 -33
  148. package/server/migrations/011_outreach.sql +47 -0
  149. package/server/migrations/012_shieldlink.sql +116 -0
  150. package/server/migrations/013_ct_monitor.sql +13 -0
  151. package/server/migrations/014_wab_advanced_features.sql +128 -0
  152. package/server/migrations/015_wab_truth_layer.sql +101 -0
  153. package/server/migrations/016_ring4_external_trust.sql +84 -0
  154. package/server/migrations/017_ring4_extensions.sql +69 -0
  155. package/server/migrations/018_commercial_foundations.sql +167 -0
  156. package/server/migrations/019_unify_tier_constraints.sql +133 -0
  157. package/server/models/adapters/index.js +33 -33
  158. package/server/models/adapters/mysql.js +183 -183
  159. package/server/models/adapters/postgresql.js +172 -172
  160. package/server/models/adapters/sqlite.js +7 -7
  161. package/server/models/db.js +740 -740
  162. package/server/observability/failure-analysis.js +337 -337
  163. package/server/observability/index.js +394 -394
  164. package/server/protocol/capabilities.js +223 -223
  165. package/server/protocol/index.js +243 -243
  166. package/server/protocol/schema.js +584 -584
  167. package/server/registry/certification.js +271 -271
  168. package/server/registry/index.js +326 -326
  169. package/server/routes/activate.js +478 -0
  170. package/server/routes/admin-outreach.js +239 -0
  171. package/server/routes/admin-plans.js +76 -76
  172. package/server/routes/admin-premium.js +674 -673
  173. package/server/routes/admin-shieldlink.js +137 -0
  174. package/server/routes/admin-shieldqr.js +90 -90
  175. package/server/routes/admin-trust-monitor.js +139 -83
  176. package/server/routes/admin.js +550 -549
  177. package/server/routes/adopt.js +61 -0
  178. package/server/routes/ads.js +130 -130
  179. package/server/routes/agent-workspace.js +540 -540
  180. package/server/routes/api-keys.js +127 -0
  181. package/server/routes/api.js +150 -150
  182. package/server/routes/auth.js +71 -71
  183. package/server/routes/billing.js +57 -57
  184. package/server/routes/commander.js +316 -316
  185. package/server/routes/customer-shieldlink.js +133 -0
  186. package/server/routes/demo-showcase.js +332 -332
  187. package/server/routes/demo-store.js +154 -154
  188. package/server/routes/diagnose.js +373 -0
  189. package/server/routes/discovery.js +2348 -2348
  190. package/server/routes/enterprise-mesh.js +170 -0
  191. package/server/routes/gateway.js +173 -173
  192. package/server/routes/governance-saas.js +203 -0
  193. package/server/routes/governance.js +208 -208
  194. package/server/routes/growth.js +1048 -0
  195. package/server/routes/intent.js +328 -0
  196. package/server/routes/license.js +251 -251
  197. package/server/routes/mesh.js +469 -469
  198. package/server/routes/noscript.js +543 -543
  199. package/server/routes/partners.js +201 -0
  200. package/server/routes/plans.js +33 -33
  201. package/server/routes/premium-v2.js +686 -686
  202. package/server/routes/premium.js +724 -724
  203. package/server/routes/providers.js +650 -650
  204. package/server/routes/reputation.js +411 -0
  205. package/server/routes/ring4.js +885 -0
  206. package/server/routes/runtime.js +2148 -2148
  207. package/server/routes/shieldlink.js +70 -0
  208. package/server/routes/shieldqr.js +88 -88
  209. package/server/routes/sovereign.js +465 -465
  210. package/server/routes/truth-layer.js +670 -0
  211. package/server/routes/universal.js +200 -200
  212. package/server/routes/unsubscribe.js +51 -0
  213. package/server/routes/wab-api.js +850 -850
  214. package/server/routes/wab-cache.js +282 -0
  215. package/server/runtime/container-worker.js +111 -111
  216. package/server/runtime/container.js +448 -448
  217. package/server/runtime/distributed-worker.js +362 -362
  218. package/server/runtime/event-bus.js +210 -210
  219. package/server/runtime/index.js +253 -253
  220. package/server/runtime/queue.js +599 -599
  221. package/server/runtime/replay.js +666 -666
  222. package/server/runtime/sandbox.js +266 -266
  223. package/server/runtime/scheduler.js +534 -534
  224. package/server/runtime/session-engine.js +293 -293
  225. package/server/runtime/state-manager.js +188 -188
  226. package/server/secrets/wab-signing-key.pem +3 -0
  227. package/server/secrets/wab-signing-pub.pem +3 -0
  228. package/server/security/cross-site-redactor.js +196 -196
  229. package/server/security/dry-run.js +180 -180
  230. package/server/security/human-gate-rate-limit.js +147 -147
  231. package/server/security/human-gate-transports.js +178 -178
  232. package/server/security/human-gate.js +281 -281
  233. package/server/security/index.js +368 -368
  234. package/server/security/intent-engine.js +245 -245
  235. package/server/security/reward-guard.js +171 -171
  236. package/server/security/rollback-store.js +239 -239
  237. package/server/security/token-scope.js +404 -404
  238. package/server/security/url-policy.js +139 -139
  239. package/server/services/adoption-agent.js +182 -0
  240. package/server/services/agent-chat.js +506 -506
  241. package/server/services/agent-learning.js +601 -601
  242. package/server/services/agent-memory.js +625 -625
  243. package/server/services/agent-mesh.js +555 -555
  244. package/server/services/agent-symphony.js +717 -717
  245. package/server/services/agent-tasks.js +1807 -1807
  246. package/server/services/api-key-engine.js +292 -292
  247. package/server/services/cluster.js +894 -894
  248. package/server/services/commander.js +738 -738
  249. package/server/services/edge-compute.js +440 -440
  250. package/server/services/email.js +233 -233
  251. package/server/services/fairness-engine.js +409 -0
  252. package/server/services/fairness.js +420 -0
  253. package/server/services/governance.js +466 -466
  254. package/server/services/hosted-runtime.js +205 -205
  255. package/server/services/lfd.js +635 -635
  256. package/server/services/local-ai.js +389 -389
  257. package/server/services/marketplace.js +270 -270
  258. package/server/services/metering.js +182 -182
  259. package/server/services/modules/affiliate-intelligence.js +93 -93
  260. package/server/services/modules/agent-firewall.js +90 -90
  261. package/server/services/modules/bounty.js +89 -89
  262. package/server/services/modules/collective-bargaining.js +92 -92
  263. package/server/services/modules/dark-pattern.js +66 -66
  264. package/server/services/modules/gov-intelligence.js +45 -45
  265. package/server/services/modules/neural.js +55 -55
  266. package/server/services/modules/notary.js +49 -49
  267. package/server/services/modules/price-time-machine.js +86 -86
  268. package/server/services/modules/protocol.js +104 -104
  269. package/server/services/negotiation.js +439 -439
  270. package/server/services/outreach-agent.js +312 -0
  271. package/server/services/plans.js +214 -214
  272. package/server/services/plugins.js +771 -771
  273. package/server/services/price-intelligence.js +566 -566
  274. package/server/services/price-shield.js +1137 -1137
  275. package/server/services/provider-clients.js +740 -740
  276. package/server/services/reputation.js +465 -465
  277. package/server/services/search-engine.js +357 -357
  278. package/server/services/security.js +513 -513
  279. package/server/services/self-healing.js +843 -843
  280. package/server/services/shieldlink.js +492 -0
  281. package/server/services/shieldqr.js +322 -322
  282. package/server/services/sovereign-shield.js +542 -542
  283. package/server/services/ssl-ct-monitor.js +224 -0
  284. package/server/services/ssl-inspector.js +42 -42
  285. package/server/services/ssl-monitor.js +167 -167
  286. package/server/services/stripe.js +206 -205
  287. package/server/services/swarm.js +788 -788
  288. package/server/services/universal-scraper.js +662 -662
  289. package/server/services/verification.js +481 -481
  290. package/server/services/vision.js +1163 -1163
  291. package/server/services/wab-crypto.js +178 -178
  292. package/server/utils/cache.js +125 -125
  293. package/server/utils/migrate.js +81 -81
  294. package/server/utils/safe-fetch.js +228 -228
  295. package/server/utils/secureFields.js +50 -50
  296. package/server/ws.js +161 -161
  297. package/templates/artisan-marketplace.yaml +104 -104
  298. package/templates/book-price-scout.yaml +98 -98
  299. package/templates/electronics-price-tracker.yaml +108 -108
  300. package/templates/flight-deal-hunter.yaml +113 -113
  301. package/templates/freelancer-direct.yaml +116 -116
  302. package/templates/grocery-price-compare.yaml +93 -93
  303. package/templates/hotel-direct-booking.yaml +113 -113
  304. package/templates/local-services.yaml +98 -98
  305. package/templates/olive-oil-tunisia.yaml +88 -88
  306. package/templates/organic-farm-fresh.yaml +101 -101
  307. package/templates/restaurant-direct.yaml +97 -97
  308. package/templates/ring4/banking-sovereign.yaml +55 -0
  309. package/templates/ring4/ecommerce-sovereign.yaml +58 -0
  310. package/templates/ring4/healthcare-sovereign.yaml +60 -0
@@ -1,1137 +1,1137 @@
1
- /**
2
- * Dynamic Pricing Shield — Price Manipulation Detection Engine
3
- * ════════════════════════════════════════════════════════════════════════
4
- * Exposes how websites manipulate prices based on user identity signals:
5
- * - Search frequency (repeated visits → higher prices)
6
- * - Geolocation (IP-based regional pricing discrimination)
7
- * - Device fingerprint (mobile vs desktop, brand premium)
8
- * - Login status (logged-in users see different prices)
9
- * - Cookies & browsing history (retargeting surcharges)
10
- * - Time-of-day / day-of-week patterns
11
- * - Referral source (search engine vs direct vs social)
12
- *
13
- * Architecture:
14
- * Multi-Identity Probing: Agent opens the same page with N distinct
15
- * identity "personas" — each with unique User-Agent, Accept-Language,
16
- * cookies, referrer, and device hints. Prices collected from each probe
17
- * are compared statistically to detect variance → manipulation.
18
- *
19
- * Integration:
20
- * - Ghost Mode (wab-browser): provides stealth fingerprints on client
21
- * - Verification Engine: cross-checks prices via DOM+vision layer
22
- * - Symphony Orchestrator: 'price-shield' template chains probing,
23
- * analysis, and negotiation into one automated pipeline
24
- * - Learning Engine: records manipulation patterns for future reference
25
- * - Reputation: penalises sites caught using dynamic pricing tricks
26
- *
27
- * Everything runs locally. No data leaves the WAB instance.
28
- */
29
-
30
- const crypto = require('crypto');
31
- const { db } = require('../models/db');
32
-
33
- // ─── Schema ──────────────────────────────────────────────────────────
34
-
35
- db.exec(`
36
- CREATE TABLE IF NOT EXISTS price_probes (
37
- id TEXT PRIMARY KEY,
38
- scan_id TEXT NOT NULL,
39
- site_id TEXT,
40
- url TEXT NOT NULL,
41
- persona_id TEXT NOT NULL,
42
- persona_label TEXT NOT NULL,
43
- persona_config TEXT DEFAULT '{}',
44
- detected_price REAL,
45
- currency TEXT DEFAULT 'USD',
46
- raw_price_text TEXT,
47
- response_headers TEXT DEFAULT '{}',
48
- cookies_received TEXT DEFAULT '[]',
49
- probe_duration_ms INTEGER DEFAULT 0,
50
- created_at TEXT DEFAULT (datetime('now'))
51
- );
52
-
53
- CREATE TABLE IF NOT EXISTS price_scans (
54
- id TEXT PRIMARY KEY,
55
- site_id TEXT,
56
- url TEXT NOT NULL,
57
- item_name TEXT,
58
- category TEXT,
59
- probe_count INTEGER DEFAULT 0,
60
- lowest_price REAL,
61
- highest_price REAL,
62
- median_price REAL,
63
- price_variance REAL DEFAULT 0,
64
- manipulation_score REAL DEFAULT 0,
65
- manipulation_type TEXT DEFAULT 'none',
66
- manipulation_details TEXT DEFAULT '{}',
67
- recommended_price REAL,
68
- recommended_persona TEXT,
69
- status TEXT DEFAULT 'pending' CHECK(status IN (
70
- 'pending','probing','analyzing','completed','failed'
71
- )),
72
- created_at TEXT DEFAULT (datetime('now')),
73
- completed_at TEXT
74
- );
75
-
76
- CREATE TABLE IF NOT EXISTS price_manipulation_log (
77
- id TEXT PRIMARY KEY,
78
- scan_id TEXT NOT NULL,
79
- site_id TEXT,
80
- url TEXT NOT NULL,
81
- manipulation_type TEXT NOT NULL,
82
- severity TEXT NOT NULL CHECK(severity IN ('low','medium','high','critical')),
83
- price_spread REAL DEFAULT 0,
84
- price_spread_pct REAL DEFAULT 0,
85
- lowest_price REAL,
86
- highest_price REAL,
87
- details TEXT DEFAULT '{}',
88
- created_at TEXT DEFAULT (datetime('now'))
89
- );
90
-
91
- CREATE TABLE IF NOT EXISTS price_history (
92
- id TEXT PRIMARY KEY,
93
- url TEXT NOT NULL,
94
- item_name TEXT,
95
- price REAL NOT NULL,
96
- currency TEXT DEFAULT 'USD',
97
- source_persona TEXT,
98
- captured_at TEXT DEFAULT (datetime('now'))
99
- );
100
-
101
- CREATE INDEX IF NOT EXISTS idx_probes_scan ON price_probes(scan_id);
102
- CREATE INDEX IF NOT EXISTS idx_probes_url ON price_probes(url);
103
- CREATE INDEX IF NOT EXISTS idx_scans_url ON price_scans(url);
104
- CREATE INDEX IF NOT EXISTS idx_scans_status ON price_scans(status);
105
- CREATE INDEX IF NOT EXISTS idx_manip_site ON price_manipulation_log(site_id);
106
- CREATE INDEX IF NOT EXISTS idx_manip_type ON price_manipulation_log(manipulation_type);
107
- CREATE INDEX IF NOT EXISTS idx_history_url ON price_history(url);
108
- CREATE INDEX IF NOT EXISTS idx_history_time ON price_history(captured_at);
109
- `);
110
-
111
- // ─── Prepared Statements ─────────────────────────────────────────────
112
-
113
- const stmts = {
114
- insertScan: db.prepare(`
115
- INSERT INTO price_scans (id, site_id, url, item_name, category, status)
116
- VALUES (?, ?, ?, ?, ?, 'pending')
117
- `),
118
- updateScan: db.prepare(`
119
- UPDATE price_scans
120
- SET probe_count = ?, lowest_price = ?, highest_price = ?, median_price = ?,
121
- price_variance = ?, manipulation_score = ?, manipulation_type = ?,
122
- manipulation_details = ?, recommended_price = ?, recommended_persona = ?,
123
- status = ?, completed_at = datetime('now')
124
- WHERE id = ?
125
- `),
126
- updateScanStatus: db.prepare(`UPDATE price_scans SET status = ? WHERE id = ?`),
127
- getScan: db.prepare(`SELECT * FROM price_scans WHERE id = ?`),
128
- getScansForUrl: db.prepare(`SELECT * FROM price_scans WHERE url = ? ORDER BY created_at DESC LIMIT ?`),
129
- getRecentScans: db.prepare(`SELECT * FROM price_scans WHERE status = 'completed' ORDER BY created_at DESC LIMIT ?`),
130
-
131
- insertProbe: db.prepare(`
132
- INSERT INTO price_probes
133
- (id, scan_id, site_id, url, persona_id, persona_label, persona_config,
134
- detected_price, currency, raw_price_text, response_headers, cookies_received, probe_duration_ms)
135
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
136
- `),
137
- getProbes: db.prepare(`SELECT * FROM price_probes WHERE scan_id = ? ORDER BY created_at ASC`),
138
-
139
- insertManipulation: db.prepare(`
140
- INSERT INTO price_manipulation_log
141
- (id, scan_id, site_id, url, manipulation_type, severity,
142
- price_spread, price_spread_pct, lowest_price, highest_price, details)
143
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
144
- `),
145
- getManipulations: db.prepare(`SELECT * FROM price_manipulation_log WHERE scan_id = ?`),
146
- getManipulationsBySite: db.prepare(`SELECT * FROM price_manipulation_log WHERE site_id = ? ORDER BY created_at DESC LIMIT ?`),
147
- getManipulationStats: db.prepare(`
148
- SELECT manipulation_type, severity,
149
- COUNT(*) as count,
150
- AVG(price_spread_pct) as avg_spread_pct,
151
- MAX(price_spread_pct) as max_spread_pct
152
- FROM price_manipulation_log
153
- GROUP BY manipulation_type, severity
154
- ORDER BY count DESC
155
- `),
156
-
157
- insertHistory: db.prepare(`
158
- INSERT INTO price_history (id, url, item_name, price, currency, source_persona)
159
- VALUES (?, ?, ?, ?, ?, ?)
160
- `),
161
- getHistory: db.prepare(`SELECT * FROM price_history WHERE url = ? ORDER BY captured_at DESC LIMIT ?`),
162
- getHistoryRange: db.prepare(`
163
- SELECT * FROM price_history
164
- WHERE url = ? AND captured_at >= ? AND captured_at <= ?
165
- ORDER BY captured_at ASC
166
- `),
167
- };
168
-
169
- // ─── Identity Personas ───────────────────────────────────────────────
170
- // Each persona simulates a distinct user profile that might trigger
171
- // different dynamic pricing on the target site.
172
-
173
- const PERSONAS = [
174
- {
175
- id: 'clean-desktop',
176
- label: 'Clean Desktop Visitor',
177
- description: 'Fresh Chrome/Windows session, no cookies, no history',
178
- category: 'baseline',
179
- config: {
180
- userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
181
- acceptLanguage: 'en-US,en;q=0.9',
182
- platform: 'Win32',
183
- mobile: false,
184
- cookies: {},
185
- referrer: '',
186
- headers: { 'Sec-CH-UA-Platform': '"Windows"', 'Sec-CH-UA-Mobile': '?0' }
187
- }
188
- },
189
- {
190
- id: 'clean-mobile',
191
- label: 'Clean Mobile Visitor',
192
- description: 'Fresh Safari/iPhone, no cookies — tests mobile pricing',
193
- category: 'device',
194
- config: {
195
- userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1',
196
- acceptLanguage: 'en-US,en;q=0.9',
197
- platform: 'iPhone',
198
- mobile: true,
199
- cookies: {},
200
- referrer: '',
201
- headers: { 'Sec-CH-UA-Platform': '"iOS"', 'Sec-CH-UA-Mobile': '?1' }
202
- }
203
- },
204
- {
205
- id: 'premium-mac',
206
- label: 'Premium Mac User',
207
- description: 'Safari on macOS — tests Apple/premium device surcharge',
208
- category: 'device',
209
- config: {
210
- userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_4) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15',
211
- acceptLanguage: 'en-US,en;q=0.9',
212
- platform: 'MacIntel',
213
- mobile: false,
214
- cookies: {},
215
- referrer: '',
216
- headers: { 'Sec-CH-UA-Platform': '"macOS"', 'Sec-CH-UA-Mobile': '?0' }
217
- }
218
- },
219
- {
220
- id: 'geo-eu',
221
- label: 'European Visitor',
222
- description: 'German Firefox on Linux — tests EU geolocation pricing',
223
- category: 'geolocation',
224
- config: {
225
- userAgent: 'Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0',
226
- acceptLanguage: 'de-DE,de;q=0.9,en-US;q=0.5,en;q=0.3',
227
- platform: 'Linux x86_64',
228
- mobile: false,
229
- cookies: {},
230
- referrer: '',
231
- headers: {
232
- 'Sec-CH-UA-Platform': '"Linux"', 'Sec-CH-UA-Mobile': '?0',
233
- 'X-Forwarded-For': '85.214.132.117'
234
- }
235
- }
236
- },
237
- {
238
- id: 'geo-mena',
239
- label: 'MENA Region Visitor',
240
- description: 'Arabic Chrome on Windows — tests Middle East pricing',
241
- category: 'geolocation',
242
- config: {
243
- userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
244
- acceptLanguage: 'ar-SA,ar;q=0.9,en-US;q=0.5,en;q=0.3',
245
- platform: 'Win32',
246
- mobile: false,
247
- cookies: {},
248
- referrer: '',
249
- headers: { 'Sec-CH-UA-Platform': '"Windows"', 'Sec-CH-UA-Mobile': '?0' }
250
- }
251
- },
252
- {
253
- id: 'geo-sea',
254
- label: 'Southeast Asia Visitor',
255
- description: 'Chrome on Android — tests SEA regional pricing',
256
- category: 'geolocation',
257
- config: {
258
- userAgent: 'Mozilla/5.0 (Linux; Android 14; SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36',
259
- acceptLanguage: 'th-TH,th;q=0.9,en-US;q=0.5,en;q=0.3',
260
- platform: 'Linux armv8l',
261
- mobile: true,
262
- cookies: {},
263
- referrer: '',
264
- headers: { 'Sec-CH-UA-Platform': '"Android"', 'Sec-CH-UA-Mobile': '?1' }
265
- }
266
- },
267
- {
268
- id: 'repeat-visitor',
269
- label: 'Repeat Visitor (3rd visit)',
270
- description: 'Simulates return visits with existing cookies — tests urgency/frequency markup',
271
- category: 'behavior',
272
- config: {
273
- userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
274
- acceptLanguage: 'en-US,en;q=0.9',
275
- platform: 'Win32',
276
- mobile: false,
277
- cookies: {
278
- '_visit_count': '3',
279
- '_last_visit': new Date(Date.now() - 3600000).toISOString(),
280
- '_viewed_items': 'true'
281
- },
282
- referrer: '',
283
- headers: { 'Sec-CH-UA-Platform': '"Windows"', 'Sec-CH-UA-Mobile': '?0' }
284
- }
285
- },
286
- {
287
- id: 'search-referral',
288
- label: 'Google Search Referral',
289
- description: 'Arrives from Google search — tests referral-based pricing',
290
- category: 'referral',
291
- config: {
292
- userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
293
- acceptLanguage: 'en-US,en;q=0.9',
294
- platform: 'Win32',
295
- mobile: false,
296
- cookies: {},
297
- referrer: 'https://www.google.com/search?q=best+deals',
298
- headers: { 'Sec-CH-UA-Platform': '"Windows"', 'Sec-CH-UA-Mobile': '?0' }
299
- }
300
- },
301
- {
302
- id: 'social-referral',
303
- label: 'Social Media Referral',
304
- description: 'Arrives from Facebook — tests social referral pricing',
305
- category: 'referral',
306
- config: {
307
- userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
308
- acceptLanguage: 'en-US,en;q=0.9',
309
- platform: 'Win32',
310
- mobile: false,
311
- cookies: {},
312
- referrer: 'https://www.facebook.com/',
313
- headers: { 'Sec-CH-UA-Platform': '"Windows"', 'Sec-CH-UA-Mobile': '?0' }
314
- }
315
- },
316
- {
317
- id: 'price-compare-referral',
318
- label: 'Price Comparison Referral',
319
- description: 'Arrives from a price comparison site — typically forces lowest price',
320
- category: 'referral',
321
- config: {
322
- userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
323
- acceptLanguage: 'en-US,en;q=0.9',
324
- platform: 'Win32',
325
- mobile: false,
326
- cookies: {},
327
- referrer: 'https://www.google.com/shopping',
328
- headers: { 'Sec-CH-UA-Platform': '"Windows"', 'Sec-CH-UA-Mobile': '?0' }
329
- }
330
- },
331
- {
332
- id: 'incognito-linux',
333
- label: 'Privacy-Focused User',
334
- description: 'Firefox on Linux, DNT + GPC enabled — tests privacy-aware pricing',
335
- category: 'privacy',
336
- config: {
337
- userAgent: 'Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0',
338
- acceptLanguage: 'en-US,en;q=0.5',
339
- platform: 'Linux x86_64',
340
- mobile: false,
341
- cookies: {},
342
- referrer: '',
343
- headers: {
344
- 'Sec-CH-UA-Platform': '"Linux"', 'Sec-CH-UA-Mobile': '?0',
345
- 'DNT': '1', 'Sec-GPC': '1'
346
- }
347
- }
348
- },
349
- {
350
- id: 'bot-like',
351
- label: 'Bot-Like Agent',
352
- description: 'Minimal headers, no JS hints — tests anti-bot price walls',
353
- category: 'stealth',
354
- config: {
355
- userAgent: 'Mozilla/5.0 (compatible; WABAgent/1.0)',
356
- acceptLanguage: 'en',
357
- platform: '',
358
- mobile: false,
359
- cookies: {},
360
- referrer: '',
361
- headers: {}
362
- }
363
- },
364
- ];
365
-
366
- // ─── Price Extraction Utilities ──────────────────────────────────────
367
-
368
- function extractPrice(text) {
369
- if (typeof text !== 'string') return null;
370
- const patterns = [
371
- /[\$€£¥]\s*([\d,]+\.?\d*)/,
372
- /([\d,]+\.?\d*)\s*[\$€£¥]/,
373
- /(?:USD|EUR|GBP|SAR|AED|TND)\s*([\d,]+\.?\d*)/i,
374
- /([\d,]+\.?\d*)\s*(?:USD|EUR|GBP|SAR|AED|TND)/i,
375
- ];
376
- for (const pattern of patterns) {
377
- const match = text.match(pattern);
378
- if (match) return parseFloat(match[1].replace(/,/g, ''));
379
- }
380
- return null;
381
- }
382
-
383
- function detectCurrency(text) {
384
- if (typeof text !== 'string') return 'USD';
385
- const map = { '$': 'USD', '€': 'EUR', '£': 'GBP', '¥': 'JPY' };
386
- for (const [sym, code] of Object.entries(map)) {
387
- if (text.includes(sym)) return code;
388
- }
389
- const codeMatch = text.match(/\b(USD|EUR|GBP|SAR|AED|TND|JPY)\b/i);
390
- return codeMatch ? codeMatch[1].toUpperCase() : 'USD';
391
- }
392
-
393
- // ─── Statistical Helpers ─────────────────────────────────────────────
394
-
395
- function median(arr) {
396
- if (!arr.length) return 0;
397
- const sorted = [...arr].sort((a, b) => a - b);
398
- const mid = Math.floor(sorted.length / 2);
399
- return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
400
- }
401
-
402
- function variance(arr) {
403
- if (arr.length < 2) return 0;
404
- const mean = arr.reduce((s, v) => s + v, 0) / arr.length;
405
- return arr.reduce((s, v) => s + (v - mean) ** 2, 0) / (arr.length - 1);
406
- }
407
-
408
- function standardDeviation(arr) {
409
- return Math.sqrt(variance(arr));
410
- }
411
-
412
- function coefficientOfVariation(arr) {
413
- if (!arr.length) return 0;
414
- const mean = arr.reduce((s, v) => s + v, 0) / arr.length;
415
- if (mean === 0) return 0;
416
- return standardDeviation(arr) / mean;
417
- }
418
-
419
- function zScores(arr) {
420
- const mean = arr.reduce((s, v) => s + v, 0) / arr.length;
421
- const sd = standardDeviation(arr);
422
- if (sd === 0) return arr.map(() => 0);
423
- return arr.map(v => (v - mean) / sd);
424
- }
425
-
426
- // ─── Core: Create Scan ───────────────────────────────────────────────
427
-
428
- /**
429
- * Initiate a new price manipulation scan for a given URL/product.
430
- * Returns a scan object with an ID the caller uses to add probes.
431
- */
432
- function createScan({ siteId, url, itemName, category }) {
433
- const id = crypto.randomBytes(12).toString('hex');
434
- stmts.insertScan.run(id, siteId || null, url, itemName || null, category || null);
435
- return {
436
- scanId: id,
437
- url,
438
- itemName,
439
- personas: PERSONAS.map(p => ({ id: p.id, label: p.label, category: p.category })),
440
- status: 'pending'
441
- };
442
- }
443
-
444
- // ─── Core: Record Probe Result ───────────────────────────────────────
445
-
446
- /**
447
- * After probing a page with a specific persona, record the result.
448
- * The agent (or browser extension) calls this once per persona.
449
- */
450
- function recordProbe(scanId, {
451
- personaId, priceText, currency, responseHeaders, cookiesReceived, durationMs
452
- }) {
453
- const persona = PERSONAS.find(p => p.id === personaId);
454
- if (!persona) return { error: 'unknown_persona', personaId };
455
-
456
- const scan = stmts.getScan.get(scanId);
457
- if (!scan) return { error: 'scan_not_found' };
458
-
459
- const price = extractPrice(priceText);
460
- const detectedCurrency = currency || detectCurrency(priceText);
461
-
462
- const probeId = crypto.randomBytes(12).toString('hex');
463
-
464
- stmts.insertProbe.run(
465
- probeId, scanId, scan.site_id, scan.url,
466
- persona.id, persona.label, JSON.stringify(persona.config),
467
- price, detectedCurrency, priceText || null,
468
- JSON.stringify(responseHeaders || {}),
469
- JSON.stringify(cookiesReceived || []),
470
- durationMs || 0
471
- );
472
-
473
- // Update scan status
474
- if (scan.status === 'pending') {
475
- stmts.updateScanStatus.run('probing', scanId);
476
- }
477
-
478
- // Record price history
479
- if (price !== null) {
480
- stmts.insertHistory.run(
481
- crypto.randomBytes(12).toString('hex'),
482
- scan.url, scan.item_name, price, detectedCurrency, persona.id
483
- );
484
- }
485
-
486
- return {
487
- probeId,
488
- personaId: persona.id,
489
- personaLabel: persona.label,
490
- detectedPrice: price,
491
- currency: detectedCurrency,
492
- status: 'recorded'
493
- };
494
- }
495
-
496
- // ─── Core: Analyze Scan ──────────────────────────────────────────────
497
-
498
- /**
499
- * After all (or enough) probes are recorded, analyze the scan to detect
500
- * price manipulation. Returns a comprehensive manipulation report.
501
- */
502
- function analyzeScan(scanId) {
503
- const scan = stmts.getScan.get(scanId);
504
- if (!scan) return { error: 'scan_not_found' };
505
-
506
- const probes = stmts.getProbes.all(scanId);
507
- if (probes.length < 2) {
508
- return { error: 'insufficient_probes', minimum: 2, current: probes.length };
509
- }
510
-
511
- stmts.updateScanStatus.run('analyzing', scanId);
512
-
513
- // Collect valid prices
514
- const validProbes = probes.filter(p => p.detected_price !== null);
515
- if (validProbes.length < 2) {
516
- stmts.updateScan.run(
517
- probes.length, null, null, null, 0, 0, 'none', '{}', null, null, 'completed', scanId
518
- );
519
- return { scanId, status: 'completed', manipulation: false, reason: 'insufficient_price_data' };
520
- }
521
-
522
- const prices = validProbes.map(p => p.detected_price);
523
- const lowestPrice = Math.min(...prices);
524
- const highestPrice = Math.max(...prices);
525
- const medianPrice = median(prices);
526
- const priceVariance = variance(prices);
527
- const cv = coefficientOfVariation(prices);
528
- const spread = highestPrice - lowestPrice;
529
- const spreadPct = lowestPrice > 0 ? (spread / lowestPrice) * 100 : 0;
530
-
531
- // ─── Manipulation Detection Algorithms ─────────────────────────
532
-
533
- const manipulations = [];
534
- let manipulationScore = 0;
535
-
536
- // 1. Overall price variance test
537
- if (cv > 0.02) { // >2% coefficient of variation
538
- const severity = cv > 0.15 ? 'critical' : cv > 0.08 ? 'high' : cv > 0.04 ? 'medium' : 'low';
539
- const severityScore = { low: 10, medium: 25, high: 50, critical: 80 };
540
- manipulations.push({
541
- type: 'price_variance',
542
- severity,
543
- description: `Price varies by ${spreadPct.toFixed(1)}% across identities ($${lowestPrice} — $${highestPrice})`,
544
- spread,
545
- spreadPct: Math.round(spreadPct * 10) / 10,
546
- cv: Math.round(cv * 1000) / 1000
547
- });
548
- manipulationScore += severityScore[severity];
549
- }
550
-
551
- // 2. Device-based discrimination
552
- const deviceAnalysis = _analyzeByCategory(validProbes, 'device');
553
- if (deviceAnalysis.detected) {
554
- manipulations.push({
555
- type: 'device_discrimination',
556
- severity: deviceAnalysis.severity,
557
- description: deviceAnalysis.description,
558
- details: deviceAnalysis.details
559
- });
560
- manipulationScore += deviceAnalysis.score;
561
- }
562
-
563
- // 3. Geolocation-based pricing
564
- const geoAnalysis = _analyzeByCategory(validProbes, 'geolocation');
565
- if (geoAnalysis.detected) {
566
- manipulations.push({
567
- type: 'geo_pricing',
568
- severity: geoAnalysis.severity,
569
- description: geoAnalysis.description,
570
- details: geoAnalysis.details
571
- });
572
- manipulationScore += geoAnalysis.score;
573
- }
574
-
575
- // 4. Referral-source manipulation
576
- const referralAnalysis = _analyzeByCategory(validProbes, 'referral');
577
- if (referralAnalysis.detected) {
578
- manipulations.push({
579
- type: 'referral_manipulation',
580
- severity: referralAnalysis.severity,
581
- description: referralAnalysis.description,
582
- details: referralAnalysis.details
583
- });
584
- manipulationScore += referralAnalysis.score;
585
- }
586
-
587
- // 5. Repeat-visitor surcharge detection
588
- const repeatProbe = validProbes.find(p => p.persona_id === 'repeat-visitor');
589
- const baselineProbe = validProbes.find(p => p.persona_id === 'clean-desktop');
590
- if (repeatProbe && baselineProbe && repeatProbe.detected_price && baselineProbe.detected_price) {
591
- const repeatDiff = repeatProbe.detected_price - baselineProbe.detected_price;
592
- const repeatPct = baselineProbe.detected_price > 0
593
- ? (repeatDiff / baselineProbe.detected_price) * 100 : 0;
594
- if (repeatPct > 1) { // >1% surcharge for returning visitors
595
- const severity = repeatPct > 10 ? 'critical' : repeatPct > 5 ? 'high' : repeatPct > 2 ? 'medium' : 'low';
596
- manipulations.push({
597
- type: 'repeat_visitor_surcharge',
598
- severity,
599
- description: `Returning visitors pay ${repeatPct.toFixed(1)}% more ($${repeatProbe.detected_price} vs $${baselineProbe.detected_price} for first-time)`,
600
- details: { repeatPrice: repeatProbe.detected_price, baselinePrice: baselineProbe.detected_price, surcharge: repeatDiff }
601
- });
602
- manipulationScore += { low: 15, medium: 30, high: 50, critical: 75 }[severity];
603
- }
604
- }
605
-
606
- // 6. Privacy penalty — sites charging more when tracking is blocked
607
- const privacyProbe = validProbes.find(p => p.persona_id === 'incognito-linux');
608
- if (privacyProbe && baselineProbe && privacyProbe.detected_price && baselineProbe.detected_price) {
609
- const privacyDiff = privacyProbe.detected_price - baselineProbe.detected_price;
610
- const privacyPct = baselineProbe.detected_price > 0
611
- ? (privacyDiff / baselineProbe.detected_price) * 100 : 0;
612
- if (privacyPct > 1) {
613
- const severity = privacyPct > 8 ? 'high' : privacyPct > 3 ? 'medium' : 'low';
614
- manipulations.push({
615
- type: 'privacy_penalty',
616
- severity,
617
- description: `Privacy-focused browsers see ${privacyPct.toFixed(1)}% higher prices — site penalises tracking blockers`,
618
- details: { privacyPrice: privacyProbe.detected_price, baselinePrice: baselineProbe.detected_price, penalty: privacyDiff }
619
- });
620
- manipulationScore += { low: 10, medium: 25, high: 45 }[severity];
621
- }
622
- }
623
-
624
- // 7. Bot-detection price wall
625
- const botProbe = validProbes.find(p => p.persona_id === 'bot-like');
626
- if (botProbe && baselineProbe) {
627
- if (botProbe.detected_price === null && baselineProbe.detected_price) {
628
- manipulations.push({
629
- type: 'bot_price_wall',
630
- severity: 'medium',
631
- description: 'Site hides prices from bot-like visitors — anti-scraping defence detected',
632
- details: { botBlocked: true }
633
- });
634
- manipulationScore += 20;
635
- } else if (botProbe.detected_price && baselineProbe.detected_price) {
636
- const botDiff = Math.abs(botProbe.detected_price - baselineProbe.detected_price);
637
- const botPct = baselineProbe.detected_price > 0
638
- ? (botDiff / baselineProbe.detected_price) * 100 : 0;
639
- if (botPct > 3) {
640
- manipulations.push({
641
- type: 'bot_price_wall',
642
- severity: botPct > 10 ? 'high' : 'medium',
643
- description: `Bot-like agents see ${botPct.toFixed(1)}% different prices — automated visitor detection active`,
644
- details: { botPrice: botProbe.detected_price, baselinePrice: baselineProbe.detected_price }
645
- });
646
- manipulationScore += botPct > 10 ? 35 : 15;
647
- }
648
- }
649
- }
650
-
651
- // 8. Time-pattern detection (using historical data)
652
- const historicalManipulation = _analyzeHistoricalPrices(scan.url);
653
- if (historicalManipulation.detected) {
654
- manipulations.push({
655
- type: 'temporal_manipulation',
656
- severity: historicalManipulation.severity,
657
- description: historicalManipulation.description,
658
- details: historicalManipulation.details
659
- });
660
- manipulationScore += historicalManipulation.score;
661
- }
662
-
663
- // ─── Clamp & Classify ──────────────────────────────────────────
664
-
665
- manipulationScore = Math.min(100, manipulationScore);
666
-
667
- const manipulationType =
668
- manipulationScore === 0 ? 'none' :
669
- manipulationScore < 20 ? 'minor' :
670
- manipulationScore < 45 ? 'moderate' :
671
- manipulationScore < 70 ? 'significant' :
672
- 'severe';
673
-
674
- // Find recommended (lowest) price persona
675
- let recommendedPrice = lowestPrice;
676
- let recommendedPersona = null;
677
- for (const probe of validProbes) {
678
- if (probe.detected_price === lowestPrice) {
679
- recommendedPersona = probe.persona_id;
680
- break;
681
- }
682
- }
683
-
684
- // ─── Z-Score Outlier Detection ─────────────────────────────────
685
-
686
- const zs = zScores(prices);
687
- const outliers = [];
688
- validProbes.forEach((p, i) => {
689
- if (Math.abs(zs[i]) > 1.5) {
690
- outliers.push({
691
- persona: p.persona_id,
692
- label: p.persona_label,
693
- price: p.detected_price,
694
- zScore: Math.round(zs[i] * 100) / 100,
695
- direction: zs[i] > 0 ? 'above_average' : 'below_average'
696
- });
697
- }
698
- });
699
-
700
- // ─── Persist ───────────────────────────────────────────────────
701
-
702
- const manipulationDetails = {
703
- manipulations,
704
- outliers,
705
- statistics: {
706
- mean: Math.round((prices.reduce((s, v) => s + v, 0) / prices.length) * 100) / 100,
707
- median: medianPrice,
708
- standardDeviation: Math.round(standardDeviation(prices) * 100) / 100,
709
- coefficientOfVariation: Math.round(cv * 1000) / 1000,
710
- probeCount: validProbes.length,
711
- totalProbes: probes.length
712
- }
713
- };
714
-
715
- stmts.updateScan.run(
716
- probes.length, lowestPrice, highestPrice, medianPrice,
717
- Math.round(priceVariance * 100) / 100, manipulationScore,
718
- manipulationType, JSON.stringify(manipulationDetails),
719
- recommendedPrice, recommendedPersona,
720
- 'completed', scanId
721
- );
722
-
723
- // Log individual manipulations
724
- for (const m of manipulations) {
725
- stmts.insertManipulation.run(
726
- crypto.randomBytes(12).toString('hex'),
727
- scanId, scan.site_id, scan.url,
728
- m.type, m.severity,
729
- spread, Math.round(spreadPct * 10) / 10,
730
- lowestPrice, highestPrice,
731
- JSON.stringify(m.details || {})
732
- );
733
- }
734
-
735
- return {
736
- scanId,
737
- url: scan.url,
738
- itemName: scan.item_name,
739
- status: 'completed',
740
- manipulation: {
741
- detected: manipulationScore > 0,
742
- score: manipulationScore,
743
- level: manipulationType,
744
- types: manipulations.map(m => m.type),
745
- count: manipulations.length
746
- },
747
- prices: {
748
- lowest: lowestPrice,
749
- highest: highestPrice,
750
- median: medianPrice,
751
- spread,
752
- spreadPct: Math.round(spreadPct * 10) / 10
753
- },
754
- recommendation: {
755
- bestPrice: recommendedPrice,
756
- bestPersona: recommendedPersona,
757
- bestPersonaLabel: PERSONAS.find(p => p.id === recommendedPersona)?.label || 'Unknown',
758
- savings: highestPrice - recommendedPrice,
759
- savingsPct: highestPrice > 0
760
- ? Math.round(((highestPrice - recommendedPrice) / highestPrice) * 1000) / 10
761
- : 0,
762
- strategy: _buildStrategy(manipulations, recommendedPersona)
763
- },
764
- manipulations,
765
- outliers,
766
- statistics: manipulationDetails.statistics,
767
- probes: validProbes.map(p => ({
768
- persona: p.persona_id,
769
- label: p.persona_label,
770
- price: p.detected_price,
771
- currency: p.currency
772
- }))
773
- };
774
- }
775
-
776
- // ─── Category Analysis Helper ────────────────────────────────────────
777
-
778
- function _analyzeByCategory(probes, category) {
779
- const persona = PERSONAS.filter(p => p.category === category);
780
- const categoryProbes = probes.filter(p => persona.some(ps => ps.id === p.persona_id));
781
- const baselineProbe = probes.find(p => p.persona_id === 'clean-desktop');
782
-
783
- if (categoryProbes.length === 0 || !baselineProbe || !baselineProbe.detected_price) {
784
- return { detected: false };
785
- }
786
-
787
- const categoryPrices = categoryProbes.filter(p => p.detected_price !== null);
788
- if (categoryPrices.length === 0) return { detected: false };
789
-
790
- const baseline = baselineProbe.detected_price;
791
- const diffs = categoryPrices.map(p => ({
792
- persona: p.persona_id,
793
- label: p.persona_label,
794
- price: p.detected_price,
795
- diff: p.detected_price - baseline,
796
- diffPct: baseline > 0 ? ((p.detected_price - baseline) / baseline) * 100 : 0
797
- }));
798
-
799
- const maxAbsDiffPct = Math.max(...diffs.map(d => Math.abs(d.diffPct)));
800
-
801
- if (maxAbsDiffPct < 1) return { detected: false };
802
-
803
- const categoryLabels = {
804
- device: 'Device-based pricing',
805
- geolocation: 'Geolocation-based pricing',
806
- referral: 'Referral-source pricing',
807
- behavior: 'Behavioral pricing',
808
- privacy: 'Privacy-based pricing',
809
- stealth: 'Bot-detection pricing'
810
- };
811
-
812
- const severity = maxAbsDiffPct > 15 ? 'critical' : maxAbsDiffPct > 8 ? 'high' : maxAbsDiffPct > 3 ? 'medium' : 'low';
813
- const score = { low: 10, medium: 25, high: 45, critical: 70 }[severity];
814
-
815
- const description = `${categoryLabels[category] || category} detected: up to ${maxAbsDiffPct.toFixed(1)}% difference vs baseline`;
816
-
817
- return {
818
- detected: true,
819
- severity,
820
- score,
821
- description,
822
- details: {
823
- category,
824
- baseline: { persona: 'clean-desktop', price: baseline },
825
- comparisons: diffs
826
- }
827
- };
828
- }
829
-
830
- // ─── Historical Price Analysis ───────────────────────────────────────
831
-
832
- function _analyzeHistoricalPrices(url) {
833
- const history = stmts.getHistory.all(url, 50);
834
-
835
- if (history.length < 5) {
836
- return { detected: false };
837
- }
838
-
839
- const prices = history.map(h => h.price);
840
- const cv = coefficientOfVariation(prices);
841
-
842
- if (cv < 0.05) return { detected: false };
843
-
844
- // Check for upward trend (price creep)
845
- const recentAvg = prices.slice(0, Math.min(5, prices.length)).reduce((s, v) => s + v, 0) /
846
- Math.min(5, prices.length);
847
- const olderAvg = prices.slice(-Math.min(5, prices.length)).reduce((s, v) => s + v, 0) /
848
- Math.min(5, prices.length);
849
-
850
- const trend = olderAvg > 0 ? ((recentAvg - olderAvg) / olderAvg) * 100 : 0;
851
-
852
- if (Math.abs(trend) < 2) return { detected: false };
853
-
854
- const direction = trend > 0 ? 'increasing' : 'decreasing';
855
- const severity = Math.abs(trend) > 15 ? 'high' : Math.abs(trend) > 5 ? 'medium' : 'low';
856
-
857
- return {
858
- detected: true,
859
- severity,
860
- score: { low: 10, medium: 20, high: 35 }[severity],
861
- description: `Price ${direction} over time: ${Math.abs(trend).toFixed(1)}% change detected across ${history.length} observations`,
862
- details: {
863
- recentAvg: Math.round(recentAvg * 100) / 100,
864
- olderAvg: Math.round(olderAvg * 100) / 100,
865
- trend: Math.round(trend * 10) / 10,
866
- direction,
867
- dataPoints: history.length
868
- }
869
- };
870
- }
871
-
872
- // ─── Strategy Builder ────────────────────────────────────────────────
873
-
874
- function _buildStrategy(manipulations, bestPersona) {
875
- const tips = [];
876
-
877
- if (manipulations.length === 0) {
878
- tips.push('No dynamic pricing detected — this site shows consistent prices across identities.');
879
- return { tips, approach: 'direct' };
880
- }
881
-
882
- const types = new Set(manipulations.map(m => m.type));
883
-
884
- if (types.has('device_discrimination')) {
885
- tips.push('Switch to a different device or browser to trigger lower pricing.');
886
- }
887
- if (types.has('geo_pricing')) {
888
- tips.push('Use a VPN to appear from a region with cheaper pricing.');
889
- }
890
- if (types.has('repeat_visitor_surcharge')) {
891
- tips.push('Clear cookies and browsing data before purchasing, or use incognito mode.');
892
- }
893
- if (types.has('referral_manipulation')) {
894
- tips.push('Try arriving via a price-comparison site or Google Shopping for lower referral-triggered prices.');
895
- }
896
- if (types.has('privacy_penalty')) {
897
- tips.push('Temporarily allow tracking — some sites charge more when tracking is blocked.');
898
- }
899
- if (types.has('bot_price_wall')) {
900
- tips.push('Ensure your browser appears as a regular human visitor.');
901
- }
902
- if (types.has('temporal_manipulation')) {
903
- tips.push('Check prices at different times of day — this site changes prices over time.');
904
- }
905
- if (types.has('price_variance')) {
906
- tips.push('Significant price variance detected. Use the recommended persona/identity to access the lowest price.');
907
- }
908
-
909
- const persona = PERSONAS.find(p => p.id === bestPersona);
910
- if (persona) {
911
- tips.push(`Best identity for lowest price: "${persona.label}" — ${persona.description}`);
912
- }
913
-
914
- return {
915
- tips,
916
- approach: manipulations.some(m => m.severity === 'critical' || m.severity === 'high')
917
- ? 'stealth' : 'optimized',
918
- recommendedPersona: bestPersona
919
- };
920
- }
921
-
922
- // ─── Quick Scan (All-in-One) ─────────────────────────────────────────
923
-
924
- /**
925
- * Perform a quick scan by providing pre-collected probe data.
926
- * Useful when the client collects all prices first, then sends them.
927
- *
928
- * @param {Object} options
929
- * @param {string} options.url - Product page URL
930
- * @param {string} options.itemName - Product name
931
- * @param {string} options.siteId - Optional site ID
932
- * @param {string} options.category - Optional product category
933
- * @param {Array} options.probes - Array of { personaId, priceText, currency? }
934
- */
935
- function quickScan({ url, itemName, siteId, category, probes }) {
936
- if (!url || !probes || !Array.isArray(probes) || probes.length < 2) {
937
- return { error: 'url and at least 2 probes are required' };
938
- }
939
-
940
- const scan = createScan({ siteId, url, itemName, category });
941
-
942
- for (const probe of probes) {
943
- if (!probe.personaId || !probe.priceText) continue;
944
- recordProbe(scan.scanId, {
945
- personaId: probe.personaId,
946
- priceText: probe.priceText,
947
- currency: probe.currency,
948
- responseHeaders: probe.responseHeaders,
949
- cookiesReceived: probe.cookiesReceived,
950
- durationMs: probe.durationMs
951
- });
952
- }
953
-
954
- return analyzeScan(scan.scanId);
955
- }
956
-
957
- // ─── Get Scan Report ─────────────────────────────────────────────────
958
-
959
- function getScanReport(scanId) {
960
- const scan = stmts.getScan.get(scanId);
961
- if (!scan) return { error: 'scan_not_found' };
962
-
963
- const probes = stmts.getProbes.all(scanId);
964
- const manipulations = stmts.getManipulations.all(scanId);
965
- const details = safeParseJSON(scan.manipulation_details);
966
-
967
- return {
968
- scan: {
969
- id: scan.id,
970
- url: scan.url,
971
- itemName: scan.item_name,
972
- category: scan.category,
973
- status: scan.status,
974
- createdAt: scan.created_at,
975
- completedAt: scan.completed_at
976
- },
977
- prices: {
978
- lowest: scan.lowest_price,
979
- highest: scan.highest_price,
980
- median: scan.median_price,
981
- variance: scan.price_variance,
982
- probeCount: scan.probe_count
983
- },
984
- manipulation: {
985
- score: scan.manipulation_score,
986
- level: scan.manipulation_type,
987
- details: details.manipulations || [],
988
- outliers: details.outliers || [],
989
- statistics: details.statistics || {}
990
- },
991
- recommendation: {
992
- bestPrice: scan.recommended_price,
993
- bestPersona: scan.recommended_persona,
994
- bestPersonaLabel: PERSONAS.find(p => p.id === scan.recommended_persona)?.label || null,
995
- strategy: scan.recommended_persona ? _buildStrategy(
996
- manipulations.map(m => ({ type: m.manipulation_type, severity: m.severity })),
997
- scan.recommended_persona
998
- ) : null
999
- },
1000
- probes: probes.map(p => ({
1001
- persona: p.persona_id,
1002
- label: p.persona_label,
1003
- price: p.detected_price,
1004
- currency: p.currency,
1005
- rawText: p.raw_price_text,
1006
- duration: p.probe_duration_ms
1007
- })),
1008
- history: manipulations.map(m => ({
1009
- type: m.manipulation_type,
1010
- severity: m.severity,
1011
- spread: m.price_spread,
1012
- spreadPct: m.price_spread_pct,
1013
- detectedAt: m.created_at
1014
- }))
1015
- };
1016
- }
1017
-
1018
- // ─── Global Statistics ───────────────────────────────────────────────
1019
-
1020
- function getGlobalStats() {
1021
- const manipStats = stmts.getManipulationStats.all();
1022
-
1023
- const totalScans = db.prepare('SELECT COUNT(*) as c FROM price_scans').get().c;
1024
- const completedScans = db.prepare("SELECT COUNT(*) as c FROM price_scans WHERE status = 'completed'").get().c;
1025
- const manipulatedScans = db.prepare("SELECT COUNT(*) as c FROM price_scans WHERE manipulation_score > 0").get().c;
1026
-
1027
- const avgScore = db.prepare('SELECT AVG(manipulation_score) as avg FROM price_scans WHERE status = ?').get('completed');
1028
-
1029
- const topManipulators = db.prepare(`
1030
- SELECT site_id, COUNT(*) as incidents,
1031
- AVG(price_spread_pct) as avg_spread,
1032
- MAX(price_spread_pct) as max_spread,
1033
- GROUP_CONCAT(DISTINCT manipulation_type) as types
1034
- FROM price_manipulation_log
1035
- WHERE site_id IS NOT NULL
1036
- GROUP BY site_id
1037
- ORDER BY incidents DESC
1038
- LIMIT 10
1039
- `).all();
1040
-
1041
- const recentScans = stmts.getRecentScans.all(10);
1042
-
1043
- return {
1044
- overview: {
1045
- totalScans,
1046
- completedScans,
1047
- manipulatedScans,
1048
- manipulationRate: totalScans > 0 ? Math.round((manipulatedScans / totalScans) * 1000) / 10 : 0,
1049
- averageManipulationScore: Math.round((avgScore?.avg || 0) * 10) / 10
1050
- },
1051
- byType: manipStats,
1052
- topManipulators: topManipulators.map(t => ({
1053
- siteId: t.site_id,
1054
- incidents: t.incidents,
1055
- avgSpread: Math.round(t.avg_spread * 10) / 10,
1056
- maxSpread: Math.round(t.max_spread * 10) / 10,
1057
- types: t.types ? t.types.split(',') : []
1058
- })),
1059
- recentScans: recentScans.map(s => ({
1060
- id: s.id,
1061
- url: s.url,
1062
- itemName: s.item_name,
1063
- manipulationScore: s.manipulation_score,
1064
- level: s.manipulation_type,
1065
- lowestPrice: s.lowest_price,
1066
- highestPrice: s.highest_price,
1067
- createdAt: s.created_at
1068
- }))
1069
- };
1070
- }
1071
-
1072
- // ─── Price History for URL ───────────────────────────────────────────
1073
-
1074
- function getPriceHistory(url, limit = 30) {
1075
- const history = stmts.getHistory.all(url, limit);
1076
- if (!history.length) return { url, history: [], trend: null };
1077
-
1078
- const prices = history.map(h => h.price);
1079
- const cv = coefficientOfVariation(prices);
1080
-
1081
- // Simple trend
1082
- const recentAvg = prices.slice(0, Math.min(5, prices.length)).reduce((s, v) => s + v, 0) /
1083
- Math.min(5, prices.length);
1084
- const olderAvg = prices.slice(-Math.min(5, prices.length)).reduce((s, v) => s + v, 0) /
1085
- Math.min(5, prices.length);
1086
- const trend = olderAvg > 0 ? ((recentAvg - olderAvg) / olderAvg) * 100 : 0;
1087
-
1088
- return {
1089
- url,
1090
- history: history.map(h => ({
1091
- price: h.price,
1092
- currency: h.currency,
1093
- persona: h.source_persona,
1094
- capturedAt: h.captured_at
1095
- })),
1096
- statistics: {
1097
- current: prices[0],
1098
- lowest: Math.min(...prices),
1099
- highest: Math.max(...prices),
1100
- average: Math.round((prices.reduce((s, v) => s + v, 0) / prices.length) * 100) / 100,
1101
- volatility: Math.round(cv * 1000) / 1000,
1102
- trend: Math.round(trend * 10) / 10,
1103
- direction: trend > 2 ? 'rising' : trend < -2 ? 'falling' : 'stable'
1104
- }
1105
- };
1106
- }
1107
-
1108
- // ─── List Available Personas ─────────────────────────────────────────
1109
-
1110
- function getPersonas() {
1111
- return PERSONAS.map(p => ({
1112
- id: p.id,
1113
- label: p.label,
1114
- description: p.description,
1115
- category: p.category
1116
- }));
1117
- }
1118
-
1119
- // ─── Utility ─────────────────────────────────────────────────────────
1120
-
1121
- function safeParseJSON(str) {
1122
- try { return JSON.parse(str); } catch { return {}; }
1123
- }
1124
-
1125
- // ─── Exports ─────────────────────────────────────────────────────────
1126
-
1127
- module.exports = {
1128
- createScan,
1129
- recordProbe,
1130
- analyzeScan,
1131
- quickScan,
1132
- getScanReport,
1133
- getGlobalStats,
1134
- getPriceHistory,
1135
- getPersonas,
1136
- PERSONAS
1137
- };
1
+ /**
2
+ * Dynamic Pricing Shield — Price Manipulation Detection Engine
3
+ * ════════════════════════════════════════════════════════════════════════
4
+ * Exposes how websites manipulate prices based on user identity signals:
5
+ * - Search frequency (repeated visits → higher prices)
6
+ * - Geolocation (IP-based regional pricing discrimination)
7
+ * - Device fingerprint (mobile vs desktop, brand premium)
8
+ * - Login status (logged-in users see different prices)
9
+ * - Cookies & browsing history (retargeting surcharges)
10
+ * - Time-of-day / day-of-week patterns
11
+ * - Referral source (search engine vs direct vs social)
12
+ *
13
+ * Architecture:
14
+ * Multi-Identity Probing: Agent opens the same page with N distinct
15
+ * identity "personas" — each with unique User-Agent, Accept-Language,
16
+ * cookies, referrer, and device hints. Prices collected from each probe
17
+ * are compared statistically to detect variance → manipulation.
18
+ *
19
+ * Integration:
20
+ * - Ghost Mode (wab-browser): provides stealth fingerprints on client
21
+ * - Verification Engine: cross-checks prices via DOM+vision layer
22
+ * - Symphony Orchestrator: 'price-shield' template chains probing,
23
+ * analysis, and negotiation into one automated pipeline
24
+ * - Learning Engine: records manipulation patterns for future reference
25
+ * - Reputation: penalises sites caught using dynamic pricing tricks
26
+ *
27
+ * Everything runs locally. No data leaves the WAB instance.
28
+ */
29
+
30
+ const crypto = require('crypto');
31
+ const { db } = require('../models/db');
32
+
33
+ // ─── Schema ──────────────────────────────────────────────────────────
34
+
35
+ db.exec(`
36
+ CREATE TABLE IF NOT EXISTS price_probes (
37
+ id TEXT PRIMARY KEY,
38
+ scan_id TEXT NOT NULL,
39
+ site_id TEXT,
40
+ url TEXT NOT NULL,
41
+ persona_id TEXT NOT NULL,
42
+ persona_label TEXT NOT NULL,
43
+ persona_config TEXT DEFAULT '{}',
44
+ detected_price REAL,
45
+ currency TEXT DEFAULT 'USD',
46
+ raw_price_text TEXT,
47
+ response_headers TEXT DEFAULT '{}',
48
+ cookies_received TEXT DEFAULT '[]',
49
+ probe_duration_ms INTEGER DEFAULT 0,
50
+ created_at TEXT DEFAULT (datetime('now'))
51
+ );
52
+
53
+ CREATE TABLE IF NOT EXISTS price_scans (
54
+ id TEXT PRIMARY KEY,
55
+ site_id TEXT,
56
+ url TEXT NOT NULL,
57
+ item_name TEXT,
58
+ category TEXT,
59
+ probe_count INTEGER DEFAULT 0,
60
+ lowest_price REAL,
61
+ highest_price REAL,
62
+ median_price REAL,
63
+ price_variance REAL DEFAULT 0,
64
+ manipulation_score REAL DEFAULT 0,
65
+ manipulation_type TEXT DEFAULT 'none',
66
+ manipulation_details TEXT DEFAULT '{}',
67
+ recommended_price REAL,
68
+ recommended_persona TEXT,
69
+ status TEXT DEFAULT 'pending' CHECK(status IN (
70
+ 'pending','probing','analyzing','completed','failed'
71
+ )),
72
+ created_at TEXT DEFAULT (datetime('now')),
73
+ completed_at TEXT
74
+ );
75
+
76
+ CREATE TABLE IF NOT EXISTS price_manipulation_log (
77
+ id TEXT PRIMARY KEY,
78
+ scan_id TEXT NOT NULL,
79
+ site_id TEXT,
80
+ url TEXT NOT NULL,
81
+ manipulation_type TEXT NOT NULL,
82
+ severity TEXT NOT NULL CHECK(severity IN ('low','medium','high','critical')),
83
+ price_spread REAL DEFAULT 0,
84
+ price_spread_pct REAL DEFAULT 0,
85
+ lowest_price REAL,
86
+ highest_price REAL,
87
+ details TEXT DEFAULT '{}',
88
+ created_at TEXT DEFAULT (datetime('now'))
89
+ );
90
+
91
+ CREATE TABLE IF NOT EXISTS price_history (
92
+ id TEXT PRIMARY KEY,
93
+ url TEXT NOT NULL,
94
+ item_name TEXT,
95
+ price REAL NOT NULL,
96
+ currency TEXT DEFAULT 'USD',
97
+ source_persona TEXT,
98
+ captured_at TEXT DEFAULT (datetime('now'))
99
+ );
100
+
101
+ CREATE INDEX IF NOT EXISTS idx_probes_scan ON price_probes(scan_id);
102
+ CREATE INDEX IF NOT EXISTS idx_probes_url ON price_probes(url);
103
+ CREATE INDEX IF NOT EXISTS idx_scans_url ON price_scans(url);
104
+ CREATE INDEX IF NOT EXISTS idx_scans_status ON price_scans(status);
105
+ CREATE INDEX IF NOT EXISTS idx_manip_site ON price_manipulation_log(site_id);
106
+ CREATE INDEX IF NOT EXISTS idx_manip_type ON price_manipulation_log(manipulation_type);
107
+ CREATE INDEX IF NOT EXISTS idx_history_url ON price_history(url);
108
+ CREATE INDEX IF NOT EXISTS idx_history_time ON price_history(captured_at);
109
+ `);
110
+
111
+ // ─── Prepared Statements ─────────────────────────────────────────────
112
+
113
+ const stmts = {
114
+ insertScan: db.prepare(`
115
+ INSERT INTO price_scans (id, site_id, url, item_name, category, status)
116
+ VALUES (?, ?, ?, ?, ?, 'pending')
117
+ `),
118
+ updateScan: db.prepare(`
119
+ UPDATE price_scans
120
+ SET probe_count = ?, lowest_price = ?, highest_price = ?, median_price = ?,
121
+ price_variance = ?, manipulation_score = ?, manipulation_type = ?,
122
+ manipulation_details = ?, recommended_price = ?, recommended_persona = ?,
123
+ status = ?, completed_at = datetime('now')
124
+ WHERE id = ?
125
+ `),
126
+ updateScanStatus: db.prepare(`UPDATE price_scans SET status = ? WHERE id = ?`),
127
+ getScan: db.prepare(`SELECT * FROM price_scans WHERE id = ?`),
128
+ getScansForUrl: db.prepare(`SELECT * FROM price_scans WHERE url = ? ORDER BY created_at DESC LIMIT ?`),
129
+ getRecentScans: db.prepare(`SELECT * FROM price_scans WHERE status = 'completed' ORDER BY created_at DESC LIMIT ?`),
130
+
131
+ insertProbe: db.prepare(`
132
+ INSERT INTO price_probes
133
+ (id, scan_id, site_id, url, persona_id, persona_label, persona_config,
134
+ detected_price, currency, raw_price_text, response_headers, cookies_received, probe_duration_ms)
135
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
136
+ `),
137
+ getProbes: db.prepare(`SELECT * FROM price_probes WHERE scan_id = ? ORDER BY created_at ASC`),
138
+
139
+ insertManipulation: db.prepare(`
140
+ INSERT INTO price_manipulation_log
141
+ (id, scan_id, site_id, url, manipulation_type, severity,
142
+ price_spread, price_spread_pct, lowest_price, highest_price, details)
143
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
144
+ `),
145
+ getManipulations: db.prepare(`SELECT * FROM price_manipulation_log WHERE scan_id = ?`),
146
+ getManipulationsBySite: db.prepare(`SELECT * FROM price_manipulation_log WHERE site_id = ? ORDER BY created_at DESC LIMIT ?`),
147
+ getManipulationStats: db.prepare(`
148
+ SELECT manipulation_type, severity,
149
+ COUNT(*) as count,
150
+ AVG(price_spread_pct) as avg_spread_pct,
151
+ MAX(price_spread_pct) as max_spread_pct
152
+ FROM price_manipulation_log
153
+ GROUP BY manipulation_type, severity
154
+ ORDER BY count DESC
155
+ `),
156
+
157
+ insertHistory: db.prepare(`
158
+ INSERT INTO price_history (id, url, item_name, price, currency, source_persona)
159
+ VALUES (?, ?, ?, ?, ?, ?)
160
+ `),
161
+ getHistory: db.prepare(`SELECT * FROM price_history WHERE url = ? ORDER BY captured_at DESC LIMIT ?`),
162
+ getHistoryRange: db.prepare(`
163
+ SELECT * FROM price_history
164
+ WHERE url = ? AND captured_at >= ? AND captured_at <= ?
165
+ ORDER BY captured_at ASC
166
+ `),
167
+ };
168
+
169
+ // ─── Identity Personas ───────────────────────────────────────────────
170
+ // Each persona simulates a distinct user profile that might trigger
171
+ // different dynamic pricing on the target site.
172
+
173
+ const PERSONAS = [
174
+ {
175
+ id: 'clean-desktop',
176
+ label: 'Clean Desktop Visitor',
177
+ description: 'Fresh Chrome/Windows session, no cookies, no history',
178
+ category: 'baseline',
179
+ config: {
180
+ userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
181
+ acceptLanguage: 'en-US,en;q=0.9',
182
+ platform: 'Win32',
183
+ mobile: false,
184
+ cookies: {},
185
+ referrer: '',
186
+ headers: { 'Sec-CH-UA-Platform': '"Windows"', 'Sec-CH-UA-Mobile': '?0' }
187
+ }
188
+ },
189
+ {
190
+ id: 'clean-mobile',
191
+ label: 'Clean Mobile Visitor',
192
+ description: 'Fresh Safari/iPhone, no cookies — tests mobile pricing',
193
+ category: 'device',
194
+ config: {
195
+ userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1',
196
+ acceptLanguage: 'en-US,en;q=0.9',
197
+ platform: 'iPhone',
198
+ mobile: true,
199
+ cookies: {},
200
+ referrer: '',
201
+ headers: { 'Sec-CH-UA-Platform': '"iOS"', 'Sec-CH-UA-Mobile': '?1' }
202
+ }
203
+ },
204
+ {
205
+ id: 'premium-mac',
206
+ label: 'Premium Mac User',
207
+ description: 'Safari on macOS — tests Apple/premium device surcharge',
208
+ category: 'device',
209
+ config: {
210
+ userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_4) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15',
211
+ acceptLanguage: 'en-US,en;q=0.9',
212
+ platform: 'MacIntel',
213
+ mobile: false,
214
+ cookies: {},
215
+ referrer: '',
216
+ headers: { 'Sec-CH-UA-Platform': '"macOS"', 'Sec-CH-UA-Mobile': '?0' }
217
+ }
218
+ },
219
+ {
220
+ id: 'geo-eu',
221
+ label: 'European Visitor',
222
+ description: 'German Firefox on Linux — tests EU geolocation pricing',
223
+ category: 'geolocation',
224
+ config: {
225
+ userAgent: 'Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0',
226
+ acceptLanguage: 'de-DE,de;q=0.9,en-US;q=0.5,en;q=0.3',
227
+ platform: 'Linux x86_64',
228
+ mobile: false,
229
+ cookies: {},
230
+ referrer: '',
231
+ headers: {
232
+ 'Sec-CH-UA-Platform': '"Linux"', 'Sec-CH-UA-Mobile': '?0',
233
+ 'X-Forwarded-For': '85.214.132.117'
234
+ }
235
+ }
236
+ },
237
+ {
238
+ id: 'geo-mena',
239
+ label: 'MENA Region Visitor',
240
+ description: 'Arabic Chrome on Windows — tests Middle East pricing',
241
+ category: 'geolocation',
242
+ config: {
243
+ userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
244
+ acceptLanguage: 'ar-SA,ar;q=0.9,en-US;q=0.5,en;q=0.3',
245
+ platform: 'Win32',
246
+ mobile: false,
247
+ cookies: {},
248
+ referrer: '',
249
+ headers: { 'Sec-CH-UA-Platform': '"Windows"', 'Sec-CH-UA-Mobile': '?0' }
250
+ }
251
+ },
252
+ {
253
+ id: 'geo-sea',
254
+ label: 'Southeast Asia Visitor',
255
+ description: 'Chrome on Android — tests SEA regional pricing',
256
+ category: 'geolocation',
257
+ config: {
258
+ userAgent: 'Mozilla/5.0 (Linux; Android 14; SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36',
259
+ acceptLanguage: 'th-TH,th;q=0.9,en-US;q=0.5,en;q=0.3',
260
+ platform: 'Linux armv8l',
261
+ mobile: true,
262
+ cookies: {},
263
+ referrer: '',
264
+ headers: { 'Sec-CH-UA-Platform': '"Android"', 'Sec-CH-UA-Mobile': '?1' }
265
+ }
266
+ },
267
+ {
268
+ id: 'repeat-visitor',
269
+ label: 'Repeat Visitor (3rd visit)',
270
+ description: 'Simulates return visits with existing cookies — tests urgency/frequency markup',
271
+ category: 'behavior',
272
+ config: {
273
+ userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
274
+ acceptLanguage: 'en-US,en;q=0.9',
275
+ platform: 'Win32',
276
+ mobile: false,
277
+ cookies: {
278
+ '_visit_count': '3',
279
+ '_last_visit': new Date(Date.now() - 3600000).toISOString(),
280
+ '_viewed_items': 'true'
281
+ },
282
+ referrer: '',
283
+ headers: { 'Sec-CH-UA-Platform': '"Windows"', 'Sec-CH-UA-Mobile': '?0' }
284
+ }
285
+ },
286
+ {
287
+ id: 'search-referral',
288
+ label: 'Google Search Referral',
289
+ description: 'Arrives from Google search — tests referral-based pricing',
290
+ category: 'referral',
291
+ config: {
292
+ userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
293
+ acceptLanguage: 'en-US,en;q=0.9',
294
+ platform: 'Win32',
295
+ mobile: false,
296
+ cookies: {},
297
+ referrer: 'https://www.google.com/search?q=best+deals',
298
+ headers: { 'Sec-CH-UA-Platform': '"Windows"', 'Sec-CH-UA-Mobile': '?0' }
299
+ }
300
+ },
301
+ {
302
+ id: 'social-referral',
303
+ label: 'Social Media Referral',
304
+ description: 'Arrives from Facebook — tests social referral pricing',
305
+ category: 'referral',
306
+ config: {
307
+ userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
308
+ acceptLanguage: 'en-US,en;q=0.9',
309
+ platform: 'Win32',
310
+ mobile: false,
311
+ cookies: {},
312
+ referrer: 'https://www.facebook.com/',
313
+ headers: { 'Sec-CH-UA-Platform': '"Windows"', 'Sec-CH-UA-Mobile': '?0' }
314
+ }
315
+ },
316
+ {
317
+ id: 'price-compare-referral',
318
+ label: 'Price Comparison Referral',
319
+ description: 'Arrives from a price comparison site — typically forces lowest price',
320
+ category: 'referral',
321
+ config: {
322
+ userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
323
+ acceptLanguage: 'en-US,en;q=0.9',
324
+ platform: 'Win32',
325
+ mobile: false,
326
+ cookies: {},
327
+ referrer: 'https://www.google.com/shopping',
328
+ headers: { 'Sec-CH-UA-Platform': '"Windows"', 'Sec-CH-UA-Mobile': '?0' }
329
+ }
330
+ },
331
+ {
332
+ id: 'incognito-linux',
333
+ label: 'Privacy-Focused User',
334
+ description: 'Firefox on Linux, DNT + GPC enabled — tests privacy-aware pricing',
335
+ category: 'privacy',
336
+ config: {
337
+ userAgent: 'Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0',
338
+ acceptLanguage: 'en-US,en;q=0.5',
339
+ platform: 'Linux x86_64',
340
+ mobile: false,
341
+ cookies: {},
342
+ referrer: '',
343
+ headers: {
344
+ 'Sec-CH-UA-Platform': '"Linux"', 'Sec-CH-UA-Mobile': '?0',
345
+ 'DNT': '1', 'Sec-GPC': '1'
346
+ }
347
+ }
348
+ },
349
+ {
350
+ id: 'bot-like',
351
+ label: 'Bot-Like Agent',
352
+ description: 'Minimal headers, no JS hints — tests anti-bot price walls',
353
+ category: 'stealth',
354
+ config: {
355
+ userAgent: 'Mozilla/5.0 (compatible; WABAgent/1.0)',
356
+ acceptLanguage: 'en',
357
+ platform: '',
358
+ mobile: false,
359
+ cookies: {},
360
+ referrer: '',
361
+ headers: {}
362
+ }
363
+ },
364
+ ];
365
+
366
+ // ─── Price Extraction Utilities ──────────────────────────────────────
367
+
368
+ function extractPrice(text) {
369
+ if (typeof text !== 'string') return null;
370
+ const patterns = [
371
+ /[\$€£¥]\s*([\d,]+\.?\d*)/,
372
+ /([\d,]+\.?\d*)\s*[\$€£¥]/,
373
+ /(?:USD|EUR|GBP|SAR|AED|TND)\s*([\d,]+\.?\d*)/i,
374
+ /([\d,]+\.?\d*)\s*(?:USD|EUR|GBP|SAR|AED|TND)/i,
375
+ ];
376
+ for (const pattern of patterns) {
377
+ const match = text.match(pattern);
378
+ if (match) return parseFloat(match[1].replace(/,/g, ''));
379
+ }
380
+ return null;
381
+ }
382
+
383
+ function detectCurrency(text) {
384
+ if (typeof text !== 'string') return 'USD';
385
+ const map = { '$': 'USD', '€': 'EUR', '£': 'GBP', '¥': 'JPY' };
386
+ for (const [sym, code] of Object.entries(map)) {
387
+ if (text.includes(sym)) return code;
388
+ }
389
+ const codeMatch = text.match(/\b(USD|EUR|GBP|SAR|AED|TND|JPY)\b/i);
390
+ return codeMatch ? codeMatch[1].toUpperCase() : 'USD';
391
+ }
392
+
393
+ // ─── Statistical Helpers ─────────────────────────────────────────────
394
+
395
+ function median(arr) {
396
+ if (!arr.length) return 0;
397
+ const sorted = [...arr].sort((a, b) => a - b);
398
+ const mid = Math.floor(sorted.length / 2);
399
+ return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
400
+ }
401
+
402
+ function variance(arr) {
403
+ if (arr.length < 2) return 0;
404
+ const mean = arr.reduce((s, v) => s + v, 0) / arr.length;
405
+ return arr.reduce((s, v) => s + (v - mean) ** 2, 0) / (arr.length - 1);
406
+ }
407
+
408
+ function standardDeviation(arr) {
409
+ return Math.sqrt(variance(arr));
410
+ }
411
+
412
+ function coefficientOfVariation(arr) {
413
+ if (!arr.length) return 0;
414
+ const mean = arr.reduce((s, v) => s + v, 0) / arr.length;
415
+ if (mean === 0) return 0;
416
+ return standardDeviation(arr) / mean;
417
+ }
418
+
419
+ function zScores(arr) {
420
+ const mean = arr.reduce((s, v) => s + v, 0) / arr.length;
421
+ const sd = standardDeviation(arr);
422
+ if (sd === 0) return arr.map(() => 0);
423
+ return arr.map(v => (v - mean) / sd);
424
+ }
425
+
426
+ // ─── Core: Create Scan ───────────────────────────────────────────────
427
+
428
+ /**
429
+ * Initiate a new price manipulation scan for a given URL/product.
430
+ * Returns a scan object with an ID the caller uses to add probes.
431
+ */
432
+ function createScan({ siteId, url, itemName, category }) {
433
+ const id = crypto.randomBytes(12).toString('hex');
434
+ stmts.insertScan.run(id, siteId || null, url, itemName || null, category || null);
435
+ return {
436
+ scanId: id,
437
+ url,
438
+ itemName,
439
+ personas: PERSONAS.map(p => ({ id: p.id, label: p.label, category: p.category })),
440
+ status: 'pending'
441
+ };
442
+ }
443
+
444
+ // ─── Core: Record Probe Result ───────────────────────────────────────
445
+
446
+ /**
447
+ * After probing a page with a specific persona, record the result.
448
+ * The agent (or browser extension) calls this once per persona.
449
+ */
450
+ function recordProbe(scanId, {
451
+ personaId, priceText, currency, responseHeaders, cookiesReceived, durationMs
452
+ }) {
453
+ const persona = PERSONAS.find(p => p.id === personaId);
454
+ if (!persona) return { error: 'unknown_persona', personaId };
455
+
456
+ const scan = stmts.getScan.get(scanId);
457
+ if (!scan) return { error: 'scan_not_found' };
458
+
459
+ const price = extractPrice(priceText);
460
+ const detectedCurrency = currency || detectCurrency(priceText);
461
+
462
+ const probeId = crypto.randomBytes(12).toString('hex');
463
+
464
+ stmts.insertProbe.run(
465
+ probeId, scanId, scan.site_id, scan.url,
466
+ persona.id, persona.label, JSON.stringify(persona.config),
467
+ price, detectedCurrency, priceText || null,
468
+ JSON.stringify(responseHeaders || {}),
469
+ JSON.stringify(cookiesReceived || []),
470
+ durationMs || 0
471
+ );
472
+
473
+ // Update scan status
474
+ if (scan.status === 'pending') {
475
+ stmts.updateScanStatus.run('probing', scanId);
476
+ }
477
+
478
+ // Record price history
479
+ if (price !== null) {
480
+ stmts.insertHistory.run(
481
+ crypto.randomBytes(12).toString('hex'),
482
+ scan.url, scan.item_name, price, detectedCurrency, persona.id
483
+ );
484
+ }
485
+
486
+ return {
487
+ probeId,
488
+ personaId: persona.id,
489
+ personaLabel: persona.label,
490
+ detectedPrice: price,
491
+ currency: detectedCurrency,
492
+ status: 'recorded'
493
+ };
494
+ }
495
+
496
+ // ─── Core: Analyze Scan ──────────────────────────────────────────────
497
+
498
+ /**
499
+ * After all (or enough) probes are recorded, analyze the scan to detect
500
+ * price manipulation. Returns a comprehensive manipulation report.
501
+ */
502
+ function analyzeScan(scanId) {
503
+ const scan = stmts.getScan.get(scanId);
504
+ if (!scan) return { error: 'scan_not_found' };
505
+
506
+ const probes = stmts.getProbes.all(scanId);
507
+ if (probes.length < 2) {
508
+ return { error: 'insufficient_probes', minimum: 2, current: probes.length };
509
+ }
510
+
511
+ stmts.updateScanStatus.run('analyzing', scanId);
512
+
513
+ // Collect valid prices
514
+ const validProbes = probes.filter(p => p.detected_price !== null);
515
+ if (validProbes.length < 2) {
516
+ stmts.updateScan.run(
517
+ probes.length, null, null, null, 0, 0, 'none', '{}', null, null, 'completed', scanId
518
+ );
519
+ return { scanId, status: 'completed', manipulation: false, reason: 'insufficient_price_data' };
520
+ }
521
+
522
+ const prices = validProbes.map(p => p.detected_price);
523
+ const lowestPrice = Math.min(...prices);
524
+ const highestPrice = Math.max(...prices);
525
+ const medianPrice = median(prices);
526
+ const priceVariance = variance(prices);
527
+ const cv = coefficientOfVariation(prices);
528
+ const spread = highestPrice - lowestPrice;
529
+ const spreadPct = lowestPrice > 0 ? (spread / lowestPrice) * 100 : 0;
530
+
531
+ // ─── Manipulation Detection Algorithms ─────────────────────────
532
+
533
+ const manipulations = [];
534
+ let manipulationScore = 0;
535
+
536
+ // 1. Overall price variance test
537
+ if (cv > 0.02) { // >2% coefficient of variation
538
+ const severity = cv > 0.15 ? 'critical' : cv > 0.08 ? 'high' : cv > 0.04 ? 'medium' : 'low';
539
+ const severityScore = { low: 10, medium: 25, high: 50, critical: 80 };
540
+ manipulations.push({
541
+ type: 'price_variance',
542
+ severity,
543
+ description: `Price varies by ${spreadPct.toFixed(1)}% across identities ($${lowestPrice} — $${highestPrice})`,
544
+ spread,
545
+ spreadPct: Math.round(spreadPct * 10) / 10,
546
+ cv: Math.round(cv * 1000) / 1000
547
+ });
548
+ manipulationScore += severityScore[severity];
549
+ }
550
+
551
+ // 2. Device-based discrimination
552
+ const deviceAnalysis = _analyzeByCategory(validProbes, 'device');
553
+ if (deviceAnalysis.detected) {
554
+ manipulations.push({
555
+ type: 'device_discrimination',
556
+ severity: deviceAnalysis.severity,
557
+ description: deviceAnalysis.description,
558
+ details: deviceAnalysis.details
559
+ });
560
+ manipulationScore += deviceAnalysis.score;
561
+ }
562
+
563
+ // 3. Geolocation-based pricing
564
+ const geoAnalysis = _analyzeByCategory(validProbes, 'geolocation');
565
+ if (geoAnalysis.detected) {
566
+ manipulations.push({
567
+ type: 'geo_pricing',
568
+ severity: geoAnalysis.severity,
569
+ description: geoAnalysis.description,
570
+ details: geoAnalysis.details
571
+ });
572
+ manipulationScore += geoAnalysis.score;
573
+ }
574
+
575
+ // 4. Referral-source manipulation
576
+ const referralAnalysis = _analyzeByCategory(validProbes, 'referral');
577
+ if (referralAnalysis.detected) {
578
+ manipulations.push({
579
+ type: 'referral_manipulation',
580
+ severity: referralAnalysis.severity,
581
+ description: referralAnalysis.description,
582
+ details: referralAnalysis.details
583
+ });
584
+ manipulationScore += referralAnalysis.score;
585
+ }
586
+
587
+ // 5. Repeat-visitor surcharge detection
588
+ const repeatProbe = validProbes.find(p => p.persona_id === 'repeat-visitor');
589
+ const baselineProbe = validProbes.find(p => p.persona_id === 'clean-desktop');
590
+ if (repeatProbe && baselineProbe && repeatProbe.detected_price && baselineProbe.detected_price) {
591
+ const repeatDiff = repeatProbe.detected_price - baselineProbe.detected_price;
592
+ const repeatPct = baselineProbe.detected_price > 0
593
+ ? (repeatDiff / baselineProbe.detected_price) * 100 : 0;
594
+ if (repeatPct > 1) { // >1% surcharge for returning visitors
595
+ const severity = repeatPct > 10 ? 'critical' : repeatPct > 5 ? 'high' : repeatPct > 2 ? 'medium' : 'low';
596
+ manipulations.push({
597
+ type: 'repeat_visitor_surcharge',
598
+ severity,
599
+ description: `Returning visitors pay ${repeatPct.toFixed(1)}% more ($${repeatProbe.detected_price} vs $${baselineProbe.detected_price} for first-time)`,
600
+ details: { repeatPrice: repeatProbe.detected_price, baselinePrice: baselineProbe.detected_price, surcharge: repeatDiff }
601
+ });
602
+ manipulationScore += { low: 15, medium: 30, high: 50, critical: 75 }[severity];
603
+ }
604
+ }
605
+
606
+ // 6. Privacy penalty — sites charging more when tracking is blocked
607
+ const privacyProbe = validProbes.find(p => p.persona_id === 'incognito-linux');
608
+ if (privacyProbe && baselineProbe && privacyProbe.detected_price && baselineProbe.detected_price) {
609
+ const privacyDiff = privacyProbe.detected_price - baselineProbe.detected_price;
610
+ const privacyPct = baselineProbe.detected_price > 0
611
+ ? (privacyDiff / baselineProbe.detected_price) * 100 : 0;
612
+ if (privacyPct > 1) {
613
+ const severity = privacyPct > 8 ? 'high' : privacyPct > 3 ? 'medium' : 'low';
614
+ manipulations.push({
615
+ type: 'privacy_penalty',
616
+ severity,
617
+ description: `Privacy-focused browsers see ${privacyPct.toFixed(1)}% higher prices — site penalises tracking blockers`,
618
+ details: { privacyPrice: privacyProbe.detected_price, baselinePrice: baselineProbe.detected_price, penalty: privacyDiff }
619
+ });
620
+ manipulationScore += { low: 10, medium: 25, high: 45 }[severity];
621
+ }
622
+ }
623
+
624
+ // 7. Bot-detection price wall
625
+ const botProbe = validProbes.find(p => p.persona_id === 'bot-like');
626
+ if (botProbe && baselineProbe) {
627
+ if (botProbe.detected_price === null && baselineProbe.detected_price) {
628
+ manipulations.push({
629
+ type: 'bot_price_wall',
630
+ severity: 'medium',
631
+ description: 'Site hides prices from bot-like visitors — anti-scraping defence detected',
632
+ details: { botBlocked: true }
633
+ });
634
+ manipulationScore += 20;
635
+ } else if (botProbe.detected_price && baselineProbe.detected_price) {
636
+ const botDiff = Math.abs(botProbe.detected_price - baselineProbe.detected_price);
637
+ const botPct = baselineProbe.detected_price > 0
638
+ ? (botDiff / baselineProbe.detected_price) * 100 : 0;
639
+ if (botPct > 3) {
640
+ manipulations.push({
641
+ type: 'bot_price_wall',
642
+ severity: botPct > 10 ? 'high' : 'medium',
643
+ description: `Bot-like agents see ${botPct.toFixed(1)}% different prices — automated visitor detection active`,
644
+ details: { botPrice: botProbe.detected_price, baselinePrice: baselineProbe.detected_price }
645
+ });
646
+ manipulationScore += botPct > 10 ? 35 : 15;
647
+ }
648
+ }
649
+ }
650
+
651
+ // 8. Time-pattern detection (using historical data)
652
+ const historicalManipulation = _analyzeHistoricalPrices(scan.url);
653
+ if (historicalManipulation.detected) {
654
+ manipulations.push({
655
+ type: 'temporal_manipulation',
656
+ severity: historicalManipulation.severity,
657
+ description: historicalManipulation.description,
658
+ details: historicalManipulation.details
659
+ });
660
+ manipulationScore += historicalManipulation.score;
661
+ }
662
+
663
+ // ─── Clamp & Classify ──────────────────────────────────────────
664
+
665
+ manipulationScore = Math.min(100, manipulationScore);
666
+
667
+ const manipulationType =
668
+ manipulationScore === 0 ? 'none' :
669
+ manipulationScore < 20 ? 'minor' :
670
+ manipulationScore < 45 ? 'moderate' :
671
+ manipulationScore < 70 ? 'significant' :
672
+ 'severe';
673
+
674
+ // Find recommended (lowest) price persona
675
+ let recommendedPrice = lowestPrice;
676
+ let recommendedPersona = null;
677
+ for (const probe of validProbes) {
678
+ if (probe.detected_price === lowestPrice) {
679
+ recommendedPersona = probe.persona_id;
680
+ break;
681
+ }
682
+ }
683
+
684
+ // ─── Z-Score Outlier Detection ─────────────────────────────────
685
+
686
+ const zs = zScores(prices);
687
+ const outliers = [];
688
+ validProbes.forEach((p, i) => {
689
+ if (Math.abs(zs[i]) > 1.5) {
690
+ outliers.push({
691
+ persona: p.persona_id,
692
+ label: p.persona_label,
693
+ price: p.detected_price,
694
+ zScore: Math.round(zs[i] * 100) / 100,
695
+ direction: zs[i] > 0 ? 'above_average' : 'below_average'
696
+ });
697
+ }
698
+ });
699
+
700
+ // ─── Persist ───────────────────────────────────────────────────
701
+
702
+ const manipulationDetails = {
703
+ manipulations,
704
+ outliers,
705
+ statistics: {
706
+ mean: Math.round((prices.reduce((s, v) => s + v, 0) / prices.length) * 100) / 100,
707
+ median: medianPrice,
708
+ standardDeviation: Math.round(standardDeviation(prices) * 100) / 100,
709
+ coefficientOfVariation: Math.round(cv * 1000) / 1000,
710
+ probeCount: validProbes.length,
711
+ totalProbes: probes.length
712
+ }
713
+ };
714
+
715
+ stmts.updateScan.run(
716
+ probes.length, lowestPrice, highestPrice, medianPrice,
717
+ Math.round(priceVariance * 100) / 100, manipulationScore,
718
+ manipulationType, JSON.stringify(manipulationDetails),
719
+ recommendedPrice, recommendedPersona,
720
+ 'completed', scanId
721
+ );
722
+
723
+ // Log individual manipulations
724
+ for (const m of manipulations) {
725
+ stmts.insertManipulation.run(
726
+ crypto.randomBytes(12).toString('hex'),
727
+ scanId, scan.site_id, scan.url,
728
+ m.type, m.severity,
729
+ spread, Math.round(spreadPct * 10) / 10,
730
+ lowestPrice, highestPrice,
731
+ JSON.stringify(m.details || {})
732
+ );
733
+ }
734
+
735
+ return {
736
+ scanId,
737
+ url: scan.url,
738
+ itemName: scan.item_name,
739
+ status: 'completed',
740
+ manipulation: {
741
+ detected: manipulationScore > 0,
742
+ score: manipulationScore,
743
+ level: manipulationType,
744
+ types: manipulations.map(m => m.type),
745
+ count: manipulations.length
746
+ },
747
+ prices: {
748
+ lowest: lowestPrice,
749
+ highest: highestPrice,
750
+ median: medianPrice,
751
+ spread,
752
+ spreadPct: Math.round(spreadPct * 10) / 10
753
+ },
754
+ recommendation: {
755
+ bestPrice: recommendedPrice,
756
+ bestPersona: recommendedPersona,
757
+ bestPersonaLabel: PERSONAS.find(p => p.id === recommendedPersona)?.label || 'Unknown',
758
+ savings: highestPrice - recommendedPrice,
759
+ savingsPct: highestPrice > 0
760
+ ? Math.round(((highestPrice - recommendedPrice) / highestPrice) * 1000) / 10
761
+ : 0,
762
+ strategy: _buildStrategy(manipulations, recommendedPersona)
763
+ },
764
+ manipulations,
765
+ outliers,
766
+ statistics: manipulationDetails.statistics,
767
+ probes: validProbes.map(p => ({
768
+ persona: p.persona_id,
769
+ label: p.persona_label,
770
+ price: p.detected_price,
771
+ currency: p.currency
772
+ }))
773
+ };
774
+ }
775
+
776
+ // ─── Category Analysis Helper ────────────────────────────────────────
777
+
778
+ function _analyzeByCategory(probes, category) {
779
+ const persona = PERSONAS.filter(p => p.category === category);
780
+ const categoryProbes = probes.filter(p => persona.some(ps => ps.id === p.persona_id));
781
+ const baselineProbe = probes.find(p => p.persona_id === 'clean-desktop');
782
+
783
+ if (categoryProbes.length === 0 || !baselineProbe || !baselineProbe.detected_price) {
784
+ return { detected: false };
785
+ }
786
+
787
+ const categoryPrices = categoryProbes.filter(p => p.detected_price !== null);
788
+ if (categoryPrices.length === 0) return { detected: false };
789
+
790
+ const baseline = baselineProbe.detected_price;
791
+ const diffs = categoryPrices.map(p => ({
792
+ persona: p.persona_id,
793
+ label: p.persona_label,
794
+ price: p.detected_price,
795
+ diff: p.detected_price - baseline,
796
+ diffPct: baseline > 0 ? ((p.detected_price - baseline) / baseline) * 100 : 0
797
+ }));
798
+
799
+ const maxAbsDiffPct = Math.max(...diffs.map(d => Math.abs(d.diffPct)));
800
+
801
+ if (maxAbsDiffPct < 1) return { detected: false };
802
+
803
+ const categoryLabels = {
804
+ device: 'Device-based pricing',
805
+ geolocation: 'Geolocation-based pricing',
806
+ referral: 'Referral-source pricing',
807
+ behavior: 'Behavioral pricing',
808
+ privacy: 'Privacy-based pricing',
809
+ stealth: 'Bot-detection pricing'
810
+ };
811
+
812
+ const severity = maxAbsDiffPct > 15 ? 'critical' : maxAbsDiffPct > 8 ? 'high' : maxAbsDiffPct > 3 ? 'medium' : 'low';
813
+ const score = { low: 10, medium: 25, high: 45, critical: 70 }[severity];
814
+
815
+ const description = `${categoryLabels[category] || category} detected: up to ${maxAbsDiffPct.toFixed(1)}% difference vs baseline`;
816
+
817
+ return {
818
+ detected: true,
819
+ severity,
820
+ score,
821
+ description,
822
+ details: {
823
+ category,
824
+ baseline: { persona: 'clean-desktop', price: baseline },
825
+ comparisons: diffs
826
+ }
827
+ };
828
+ }
829
+
830
+ // ─── Historical Price Analysis ───────────────────────────────────────
831
+
832
+ function _analyzeHistoricalPrices(url) {
833
+ const history = stmts.getHistory.all(url, 50);
834
+
835
+ if (history.length < 5) {
836
+ return { detected: false };
837
+ }
838
+
839
+ const prices = history.map(h => h.price);
840
+ const cv = coefficientOfVariation(prices);
841
+
842
+ if (cv < 0.05) return { detected: false };
843
+
844
+ // Check for upward trend (price creep)
845
+ const recentAvg = prices.slice(0, Math.min(5, prices.length)).reduce((s, v) => s + v, 0) /
846
+ Math.min(5, prices.length);
847
+ const olderAvg = prices.slice(-Math.min(5, prices.length)).reduce((s, v) => s + v, 0) /
848
+ Math.min(5, prices.length);
849
+
850
+ const trend = olderAvg > 0 ? ((recentAvg - olderAvg) / olderAvg) * 100 : 0;
851
+
852
+ if (Math.abs(trend) < 2) return { detected: false };
853
+
854
+ const direction = trend > 0 ? 'increasing' : 'decreasing';
855
+ const severity = Math.abs(trend) > 15 ? 'high' : Math.abs(trend) > 5 ? 'medium' : 'low';
856
+
857
+ return {
858
+ detected: true,
859
+ severity,
860
+ score: { low: 10, medium: 20, high: 35 }[severity],
861
+ description: `Price ${direction} over time: ${Math.abs(trend).toFixed(1)}% change detected across ${history.length} observations`,
862
+ details: {
863
+ recentAvg: Math.round(recentAvg * 100) / 100,
864
+ olderAvg: Math.round(olderAvg * 100) / 100,
865
+ trend: Math.round(trend * 10) / 10,
866
+ direction,
867
+ dataPoints: history.length
868
+ }
869
+ };
870
+ }
871
+
872
+ // ─── Strategy Builder ────────────────────────────────────────────────
873
+
874
+ function _buildStrategy(manipulations, bestPersona) {
875
+ const tips = [];
876
+
877
+ if (manipulations.length === 0) {
878
+ tips.push('No dynamic pricing detected — this site shows consistent prices across identities.');
879
+ return { tips, approach: 'direct' };
880
+ }
881
+
882
+ const types = new Set(manipulations.map(m => m.type));
883
+
884
+ if (types.has('device_discrimination')) {
885
+ tips.push('Switch to a different device or browser to trigger lower pricing.');
886
+ }
887
+ if (types.has('geo_pricing')) {
888
+ tips.push('Use a VPN to appear from a region with cheaper pricing.');
889
+ }
890
+ if (types.has('repeat_visitor_surcharge')) {
891
+ tips.push('Clear cookies and browsing data before purchasing, or use incognito mode.');
892
+ }
893
+ if (types.has('referral_manipulation')) {
894
+ tips.push('Try arriving via a price-comparison site or Google Shopping for lower referral-triggered prices.');
895
+ }
896
+ if (types.has('privacy_penalty')) {
897
+ tips.push('Temporarily allow tracking — some sites charge more when tracking is blocked.');
898
+ }
899
+ if (types.has('bot_price_wall')) {
900
+ tips.push('Ensure your browser appears as a regular human visitor.');
901
+ }
902
+ if (types.has('temporal_manipulation')) {
903
+ tips.push('Check prices at different times of day — this site changes prices over time.');
904
+ }
905
+ if (types.has('price_variance')) {
906
+ tips.push('Significant price variance detected. Use the recommended persona/identity to access the lowest price.');
907
+ }
908
+
909
+ const persona = PERSONAS.find(p => p.id === bestPersona);
910
+ if (persona) {
911
+ tips.push(`Best identity for lowest price: "${persona.label}" — ${persona.description}`);
912
+ }
913
+
914
+ return {
915
+ tips,
916
+ approach: manipulations.some(m => m.severity === 'critical' || m.severity === 'high')
917
+ ? 'stealth' : 'optimized',
918
+ recommendedPersona: bestPersona
919
+ };
920
+ }
921
+
922
+ // ─── Quick Scan (All-in-One) ─────────────────────────────────────────
923
+
924
+ /**
925
+ * Perform a quick scan by providing pre-collected probe data.
926
+ * Useful when the client collects all prices first, then sends them.
927
+ *
928
+ * @param {Object} options
929
+ * @param {string} options.url - Product page URL
930
+ * @param {string} options.itemName - Product name
931
+ * @param {string} options.siteId - Optional site ID
932
+ * @param {string} options.category - Optional product category
933
+ * @param {Array} options.probes - Array of { personaId, priceText, currency? }
934
+ */
935
+ function quickScan({ url, itemName, siteId, category, probes }) {
936
+ if (!url || !probes || !Array.isArray(probes) || probes.length < 2) {
937
+ return { error: 'url and at least 2 probes are required' };
938
+ }
939
+
940
+ const scan = createScan({ siteId, url, itemName, category });
941
+
942
+ for (const probe of probes) {
943
+ if (!probe.personaId || !probe.priceText) continue;
944
+ recordProbe(scan.scanId, {
945
+ personaId: probe.personaId,
946
+ priceText: probe.priceText,
947
+ currency: probe.currency,
948
+ responseHeaders: probe.responseHeaders,
949
+ cookiesReceived: probe.cookiesReceived,
950
+ durationMs: probe.durationMs
951
+ });
952
+ }
953
+
954
+ return analyzeScan(scan.scanId);
955
+ }
956
+
957
+ // ─── Get Scan Report ─────────────────────────────────────────────────
958
+
959
+ function getScanReport(scanId) {
960
+ const scan = stmts.getScan.get(scanId);
961
+ if (!scan) return { error: 'scan_not_found' };
962
+
963
+ const probes = stmts.getProbes.all(scanId);
964
+ const manipulations = stmts.getManipulations.all(scanId);
965
+ const details = safeParseJSON(scan.manipulation_details);
966
+
967
+ return {
968
+ scan: {
969
+ id: scan.id,
970
+ url: scan.url,
971
+ itemName: scan.item_name,
972
+ category: scan.category,
973
+ status: scan.status,
974
+ createdAt: scan.created_at,
975
+ completedAt: scan.completed_at
976
+ },
977
+ prices: {
978
+ lowest: scan.lowest_price,
979
+ highest: scan.highest_price,
980
+ median: scan.median_price,
981
+ variance: scan.price_variance,
982
+ probeCount: scan.probe_count
983
+ },
984
+ manipulation: {
985
+ score: scan.manipulation_score,
986
+ level: scan.manipulation_type,
987
+ details: details.manipulations || [],
988
+ outliers: details.outliers || [],
989
+ statistics: details.statistics || {}
990
+ },
991
+ recommendation: {
992
+ bestPrice: scan.recommended_price,
993
+ bestPersona: scan.recommended_persona,
994
+ bestPersonaLabel: PERSONAS.find(p => p.id === scan.recommended_persona)?.label || null,
995
+ strategy: scan.recommended_persona ? _buildStrategy(
996
+ manipulations.map(m => ({ type: m.manipulation_type, severity: m.severity })),
997
+ scan.recommended_persona
998
+ ) : null
999
+ },
1000
+ probes: probes.map(p => ({
1001
+ persona: p.persona_id,
1002
+ label: p.persona_label,
1003
+ price: p.detected_price,
1004
+ currency: p.currency,
1005
+ rawText: p.raw_price_text,
1006
+ duration: p.probe_duration_ms
1007
+ })),
1008
+ history: manipulations.map(m => ({
1009
+ type: m.manipulation_type,
1010
+ severity: m.severity,
1011
+ spread: m.price_spread,
1012
+ spreadPct: m.price_spread_pct,
1013
+ detectedAt: m.created_at
1014
+ }))
1015
+ };
1016
+ }
1017
+
1018
+ // ─── Global Statistics ───────────────────────────────────────────────
1019
+
1020
+ function getGlobalStats() {
1021
+ const manipStats = stmts.getManipulationStats.all();
1022
+
1023
+ const totalScans = db.prepare('SELECT COUNT(*) as c FROM price_scans').get().c;
1024
+ const completedScans = db.prepare("SELECT COUNT(*) as c FROM price_scans WHERE status = 'completed'").get().c;
1025
+ const manipulatedScans = db.prepare("SELECT COUNT(*) as c FROM price_scans WHERE manipulation_score > 0").get().c;
1026
+
1027
+ const avgScore = db.prepare('SELECT AVG(manipulation_score) as avg FROM price_scans WHERE status = ?').get('completed');
1028
+
1029
+ const topManipulators = db.prepare(`
1030
+ SELECT site_id, COUNT(*) as incidents,
1031
+ AVG(price_spread_pct) as avg_spread,
1032
+ MAX(price_spread_pct) as max_spread,
1033
+ GROUP_CONCAT(DISTINCT manipulation_type) as types
1034
+ FROM price_manipulation_log
1035
+ WHERE site_id IS NOT NULL
1036
+ GROUP BY site_id
1037
+ ORDER BY incidents DESC
1038
+ LIMIT 10
1039
+ `).all();
1040
+
1041
+ const recentScans = stmts.getRecentScans.all(10);
1042
+
1043
+ return {
1044
+ overview: {
1045
+ totalScans,
1046
+ completedScans,
1047
+ manipulatedScans,
1048
+ manipulationRate: totalScans > 0 ? Math.round((manipulatedScans / totalScans) * 1000) / 10 : 0,
1049
+ averageManipulationScore: Math.round((avgScore?.avg || 0) * 10) / 10
1050
+ },
1051
+ byType: manipStats,
1052
+ topManipulators: topManipulators.map(t => ({
1053
+ siteId: t.site_id,
1054
+ incidents: t.incidents,
1055
+ avgSpread: Math.round(t.avg_spread * 10) / 10,
1056
+ maxSpread: Math.round(t.max_spread * 10) / 10,
1057
+ types: t.types ? t.types.split(',') : []
1058
+ })),
1059
+ recentScans: recentScans.map(s => ({
1060
+ id: s.id,
1061
+ url: s.url,
1062
+ itemName: s.item_name,
1063
+ manipulationScore: s.manipulation_score,
1064
+ level: s.manipulation_type,
1065
+ lowestPrice: s.lowest_price,
1066
+ highestPrice: s.highest_price,
1067
+ createdAt: s.created_at
1068
+ }))
1069
+ };
1070
+ }
1071
+
1072
+ // ─── Price History for URL ───────────────────────────────────────────
1073
+
1074
+ function getPriceHistory(url, limit = 30) {
1075
+ const history = stmts.getHistory.all(url, limit);
1076
+ if (!history.length) return { url, history: [], trend: null };
1077
+
1078
+ const prices = history.map(h => h.price);
1079
+ const cv = coefficientOfVariation(prices);
1080
+
1081
+ // Simple trend
1082
+ const recentAvg = prices.slice(0, Math.min(5, prices.length)).reduce((s, v) => s + v, 0) /
1083
+ Math.min(5, prices.length);
1084
+ const olderAvg = prices.slice(-Math.min(5, prices.length)).reduce((s, v) => s + v, 0) /
1085
+ Math.min(5, prices.length);
1086
+ const trend = olderAvg > 0 ? ((recentAvg - olderAvg) / olderAvg) * 100 : 0;
1087
+
1088
+ return {
1089
+ url,
1090
+ history: history.map(h => ({
1091
+ price: h.price,
1092
+ currency: h.currency,
1093
+ persona: h.source_persona,
1094
+ capturedAt: h.captured_at
1095
+ })),
1096
+ statistics: {
1097
+ current: prices[0],
1098
+ lowest: Math.min(...prices),
1099
+ highest: Math.max(...prices),
1100
+ average: Math.round((prices.reduce((s, v) => s + v, 0) / prices.length) * 100) / 100,
1101
+ volatility: Math.round(cv * 1000) / 1000,
1102
+ trend: Math.round(trend * 10) / 10,
1103
+ direction: trend > 2 ? 'rising' : trend < -2 ? 'falling' : 'stable'
1104
+ }
1105
+ };
1106
+ }
1107
+
1108
+ // ─── List Available Personas ─────────────────────────────────────────
1109
+
1110
+ function getPersonas() {
1111
+ return PERSONAS.map(p => ({
1112
+ id: p.id,
1113
+ label: p.label,
1114
+ description: p.description,
1115
+ category: p.category
1116
+ }));
1117
+ }
1118
+
1119
+ // ─── Utility ─────────────────────────────────────────────────────────
1120
+
1121
+ function safeParseJSON(str) {
1122
+ try { return JSON.parse(str); } catch { return {}; }
1123
+ }
1124
+
1125
+ // ─── Exports ─────────────────────────────────────────────────────────
1126
+
1127
+ module.exports = {
1128
+ createScan,
1129
+ recordProbe,
1130
+ analyzeScan,
1131
+ quickScan,
1132
+ getScanReport,
1133
+ getGlobalStats,
1134
+ getPriceHistory,
1135
+ getPersonas,
1136
+ PERSONAS
1137
+ };