Skip to content

Commit 269e30a

Browse files
author
SIN-Agent
committed
feat: survey X-ray modules — panel, traps, reward, risk, questions
Five new scanner modules for survey page pre-analysis: - panel-detector: fingerprints 7 panel engines (Dynata, Cint, Lucid, etc.) - trap-scanner: detects honeypots, attention checks, consistency traps - reward-estimator: extracts EUR/min, EUR/hour from page content - risk-assessor: predicts DQ probability with panel reputation scoring - question-classifier: identifies radio/matrix/slider/text types CLI: 'unmask survey scan <url>' — full pre-flight analysis CLI: 'unmask survey panel <url>' — panel detection only CLI: 'unmask survey traps <url>' — trap scanning only Architecture: unmask-cli (X-ray/Röntgen) → playstealth-cli (Ninja mask) Sense before you act. Analyze before you interact. Build: 0 errors | Tests: 34/34 pass
1 parent e050d63 commit 269e30a

7 files changed

Lines changed: 628 additions & 18 deletions

File tree

src/cli.ts

Lines changed: 92 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import path from 'node:path';
1313
import { homedir } from 'node:os';
1414
import { Command } from 'commander';
1515
import { inspect } from './commands/inspect.js';
16-
import { ConsoleListener } from './modules/console.js';
1716
import { DomScanner } from './modules/dom.js';
1817
import { NetworkSniffer } from './modules/network.js';
1918
import { launchBrowser } from './browser/runner.js';
@@ -122,24 +121,99 @@ program
122121
});
123122

