| import { PlaywrightCrawler, ProxyConfiguration, Dataset, log } from 'crawlee'; |
| import { Page } from 'playwright'; |
| import { getCrawlerConfig, CrawlerConfig } from './config.js'; |
| import { ProductData } from './productData.js'; |
| import { createProxyConfiguration } from './proxy-config.js'; |
| |
| import { injectAntiDetectionScripts, simulateHumanBehavior } from './anti-detection.js'; |
|
|
| |
| let currentConfig: CrawlerConfig = getCrawlerConfig(); |
|
|
| |
| |
| |
| async function extractText(page: Page, ...selectors: string[]): Promise<string | null> { |
| for (const selector of selectors) { |
| try { |
| const locator = page.locator(selector).first(); |
| const count = await locator.count(); |
| |
| if (count > 0) { |
| const text = await locator.textContent(); |
| if (text && text.trim()) { |
| return text.trim(); |
| } |
| } |
| } catch (error: any) { |
| log.debug(`文本选择器失败:${selector}`, error.message); |
| } |
| } |
| return null; |
| } |
|
|
| |
| |
| |
| async function extractOttoData(page: Page, product: ProductData = {} as ProductData): Promise<ProductData> { |
| try { |
| const selectors = currentConfig.selectors; |
|
|
| |
| const brand = await extractText(page, ...selectors.brand); |
| if (brand) product.brand = brand; |
| |
| |
| const price = await extractText(page, ...selectors.price); |
| if (price) product.price = price; |
|
|
| const originalPrice = await extractText(page, ...selectors.originalPrice); |
| if (price) product.originalPrice = originalPrice; |
| |
| |
| const rating = await extractText(page, ...selectors.rating); |
| if (rating) product.rating = rating; |
| |
| |
| const reviews = await extractText(page, ...selectors.reviews); |
| if (reviews) product.reviews = reviews; |
| |
| log.info(`✓ 成功提取商品数据:`, { |
| brand: product.brand || 'N/A', |
| price: product.price || 'N/A', |
| rating: product.rating || 'N/A', |
| reviews: product.reviews || 'N/A' |
| }); |
| |
| return product; |
| } catch (error: any) { |
| log.warning('Otto 数据提取失败:', error.message); |
| return product; |
| } |
| } |
|
|
| |
| |
| |
| function createOttoCrawler(options: { preset?: string } & Partial<CrawlerConfig> = {}) { |
| |
| const config = getCrawlerConfig(options.preset, options); |
| currentConfig = config; |
| |
| |
| const launchOptions: any = { |
| ...config.launchOptions, |
| headless: config.launchOptions.headless === 'new' ? true : config.launchOptions.headless, |
| |
| timeout: 120000, |
| }; |
| |
| |
| const proxyConfiguration = createProxyConfiguration(); |
| |
| const crawler = new PlaywrightCrawler({ |
| maxConcurrency: config.maxConcurrency, |
| requestHandlerTimeoutSecs: config.requestHandlerTimeoutSecs, |
| maxRequestRetries: config.maxRequestRetries, |
| navigationTimeoutSecs: 120, |
| |
| proxyConfiguration: proxyConfiguration, |
| launchContext: { |
| launchOptions |
| }, |
| preNavigationHooks: [ |
| async ({ page }) => { |
| |
| await injectAntiDetectionScripts(page); |
| |
| |
| await page.route('**/*', (route, request) => { |
| const resourceType = request.resourceType(); |
| |
| if (['image', 'font', 'media'].includes(resourceType)) { |
| route.abort(); |
| } else { |
| route.continue(); |
| } |
| }); |
| } |
| ], |
| async requestHandler({ request, page, log }) { |
| const startTime = Date.now(); |
| log.info(`🔄 开始处理: ${request.url}`); |
| |
| |
| await page.waitForLoadState('domcontentloaded'); |
| |
| |
| await simulateHumanBehavior(page); |
| |
| |
| const waitStartTime = Date.now(); |
| try { |
| await page.waitForSelector('span.js_pdp_cr-rating--review-count', { |
| state: 'visible', |
| timeout: 5000 |
| }); |
| log.info(`⏱️ 元素等待耗时: ${Date.now() - waitStartTime}ms`); |
| } catch (e) { |
| log.warning('⚠️ 未检测到关键元素,但将继续尝试提取数据'); |
| } |
| |
| |
| await page.waitForTimeout(300); |
| |
| const productData: ProductData = { |
| url: request.url, |
| crawledAt: new Date().toISOString() |
| }; |
| |
| const extractStartTime = Date.now(); |
| const extractedData = await extractOttoData(page, productData); |
| const extractTime = Date.now() - extractStartTime; |
| log.info(`⏱️ 数据提取耗时: ${extractTime}ms`); |
| |
| await Dataset.pushData(extractedData); |
| |
| const totalTime = Date.now() - startTime; |
| log.info(`✅ 完成处理: ${request.url} (总耗时: ${totalTime}ms)`); |
| }, |
| |
| async failedRequestHandler({ request, log }, error) { |
| log.error(`请求失败 (重试 ${request.retryCount}/${config.maxRequestRetries}): ${request.url}`, { |
| message: (error as Error).message |
| }); |
| }, |
| |
| errorHandler({ log }, error) { |
| log.error('爬虫全局错误:', { message: (error as Error).message }); |
| } |
| }); |
| |
| return crawler; |
| } |
|
|
| |
| |
| |
| async function runOttoCrawler(urls: string | string[], options: { preset?: string } & Partial<CrawlerConfig> = {}): Promise<ProductData[]> { |
| const overallStart = Date.now(); |
| const urlList = Array.isArray(urls) ? urls : [urls]; |
| |
| log.info('🚀 开始 Otto 商品爬虫...'); |
| log.info(`📋 待爬取 URL 数量: ${urlList.length}`); |
| log.info(`🔧 配置预设: ${options.preset || 'default'}`); |
| |
| |
| const createStart = Date.now(); |
| const crawler = createOttoCrawler(options); |
| const createDuration = Date.now() - createStart; |
| log.info(`⏱️ [步骤1] 创建爬虫实例耗时: ${createDuration}ms`); |
| |
| |
| const addRequestsStart = Date.now(); |
| await crawler.addRequests(urlList.map(url => ({ |
| url, |
| userData: { label: 'DETAIL' } |
| }))); |
| const addRequestsDuration = Date.now() - addRequestsStart; |
| log.info(`⏱️ [步骤2] 添加请求到队列耗时: ${addRequestsDuration}ms`); |
| |
| |
| log.info('🔄 开始执行爬取任务...'); |
| const runStart = Date.now(); |
| await crawler.run(); |
| const runDuration = Date.now() - runStart; |
| log.info(`⏱️ [步骤3] 执行爬取任务耗时: ${runDuration}ms (${(runDuration / 1000).toFixed(2)}s)`); |
| |
| |
| const getDataStart = Date.now(); |
| const results = await Dataset.getData(); |
| const getDataDuration = Date.now() - getDataStart; |
| log.info(`⏱️ [步骤4] 获取结果数据耗时: ${getDataDuration}ms`); |
| |
| const overallDuration = Date.now() - overallStart; |
| log.info(`\n✅ 爬虫完成!`); |
| log.info(`📊 成功抓取: ${results.items.length} 个商品`); |
| log.info(`⏱️ [总耗时] ${overallDuration}ms (${(overallDuration / 1000).toFixed(2)}s)`); |
| log.info(`📈 平均每个URL耗时: ${(overallDuration / urlList.length).toFixed(2)}ms`); |
| |
| return results.items as ProductData[]; |
| } |
|
|
| |
| |
| |
| function switchPreset(preset: string) { |
| currentConfig = getCrawlerConfig(preset); |
| log.info(`🔄 已切换到预设: ${preset}`); |
| } |
|
|
| export { |
| runOttoCrawler, |
| createOttoCrawler, |
| extractOttoData, |
| extractText, |
| switchPreset, |
| currentConfig |
| }; |
|
|
| |
| if (import.meta.url === `file://${process.argv[1]}`) { |
| const exampleUrls = [ |
| 'https://www.otto.de/p/flieks-etagenbett-kinderbett-hausbett-90x200cm-mit-schoenem-fenster-dach-und-schraegleiter-S03FA0WG/' |
| ]; |
| |
| runOttoCrawler(exampleUrls, { preset: 'stealth' }) |
| .then(results => { |
| console.log('\n📦 抓取结果:'); |
| console.log(JSON.stringify(results, null, 2)); |
| }) |
| .catch(error => { |
| console.error('❌ 爬虫运行失败:', error); |
| process.exit(1); |
| }); |
| } |