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