124123
// ---- console ----------------------------------------------------------------
125-
program
126-
.command('console')
127-
.description('Live console listener (unmask-console).')
128-
.argument('<url>', 'Page URL to listen to')
129-
.option('--headful', 'launch a visible browser', false)
130-
.option('--wait-ms <ms>', 'how long to listen after navigation in ms', '8000')
131-
.option('-o, --output <path>', 'write JSON to this path instead of stdout')
132-
.action(async (url: string, opts) => {
133-
const handle = await launchBrowser({ headless: !opts.headful });
134-
const listener = new ConsoleListener();
135-
try {
136-
listener.attach(handle.page);
137-
await handle.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60_000 });
138-
await handle.page.waitForTimeout(Number(opts.waitMs) || 8_000);
139-
await emit(listener.results(), opts.output);
140-
} finally {
141-
await handle.close();
124+
125+
// ---- survey (X-ray for playstealth-cli) -------------------------------------
126+
const survey = program
127+
.command("survey")
128+
.description("Survey page analysis for playstealth-cli integration.");
129+
130+
survey
131+
.command("scan")
132+
.description("Full survey page scan: panel, traps, reward, risk, questions.")
133+
.argument("<url>", "Survey page URL")
134+
.option("--headful", "launch a visible browser", false)
135+
.option("--json", "output JSON only", false)
136+
.action(async (url: string, opts: Record<string, string>) => {
137+
const { launchBrowser } = await import("./browser/runner.js");
138+
const { PanelDetector } = await import(
139+
"./modules/survey/panel-detector.js"
140+
);
141+
const { TrapScanner } = await import("./modules/survey/trap-scanner.js");
142+
const { RewardEstimator } = await import(
143+
"./modules/survey/reward-estimator.js"
144+
);
145+
const { RiskAssessor } = await import(
146+
"./modules/survey/risk-assessor.js"
147+
);
148+
const { QuestionClassifier } = await import(
149+
"./modules/survey/question-classifier.js"
150+
);
151+
152+
const handle = await launchBrowser({
153+
headless: opts.headful !== "true",
154+
});
155+
await handle.page.goto(url, { waitUntil: "domcontentloaded" });
156+
157+
const panel = await new PanelDetector().detect(handle.page);
158+
const traps = await new TrapScanner().scan(handle.page);
159+
const reward = await new RewardEstimator().estimate(handle.page);
160+
const questions = await new QuestionClassifier().classify(handle.page);
161+
const risk = new RiskAssessor().assess(
162+
panel.id,
163+
traps,
164+
0,
165+
questions.filter((q) => q.type === "text" || q.type === "textarea")
166+
.length
167+
);
168+
169+
const result = { panel, traps, reward, risk, questions };
170+
171+
if (opts.json) {
172+
console.log(JSON.stringify(result, null, 2));
173+
} else {
174+
console.log(`\n🔍 Survey Scan: ${url}`);
175+
console.log(` Panel: ${panel.id} (${(panel.confidence * 100).toFixed(0)}%)`);
176+
console.log(` Reward: ${reward.amountEur ?? "?"} € / ${reward.timeMinutes ?? "?"} min → ${reward.eurPerHour ?? "?"} €/h`);
177+
console.log(` Risk: ${risk.riskLevel} (${(risk.dqProbability * 100).toFixed(0)}% DQ probability)`);
178+
console.log(` Traps: ${traps.length} found`);
179+
for (const t of traps) {
180+
console.log(` ⚠️ ${t.type}: ${t.description}`);
181+
}
182+
console.log(` Questions: ${questions.map((q) => `${q.type}(${q.count})`).join(", ")}`);
183+
console.log();
142184
}
185+
186+
await handle.close();
187+
});
188+
189+
survey
190+
.command("panel")
191+
.description("Detect which survey panel engine is running.")
192+
.argument("<url>", "Survey page URL")
193+
.action(async (url: string) => {
194+
const { launchBrowser } = await import("./browser/runner.js");
195+
const { PanelDetector } = await import(
196+
"./modules/survey/panel-detector.js"
197+
);
198+
const handle = await launchBrowser({ headless: true });
199+
await handle.page.goto(url, { waitUntil: "domcontentloaded" });
200+
const result = await new PanelDetector().detect(handle.page);
201+
console.log(JSON.stringify(result, null, 2));
202+
await handle.close();
203+
});
204+
205+
survey
206+
.command("traps")
207+
.description("Scan survey page for honeypots and attention checks.")
208+
.argument("<url>", "Survey page URL")
209+
.action(async (url: string) => {
210+
const { launchBrowser } = await import("./browser/runner.js");
211+
const { TrapScanner } = await import("./modules/survey/trap-scanner.js");
212+
const handle = await launchBrowser({ headless: true });
213+
await handle.page.goto(url, { waitUntil: "domcontentloaded" });
214+
const result = await new TrapScanner().scan(handle.page);
215+
console.log(JSON.stringify(result, null, 2));
216+
await handle.close();
143217
});
144218

145219
// ---- queue ------------------------------------------------------------------

src/modules/survey/index.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/**
2+
* Barrel export for survey-specific scanner modules.
3+
*
4+
* These five modules form the "X-ray vision" that analyzes a survey page
5+
* BEFORE playstealth-cli interacts with it:
6+
*
7+
* panel-detector → which panel engine is this?
8+
* trap-scanner → what traps/honeypots are on this page?
9+
* reward-estimator → how much does this survey pay?
10+
* risk-assessor → how likely is a DQ?
11+
* question-classifier → what question types are on this page?
12+
*/
13+
export { PanelDetector } from "./panel-detector.js";
14+
export type { PanelId, PanelInfo } from "./panel-detector.js";
15+
16+
export { TrapScanner } from "./trap-scanner.js";
17+
export type { TrapInfo } from "./trap-scanner.js";
18+
19+
export { RewardEstimator } from "./reward-estimator.js";
20+
export type { RewardInfo } from "./reward-estimator.js";
21+
22+
export { RiskAssessor } from "./risk-assessor.js";
23+
export type { RiskAssessment } from "./risk-assessor.js";
24+
25+
export { QuestionClassifier } from "./question-classifier.js";
26+
export type { QuestionType, QuestionInfo } from "./question-classifier.js";
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
/**
2+
* Panel Detector — identifies which survey panel engine is running.
3+
*
4+
* HeyPiggy doesn't run surveys itself — it redirects to third-party panels.
5+
* Each panel has distinct DOM signatures, URL patterns, and behavioral
6+
* characteristics. This module fingerprints the active panel.
7+
*/
8+
import type { Page } from "playwright";
9+
10+
export type PanelId =
11+
| "dynata"
12+
| "cint"
13+
| "lucid"
14+
| "purespectrum"
15+
| "sapio"
16+
| "qualtrics"
17+
| "decipher"
18+
| "unknown";
19+
20+
export interface PanelInfo {
21+
id: PanelId;
22+
confidence: number; // 0..1
23+
signals: string[];
24+
urlFragment: string;
25+
}
26+
27+
const PANEL_FINGERPRINTS: Record<
28+
Exclude<PanelId, "unknown">,
29+
{ domains: string[]; selectors: string[]; urlTokens: string[] }
30+
> = {
31+
dynata: {
32+
domains: ["dynata.com", "sawtoothsoftware.com"],
33+
selectors: [
34+
'[id*="Dynata"]',
35+
'[class*="dynata"]',
36+
'script[src*="dynata"]',
37+
],
38+
urlTokens: ["dynata", "sawtooth"],
39+
},
40+
cint: {
41+
domains: ["cint.com", "cintworks.com"],
42+
selectors: ['[id*="cint"]', '[class*="cint"]', 'script[src*="cint"]'],
43+
urlTokens: ["cint", "cintworks"],
44+
},
45+
lucid: {
46+
domains: ["luc.id", "lucidhq.com", "fulcrum.com"],
47+
selectors: [
48+
'[id*="lucid"]',
49+
'[class*="lucid"]',
50+
'script[src*="lucid"]',
51+
],
52+
urlTokens: ["lucid", "fulcrum", "luc.id"],
53+
},
54+
purespectrum: {
55+
domains: ["purespectrum.com", "puresurvey.com"],
56+
selectors: [
57+
'[id*="PureSpectrum"]',
58+
'[class*="purespectrum"]',
59+
'script[src*="purespectrum"]',
60+
],
61+
urlTokens: ["purespectrum", "puresurvey"],
62+
},
63+
sapio: {
64+
domains: ["sapioresearch.com", "sapiosurveys.com"],
65+
selectors: [
66+
'[id*="sapio"]',
67+
'[class*="sapio"]',
68+
'script[src*="sapio"]',
69+
],
70+
urlTokens: ["sapio", "sapioresearch"],
71+
},
72+
qualtrics: {
73+
domains: ["qualtrics.com"],
74+
selectors: [
75+
'[id*="QSI"]',
76+
'[class*="Skin"]',
77+
'script[src*="qualtrics"]',
78+
"#Questions",
79+
],
80+
urlTokens: ["qualtrics", "jfe", "SV_"],
81+
},
82+
decipher: {
83+
domains: ["decipherinc.com", "confirmit.com"],
84+
selectors: [
85+
'[id*="survey"]',
86+
'[class*="decipher"]',
87+
'script[src*="decipher"]',
88+
],
89+
urlTokens: ["decipher", "confirmit"],
90+
},
91+
};
92+
93+
export class PanelDetector {
94+
async detect(page: Page): Promise<PanelInfo> {
95+
const url = page.url().toLowerCase();
96+
const signals: string[] = [];
97+
98+
// Check domains and URL tokens
99+
for (const [panelId, fp] of Object.entries(PANEL_FINGERPRINTS)) {
100+
for (const domain of fp.domains) {
101+
if (url.includes(domain)) {
102+
signals.push(`domain:${domain}`);
103+
}
104+
}
105+
for (const token of fp.urlTokens) {
106+
if (url.includes(token)) {
107+
signals.push(`url:${token}`);
108+
}
109+
}
110+
// Check DOM selectors
111+
for (const sel of fp.selectors) {
112+
try {
113+
const count = await page.locator(sel).count();
114+
if (count > 0) {
115+
signals.push(`dom:${sel}`);
116+
}
117+
} catch {
118+
// selector not valid, skip
119+
}
120+
}
121+
122+
if (signals.length >= 2) {
123+
return {
124+
id: panelId as PanelId,
125+
confidence: Math.min(1.0, signals.length * 0.3),
126+
signals: [...new Set(signals)],
127+
urlFragment: url.slice(0, 80),
128+
};
129+
}
130+
signals.length = 0;
131+
}
132+
133+
return {
134+
id: "unknown",
135+
confidence: 0.1,
136+
signals: [`url:${url.slice(0, 60)}`],
137+
urlFragment: url.slice(0, 80),
138+
};
139+
}
140+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/**
2+
* Question Classifier — identifies question types on survey pages.
3+
*
4+
* Scans the DOM for radio buttons, checkboxes, sliders, matrices,
5+
* text fields, dropdowns, and other question patterns. This feeds
6+
* into playstealth-cli's question_router for optimal answer strategy.
7+
*/
8+
import type { Page } from "playwright";
9+
10+
export type QuestionType =
11+
| "radio"
12+
| "checkbox"
13+
| "matrix"
14+
| "slider"
15+
| "text"
16+
| "textarea"
17+
| "select"
18+
| "number"
19+
| "date"
20+
| "rank_order"
21+
| "consent"
22+
| "unknown";
23+
24+
export interface QuestionInfo {
25+
type: QuestionType;
26+
count: number;
27+
selectors: string[];
28+
label?: string;
29+
}
30+
31+
export class QuestionClassifier {
32+
async classify(page: Page): Promise<QuestionInfo[]> {
33+
const questions: QuestionInfo[] = [];
34+
35+
const checks: Array<{
36+
type: QuestionType;
37+
selector: string;
38+
label?: string;
39+
}> = [
40+
{ type: "slider", selector: 'input[type="range"]' },
41+
{ type: "date", selector: 'input[type="date"]' },
42+
{ type: "number", selector: 'input[type="number"], input[inputmode="numeric"]' },
43+
{ type: "select", selector: "select" },
44+
{ type: "textarea", selector: "textarea" },
45+
{ type: "text", selector: 'input[type="text"], input:not([type])' },
46+
{ type: "radio", selector: 'input[type="radio"]' },
47+
{ type: "checkbox", selector: 'input[type="checkbox"]' },
48+
{ type: "matrix", selector: 'table.question, [class*="matrix"], [class*="grid"], [data-question-type="matrix"]' },
49+
{ type: "rank_order", selector: '[class*="rank-order"], [data-question-type="rank-order"]' },
50+
{ type: "consent", selector: 'button:has-text("Accept"), button:has-text("Akzeptieren"), button:has-text("Agree")' },
51+
];
52+
53+
for (const check of checks) {
54+
try {
55+
const count = await page.locator(check.selector).count();
56+
if (count > 0) {
57+
questions.push({
58+
type: check.type,
59+
count,
60+
selectors: [check.selector],
61+
label: check.label,
62+
});
63+
}
64+
} catch {
65+
// skip invalid selector
66+
}
67+
}
68+
69+
// If no known types found, mark as unknown
70+
if (questions.length === 0) {
71+
questions.push({
72+
type: "unknown",
73+
count: 0,
74+
selectors: ["body"],
75+
label: "No known question type detected",
76+
});
77+
}
78+
79+
return questions;
80+
}
81+
82+
/** Get the primary question type (first non-consent type) */
83+
primary(questions: QuestionInfo[]): QuestionType {
84+
const nonConsent = questions.filter((q) => q.type !== "consent");
85+
return nonConsent[0]?.type ?? "unknown";
86+
}
87+
}

0 commit comments

Comments
 (0)