web-agent-bridge 3.2.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (202) hide show
  1. package/LICENSE +72 -72
  2. package/README.ar.md +1286 -1152
  3. package/README.md +1764 -1635
  4. package/bin/agent-runner.js +474 -474
  5. package/bin/cli.js +237 -138
  6. package/bin/wab.js +80 -80
  7. package/examples/bidi-agent.js +119 -119
  8. package/examples/cross-site-agent.js +91 -91
  9. package/examples/mcp-agent.js +94 -94
  10. package/examples/next-app-router/README.md +44 -44
  11. package/examples/puppeteer-agent.js +108 -108
  12. package/examples/saas-dashboard/README.md +55 -55
  13. package/examples/shopify-hydrogen/README.md +74 -74
  14. package/examples/vision-agent.js +171 -171
  15. package/examples/wordpress-elementor/README.md +77 -77
  16. package/package.json +16 -3
  17. package/public/.well-known/agent-tools.json +180 -180
  18. package/public/.well-known/ai-assets.json +59 -59
  19. package/public/.well-known/security.txt +8 -0
  20. package/public/agent-workspace.html +349 -349
  21. package/public/ai.html +198 -198
  22. package/public/api.html +413 -412
  23. package/public/browser.html +486 -486
  24. package/public/commander-dashboard.html +243 -243
  25. package/public/cookies.html +210 -210
  26. package/public/css/agent-workspace.css +1713 -1713
  27. package/public/css/premium.css +317 -317
  28. package/public/css/styles.css +1235 -1235
  29. package/public/dashboard.html +706 -706
  30. package/public/dns.html +507 -0
  31. package/public/docs.html +587 -587
  32. package/public/feed.xml +89 -89
  33. package/public/growth.html +463 -463
  34. package/public/index.html +1070 -982
  35. package/public/integrations.html +556 -0
  36. package/public/js/agent-workspace.js +1740 -1740
  37. package/public/js/auth-nav.js +31 -31
  38. package/public/js/auth-redirect.js +12 -12
  39. package/public/js/cookie-consent.js +56 -56
  40. package/public/js/wab-demo-page.js +721 -721
  41. package/public/js/ws-client.js +74 -74
  42. package/public/llms-full.txt +360 -360
  43. package/public/llms.txt +125 -125
  44. package/public/login.html +85 -85
  45. package/public/mesh-dashboard.html +328 -328
  46. package/public/openapi.json +580 -580
  47. package/public/phone-shield.html +281 -0
  48. package/public/premium-dashboard.html +2489 -2489
  49. package/public/premium.html +793 -793
  50. package/public/privacy.html +297 -297
  51. package/public/register.html +105 -105
  52. package/public/robots.txt +87 -87
  53. package/public/script/wab-consent.d.ts +36 -36
  54. package/public/script/wab-consent.js +104 -104
  55. package/public/script/wab-schema.js +131 -131
  56. package/public/script/wab.d.ts +108 -108
  57. package/public/script/wab.min.js +580 -580
  58. package/public/security.txt +8 -0
  59. package/public/terms.html +256 -256
  60. package/script/ai-agent-bridge.js +1754 -1754
  61. package/sdk/README.md +99 -99
  62. package/sdk/agent-mesh.js +449 -449
  63. package/sdk/commander.js +262 -262
  64. package/sdk/index.d.ts +464 -464
  65. package/sdk/index.js +12 -1
  66. package/sdk/multi-agent.js +318 -318
  67. package/sdk/package.json +1 -1
  68. package/sdk/safety-shield.js +219 -0
  69. package/sdk/schema-discovery.js +83 -83
  70. package/server/adapters/index.js +520 -520
  71. package/server/config/plans.js +367 -367
  72. package/server/config/secrets.js +102 -102
  73. package/server/control-plane/index.js +301 -301
  74. package/server/data-plane/index.js +354 -354
  75. package/server/index.js +531 -427
  76. package/server/llm/index.js +404 -404
  77. package/server/middleware/adminAuth.js +35 -35
  78. package/server/middleware/auth.js +50 -50
  79. package/server/middleware/featureGate.js +88 -88
  80. package/server/middleware/rateLimits.js +100 -100
  81. package/server/middleware/sensitiveAction.js +157 -0
  82. package/server/migrations/001_add_analytics_indexes.sql +7 -7
  83. package/server/migrations/002_premium_features.sql +418 -418
  84. package/server/migrations/003_ads_integer_cents.sql +33 -33
  85. package/server/migrations/004_agent_os.sql +158 -158
  86. package/server/migrations/005_marketplace_metering.sql +126 -126
  87. package/server/models/adapters/index.js +33 -33
  88. package/server/models/adapters/mysql.js +183 -183
  89. package/server/models/adapters/postgresql.js +172 -172
  90. package/server/models/adapters/sqlite.js +7 -7
  91. package/server/models/db.js +681 -681
  92. package/server/observability/failure-analysis.js +337 -337
  93. package/server/observability/index.js +394 -394
  94. package/server/protocol/capabilities.js +223 -223
  95. package/server/protocol/index.js +243 -243
  96. package/server/protocol/schema.js +584 -584
  97. package/server/registry/certification.js +271 -271
  98. package/server/registry/index.js +326 -326
  99. package/server/routes/admin-premium.js +671 -671
  100. package/server/routes/admin.js +261 -261
  101. package/server/routes/ads.js +130 -130
  102. package/server/routes/agent-workspace.js +540 -540
  103. package/server/routes/api.js +150 -150
  104. package/server/routes/auth.js +71 -71
  105. package/server/routes/billing.js +45 -45
  106. package/server/routes/commander.js +316 -316
  107. package/server/routes/demo-showcase.js +332 -332
  108. package/server/routes/demo-store.js +154 -0
  109. package/server/routes/discovery.js +417 -417
  110. package/server/routes/gateway.js +173 -157
  111. package/server/routes/license.js +251 -240
  112. package/server/routes/mesh.js +469 -469
  113. package/server/routes/noscript.js +543 -543
  114. package/server/routes/premium-v2.js +686 -686
  115. package/server/routes/premium.js +724 -724
  116. package/server/routes/runtime.js +2148 -2147
  117. package/server/routes/sovereign.js +465 -385
  118. package/server/routes/universal.js +200 -185
  119. package/server/routes/wab-api.js +850 -501
  120. package/server/runtime/container-worker.js +111 -111
  121. package/server/runtime/container.js +448 -448
  122. package/server/runtime/distributed-worker.js +362 -362
  123. package/server/runtime/event-bus.js +210 -210
  124. package/server/runtime/index.js +253 -253
  125. package/server/runtime/queue.js +599 -599
  126. package/server/runtime/replay.js +666 -666
  127. package/server/runtime/sandbox.js +266 -266
  128. package/server/runtime/scheduler.js +534 -534
  129. package/server/runtime/session-engine.js +293 -293
  130. package/server/runtime/state-manager.js +188 -188
  131. package/server/security/cross-site-redactor.js +196 -0
  132. package/server/security/dry-run.js +180 -0
  133. package/server/security/human-gate-rate-limit.js +147 -0
  134. package/server/security/human-gate-transports.js +178 -0
  135. package/server/security/human-gate.js +281 -0
  136. package/server/security/index.js +368 -368
  137. package/server/security/intent-engine.js +245 -0
  138. package/server/security/reward-guard.js +171 -0
  139. package/server/security/rollback-store.js +239 -0
  140. package/server/security/token-scope.js +404 -0
  141. package/server/security/url-policy.js +139 -0
  142. package/server/services/agent-chat.js +506 -506
  143. package/server/services/agent-learning.js +601 -575
  144. package/server/services/agent-memory.js +625 -625
  145. package/server/services/agent-mesh.js +555 -539
  146. package/server/services/agent-symphony.js +717 -717
  147. package/server/services/agent-tasks.js +1807 -1807
  148. package/server/services/api-key-engine.js +292 -261
  149. package/server/services/cluster.js +894 -894
  150. package/server/services/commander.js +738 -738
  151. package/server/services/edge-compute.js +440 -440
  152. package/server/services/email.js +204 -204
  153. package/server/services/hosted-runtime.js +205 -205
  154. package/server/services/lfd.js +635 -635
  155. package/server/services/local-ai.js +389 -389
  156. package/server/services/marketplace.js +270 -270
  157. package/server/services/metering.js +182 -182
  158. package/server/services/modules/affiliate-intelligence.js +93 -93
  159. package/server/services/modules/agent-firewall.js +90 -90
  160. package/server/services/modules/bounty.js +89 -89
  161. package/server/services/modules/collective-bargaining.js +92 -92
  162. package/server/services/modules/dark-pattern.js +66 -66
  163. package/server/services/modules/gov-intelligence.js +45 -45
  164. package/server/services/modules/neural.js +55 -55
  165. package/server/services/modules/notary.js +49 -49
  166. package/server/services/modules/price-time-machine.js +86 -86
  167. package/server/services/modules/protocol.js +104 -104
  168. package/server/services/negotiation.js +439 -439
  169. package/server/services/plugins.js +771 -771
  170. package/server/services/price-intelligence.js +566 -566
  171. package/server/services/price-shield.js +1137 -1137
  172. package/server/services/reputation.js +465 -465
  173. package/server/services/search-engine.js +357 -357
  174. package/server/services/security.js +513 -513
  175. package/server/services/self-healing.js +843 -843
  176. package/server/services/sovereign-shield.js +542 -0
  177. package/server/services/stripe.js +192 -192
  178. package/server/services/swarm.js +788 -788
  179. package/server/services/universal-scraper.js +662 -661
  180. package/server/services/verification.js +481 -481
  181. package/server/services/vision.js +1163 -1163
  182. package/server/utils/cache.js +125 -125
  183. package/server/utils/migrate.js +81 -81
  184. package/server/utils/safe-fetch.js +228 -0
  185. package/server/utils/secureFields.js +50 -50
  186. package/server/ws.js +161 -161
  187. package/templates/artisan-marketplace.yaml +104 -104
  188. package/templates/book-price-scout.yaml +98 -98
  189. package/templates/electronics-price-tracker.yaml +108 -108
  190. package/templates/flight-deal-hunter.yaml +113 -113
  191. package/templates/freelancer-direct.yaml +116 -116
  192. package/templates/grocery-price-compare.yaml +93 -93
  193. package/templates/hotel-direct-booking.yaml +113 -113
  194. package/templates/local-services.yaml +98 -98
  195. package/templates/olive-oil-tunisia.yaml +88 -88
  196. package/templates/organic-farm-fresh.yaml +101 -101
  197. package/templates/restaurant-direct.yaml +97 -97
  198. package/public/score.html +0 -263
  199. package/server/migrations/006_growth_suite.sql +0 -138
  200. package/server/routes/growth.js +0 -962
  201. package/server/services/fairness-engine.js +0 -409
  202. package/server/services/fairness.js +0 -420
@@ -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
+ };