stealth-cli 0.5.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 +21 -0
- package/README.md +295 -0
- package/bin/stealth.js +50 -0
- package/package.json +65 -0
- package/skills/SKILL.md +244 -0
- package/src/browser.js +341 -0
- package/src/client.js +115 -0
- package/src/commands/batch.js +180 -0
- package/src/commands/browse.js +101 -0
- package/src/commands/config.js +85 -0
- package/src/commands/crawl.js +169 -0
- package/src/commands/daemon.js +143 -0
- package/src/commands/extract.js +153 -0
- package/src/commands/fingerprint.js +306 -0
- package/src/commands/interactive.js +284 -0
- package/src/commands/mcp.js +68 -0
- package/src/commands/monitor.js +160 -0
- package/src/commands/pdf.js +109 -0
- package/src/commands/profile.js +112 -0
- package/src/commands/proxy.js +116 -0
- package/src/commands/screenshot.js +96 -0
- package/src/commands/search.js +162 -0
- package/src/commands/serve.js +240 -0
- package/src/config.js +123 -0
- package/src/cookies.js +67 -0
- package/src/daemon-entry.js +19 -0
- package/src/daemon.js +294 -0
- package/src/errors.js +136 -0
- package/src/extractors/base.js +59 -0
- package/src/extractors/bing.js +47 -0
- package/src/extractors/duckduckgo.js +91 -0
- package/src/extractors/github.js +103 -0
- package/src/extractors/google.js +173 -0
- package/src/extractors/index.js +55 -0
- package/src/extractors/youtube.js +87 -0
- package/src/humanize.js +210 -0
- package/src/index.js +32 -0
- package/src/macros.js +36 -0
- package/src/mcp-server.js +341 -0
- package/src/output.js +65 -0
- package/src/profiles.js +308 -0
- package/src/proxy-pool.js +256 -0
- package/src/retry.js +112 -0
- package/src/session.js +159 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub Search extractor
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export const name = 'github';
|
|
6
|
+
|
|
7
|
+
export function canHandle(url) {
|
|
8
|
+
return /github\.com\/search/.test(url);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function waitForResults(page) {
|
|
12
|
+
const selectors = [
|
|
13
|
+
'[data-testid="results-list"]',
|
|
14
|
+
'.repo-list',
|
|
15
|
+
'[data-testid="result"]',
|
|
16
|
+
'.search-title',
|
|
17
|
+
'.Box-row', // Modern GitHub search layout
|
|
18
|
+
'div[data-testid="result"]',
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
for (const sel of selectors) {
|
|
22
|
+
try {
|
|
23
|
+
await page.waitForSelector(sel, { timeout: 3000 });
|
|
24
|
+
return;
|
|
25
|
+
} catch {}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// GitHub search can be slow, give it more time
|
|
29
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function extractResults(page, maxResults = 10) {
|
|
33
|
+
await waitForResults(page);
|
|
34
|
+
|
|
35
|
+
return page.evaluate((max) => {
|
|
36
|
+
const results = [];
|
|
37
|
+
|
|
38
|
+
// Modern GitHub search (React)
|
|
39
|
+
const items = document.querySelectorAll(
|
|
40
|
+
'[data-testid="results-list"] > div, .search-title, .repo-list-item, [data-testid="result"]',
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
for (const item of items) {
|
|
44
|
+
if (results.length >= max) break;
|
|
45
|
+
|
|
46
|
+
// Repository link — try multiple selectors
|
|
47
|
+
const linkEl = item.querySelector(
|
|
48
|
+
'a[href*="github.com/"]:has(.search-match), a.v-align-middle, a[data-testid="result-title-link"]',
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
if (linkEl) {
|
|
52
|
+
const descEl = item.querySelector('p, .mb-1, [data-testid="result-description"]');
|
|
53
|
+
const langEl = item.querySelector('[itemprop="programmingLanguage"], span.repo-language-color + span');
|
|
54
|
+
const starsEl = item.querySelector('a[href*="/stargazers"], [aria-label*="star"]');
|
|
55
|
+
const updatedEl = item.querySelector('relative-time, [datetime]');
|
|
56
|
+
|
|
57
|
+
results.push({
|
|
58
|
+
title: linkEl.textContent?.trim() || '',
|
|
59
|
+
url: linkEl.href,
|
|
60
|
+
description: descEl?.textContent?.trim() || '',
|
|
61
|
+
language: langEl?.textContent?.trim() || '',
|
|
62
|
+
stars: starsEl?.textContent?.trim() || '',
|
|
63
|
+
updated: updatedEl?.getAttribute('datetime') || updatedEl?.textContent?.trim() || '',
|
|
64
|
+
});
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Fallback: any link to a repo pattern /user/repo
|
|
69
|
+
const anyLink = item.querySelector('a[href*="github.com/"]');
|
|
70
|
+
if (anyLink && anyLink.href.match(/github\.com\/[\w-]+\/[\w.-]+$/)) {
|
|
71
|
+
const descEl = item.querySelector('p, .mb-1');
|
|
72
|
+
results.push({
|
|
73
|
+
title: anyLink.textContent?.trim() || '',
|
|
74
|
+
url: anyLink.href,
|
|
75
|
+
description: descEl?.textContent?.trim() || '',
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Strategy 2: Fallback — grab all repo-like links on the page
|
|
81
|
+
if (results.length === 0) {
|
|
82
|
+
const allLinks = document.querySelectorAll('a[href]');
|
|
83
|
+
const seenUrls = new Set();
|
|
84
|
+
for (const link of allLinks) {
|
|
85
|
+
if (results.length >= max) break;
|
|
86
|
+
const href = link.href;
|
|
87
|
+
if (
|
|
88
|
+
href?.match(/github\.com\/[\w-]+\/[\w.-]+$/) &&
|
|
89
|
+
!href.includes('/search') &&
|
|
90
|
+
!seenUrls.has(href)
|
|
91
|
+
) {
|
|
92
|
+
seenUrls.add(href);
|
|
93
|
+
const text = link.textContent?.trim();
|
|
94
|
+
if (text && text.length > 2 && text.includes('/')) {
|
|
95
|
+
results.push({ title: text, url: href, description: '' });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return results;
|
|
102
|
+
}, maxResults);
|
|
103
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google Search extractor
|
|
3
|
+
*
|
|
4
|
+
* Strategy:
|
|
5
|
+
* 1. Navigate to google.com homepage first (not direct search URL)
|
|
6
|
+
* 2. Type query into search box like a human
|
|
7
|
+
* 3. Wait for async SERP rendering
|
|
8
|
+
* 4. Extract structured results from multiple layout variants
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { humanType, humanScroll, randomDelay } from '../humanize.js';
|
|
12
|
+
|
|
13
|
+
export const name = 'google';
|
|
14
|
+
|
|
15
|
+
export function canHandle(url) {
|
|
16
|
+
return /google\.\w+\/search/.test(url);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Perform Google search with human-like behavior
|
|
21
|
+
* Instead of direct URL, simulates typing into search box
|
|
22
|
+
*
|
|
23
|
+
* @param {Page} page - Playwright page
|
|
24
|
+
* @param {string} query - Search query
|
|
25
|
+
* @returns {boolean} success
|
|
26
|
+
*/
|
|
27
|
+
export async function humanSearch(page, query) {
|
|
28
|
+
// Step 1: Visit Google homepage
|
|
29
|
+
await page.goto('https://www.google.com', {
|
|
30
|
+
waitUntil: 'domcontentloaded',
|
|
31
|
+
timeout: 15000,
|
|
32
|
+
});
|
|
33
|
+
await randomDelay(800, 2000);
|
|
34
|
+
|
|
35
|
+
// Step 2: Handle cookie consent (EU/UK)
|
|
36
|
+
try {
|
|
37
|
+
const consentBtn = page.locator(
|
|
38
|
+
'button:has-text("Accept all"), button:has-text("Accept"), button:has-text("Agree"), button:has-text("I agree"), [id*="accept"], [aria-label*="Accept"]',
|
|
39
|
+
);
|
|
40
|
+
if (await consentBtn.first().isVisible({ timeout: 1500 })) {
|
|
41
|
+
await consentBtn.first().click({ timeout: 2000 });
|
|
42
|
+
await randomDelay(500, 1000);
|
|
43
|
+
}
|
|
44
|
+
} catch {}
|
|
45
|
+
|
|
46
|
+
// Step 3: Type query with human timing
|
|
47
|
+
try {
|
|
48
|
+
await humanType(page, 'textarea[name="q"], input[name="q"]', query, {
|
|
49
|
+
pressEnter: true,
|
|
50
|
+
});
|
|
51
|
+
return true;
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Wait for Google results to render (they load asynchronously)
|
|
59
|
+
*/
|
|
60
|
+
export async function waitForResults(page) {
|
|
61
|
+
// Multiple selectors for different Google layouts
|
|
62
|
+
const selectors = [
|
|
63
|
+
'#rso h3',
|
|
64
|
+
'#search h3',
|
|
65
|
+
'#rso [data-snhf]',
|
|
66
|
+
'#rso a[href]:not([href^="/search"])',
|
|
67
|
+
'.g h3',
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
for (const sel of selectors) {
|
|
71
|
+
try {
|
|
72
|
+
await page.waitForSelector(sel, { timeout: 3000 });
|
|
73
|
+
return;
|
|
74
|
+
} catch {}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Last resort: wait for any meaningful content
|
|
78
|
+
await randomDelay(2000, 3000);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Check if Google blocked us (CAPTCHA / sorry page)
|
|
83
|
+
*/
|
|
84
|
+
export function isBlocked(url) {
|
|
85
|
+
return url.includes('/sorry/') || url.includes('consent.google') || url.includes('/recaptcha/');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Extract Google search results
|
|
90
|
+
*/
|
|
91
|
+
export async function extractResults(page, maxResults = 10) {
|
|
92
|
+
await waitForResults(page);
|
|
93
|
+
|
|
94
|
+
return page.evaluate((max) => {
|
|
95
|
+
const results = [];
|
|
96
|
+
|
|
97
|
+
// Strategy 1: Standard search result cards
|
|
98
|
+
const items = document.querySelectorAll(
|
|
99
|
+
'#rso .g, #rso [data-sokoban-container], #rso [data-snhf], #search .g, .MjjYud',
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
for (const item of items) {
|
|
103
|
+
if (results.length >= max) break;
|
|
104
|
+
|
|
105
|
+
const linkEl = item.querySelector(
|
|
106
|
+
'a[href]:not([href^="/search"]):not([href^="#"]):not([href*="google.com/search"])',
|
|
107
|
+
);
|
|
108
|
+
const titleEl = item.querySelector('h3');
|
|
109
|
+
const snippetEl = item.querySelector(
|
|
110
|
+
'[data-sncf], .VwiC3b, [style*="-webkit-line-clamp"], .IsZvec, .lEBKkf',
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
if (linkEl && titleEl) {
|
|
114
|
+
const href = linkEl.href;
|
|
115
|
+
if (href && href.startsWith('http') && !href.includes('google.com/search')) {
|
|
116
|
+
results.push({
|
|
117
|
+
title: titleEl.textContent?.trim() || '',
|
|
118
|
+
url: href,
|
|
119
|
+
snippet: snippetEl?.textContent?.trim() || '',
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Strategy 2: Fallback — look for any external links with headings
|
|
126
|
+
if (results.length === 0) {
|
|
127
|
+
const fallbackLinks = document.querySelectorAll('#rso a[href], #search a[href]');
|
|
128
|
+
const seenUrls = new Set();
|
|
129
|
+
|
|
130
|
+
for (const link of fallbackLinks) {
|
|
131
|
+
if (results.length >= max) break;
|
|
132
|
+
const href = link.href;
|
|
133
|
+
if (
|
|
134
|
+
href?.startsWith('http') &&
|
|
135
|
+
!href.includes('google.') &&
|
|
136
|
+
!href.includes('gstatic.') &&
|
|
137
|
+
!seenUrls.has(href)
|
|
138
|
+
) {
|
|
139
|
+
seenUrls.add(href);
|
|
140
|
+
const heading = link.querySelector('h3, h2, [role="heading"]');
|
|
141
|
+
const text = heading?.textContent?.trim() || link.textContent?.trim();
|
|
142
|
+
if (text && text.length > 3) {
|
|
143
|
+
results.push({ title: text.slice(0, 200), url: href, snippet: '' });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return results;
|
|
150
|
+
}, maxResults);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Extract "People also ask" questions
|
|
155
|
+
*/
|
|
156
|
+
export async function extractPeopleAlsoAsk(page, maxResults = 5) {
|
|
157
|
+
return page.evaluate((max) => {
|
|
158
|
+
const questions = [];
|
|
159
|
+
const items = document.querySelectorAll(
|
|
160
|
+
'[data-sgrd] [role="button"], .related-question-pair, [jsname="Cpkphb"]',
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
for (const item of items) {
|
|
164
|
+
if (questions.length >= max) break;
|
|
165
|
+
const text = item.textContent?.trim();
|
|
166
|
+
if (text && text.length > 10) {
|
|
167
|
+
questions.push(text);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return questions;
|
|
172
|
+
}, maxResults);
|
|
173
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extractor registry — auto-selects the best extractor for a URL/engine
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as google from './google.js';
|
|
6
|
+
import * as duckduckgo from './duckduckgo.js';
|
|
7
|
+
import * as bing from './bing.js';
|
|
8
|
+
import * as github from './github.js';
|
|
9
|
+
import * as youtube from './youtube.js';
|
|
10
|
+
import * as base from './base.js';
|
|
11
|
+
|
|
12
|
+
const extractors = [google, duckduckgo, bing, github, youtube];
|
|
13
|
+
|
|
14
|
+
// Map engine names to extractors
|
|
15
|
+
const engineMap = {
|
|
16
|
+
google: google,
|
|
17
|
+
duckduckgo: duckduckgo,
|
|
18
|
+
bing: bing,
|
|
19
|
+
github: github,
|
|
20
|
+
youtube: youtube,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Get the best extractor for a given engine name
|
|
25
|
+
*
|
|
26
|
+
* @param {string} engine - Engine name (google, bing, etc.)
|
|
27
|
+
* @returns {object} Extractor module
|
|
28
|
+
*/
|
|
29
|
+
export function getExtractorByEngine(engine) {
|
|
30
|
+
return engineMap[engine.toLowerCase()] || base;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Get the best extractor for a given URL
|
|
35
|
+
*
|
|
36
|
+
* @param {string} url - Page URL
|
|
37
|
+
* @returns {object} Extractor module
|
|
38
|
+
*/
|
|
39
|
+
export function getExtractorByUrl(url) {
|
|
40
|
+
for (const extractor of extractors) {
|
|
41
|
+
if (extractor.canHandle(url)) {
|
|
42
|
+
return extractor;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return base;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* List all available extractors
|
|
50
|
+
*/
|
|
51
|
+
export function listExtractors() {
|
|
52
|
+
return [...extractors.map((e) => e.name), base.name];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export { google, duckduckgo, bing, github, youtube, base };
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* YouTube Search extractor
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export const name = 'youtube';
|
|
6
|
+
|
|
7
|
+
export function canHandle(url) {
|
|
8
|
+
return /youtube\.com\/results/.test(url);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function waitForResults(page) {
|
|
12
|
+
try {
|
|
13
|
+
await page.waitForSelector(
|
|
14
|
+
'ytd-video-renderer, ytd-channel-renderer, #contents ytd-item-section-renderer',
|
|
15
|
+
{ timeout: 6000 },
|
|
16
|
+
);
|
|
17
|
+
} catch {}
|
|
18
|
+
|
|
19
|
+
// YouTube loads results dynamically; give extra time
|
|
20
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function extractResults(page, maxResults = 10) {
|
|
24
|
+
await waitForResults(page);
|
|
25
|
+
|
|
26
|
+
return page.evaluate((max) => {
|
|
27
|
+
const results = [];
|
|
28
|
+
|
|
29
|
+
// Video renderers
|
|
30
|
+
const items = document.querySelectorAll(
|
|
31
|
+
'ytd-video-renderer, ytd-channel-renderer, ytd-playlist-renderer',
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
for (const item of items) {
|
|
35
|
+
if (results.length >= max) break;
|
|
36
|
+
|
|
37
|
+
const isChannel = item.tagName === 'YTD-CHANNEL-RENDERER';
|
|
38
|
+
const isPlaylist = item.tagName === 'YTD-PLAYLIST-RENDERER';
|
|
39
|
+
|
|
40
|
+
// Video title link
|
|
41
|
+
const titleLink = item.querySelector(
|
|
42
|
+
'a#video-title, h3 a, a.channel-link, a[href*="/watch"], a[href*="/playlist"]',
|
|
43
|
+
);
|
|
44
|
+
if (!titleLink) continue;
|
|
45
|
+
|
|
46
|
+
const title = titleLink.textContent?.trim() || titleLink.getAttribute('title') || '';
|
|
47
|
+
const url = titleLink.href;
|
|
48
|
+
|
|
49
|
+
if (!url || !title) continue;
|
|
50
|
+
|
|
51
|
+
// Channel name
|
|
52
|
+
const channelEl = item.querySelector(
|
|
53
|
+
'ytd-channel-name a, .ytd-channel-name a, #channel-info a, #text.ytd-channel-name',
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
// View count and upload date
|
|
57
|
+
const metaItems = item.querySelectorAll(
|
|
58
|
+
'#metadata-line span, .inline-metadata-item, .ytd-video-meta-block span',
|
|
59
|
+
);
|
|
60
|
+
const views = metaItems[0]?.textContent?.trim() || '';
|
|
61
|
+
const uploadDate = metaItems[1]?.textContent?.trim() || '';
|
|
62
|
+
|
|
63
|
+
// Duration
|
|
64
|
+
const durationEl = item.querySelector(
|
|
65
|
+
'span.ytd-thumbnail-overlay-time-status-renderer, badge-shape .badge-shape-wiz__text',
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
// Thumbnail
|
|
69
|
+
const thumbEl = item.querySelector('img#img, ytd-thumbnail img');
|
|
70
|
+
|
|
71
|
+
const result = {
|
|
72
|
+
type: isChannel ? 'channel' : isPlaylist ? 'playlist' : 'video',
|
|
73
|
+
title,
|
|
74
|
+
url,
|
|
75
|
+
channel: channelEl?.textContent?.trim() || '',
|
|
76
|
+
views,
|
|
77
|
+
uploadDate,
|
|
78
|
+
duration: durationEl?.textContent?.trim() || '',
|
|
79
|
+
thumbnail: thumbEl?.src || '',
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
results.push(result);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return results;
|
|
86
|
+
}, maxResults);
|
|
87
|
+
}
|
package/src/humanize.js
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Human behavior simulation — makes browser automation less detectable
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Random delay between min and max ms
|
|
7
|
+
*/
|
|
8
|
+
export function randomDelay(min = 200, max = 800) {
|
|
9
|
+
const delay = min + Math.random() * (max - min);
|
|
10
|
+
return new Promise((resolve) => setTimeout(resolve, delay));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Simulate human-like scrolling pattern
|
|
15
|
+
* - Variable speed
|
|
16
|
+
* - Occasional pauses
|
|
17
|
+
* - Sometimes scroll back up slightly
|
|
18
|
+
*/
|
|
19
|
+
export async function humanScroll(page, opts = {}) {
|
|
20
|
+
const { scrolls = 3, direction = 'down' } = opts;
|
|
21
|
+
|
|
22
|
+
for (let i = 0; i < scrolls; i++) {
|
|
23
|
+
// Variable scroll amount (200-600px)
|
|
24
|
+
const amount = 200 + Math.random() * 400;
|
|
25
|
+
const delta = direction === 'up' ? -amount : amount;
|
|
26
|
+
|
|
27
|
+
await page.mouse.wheel(0, delta);
|
|
28
|
+
|
|
29
|
+
// Random pause between scrolls (300-1200ms)
|
|
30
|
+
await randomDelay(300, 1200);
|
|
31
|
+
|
|
32
|
+
// 20% chance to scroll back slightly (human-like hesitation)
|
|
33
|
+
if (Math.random() < 0.2 && i < scrolls - 1) {
|
|
34
|
+
const backAmount = 50 + Math.random() * 100;
|
|
35
|
+
await page.mouse.wheel(0, -backAmount * Math.sign(delta));
|
|
36
|
+
await randomDelay(200, 500);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Move mouse along a bezier curve (more natural than straight line)
|
|
43
|
+
*/
|
|
44
|
+
export async function humanMouseMove(page, targetX, targetY, opts = {}) {
|
|
45
|
+
const { steps = 15 } = opts;
|
|
46
|
+
|
|
47
|
+
// Get current or random starting position
|
|
48
|
+
const startX = 100 + Math.random() * 400;
|
|
49
|
+
const startY = 100 + Math.random() * 300;
|
|
50
|
+
|
|
51
|
+
// Generate control points for bezier curve
|
|
52
|
+
const cp1x = startX + (targetX - startX) * 0.3 + (Math.random() - 0.5) * 100;
|
|
53
|
+
const cp1y = startY + (targetY - startY) * 0.1 + (Math.random() - 0.5) * 100;
|
|
54
|
+
const cp2x = startX + (targetX - startX) * 0.7 + (Math.random() - 0.5) * 80;
|
|
55
|
+
const cp2y = startY + (targetY - startY) * 0.9 + (Math.random() - 0.5) * 80;
|
|
56
|
+
|
|
57
|
+
for (let i = 0; i <= steps; i++) {
|
|
58
|
+
const t = i / steps;
|
|
59
|
+
|
|
60
|
+
// Cubic bezier interpolation
|
|
61
|
+
const x = cubicBezier(t, startX, cp1x, cp2x, targetX);
|
|
62
|
+
const y = cubicBezier(t, startY, cp1y, cp2y, targetY);
|
|
63
|
+
|
|
64
|
+
await page.mouse.move(x, y);
|
|
65
|
+
|
|
66
|
+
// Variable speed: slower at start and end (ease-in-out)
|
|
67
|
+
const speedFactor = Math.sin(t * Math.PI); // 0→1→0
|
|
68
|
+
const delay = 5 + (1 - speedFactor) * 15;
|
|
69
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Type text with human-like timing
|
|
75
|
+
* - Variable delay between keystrokes
|
|
76
|
+
* - Occasional longer pauses (thinking)
|
|
77
|
+
* - Optionally make typos and correct them
|
|
78
|
+
*/
|
|
79
|
+
export async function humanType(page, selector, text, opts = {}) {
|
|
80
|
+
const { typoRate = 0, pressEnter = false } = opts;
|
|
81
|
+
|
|
82
|
+
// Click the input first
|
|
83
|
+
await page.click(selector, { timeout: 5000 });
|
|
84
|
+
await randomDelay(200, 500);
|
|
85
|
+
|
|
86
|
+
// Clear existing content
|
|
87
|
+
await page.fill(selector, '');
|
|
88
|
+
await randomDelay(100, 300);
|
|
89
|
+
|
|
90
|
+
// Type character by character
|
|
91
|
+
for (let i = 0; i < text.length; i++) {
|
|
92
|
+
const char = text[i];
|
|
93
|
+
|
|
94
|
+
// Simulate typo (if enabled)
|
|
95
|
+
if (typoRate > 0 && Math.random() < typoRate) {
|
|
96
|
+
// Type wrong character
|
|
97
|
+
const wrongChar = String.fromCharCode(char.charCodeAt(0) + (Math.random() > 0.5 ? 1 : -1));
|
|
98
|
+
await page.keyboard.type(wrongChar, { delay: 30 + Math.random() * 50 });
|
|
99
|
+
await randomDelay(100, 300);
|
|
100
|
+
|
|
101
|
+
// Delete it
|
|
102
|
+
await page.keyboard.press('Backspace');
|
|
103
|
+
await randomDelay(50, 150);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Type correct character
|
|
107
|
+
await page.keyboard.type(char, { delay: 30 + Math.random() * 80 });
|
|
108
|
+
|
|
109
|
+
// Occasional longer pause (thinking / reading)
|
|
110
|
+
if (Math.random() < 0.05) {
|
|
111
|
+
await randomDelay(300, 800);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (pressEnter) {
|
|
116
|
+
await randomDelay(300, 600);
|
|
117
|
+
await page.keyboard.press('Enter');
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Simulate human click with mouse movement
|
|
123
|
+
*/
|
|
124
|
+
export async function humanClick(page, selector, opts = {}) {
|
|
125
|
+
const { timeout = 5000 } = opts;
|
|
126
|
+
|
|
127
|
+
const element = page.locator(selector);
|
|
128
|
+
const box = await element.boundingBox({ timeout });
|
|
129
|
+
|
|
130
|
+
if (!box) {
|
|
131
|
+
// Fallback to regular click
|
|
132
|
+
await element.click({ timeout });
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Move to element with slight offset (humans don't click exact center)
|
|
137
|
+
const offsetX = (Math.random() - 0.5) * box.width * 0.4;
|
|
138
|
+
const offsetY = (Math.random() - 0.5) * box.height * 0.3;
|
|
139
|
+
const targetX = box.x + box.width / 2 + offsetX;
|
|
140
|
+
const targetY = box.y + box.height / 2 + offsetY;
|
|
141
|
+
|
|
142
|
+
await humanMouseMove(page, targetX, targetY);
|
|
143
|
+
await randomDelay(50, 150);
|
|
144
|
+
|
|
145
|
+
// Mouse down → small pause → mouse up (human click duration)
|
|
146
|
+
await page.mouse.down();
|
|
147
|
+
await randomDelay(30, 80);
|
|
148
|
+
await page.mouse.up();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Warmup routine - perform natural browsing actions before target URL
|
|
153
|
+
* This helps establish a "normal" browsing fingerprint
|
|
154
|
+
*/
|
|
155
|
+
export async function warmup(page, opts = {}) {
|
|
156
|
+
const { sites = null, scrollAfter = true } = opts;
|
|
157
|
+
|
|
158
|
+
const defaultSites = [
|
|
159
|
+
'https://www.wikipedia.org',
|
|
160
|
+
'https://www.github.com',
|
|
161
|
+
'https://news.ycombinator.com',
|
|
162
|
+
];
|
|
163
|
+
|
|
164
|
+
const warmupSites = sites || defaultSites;
|
|
165
|
+
|
|
166
|
+
// Pick 1 random warmup site
|
|
167
|
+
const site = warmupSites[Math.floor(Math.random() * warmupSites.length)];
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
await page.goto(site, { waitUntil: 'domcontentloaded', timeout: 10000 });
|
|
171
|
+
await randomDelay(1000, 3000);
|
|
172
|
+
|
|
173
|
+
if (scrollAfter) {
|
|
174
|
+
await humanScroll(page, { scrolls: 2 });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Random mouse movement
|
|
178
|
+
await humanMouseMove(page, 300 + Math.random() * 600, 200 + Math.random() * 400);
|
|
179
|
+
await randomDelay(500, 1500);
|
|
180
|
+
} catch {
|
|
181
|
+
// Warmup failure is not critical
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Post-navigation human behavior — run after page loads
|
|
187
|
+
*/
|
|
188
|
+
export async function postNavigationBehavior(page, opts = {}) {
|
|
189
|
+
const { scroll = true, mouseMove = true, minDelay = 500, maxDelay = 1500 } = opts;
|
|
190
|
+
|
|
191
|
+
// Wait a natural amount before interacting
|
|
192
|
+
await randomDelay(minDelay, maxDelay);
|
|
193
|
+
|
|
194
|
+
// Slight mouse movement (human looking at page)
|
|
195
|
+
if (mouseMove) {
|
|
196
|
+
await humanMouseMove(page, 300 + Math.random() * 600, 150 + Math.random() * 300);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Natural scroll to show content loading
|
|
200
|
+
if (scroll) {
|
|
201
|
+
await humanScroll(page, { scrolls: 1 });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// --- Math helpers ---
|
|
206
|
+
|
|
207
|
+
function cubicBezier(t, p0, p1, p2, p3) {
|
|
208
|
+
const mt = 1 - t;
|
|
209
|
+
return mt * mt * mt * p0 + 3 * mt * mt * t * p1 + 3 * mt * t * t * p2 + t * t * t * p3;
|
|
210
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stealth-cli — Public API (SDK mode)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
// Core browser
|
|
6
|
+
export {
|
|
7
|
+
launchBrowser, closeBrowser, navigate, getSnapshot,
|
|
8
|
+
getTextContent, getTitle, getUrl, takeScreenshot, evaluate, waitForReady,
|
|
9
|
+
} from './browser.js';
|
|
10
|
+
|
|
11
|
+
// Search
|
|
12
|
+
export { expandMacro, getSupportedEngines } from './macros.js';
|
|
13
|
+
export { getExtractorByEngine, getExtractorByUrl } from './extractors/index.js';
|
|
14
|
+
|
|
15
|
+
// Cookies
|
|
16
|
+
export { parseCookieFile, loadCookies } from './cookies.js';
|
|
17
|
+
|
|
18
|
+
// Retry + Humanize
|
|
19
|
+
export { withRetry, navigateWithRetry } from './retry.js';
|
|
20
|
+
export {
|
|
21
|
+
randomDelay, humanScroll, humanMouseMove,
|
|
22
|
+
humanType, humanClick, warmup, postNavigationBehavior,
|
|
23
|
+
} from './humanize.js';
|
|
24
|
+
|
|
25
|
+
// Profile + Session + Proxy
|
|
26
|
+
export { createProfile, loadProfile, listProfiles, deleteProfile } from './profiles.js';
|
|
27
|
+
export { getSession, saveSession, captureSession, restoreSession, listSessions } from './session.js';
|
|
28
|
+
export { addProxy, removeProxy, listProxies, getNextProxy, getRandomProxy, testProxy } from './proxy-pool.js';
|
|
29
|
+
|
|
30
|
+
// Daemon
|
|
31
|
+
export { isDaemonRunning } from './daemon.js';
|
|
32
|
+
export { daemonStatus, daemonShutdown } from './client.js';
|
package/src/macros.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search macros - URL templates for common search engines and sites
|
|
3
|
+
* Inspired by camofox-browser (MIT License)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const MACROS = {
|
|
7
|
+
google: (q) => `https://www.google.com/search?q=${encodeURIComponent(q)}`,
|
|
8
|
+
youtube: (q) => `https://www.youtube.com/results?search_query=${encodeURIComponent(q)}`,
|
|
9
|
+
amazon: (q) => `https://www.amazon.com/s?k=${encodeURIComponent(q)}`,
|
|
10
|
+
reddit: (q) => `https://www.reddit.com/search?q=${encodeURIComponent(q)}`,
|
|
11
|
+
wikipedia: (q) => `https://en.wikipedia.org/wiki/Special:Search?search=${encodeURIComponent(q)}`,
|
|
12
|
+
twitter: (q) => `https://twitter.com/search?q=${encodeURIComponent(q)}`,
|
|
13
|
+
yelp: (q) => `https://www.yelp.com/search?find_desc=${encodeURIComponent(q)}`,
|
|
14
|
+
linkedin: (q) => `https://www.linkedin.com/search/results/all/?keywords=${encodeURIComponent(q)}`,
|
|
15
|
+
tiktok: (q) => `https://www.tiktok.com/search?q=${encodeURIComponent(q)}`,
|
|
16
|
+
github: (q) => `https://github.com/search?q=${encodeURIComponent(q)}&type=repositories`,
|
|
17
|
+
stackoverflow: (q) => `https://stackoverflow.com/search?q=${encodeURIComponent(q)}`,
|
|
18
|
+
npmjs: (q) => `https://www.npmjs.com/search?q=${encodeURIComponent(q)}`,
|
|
19
|
+
bing: (q) => `https://www.bing.com/search?q=${encodeURIComponent(q)}`,
|
|
20
|
+
duckduckgo: (q) => `https://duckduckgo.com/?q=${encodeURIComponent(q)}`,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Expand a search macro to a URL
|
|
25
|
+
*/
|
|
26
|
+
export function expandMacro(engine, query) {
|
|
27
|
+
const fn = MACROS[engine.toLowerCase()];
|
|
28
|
+
return fn ? fn(query) : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Get list of supported search engines
|
|
33
|
+
*/
|
|
34
|
+
export function getSupportedEngines() {
|
|
35
|
+
return Object.keys(MACROS);
|
|
36
|
+
}
|