diff --git a/cropped_result.png b/cropped_result.png new file mode 100644 index 0000000..2ba89e9 Binary files /dev/null and b/cropped_result.png differ diff --git a/eng.traineddata b/eng.traineddata new file mode 100644 index 0000000..6d11002 Binary files /dev/null and b/eng.traineddata differ diff --git a/failed_sites.txt b/failed_sites.txt index 4926311..e80c6c4 100644 --- a/failed_sites.txt +++ b/failed_sites.txt @@ -6,3 +6,10 @@ DATE | TIME | WEBSITE | REASON 14/02/2026 | 22:46:37 | Gate6 (https://gate6.vn/) | Navigating frame was detached 14/02/2026 | 22:46:37 | Kulala Land (https://kulalaland. | Navigating frame was detached 14/02/2026 | 22:46:37 | sneakywholefoods (https://www.sn | Connection closed. +14/02/2026 | 23:00:22 | Site_1 (https://jubina.com/list) | Failure keyword detected: "verification" +14/02/2026 | 23:04:00 | Site_1 (https://jubina.com/list) | Submit failed: Execution context was destroyed, most likely because of a navigation. +15/02/2026 | 00:06:51 | Site_1 (https://jubina.com/list) | Form still visible after submission (likely failed) +15/02/2026 | 00:18:09 | Dutch News Test (https://www.dut | Form still visible after submission (likely failed) +15/02/2026 | 00:18:27 | Site_1 (https://jubina.com/list) | Form still visible after submission (likely failed) +15/02/2026 | 00:18:50 | Site_1 (https://jubina.com/list) | Form still visible after submission (likely failed) +15/02/2026 | 00:20:19 | Site_1 (https://jubina.com/list) | Form still visible after submission (likely failed) diff --git a/index.js b/index.js index d0eda46..c108263 100644 --- a/index.js +++ b/index.js @@ -84,7 +84,7 @@ async function main() { const site = sitesToProcess[i]; console.log(`[${i + 1}/${sitesToProcess.length}]`); - const result = await processSite(browser, site, globalCreds, browserConfig, cliOptions.keepOpen); + const result = await processSite(browser, site, globalCreds, browserConfig, cliOptions.keepOpen, cliOptions.dryRun); if (result.siteFailed) { failedSites.push({ diff --git a/list.txt b/list.txt index dafe32a..7a41dbd 100644 --- a/list.txt +++ b/list.txt @@ -1,6 +1,6 @@ https://immaculatevegan.com/blogs/magazine https://gate6.vn/ -https://www.sprintzeal.com/blog +https://www.sprintzeal.com/ https://kulalaland.com/ https://www.goplaces.co.uk/ https://www.fiftyfiftybottles.com/ diff --git a/package-lock.json b/package-lock.json index 8f4b886..d6c80b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,9 +10,11 @@ "license": "ISC", "dependencies": { "@iarna/toml": "^2.2.5", + "2captcha": "^3.0.5-2", "commander": "^14.0.3", "playwright": "^1.56.1", "playwright-extra": "^4.3.6", + "pngjs": "^7.0.0", "puppeteer": "^24.30.0", "puppeteer-extra": "^3.3.6", "puppeteer-extra-plugin-stealth": "^2.11.2", @@ -110,6 +112,15 @@ "@types/node": "*" } }, + "node_modules/2captcha": { + "version": "3.0.5-2", + "resolved": "https://registry.npmjs.org/2captcha/-/2captcha-3.0.5-2.tgz", + "integrity": "sha512-p/C1UFss2PvBxYjypuRZ4zE1n3C5VK7hnNijnTetF9sKg5iifF6xfos3h3CJQLGB//0Ksa1C77ItM3gOhHy7fA==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.1" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -1186,6 +1197,15 @@ } } }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", diff --git a/package.json b/package.json index 6d298f6..0d5a458 100644 --- a/package.json +++ b/package.json @@ -12,9 +12,11 @@ "type": "commonjs", "dependencies": { "@iarna/toml": "^2.2.5", + "2captcha": "^3.0.5-2", "commander": "^14.0.3", "playwright": "^1.56.1", "playwright-extra": "^4.3.6", + "pngjs": "^7.0.0", "puppeteer": "^24.30.0", "puppeteer-extra": "^3.3.6", "puppeteer-extra-plugin-stealth": "^2.11.2", diff --git a/src/captcha.js b/src/captcha.js index 0b154f2..13cd576 100644 --- a/src/captcha.js +++ b/src/captcha.js @@ -1,4 +1,9 @@ const Tesseract = require('tesseract.js'); +const https = require('https'); +const http = require('http'); + +const TwoCaptcha = require('2captcha'); +const solver = new TwoCaptcha.Solver('9ed0ef51badf9a017ac50aea413d8001'); const CAPTCHA_PATTERNS = { recaptcha: { @@ -23,6 +28,18 @@ const CAPTCHA_PATTERNS = { } }; +function fetchImageAsBuffer(url) { + return new Promise((resolve, reject) => { + const client = url.startsWith('https') ? https : http; + client.get(url, (res) => { + const chunks = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => resolve(Buffer.concat(chunks))); + res.on('error', reject); + }).on('error', reject); + }); +} + async function detectCaptcha(page) { const captchaInfo = await page.evaluate((patterns) => { const results = []; @@ -103,30 +120,163 @@ async function solveSimpleCaptcha(page) { }); if (imageCaptchaInfo) { - console.log(' Attempting OCR-based CAPTCHA solving...'); - try { - const { data: { text } } = await Tesseract.recognize( - imageCaptchaInfo.src, - 'eng', - { logger: m => {} } - ); + console.log(' Attempting 2Captcha image solving...'); + console.log(` CAPTCHA image selector: ${imageCaptchaInfo.selector}`); + + // Wait for CAPTCHA image to fully load before capturing + console.log(' Waiting for CAPTCHA image to load...'); + let imageLoaded = false; + const maxWaitMs = 5000; + const checkIntervalMs = 200; + let waitedMs = 0; + + while (!imageLoaded && waitedMs < maxWaitMs) { + imageLoaded = await page.evaluate((selector) => { + const el = document.querySelector(selector); + return el && el.naturalWidth > 0 && el.naturalHeight > 0; + }, imageCaptchaInfo.selector).catch(() => false); - const cleanedText = text.replace(/[^a-zA-Z0-9]/g, '').trim(); - - if (cleanedText && cleanedText.length >= 3) { - console.log(` OCR read: "${cleanedText}"`); - return { - type: 'image', - answer: cleanedText, - originalText: text, - imageSrc: imageCaptchaInfo.src - }; + if (!imageLoaded) { + await new Promise(r => setTimeout(r, checkIntervalMs)); + waitedMs += checkIntervalMs; } - } catch (ocrError) { - console.log(` OCR failed: ${ocrError.message}`); } - return { type: 'image', message: 'Image CAPTCHA detected - OCR failed, manual solving required' }; + if (imageLoaded) { + console.log(` CAPTCHA image loaded successfully (waited ${waitedMs}ms)`); + } else { + console.log(' WARNING: CAPTCHA may not have fully loaded, proceeding anyway...'); + } + + // Additional small delay to ensure rendering is complete + await new Promise(r => setTimeout(r, 300)); + + try { + let imageData; + + // Get CAPTCHA element bounding box + const captchaInfo = await page.evaluate((selector) => { + const img = document.querySelector(selector); + if (!img) return null; + const rect = img.getBoundingClientRect(); + return { + src: img.src, + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height + }; + }, imageCaptchaInfo.selector); + + if (captchaInfo && captchaInfo.width > 0) { + console.log(' CAPTCHA at:', captchaInfo.x, captchaInfo.y, captchaInfo.width, 'x', captchaInfo.height); + + // Try to get element screenshot directly + try { + const captchaElement = await page.$(imageCaptchaInfo.selector); + if (captchaElement) { + const elementScreenshot = await captchaElement.screenshot({ type: 'png' }); + if (Buffer.isBuffer(elementScreenshot)) { + imageData = elementScreenshot.toString('base64'); + } else { + imageData = Buffer.from(elementScreenshot).toString('base64'); + } + console.log(' Element screenshot captured, size:', imageData.length, 'bytes (base64)'); + } + } catch (elemError) { + console.log(' Element screenshot failed:', elemError.message); + + // Fallback: capture full page and crop + const fullScreenshot = await page.screenshot({ type: 'png', encoding: 'binary' }); + const screenshotBuffer = Buffer.from(fullScreenshot, 'binary'); + const png = require('pngjs').PNG.sync.read(screenshotBuffer); + + const cropX = Math.max(0, Math.floor(captchaInfo.x)); + const cropY = Math.max(0, Math.floor(captchaInfo.y)); + const cropW = Math.floor(captchaInfo.width); + const cropH = Math.floor(captchaInfo.height); + + console.log(' Cropping to:', cropX, cropY, cropW, cropH); + + const cropped = new require('pngjs').PNG({ width: cropW, height: cropH }); + + for (let y = 0; y < cropH; y++) { + for (let x = 0; x < cropW; x++) { + const srcIdx = ((cropY + y) * png.width + (cropX + x)) * 4; + const dstIdx = (y * cropW + x) * 4; + cropped.data[dstIdx] = png.data[srcIdx]; + cropped.data[dstIdx + 1] = png.data[srcIdx + 1]; + cropped.data[dstIdx + 2] = png.data[srcIdx + 2]; + cropped.data[dstIdx + 3] = png.data[srcIdx + 3]; + } + } + + imageData = require('pngjs').PNG.sync.write(cropped).toString('base64'); + console.log(' Cropped image size:', imageData.length, 'bytes (base64)'); + } + } else if (captchaInfo && imageCaptchaInfo.src.startsWith('data:')) { + const base64 = imageCaptchaInfo.src.replace(/^data:image\/\w+;base64,/, ''); + imageData = base64; + } + + console.log(' Sending to 2Captcha (human workers)...'); + const result = await solver.imageCaptcha(imageData, { + numeric: 0, + minLength: 1, + maxLength: 10, + phrase: 0, + caseSensitive: 1, + calc: 0, + lang: 'en' + }); + + console.log(` 2Captcha solved: "${result.data}"`); + return { + type: 'image', + answer: result.data.trim(), + originalText: result.data, + service: '2captcha', + imageSrc: imageCaptchaInfo.src + }; + + } catch (error) { + console.log(` 2Captcha failed: ${error.message}`); + + console.log(' Falling back to Tesseract OCR...'); + try { + const { data: { text, confidence } } = await Tesseract.recognize( + imageCaptchaInfo.src, + 'eng', + { logger: m => {} } + ); + + console.log(` OCR raw output: "${text}"`); + console.log(` OCR confidence: ${confidence}%`); + + const cleanedText = text.replace(/[^a-zA-Z0-9]/g, '').trim(); + + if (cleanedText && cleanedText.length >= 3) { + console.log(` OCR fallback read: "${cleanedText}"`); + return { + type: 'image', + answer: cleanedText, + originalText: text, + confidence: confidence, + service: 'tesseract-fallback', + imageSrc: imageCaptchaInfo.src + }; + } + } catch (ocrError) { + console.log(` OCR fallback also failed: ${ocrError.message}`); + } + + return { + type: 'image', + answer: null, + message: `Both 2Captcha and OCR failed: ${error.message}`, + imageSrc: imageCaptchaInfo.src + }; + } } return null; @@ -135,5 +285,6 @@ async function solveSimpleCaptcha(page) { module.exports = { CAPTCHA_PATTERNS, detectCaptcha, - solveSimpleCaptcha + solveSimpleCaptcha, + fetchImageAsBuffer }; diff --git a/src/cli.js b/src/cli.js index 78b4b13..0c18d77 100644 --- a/src/cli.js +++ b/src/cli.js @@ -10,36 +10,39 @@ function createCLI() { .option('-f, --file ', 'Load sites from text file (one URL per line)') .option('-k, --keep-open', 'Keep browser tabs open after processing for verification') .option('-l, --log', 'Save output to timestamped log file') + .option('-d, --dry-run', 'Test mode - detect CAPTCHA but skip solving (no API calls)') .option('-h, --help', 'Show help message') .argument('[target]', 'Site name or URL to process (filters from config.toml sites)'); program.on('option:help', () => { console.log(` -Newsletter Signup Automation Tool + Newsletter Signup Automation Tool -Usage: node index.js [options] [site-name-or-url] + Usage: node index.js [options] [site-name-or-url] -Options: - -f, --file= Load sites from text file (one URL per line) - -k, --keep-open Keep browser tabs open after processing for verification - -l, --log Save output to timestamped log file - -h, --help Show this help message + Options: + -f, --file= Load sites from text file (one URL per line) + -k, --keep-open Keep browser tabs open after processing for verification + -l, --log Save output to timestamped log file + -d, --dry-run Test mode - detect CAPTCHA but skip solving (no API calls) + -h, --help Show this help message -Examples: - node index.js Process all sites from config.toml - node index.js --file=sites.txt Process sites from file - node index.js "Dutch News" Process specific site - node index.js --file=sites.txt --keep-open Keep tabs open for verification - node index.js --log Save output to log file + Examples: + node index.js Process all sites from config.toml + node index.js --file=sites.txt Process sites from file + node index.js "Dutch News" Process specific site + node index.js --file=sites.txt --keep-open Keep tabs open for verification + node index.js --log Save output to log file + node index.js --dry-run Test without calling 2Captcha API -Text file format (sites.txt): - # Lines starting with # are comments - https://example.com/newsletter - https://another-site.com/signup + Text file format (sites.txt): + # Lines starting with # are comments + https://example.com/newsletter + https://another-site.com/signup -Configuration: - Edit config.toml to set credentials and default sites -`); + Configuration: + Edit config.toml to set credentials and default sites + `); process.exit(0); }); @@ -59,6 +62,7 @@ function parseCLI(args = process.argv.slice(2)) { file: opts.file || null, keepOpen: opts.keepOpen || false, log: opts.log || false, + dryRun: opts.dryRun || false, help: opts.help || false }; } diff --git a/src/processor.js b/src/processor.js index aaf2bae..480c6de 100644 --- a/src/processor.js +++ b/src/processor.js @@ -3,8 +3,9 @@ const { detectFormFields } = require('./fields'); const { detectSubmitButton } = require('./submit'); const { detectCaptcha, solveSimpleCaptcha } = require('./captcha'); const { classifyNewsletterForm } = require('./classifier'); +const { detectSubmissionResult } = require('./submission'); -async function processSite(browser, site, globalCreds, browserConfig, keepOpen = false) { +async function processSite(browser, site, globalCreds, browserConfig, keepOpen = false, dryRun = false) { let siteFailed = false; let failureReason = ''; let page = null; @@ -42,38 +43,66 @@ async function processSite(browser, site, globalCreds, browserConfig, keepOpen = } if (captchaInfo.some(c => c.type === 'mathCaptcha')) { - const solution = await solveSimpleCaptcha(page); - if (solution && solution.answer) { - console.log(` Auto-solving math: ${solution.text} = ${solution.answer}`); - try { - await page.evaluate((answer) => { - const input = document.querySelector('input[name*="captcha"], input[class*="captcha"], #captcha'); - if (input) { - input.value = answer; - input.dispatchEvent(new Event('input', { bubbles: true })); - } - }, solution.answer); - } catch (e) { - console.warn(` Failed to fill math answer: ${e.message}`); + if (dryRun) { + console.log(` [DRY-RUN] Would solve math CAPTCHA`); + } else { + const solution = await solveSimpleCaptcha(page); + if (solution && solution.answer) { + console.log(` Auto-solving math: ${solution.text} = ${solution.answer}`); + try { + await page.evaluate((answer) => { + const input = document.querySelector('input[name*="captcha"], input[class*="captcha"], #captcha'); + if (input) { + input.value = answer; + input.dispatchEvent(new Event('input', { bubbles: true })); + } + }, solution.answer); + } catch (e) { + console.warn(` Failed to fill math answer: ${e.message}`); + } } } } - if (captchaInfo.some(c => c.type === 'imageCaptcha')) { - const solution = await solveSimpleCaptcha(page); - if (solution && solution.type === 'image' && solution.answer) { - console.log(` OCR solved image CAPTCHA: "${solution.answer}"`); - try { - await page.evaluate((answer) => { - const input = document.querySelector('input[name*="captcha"], input[class*="captcha"], input[id*="captcha"], #captcha, .captcha-input'); - if (input) { - input.value = answer; - input.dispatchEvent(new Event('input', { bubbles: true })); - input.dispatchEvent(new Event('change', { bubbles: true })); - } - }, solution.answer); - } catch (e) { - console.warn(` Failed to fill OCR answer: ${e.message}`); + if (captchaInfo.some(c => c.type === 'textCaptcha')) { + console.log(` Text CAPTCHA detected - looking for input field...`); + const textCaptchaFilled = await page.evaluate(() => { + const input = document.querySelector('input[name*="captcha"], input[id*="captcha"], .captcha-input, #captcha, input[placeholder*="captcha" i]'); + if (input) { + return { + found: true, + selector: input.id ? `#${input.id}` : (input.name ? `[name="${input.name}"]` : 'input[name*="captcha"]') + }; + } + return { found: false }; + }); + if (textCaptchaFilled.found) { + console.log(` Text CAPTCHA input found: ${textCaptchaFilled.selector}`); + } + } + + if (captchaInfo.some(c => c.type === 'imageCaptcha' || c.type === 'textCaptcha')) { + if (dryRun) { + console.log(` [DRY-RUN] Would solve image/text CAPTCHA (no API calls)`); + } else { + const solution = await solveSimpleCaptcha(page); + if (solution && solution.answer) { + console.log(` OCR solved CAPTCHA: "${solution.answer}"`); + try { + await page.evaluate((answer) => { + const input = document.querySelector('input[name*="captcha"], input[class*="captcha"], input[id*="captcha"], #captcha, .captcha-input'); + if (input) { + input.value = answer; + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new Event('change', { bubbles: true })); + console.log(` Filled CAPTCHA with: ${answer}`); + } + }, solution.answer); + } catch (e) { + console.warn(` Failed to fill CAPTCHA answer: ${e.message}`); + } + } else if (solution && solution.message) { + console.log(` CAPTCHA solving failed: ${solution.message}`); } } } @@ -190,8 +219,43 @@ async function processSite(browser, site, globalCreds, browserConfig, keepOpen = if (submitSelector) { try { + const urlBeforeSubmit = page.url(); await page.click(submitSelector); console.log(` Submitted (${submitSelector})`); + + await new Promise(r => setTimeout(r, 1500)); + + let submissionResult; + try { + const urlAfterSubmit = page.url(); + if (urlAfterSubmit !== urlBeforeSubmit) { + console.log(` Page navigated to: ${urlAfterSubmit}`); + } + + submissionResult = await detectSubmissionResult(page); + } catch (resultError) { + console.log(` Could not detect submission result: ${resultError.message}`); + submissionResult = { failure: false, success: false, signals: ['Could not determine - page may have redirected'] }; + } + + console.log(`\n Submission Result Analysis:`); + + if (submissionResult.signals && submissionResult.signals.length > 0) { + for (const signal of submissionResult.signals) { + console.log(` - ${signal}`); + } + } + + if (submissionResult.failure) { + siteFailed = true; + failureReason = failureReason || submissionResult.failureReason || 'Form submission failed'; + console.error(` FAILED: ${submissionResult.failureReason}`); + } else if (submissionResult.success) { + console.log(` SUCCESS: Subscription appears to have succeeded`); + } else { + console.log(` Result: Unknown (page may have redirected to confirmation)`); + } + } catch (submitError) { siteFailed = true; failureReason = failureReason || `Submit failed: ${submitError.message}`; diff --git a/src/submission.js b/src/submission.js new file mode 100644 index 0000000..b79439f --- /dev/null +++ b/src/submission.js @@ -0,0 +1,97 @@ +async function detectSubmissionResult(page) { + const result = await page.evaluate(() => { + const results = { + success: false, + failure: false, + failureReason: null, + currentUrl: window.location.href, + signals: [] + }; + + const errorSelectors = [ + '.error', '.Error', '.ERROR', + '[class*="error" i]', + '.alert-danger', '.alert-error', + '[class*="failed" i]', + '[class*="invalid" i]', + '[class*="wrong" i]', + '#error', '#Error', + '[role="alert"]', + '.message.error', + '.error-message', + '.form-error', + '[data-form-error]' + ]; + + for (const selector of errorSelectors) { + const el = document.querySelector(selector); + if (el) { + const text = el.textContent?.toLowerCase() || ''; + const style = window.getComputedStyle(el); + if (style.display !== 'none' && + style.visibility !== 'hidden' && + style.opacity !== '0' && + text.length > 0 && + text.length < 500) { + results.failure = true; + results.failureReason = `Error message detected: ${text.substring(0, 100)}`; + results.signals.push(`Error: ${text.substring(0, 100)}`); + break; + } + } + } + + const pageText = document.body.innerText.toLowerCase(); + + const successKeywords = [ + 'thank you', 'thanks for', 'successfully', 'subscribed', 'subscription confirmed', + 'you are now', 'welcome', 'email confirmed', 'verification sent', + 'check your email', 'confirm your email', 'almost done', + 'signed up', 'signed up successfully', 'subscription successful' + ]; + + for (const keyword of successKeywords) { + if (pageText.includes(keyword)) { + results.success = true; + results.signals.push(`Success keyword: "${keyword}"`); + break; + } + } + + const failureKeywords = [ + 'incorrect', 'invalid captcha', 'wrong captcha', 'incorrect captcha', + 'captcha incorrect', 'captcha invalid', 'captcha error', + 'please complete the captcha', 'complete the captcha', + 'try again', 'submission failed' + ]; + + if (!results.failure) { + for (const keyword of failureKeywords) { + if (pageText.includes(keyword)) { + results.failure = true; + results.failureReason = `Failure keyword detected: "${keyword}"`; + results.signals.push(`Failure: ${keyword}`); + break; + } + } + } + + const formStillVisible = document.querySelector('form') !== null; + const emailInputStillVisible = document.querySelector('input[type="email"]') !== null || + document.querySelector('input[name*="email" i]') !== null; + + if (formStillVisible && emailInputStillVisible && !results.success) { + results.failure = true; + results.failureReason = results.failureReason || 'Form still visible after submission (likely failed)'; + results.signals.push('Form still visible - submission may have failed'); + } + + return results; + }); + + return result; +} + +module.exports = { + detectSubmissionResult +}; diff --git a/test.png b/test.png new file mode 100644 index 0000000..4c015f7 Binary files /dev/null and b/test.png differ diff --git a/test.txt b/test.txt new file mode 100644 index 0000000..6db2bfe --- /dev/null +++ b/test.txt @@ -0,0 +1 @@ +https://jubina.com/list diff --git a/test_captcha.txt b/test_captcha.txt new file mode 100644 index 0000000..6db2bfe --- /dev/null +++ b/test_captcha.txt @@ -0,0 +1 @@ +https://jubina.com/list diff --git a/test_crop.png b/test_crop.png new file mode 100644 index 0000000..63a5b60 Binary files /dev/null and b/test_crop.png differ