GitHub Actions Bot commited on
Commit ·
4d4616f
1
Parent(s): ec4bf22
🚀 Auto-deploy: 8968db766ef462f994117cc8e723669bb87429fb
Browse files- package.json +1 -1
- src/anti-detection.ts +121 -0
- src/config.ts +17 -6
- src/otto-crawler.ts +16 -7
- src/proxy-config.ts +20 -40
package.json
CHANGED
|
@@ -7,7 +7,7 @@
|
|
| 7 |
"start": "tsx quick-start.ts",
|
| 8 |
"serve": "tsx watch server.ts",
|
| 9 |
"start:prod": "tsx server.ts",
|
| 10 |
-
"crawl:otto": "tsx src/
|
| 11 |
"crawl:amazon": "tsx src/amazon-crawler.ts",
|
| 12 |
"crawl:batch": "tsx src/batch-crawler.ts",
|
| 13 |
"quick": "tsx quick-start.ts",
|
|
|
|
| 7 |
"start": "tsx quick-start.ts",
|
| 8 |
"serve": "tsx watch server.ts",
|
| 9 |
"start:prod": "tsx server.ts",
|
| 10 |
+
"crawl:otto": "tsx src/-crawler.ts",
|
| 11 |
"crawl:amazon": "tsx src/amazon-crawler.ts",
|
| 12 |
"crawl:batch": "tsx src/batch-crawler.ts",
|
| 13 |
"quick": "tsx quick-start.ts",
|
src/anti-detection.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Page } from 'playwright';
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* 注入全面的反自动化检测脚本
|
| 5 |
+
*/
|
| 6 |
+
export async function injectAntiDetectionScripts(page: Page): Promise<void> {
|
| 7 |
+
await page.addInitScript(() => {
|
| 8 |
+
// 覆盖navigator.webdriver属性
|
| 9 |
+
Object.defineProperty(navigator, 'webdriver', {
|
| 10 |
+
get: () => undefined,
|
| 11 |
+
});
|
| 12 |
+
|
| 13 |
+
// 移除Chrome特有属性
|
| 14 |
+
(window as any).chrome = {
|
| 15 |
+
runtime: {},
|
| 16 |
+
};
|
| 17 |
+
|
| 18 |
+
// 覆盖plugins和languages
|
| 19 |
+
Object.defineProperty(navigator, 'plugins', {
|
| 20 |
+
get: () => [1, 2, 3, 4, 5] as any,
|
| 21 |
+
});
|
| 22 |
+
|
| 23 |
+
Object.defineProperty(navigator, 'languages', {
|
| 24 |
+
get: () => ['en-US', 'en'],
|
| 25 |
+
});
|
| 26 |
+
|
| 27 |
+
// 隐藏WebDriver属性的其他方式
|
| 28 |
+
if ('_Selenium_IDE_Recorder' in window) {
|
| 29 |
+
delete window['_Selenium_IDE_Recorder'];
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
// 覆盖userAgent中的Headless标识
|
| 33 |
+
const originalUserAgent = navigator.userAgent;
|
| 34 |
+
Object.defineProperty(navigator, 'userAgent', {
|
| 35 |
+
value: originalUserAgent.replace('HeadlessChrome', 'Chrome'),
|
| 36 |
+
});
|
| 37 |
+
|
| 38 |
+
// 隐藏automation相关属性
|
| 39 |
+
Object.defineProperty(navigator, 'permissions', {
|
| 40 |
+
value: {
|
| 41 |
+
...navigator.permissions,
|
| 42 |
+
query: new Proxy(navigator.permissions.query, {
|
| 43 |
+
apply(target, thisArg, args) {
|
| 44 |
+
return target.apply(thisArg, args).catch(() => ({
|
| 45 |
+
state: 'granted',
|
| 46 |
+
onchange: null,
|
| 47 |
+
addEventListener: () => {},
|
| 48 |
+
removeEventListener: () => {},
|
| 49 |
+
dispatchEvent: () => true,
|
| 50 |
+
}));
|
| 51 |
+
}
|
| 52 |
+
})
|
| 53 |
+
}
|
| 54 |
+
});
|
| 55 |
+
});
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
/**
|
| 59 |
+
* 设置真实的浏览器视窗尺寸
|
| 60 |
+
*/
|
| 61 |
+
export async function setRealisticViewport(page: Page): Promise<void> {
|
| 62 |
+
// 设置常见的桌面分辨率
|
| 63 |
+
const viewports = [
|
| 64 |
+
{ width: 1920, height: 1080 },
|
| 65 |
+
{ width: 1366, height: 768 },
|
| 66 |
+
{ width: 1536, height: 864 },
|
| 67 |
+
{ width: 1440, height: 900 }
|
| 68 |
+
];
|
| 69 |
+
const randomViewport = viewports[Math.floor(Math.random() * viewports.length)];
|
| 70 |
+
await page.setViewportSize(randomViewport);
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
/**
|
| 74 |
+
* 模拟真实用户行为
|
| 75 |
+
*/
|
| 76 |
+
export async function simulateHumanBehavior(page: Page): Promise<void> {
|
| 77 |
+
try {
|
| 78 |
+
// 设置真实视窗
|
| 79 |
+
await setRealisticViewport(page);
|
| 80 |
+
|
| 81 |
+
// 随机鼠标移动
|
| 82 |
+
const dimensions = await page.evaluate(() => ({
|
| 83 |
+
width: document.documentElement.clientWidth,
|
| 84 |
+
height: document.documentElement.clientHeight
|
| 85 |
+
}));
|
| 86 |
+
|
| 87 |
+
if (dimensions.width > 0 && dimensions.height > 0) {
|
| 88 |
+
// 多次随机移动模拟真实浏览
|
| 89 |
+
for (let i = 0; i < 2 + Math.floor(Math.random() * 3); i++) {
|
| 90 |
+
const x = Math.floor(Math.random() * dimensions.width * 0.8) + 50;
|
| 91 |
+
const y = Math.floor(Math.random() * dimensions.height * 0.8) + 50;
|
| 92 |
+
await page.mouse.move(x, y);
|
| 93 |
+
await page.waitForTimeout(200 + Math.random() * 300);
|
| 94 |
+
}
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
// 随机滚动
|
| 98 |
+
const scrollHeight = await page.evaluate(() => document.body.scrollHeight);
|
| 99 |
+
if (scrollHeight > 0) {
|
| 100 |
+
const scrollPositions = [0.1, 0.3, 0.5, 0.7];
|
| 101 |
+
const randomPos = scrollPositions[Math.floor(Math.random() * scrollPositions.length)];
|
| 102 |
+
const randomScroll = Math.min(scrollHeight * randomPos, 1200);
|
| 103 |
+
await page.evaluate((height) => window.scrollTo(0, height), randomScroll);
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
// 随机等待时间
|
| 107 |
+
const randomWait = Math.floor(Math.random() * 1000) + 800;
|
| 108 |
+
await page.waitForTimeout(randomWait);
|
| 109 |
+
|
| 110 |
+
} catch (error) {
|
| 111 |
+
// 忽略模拟行为的错误,不影响主要功能
|
| 112 |
+
}
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
/**
|
| 116 |
+
* 添加随机延迟
|
| 117 |
+
*/
|
| 118 |
+
export async function addRandomDelay(page: Page, minMs: number = 1500, maxMs: number = 4000): Promise<void> {
|
| 119 |
+
const delay = Math.floor(Math.random() * (maxMs - minMs)) + minMs;
|
| 120 |
+
await page.waitForTimeout(delay);
|
| 121 |
+
}
|
src/config.ts
CHANGED
|
@@ -83,11 +83,11 @@ const PRESETS: Record<string, Partial<CrawlerConfig>> = {
|
|
| 83 |
}
|
| 84 |
},
|
| 85 |
stealth: {
|
| 86 |
-
maxConcurrency:
|
| 87 |
-
requestHandlerTimeoutSecs:
|
| 88 |
-
maxRequestRetries:
|
| 89 |
-
pageLoadWaitMs:
|
| 90 |
-
useProxy:
|
| 91 |
launchOptions: {
|
| 92 |
headless: 'new',
|
| 93 |
args: [
|
|
@@ -105,7 +105,18 @@ const PRESETS: Record<string, Partial<CrawlerConfig>> = {
|
|
| 105 |
'--mute-audio',
|
| 106 |
'--no-first-run',
|
| 107 |
'--no-pings',
|
| 108 |
-
'--disable-features=IsolateOrigins,site-per-process'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
]
|
| 110 |
}
|
| 111 |
}
|
|
|
|
| 83 |
}
|
| 84 |
},
|
| 85 |
stealth: {
|
| 86 |
+
maxConcurrency: 2, // 进一步降低并发数
|
| 87 |
+
requestHandlerTimeoutSecs: 240, // 延长超时时间
|
| 88 |
+
maxRequestRetries: 2, // 增加重试次数
|
| 89 |
+
pageLoadWaitMs: 800, // 增加页面等待时间
|
| 90 |
+
useProxy: false, // 启用代理
|
| 91 |
launchOptions: {
|
| 92 |
headless: 'new',
|
| 93 |
args: [
|
|
|
|
| 105 |
'--mute-audio',
|
| 106 |
'--no-first-run',
|
| 107 |
'--no-pings',
|
| 108 |
+
'--disable-features=IsolateOrigins,site-per-process',
|
| 109 |
+
// 新增反检测参数
|
| 110 |
+
'--disable-blink-features=AutomationControlled',
|
| 111 |
+
'--disable-automation',
|
| 112 |
+
'--disable-web-security',
|
| 113 |
+
'--allow-running-insecure-content',
|
| 114 |
+
'--disable-features=VizDisplayCompositor',
|
| 115 |
+
'--disable-features=ImprovedCookieControls,SameSiteByDefaultCookies,CookiesWithoutSameSiteMustBeSecure',
|
| 116 |
+
'--disable-features=WebRtcHideLocalIpsWithMdns',
|
| 117 |
+
'--disable-features=WebContentsForceDark',
|
| 118 |
+
'--disable-features=AutofillEnableAccountWalletStorage',
|
| 119 |
+
'--user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
| 120 |
]
|
| 121 |
}
|
| 122 |
}
|
src/otto-crawler.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { Page } from 'playwright';
|
|
| 3 |
import { getCrawlerConfig, CrawlerConfig } from './config.js';
|
| 4 |
import { ProductData } from './productData.js';
|
| 5 |
import { createProxyConfiguration } from './proxy-config.js';
|
|
|
|
|
|
|
| 6 |
|
| 7 |
// 默认配置
|
| 8 |
let currentConfig: CrawlerConfig = getCrawlerConfig();
|
|
@@ -93,13 +95,16 @@ function createOttoCrawler(options: { preset?: string } & Partial<CrawlerConfig>
|
|
| 93 |
requestHandlerTimeoutSecs: config.requestHandlerTimeoutSecs,
|
| 94 |
maxRequestRetries: config.maxRequestRetries,
|
| 95 |
navigationTimeoutSecs: 120, // 导航超时120秒
|
| 96 |
-
// 使用
|
| 97 |
proxyConfiguration: proxyConfiguration,
|
| 98 |
launchContext: {
|
| 99 |
launchOptions
|
| 100 |
},
|
| 101 |
preNavigationHooks: [
|
| 102 |
async ({ page }) => {
|
|
|
|
|
|
|
|
|
|
| 103 |
// 拦截并 abort 非必要的资源类型
|
| 104 |
await page.route('**/*', (route, request) => {
|
| 105 |
const resourceType = request.resourceType();
|
|
@@ -116,17 +121,21 @@ function createOttoCrawler(options: { preset?: string } & Partial<CrawlerConfig>
|
|
| 116 |
const startTime = Date.now();
|
| 117 |
log.info(`🔄 开始处理: ${request.url}`);
|
| 118 |
|
|
|
|
|
|
|
|
|
|
| 119 |
// 1. 等待 DOM 加载完成
|
| 120 |
const domStartTime = Date.now();
|
| 121 |
await page.waitForLoadState('domcontentloaded');
|
| 122 |
const domLoadTime = Date.now() - domStartTime;
|
| 123 |
log.info(`⏱️ DOM 加载耗时: ${domLoadTime}ms`);
|
| 124 |
|
| 125 |
-
// 2.
|
|
|
|
|
|
|
|
|
|
| 126 |
const waitStartTime = Date.now();
|
| 127 |
const waitForSelectors = [
|
| 128 |
-
// ...currentConfig.selectors.title || [],
|
| 129 |
-
// ...currentConfig.selectors.price || []
|
| 130 |
'span.js_pdp_cr-rating--review-count'
|
| 131 |
];
|
| 132 |
|
|
@@ -151,7 +160,7 @@ function createOttoCrawler(options: { preset?: string } & Partial<CrawlerConfig>
|
|
| 151 |
log.warning('⚠️ 未检测到关键元素,但将继续尝试提取数据');
|
| 152 |
}
|
| 153 |
|
| 154 |
-
//
|
| 155 |
await page.waitForTimeout(500);
|
| 156 |
|
| 157 |
const productData: ProductData = {
|
|
@@ -252,10 +261,10 @@ export {
|
|
| 252 |
// 如果直接运行此文件,执行示例
|
| 253 |
if (import.meta.url === `file://${process.argv[1]}`) {
|
| 254 |
const exampleUrls = [
|
| 255 |
-
'https://www.otto.de/p/
|
| 256 |
];
|
| 257 |
|
| 258 |
-
runOttoCrawler(exampleUrls, { preset: '
|
| 259 |
.then(results => {
|
| 260 |
console.log('\n📦 抓取结果:');
|
| 261 |
console.log(JSON.stringify(results, null, 2));
|
|
|
|
| 3 |
import { getCrawlerConfig, CrawlerConfig } from './config.js';
|
| 4 |
import { ProductData } from './productData.js';
|
| 5 |
import { createProxyConfiguration } from './proxy-config.js';
|
| 6 |
+
// 导入反检测模块
|
| 7 |
+
import { injectAntiDetectionScripts, simulateHumanBehavior, addRandomDelay } from './anti-detection.js';
|
| 8 |
|
| 9 |
// 默认配置
|
| 10 |
let currentConfig: CrawlerConfig = getCrawlerConfig();
|
|
|
|
| 95 |
requestHandlerTimeoutSecs: config.requestHandlerTimeoutSecs,
|
| 96 |
maxRequestRetries: config.maxRequestRetries,
|
| 97 |
navigationTimeoutSecs: 120, // 导航超时120秒
|
| 98 |
+
// 使用 Decodo 代理配置
|
| 99 |
proxyConfiguration: proxyConfiguration,
|
| 100 |
launchContext: {
|
| 101 |
launchOptions
|
| 102 |
},
|
| 103 |
preNavigationHooks: [
|
| 104 |
async ({ page }) => {
|
| 105 |
+
// 注入反检测脚本
|
| 106 |
+
await injectAntiDetectionScripts(page);
|
| 107 |
+
|
| 108 |
// 拦截并 abort 非必要的资源类型
|
| 109 |
await page.route('**/*', (route, request) => {
|
| 110 |
const resourceType = request.resourceType();
|
|
|
|
| 121 |
const startTime = Date.now();
|
| 122 |
log.info(`🔄 开始处理: ${request.url}`);
|
| 123 |
|
| 124 |
+
// 添加随机延迟,模拟真实用户行为
|
| 125 |
+
await addRandomDelay(page, 1000, 3000);
|
| 126 |
+
|
| 127 |
// 1. 等待 DOM 加载完成
|
| 128 |
const domStartTime = Date.now();
|
| 129 |
await page.waitForLoadState('domcontentloaded');
|
| 130 |
const domLoadTime = Date.now() - domStartTime;
|
| 131 |
log.info(`⏱️ DOM 加载耗时: ${domLoadTime}ms`);
|
| 132 |
|
| 133 |
+
// 2. 模拟真实用户行为
|
| 134 |
+
await simulateHumanBehavior(page);
|
| 135 |
+
|
| 136 |
+
// 3. 智能等待:等待页面标题或价格等关键元素出现
|
| 137 |
const waitStartTime = Date.now();
|
| 138 |
const waitForSelectors = [
|
|
|
|
|
|
|
| 139 |
'span.js_pdp_cr-rating--review-count'
|
| 140 |
];
|
| 141 |
|
|
|
|
| 160 |
log.warning('⚠️ 未检测到关键元素,但将继续尝试提取数据');
|
| 161 |
}
|
| 162 |
|
| 163 |
+
// 4. 短暂等待让动态内容渲染(可选,根据实际情况调整)
|
| 164 |
await page.waitForTimeout(500);
|
| 165 |
|
| 166 |
const productData: ProductData = {
|
|
|
|
| 261 |
// 如果直接运行此文件,执行示例
|
| 262 |
if (import.meta.url === `file://${process.argv[1]}`) {
|
| 263 |
const exampleUrls = [
|
| 264 |
+
'https://www.otto.de/p/flieks-etagenbett-kinderbett-hausbett-90x200cm-mit-schoenem-fenster-dach-und-schraegleiter-S03FA0WG/'
|
| 265 |
];
|
| 266 |
|
| 267 |
+
runOttoCrawler(exampleUrls, { preset: 'stealth' })
|
| 268 |
.then(results => {
|
| 269 |
console.log('\n📦 抓取结果:');
|
| 270 |
console.log(JSON.stringify(results, null, 2));
|
src/proxy-config.ts
CHANGED
|
@@ -1,47 +1,27 @@
|
|
| 1 |
import { ProxyConfiguration } from 'crawlee';
|
| 2 |
-
import * as fs from 'fs';
|
| 3 |
-
import * as path from 'path';
|
| 4 |
|
| 5 |
-
//
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
const content = fs.readFileSync(ipFilePath, 'utf-8');
|
| 15 |
-
const lines = content.split('\n').map(line => line.trim()).filter(line => line.length > 0);
|
| 16 |
-
|
| 17 |
-
// 转换为完整的代理URL格式 (假设是HTTP代理,无认证)
|
| 18 |
-
const proxyUrls = lines.map(ipPort => {
|
| 19 |
-
// 检查是否已经是完整的URL格式
|
| 20 |
-
if (ipPort.startsWith('http://') || ipPort.startsWith('https://')) {
|
| 21 |
-
return ipPort;
|
| 22 |
-
}
|
| 23 |
-
// 否则假设是IP:PORT格式,转换为http://IP:PORT
|
| 24 |
-
return `http://${ipPort}`;
|
| 25 |
-
});
|
| 26 |
-
|
| 27 |
-
console.log(`✅ 成功加载 ${proxyUrls.length} 个代理IP`);
|
| 28 |
-
return proxyUrls;
|
| 29 |
-
} catch (error) {
|
| 30 |
-
console.error('❌ 加载代理IP失败:', error);
|
| 31 |
-
return [];
|
| 32 |
-
}
|
| 33 |
}
|
| 34 |
|
| 35 |
-
// 创建代理配置
|
| 36 |
export function createProxyConfiguration(): ProxyConfiguration | undefined {
|
| 37 |
-
const
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
console.warn('⚠️ 未找到可用的代理IP,将不使用代理');
|
| 41 |
-
return undefined;
|
| 42 |
-
}
|
| 43 |
-
|
| 44 |
return new ProxyConfiguration({
|
| 45 |
-
proxyUrls:
|
| 46 |
});
|
| 47 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import { ProxyConfiguration } from 'crawlee';
|
|
|
|
|
|
|
| 2 |
|
| 3 |
+
// Dataify 动态住宅代理配置
|
| 4 |
+
const PROXY_HOST = 'eu.dataify.top';
|
| 5 |
+
const PROXY_PORT = '6600';
|
| 6 |
+
const PROXY_USERNAME = 'gqFOVXhf9yd2-r-de';
|
| 7 |
+
const PROXY_PASSWORD = 'm9E0BDFM';
|
| 8 |
+
|
| 9 |
+
// 创建代理URL
|
| 10 |
+
function getProxyUrl(): string {
|
| 11 |
+
return `https://${PROXY_USERNAME}:${PROXY_PASSWORD}@${PROXY_HOST}:${PROXY_PORT}`;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
}
|
| 13 |
|
| 14 |
+
// 创建代理配置(Dataify 动态代理,每次请求自动轮换IP)
|
| 15 |
export function createProxyConfiguration(): ProxyConfiguration | undefined {
|
| 16 |
+
const proxyUrl = getProxyUrl();
|
| 17 |
+
console.log(`✅ 使用 Dataify 动态代理: ${PROXY_HOST}:${PROXY_PORT}`);
|
| 18 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
return new ProxyConfiguration({
|
| 20 |
+
proxyUrls: [proxyUrl],
|
| 21 |
});
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
// 保留向后兼容的函数
|
| 25 |
+
export function loadProxyUrls(): string[] {
|
| 26 |
+
return [getProxyUrl()];
|
| 27 |
+
}
|