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.
- package/LICENSE +72 -21
- package/README.ar.md +1286 -1073
- package/README.md +1764 -1535
- package/bin/agent-runner.js +474 -474
- package/bin/cli.js +237 -138
- package/bin/wab.js +80 -80
- package/examples/bidi-agent.js +119 -119
- package/examples/cross-site-agent.js +91 -91
- package/examples/mcp-agent.js +94 -94
- package/examples/next-app-router/README.md +44 -44
- package/examples/puppeteer-agent.js +108 -108
- package/examples/saas-dashboard/README.md +55 -55
- package/examples/shopify-hydrogen/README.md +74 -74
- package/examples/vision-agent.js +171 -171
- package/examples/wordpress-elementor/README.md +77 -77
- package/package.json +17 -3
- package/public/.well-known/agent-tools.json +180 -180
- package/public/.well-known/ai-assets.json +59 -59
- package/public/.well-known/ai-plugin.json +28 -0
- package/public/.well-known/security.txt +8 -0
- package/public/agent-workspace.html +349 -347
- package/public/ai.html +198 -196
- package/public/api.html +413 -0
- package/public/browser.html +486 -484
- package/public/commander-dashboard.html +243 -243
- package/public/cookies.html +210 -208
- package/public/css/agent-workspace.css +1713 -1713
- package/public/css/premium.css +317 -317
- package/public/css/styles.css +1235 -1235
- package/public/dashboard.html +706 -704
- package/public/demo.html +1770 -1
- package/public/dns.html +507 -0
- package/public/docs.html +587 -585
- package/public/feed.xml +89 -89
- package/public/growth.html +463 -0
- package/public/index.html +341 -9
- package/public/integrations.html +556 -0
- package/public/js/agent-workspace.js +1740 -1740
- package/public/js/auth-nav.js +31 -31
- package/public/js/auth-redirect.js +12 -12
- package/public/js/cookie-consent.js +56 -56
- package/public/js/wab-demo-page.js +721 -721
- package/public/js/ws-client.js +74 -74
- package/public/llms-full.txt +360 -309
- package/public/llms.txt +125 -86
- package/public/login.html +85 -83
- package/public/mesh-dashboard.html +328 -328
- package/public/openapi.json +580 -580
- package/public/phone-shield.html +281 -0
- package/public/premium-dashboard.html +2489 -2487
- package/public/premium.html +793 -791
- package/public/privacy.html +297 -295
- package/public/register.html +105 -103
- package/public/robots.txt +87 -87
- package/public/script/wab-consent.d.ts +36 -36
- package/public/script/wab-consent.js +104 -104
- package/public/script/wab-schema.js +131 -131
- package/public/script/wab.d.ts +108 -108
- package/public/script/wab.min.js +580 -580
- package/public/security.txt +8 -0
- package/public/terms.html +256 -254
- package/script/ai-agent-bridge.js +1754 -1754
- package/sdk/README.md +99 -99
- package/sdk/agent-mesh.js +449 -449
- package/sdk/commander.js +262 -262
- package/sdk/index.d.ts +464 -464
- package/sdk/index.js +18 -1
- package/sdk/multi-agent.js +318 -318
- package/sdk/package.json +12 -1
- package/sdk/safety-shield.js +219 -0
- package/sdk/schema-discovery.js +83 -83
- package/server/adapters/index.js +520 -520
- package/server/config/plans.js +367 -367
- package/server/config/secrets.js +102 -102
- package/server/control-plane/index.js +301 -301
- package/server/data-plane/index.js +354 -354
- package/server/index.js +175 -19
- package/server/llm/index.js +404 -404
- package/server/middleware/adminAuth.js +35 -35
- package/server/middleware/auth.js +50 -50
- package/server/middleware/featureGate.js +88 -88
- package/server/middleware/rateLimits.js +100 -100
- package/server/middleware/sensitiveAction.js +157 -0
- package/server/migrations/001_add_analytics_indexes.sql +7 -7
- package/server/migrations/002_premium_features.sql +418 -418
- package/server/migrations/003_ads_integer_cents.sql +33 -33
- package/server/migrations/004_agent_os.sql +158 -158
- package/server/migrations/005_marketplace_metering.sql +126 -126
- package/server/models/adapters/index.js +33 -33
- package/server/models/adapters/mysql.js +183 -183
- package/server/models/adapters/postgresql.js +172 -172
- package/server/models/adapters/sqlite.js +7 -7
- package/server/models/db.js +681 -681
- package/server/observability/failure-analysis.js +337 -337
- package/server/observability/index.js +394 -394
- package/server/protocol/capabilities.js +223 -223
- package/server/protocol/index.js +243 -243
- package/server/protocol/schema.js +584 -584
- package/server/registry/certification.js +271 -271
- package/server/registry/index.js +326 -326
- package/server/routes/admin-premium.js +671 -671
- package/server/routes/admin.js +261 -261
- package/server/routes/ads.js +130 -130
- package/server/routes/agent-workspace.js +540 -378
- package/server/routes/api.js +150 -150
- package/server/routes/auth.js +71 -71
- package/server/routes/billing.js +45 -45
- package/server/routes/commander.js +316 -316
- package/server/routes/demo-showcase.js +332 -0
- package/server/routes/demo-store.js +154 -0
- package/server/routes/discovery.js +417 -406
- package/server/routes/gateway.js +173 -0
- package/server/routes/license.js +251 -240
- package/server/routes/mesh.js +469 -469
- package/server/routes/noscript.js +543 -543
- package/server/routes/premium-v2.js +686 -686
- package/server/routes/premium.js +724 -724
- package/server/routes/runtime.js +2148 -2147
- package/server/routes/sovereign.js +465 -385
- package/server/routes/universal.js +200 -177
- package/server/routes/wab-api.js +850 -491
- package/server/runtime/container-worker.js +111 -111
- package/server/runtime/container.js +448 -448
- package/server/runtime/distributed-worker.js +362 -362
- package/server/runtime/event-bus.js +210 -210
- package/server/runtime/index.js +253 -253
- package/server/runtime/queue.js +599 -599
- package/server/runtime/replay.js +666 -666
- package/server/runtime/sandbox.js +266 -266
- package/server/runtime/scheduler.js +534 -534
- package/server/runtime/session-engine.js +293 -293
- package/server/runtime/state-manager.js +188 -188
- package/server/security/cross-site-redactor.js +196 -0
- package/server/security/dry-run.js +180 -0
- package/server/security/human-gate-rate-limit.js +147 -0
- package/server/security/human-gate-transports.js +178 -0
- package/server/security/human-gate.js +281 -0
- package/server/security/index.js +368 -368
- package/server/security/intent-engine.js +245 -0
- package/server/security/reward-guard.js +171 -0
- package/server/security/rollback-store.js +239 -0
- package/server/security/token-scope.js +404 -0
- package/server/security/url-policy.js +139 -0
- package/server/services/agent-chat.js +506 -506
- package/server/services/agent-learning.js +601 -575
- package/server/services/agent-memory.js +625 -625
- package/server/services/agent-mesh.js +555 -539
- package/server/services/agent-symphony.js +717 -717
- package/server/services/agent-tasks.js +1807 -1807
- package/server/services/api-key-engine.js +292 -0
- package/server/services/cluster.js +894 -894
- package/server/services/commander.js +738 -738
- package/server/services/edge-compute.js +440 -440
- package/server/services/email.js +204 -204
- package/server/services/hosted-runtime.js +205 -205
- package/server/services/lfd.js +635 -616
- package/server/services/local-ai.js +389 -389
- package/server/services/marketplace.js +270 -270
- package/server/services/metering.js +182 -182
- package/server/services/modules/affiliate-intelligence.js +93 -0
- package/server/services/modules/agent-firewall.js +90 -0
- package/server/services/modules/bounty.js +89 -0
- package/server/services/modules/collective-bargaining.js +92 -0
- package/server/services/modules/dark-pattern.js +66 -0
- package/server/services/modules/gov-intelligence.js +45 -0
- package/server/services/modules/neural.js +55 -0
- package/server/services/modules/notary.js +49 -0
- package/server/services/modules/price-time-machine.js +86 -0
- package/server/services/modules/protocol.js +104 -0
- package/server/services/negotiation.js +439 -439
- package/server/services/plugins.js +771 -771
- package/server/services/premium.js +1 -1
- package/server/services/price-intelligence.js +566 -565
- package/server/services/price-shield.js +1137 -1137
- package/server/services/reputation.js +465 -465
- package/server/services/search-engine.js +357 -357
- package/server/services/security.js +513 -513
- package/server/services/self-healing.js +843 -843
- package/server/services/sovereign-shield.js +542 -0
- package/server/services/stripe.js +192 -192
- package/server/services/swarm.js +788 -788
- package/server/services/universal-scraper.js +662 -661
- package/server/services/verification.js +481 -481
- package/server/services/vision.js +1163 -1163
- package/server/utils/cache.js +125 -125
- package/server/utils/migrate.js +81 -81
- package/server/utils/safe-fetch.js +228 -0
- package/server/utils/secureFields.js +50 -50
- package/server/ws.js +161 -161
- package/templates/artisan-marketplace.yaml +104 -104
- package/templates/book-price-scout.yaml +98 -98
- package/templates/electronics-price-tracker.yaml +108 -108
- package/templates/flight-deal-hunter.yaml +113 -113
- package/templates/freelancer-direct.yaml +116 -116
- package/templates/grocery-price-compare.yaml +93 -93
- package/templates/hotel-direct-booking.yaml +113 -113
- package/templates/local-services.yaml +98 -98
- package/templates/olive-oil-tunisia.yaml +88 -88
- package/templates/organic-farm-fresh.yaml +101 -101
- package/templates/restaurant-direct.yaml +97 -97
- package/server/services/fairness-engine.js +0 -409
- 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
|
+
};
|