عند حلّ عشرات أو مئات اختبارات CAPTCHA في وقت واحد، لا يكون السؤال الأصعب "كيف أحلّ واحداً؟" بل "ماذا أفعل عندما يفشل بعضها؟". الجواب المباشر: أرسل كل المهام دفعة واحدة عبر Promise.allSettled، فهو ينتظر اكتمال كل وعد ويعيد لك حالة كل مهمة على حِدة — النجاح مع قيمته، والفشل مع سببه — بحيث لا تُهدر مهمة ناجحة بسبب أخرى فاشلة.
تخيّل فريقاً في متجر إلكتروني بمنطقة الخليج يفحص كل ليلة آلاف صفحات المنتجات المحمية بـ reCAPTCHA للتحقق من الأسعار والمخزون. لو استخدم أداة تتوقف عند أول خطأ، لضاعت نتائج مئات الصفحات الناجحة بسبب انقطاع شبكي واحد. هنا يصبح اختيار آلية التجميع الصحيحة فرقاً بين خط إنتاج موثوق وآخر هشّ. هذا الدليل يبني هذا الخط خطوة بخطوة على CaptchaAI باستخدام Node.js.
Promise.all مقابل Promise.allSettled: أيّهما تختار؟
الفرق بين الدالتين ليس أسلوبياً بل جوهري في سلوك الفشل. يرفض Promise.all الدفعة كاملة عند أول مهمة تفشل، فتفقد كل النتائج المعلّقة حتى لو نجح معظمها. أما Promise.allSettled فيكمل انتظار كل الوعود ثم يسلّمك مصفوفة تصف حالة كل مهمة:
// Promise.all — REJECTS if ANY task fails
const results = await Promise.all(tasks.map(solve)); // Throws on first error
// Promise.allSettled — RESOLVES always, with status for each
const results = await Promise.allSettled(tasks.map(solve));
// [{status: "fulfilled", value: "..."}, {status: "rejected", reason: Error}]
الجدول التالي يلخّص متى تناسب كل دالة سير عمل حل الكابتشا:
| الدالة | سلوكها عند أول فشل | ما تُعيده | تناسب |
|---|---|---|---|
Promise.all |
ترفض فوراً وتوقف الدفعة | لا شيء (ترمي استثناءً) | العمليات التي تتطلب نجاح الكل أو لا شيء |
Promise.allSettled |
تكمل حتى النهاية | حالة كل مهمة منفردة | حل دفعات الكابتشا حيث الفشل الجزئي متوقّع |
القاعدة العملية: كلما كان الفشل الجزئي احتمالاً واقعياً — وهو دائماً كذلك عند التعامل مع شبكات وخوادم خارجية — كان Promise.allSettled هو الخيار الأسلم.
البنية الأساسية لحل الدفعات
تبدأ أي معالجة دُفعية بدالة واحدة تحلّ كابتشا مفرداً: ترسل الطلب إلى نقطة النهاية in.php، تحفظ معرّف المهمة، ثم تستطلع res.php دورياً حتى تجهز النتيجة. بعد ذلك نغلّف هذه الدالة داخل Promise.allSettled ونفرز المخرجات إلى ناجحة وفاشلة:
const axios = require("axios");
const API_KEY = process.env.CAPTCHAAI_API_KEY;
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function solveCaptcha(sitekey, pageurl) {
// Submit
const submitResp = await axios.post(
"https://ocr.captchaai.com/in.php",
null,
{
params: {
key: API_KEY,
method: "userrecaptcha",
googlekey: sitekey,
pageurl: pageurl,
json: 1,
},
}
);
if (submitResp.data.status !== 1) {
throw new Error(submitResp.data.request);
}
const captchaId = submitResp.data.request;
// Poll
for (let i = 0; i < 60; i++) {
await sleep(5000);
const result = await axios.get("https://ocr.captchaai.com/res.php", {
params: { key: API_KEY, action: "get", id: captchaId, json: 1 },
});
if (result.data.status === 1) return result.data.request;
if (result.data.request !== "CAPCHA_NOT_READY") {
throw new Error(result.data.request);
}
}
throw new Error("TIMEOUT");
}
async function batchSolve(tasks) {
const promises = tasks.map((task) =>
solveCaptcha(task.sitekey, task.pageurl).then((solution) => ({
...task,
solution,
}))
);
const results = await Promise.allSettled(promises);
const solved = [];
const failed = [];
for (let i = 0; i < results.length; i++) {
if (results[i].status === "fulfilled") {
solved.push(results[i].value);
} else {
failed.push({
task: tasks[i],
error: results[i].reason.message,
});
}
}
return { solved, failed };
}
// Usage
(async () => {
const tasks = [
{
sitekey: "6Le-wvkSAAAAAPBMRTvw0Q4Muexq9bi0DJwx_mJ-",
pageurl: "https://example.com/page/1",
},
{
sitekey: "6Le-wvkSAAAAAPBMRTvw0Q4Muexq9bi0DJwx_mJ-",
pageurl: "https://example.com/page/2",
},
{
sitekey: "6Le-wvkSAAAAAPBMRTvw0Q4Muexq9bi0DJwx_mJ-",
pageurl: "https://example.com/page/3",
},
];
const { solved, failed } = await batchSolve(tasks);
console.log(`Solved: ${solved.length}, Failed: ${failed.length}`);
for (const s of solved) {
console.log(` ✓ ${s.pageurl}: ${s.solution.substring(0, 30)}...`);
}
for (const f of failed) {
console.log(` ✗ ${f.task.pageurl}: ${f.error}`);
}
})();
لاحظ أن الاستطلاع يتم كل 5 ثوانٍ حتى 60 مرة كحدّ أقصى قبل إطلاق TIMEOUT، وهي فترة كافية لأغلب أنواع الكابتشا المدعومة. المثال مكتوب لـ reCAPTCHA عبر الدالة userrecaptcha، لكن النمط نفسه يعمل مع أي نوع يدعمه CaptchaAI — مثل Cloudflare Turnstile أو GeeTest v3 — بتغيير قيمة method والمعاملات المطلوبة فقط.
التحكم في التزامن ومطابقته لعدد الـ threads
إرسال 1000 اختبار CAPTCHA دفعة واحدة يُرهق الاتصالات ويستنزف الموارد دون فائدة. الحل هو محدّد تزامن يُبقي عدداً ثابتاً من المهام قيد التنفيذ ويُغذّي عمّالاً متوازيين من قائمة واحدة:
async function batchSolveWithLimit(tasks, concurrency = 10) {
const results = [];
let index = 0;
async function worker() {
while (index < tasks.length) {
const i = index++;
const task = tasks[i];
try {
const solution = await solveCaptcha(task.sitekey, task.pageurl);
results[i] = { status: "fulfilled", value: { ...task, solution } };
} catch (err) {
results[i] = { status: "rejected", reason: err };
}
}
}
// Launch concurrent workers
const workers = Array.from({ length: concurrency }, () => worker());
await Promise.allSettled(workers);
const solved = results
.filter((r) => r.status === "fulfilled")
.map((r) => r.value);
const failed = results
.filter((r) => r.status === "rejected")
.map((r, i) => ({ task: tasks[i], error: r.reason.message }));
return { solved, failed };
}
// Solve 100 CAPTCHAs, 10 at a time
const { solved, failed } = await batchSolveWithLimit(tasks, 10);
هنا تظهر نقطة تربط الكود بالتكلفة: يفوتر CaptchaAI حسب عدد الـ threads المتزامنة لا حسب عدد عمليات الحل، مع عدد غير محدود من الحلول لكل thread خلال الشهر. لذلك يجب ألّا يتجاوز concurrency عدد الـ threads في خطتك. فإذا ضبطت التزامن على 10 مهام، تكفيك خطة STANDARD ($30 شهرياً، 15 thread)، أما التزامن الأعلى مثل 50 مهمة متوازية فيناسبه ADVANCE ($90 شهرياً، 50 thread). ضبط الرقمين معاً يمنع أخطاء الازدحام ويستثمر خطتك بالكامل دون رسوم مفاجئة.
إعادة محاولة المهام الفاشلة
ليست كل الإخفاقات متساوية. فشل مؤقت مثل TIMEOUT أو ERROR_NO_SLOT_AVAILABLE يستحق إعادة المحاولة، بينما خطأ في مفتاح الموقع لن يُصلحه التكرار. الدالة التالية تعيد إرسال المهام القابلة للإصلاح فقط، على عدد محدود من المحاولات:
async function batchSolveWithRetry(tasks, maxRetries = 2, concurrency = 10) {
let currentTasks = [...tasks];
let allSolved = [];
for (let attempt = 0; attempt <= maxRetries; attempt++) {
if (currentTasks.length === 0) break;
console.log(
`Attempt ${attempt + 1}: solving ${currentTasks.length} tasks...`
);
const { solved, failed } = await batchSolveWithLimit(
currentTasks,
concurrency
);
allSolved = [...allSolved, ...solved];
// Only retry transient errors
const retryable = failed.filter(
(f) =>
f.error === "TIMEOUT" ||
f.error === "ERROR_NO_SLOT_AVAILABLE" ||
f.error === "ERROR_TOO_MUCH_REQUESTS"
);
currentTasks = retryable.map((f) => f.task);
if (retryable.length > 0) {
console.log(` Retrying ${retryable.length} failed tasks...`);
}
}
const finalFailed = currentTasks; // Anything left after all retries
return { solved: allSolved, failed: finalFailed };
}
قصر إعادة المحاولة على الأخطاء العابرة يحميك من حلقات لا تنتهي على مهام معطوبة بنيوياً، ويوجّه الـ threads نحو ما يمكن إنقاذه فعلاً.
تتبّع تقدّم المعالجة لحظياً
عند تشغيل دفعات كبيرة يحتاج المشغّل إلى مؤشّر حيّ يوضّح كم مهمة اكتملت وكم نجحت وكم أخفقت، بدل انتظار صامت طويل. نُحقّق ذلك بتغليف كل مهمة بعدّادات تُحدَّث فور اكتمالها:
async function batchSolveWithProgress(tasks, concurrency = 10) {
let completed = 0;
let succeeded = 0;
let failed = 0;
const wrapped = tasks.map((task) =>
solveCaptcha(task.sitekey, task.pageurl)
.then((solution) => {
succeeded++;
completed++;
process.stdout.write(
`\rProgress: ${completed}/${tasks.length} (${succeeded} ok, ${failed} err)`
);
return { ...task, solution };
})
.catch((err) => {
failed++;
completed++;
process.stdout.write(
`\rProgress: ${completed}/${tasks.length} (${succeeded} ok, ${failed} err)`
);
throw err;
})
);
const results = await Promise.allSettled(wrapped);
console.log("\nDone.");
return results;
}
مؤشّر التقدّم مفيد بشكل خاص في بيئات التكامل المستمر أو المهام المجدولة ليلاً، حيث يساعد على رصد تدهور معدل النجاح مبكراً قبل أن يمر بلا انتباه.
تصنيف النتائج إلى فئات قابلة للتنفيذ
مصفوفة Promise.allSettled خام بطبيعتها؛ لتحويلها إلى قرار عملي نفرزها إلى ثلاث فئات: ناجحة، وأخطاء عابرة تُعاد محاولتها، وأخطاء دائمة تُحوّل إلى مراجعة يدوية:
function categorizeResults(settled, originalTasks) {
const categories = {
solved: [],
transientErrors: [],
permanentErrors: [],
};
const TRANSIENT = new Set([
"TIMEOUT",
"ERROR_NO_SLOT_AVAILABLE",
"ERROR_TOO_MUCH_REQUESTS",
]);
for (let i = 0; i < settled.length; i++) {
const r = settled[i];
if (r.status === "fulfilled") {
categories.solved.push(r.value);
} else {
const error = r.reason.message;
const entry = { task: originalTasks[i], error };
if (TRANSIENT.has(error)) {
categories.transientErrors.push(entry);
} else {
categories.permanentErrors.push(entry);
}
}
}
return categories;
}
هذا الفصل يجعل خط الإنتاج قابلاً للمراقبة: تُغذّى الأخطاء العابرة لدالة إعادة المحاولة، بينما تُسجَّل الأخطاء الدائمة لتحليلها لاحقاً بدل إغراق السجلات بضجيج متكرر.
استكشاف الأخطاء وإصلاحها
| المشكلة | السبب المحتمل | الإجراء |
|---|---|---|
| فشل جميع المهام بمهلة انتهاء | تزامن مرتفع أكثر من اللازم يُرهق الاتصالات أو الخادم الوسيط | اخفض concurrency إلى ما بين 5 و10 وطابقه مع عدد threads خطتك |
ظهور ERR_SOCKET_EXHAUSTION |
عدد كبير من اتصالات HTTP المتزامنة | استخدم http.Agent مع حدّ maxSockets لإعادة استعمال الاتصالات |
| ترتيب النتائج غير مطابق للإرسال | اختلاف ترتيب الاكتمال غير المتزامن عن ترتيب الإرسال | خزّن النتائج حسب الفهرس i كما في أمثلة هذا الدليل |
| نمو استهلاك الذاكرة في الدفعات الضخمة | الاحتفاظ بكل الوعود في الذاكرة دفعة واحدة | قسّم المهام إلى شرائح من 100 إلى 500 مهمة |
| يُنشأ الرمز لكن الموقع المستهدف يرفضه | مفتاح الموقع أو الصفحة أو سياق الجلسة لا يتطابق | أعد التقاط المعاملات واستخدم الرمز داخل جلسة HTTP أو المتصفح نفسها |
الأسئلة الشائعة
لماذا لا أفقد النتائج الناجحة عند فشل بعض المهام؟
لأن Promise.allSettled لا يرفض الدفعة عند أول خطأ كما يفعل Promise.all، بل ينتظر كل الوعود ويعيد لك حالة كل مهمة منفصلة. النتائج الناجحة تبقى في متناولك حتى لو أخفقت مهام أخرى في الدفعة نفسها.
كيف أوفّق بين مستوى التزامن وعدد الـ threads في خطتي؟
اجعل قيمة concurrency مساوية أو أقل من عدد الـ threads في خطتك. يفوتر CaptchaAI حسب الـ threads المتزامنة مع حلول غير محدودة لكل thread، فإذا أردت 50 مهمة متوازية فاختر خطة توفّر 50 thread على الأقل مثل ADVANCE ($90 شهرياً). تجاوز هذا الحد يرفع معدلات الأخطاء دون زيادة الإنتاجية الفعلية.
متى أستخدم إعادة المحاولة بدل تجاهل الفشل؟
أعد المحاولة على الأخطاء العابرة فقط مثل TIMEOUT أو ERROR_NO_SLOT_AVAILABLE، لأنها غالباً نتيجة ازدحام لحظي. أما الأخطاء البنيوية كمفتاح موقع خاطئ أو صفحة غير صحيحة فلن يُصلحها التكرار، ويجب تحويلها إلى مراجعة يدوية.
هل يصلح النمط نفسه لأنواع CAPTCHA غير reCAPTCHA؟
نعم. البنية المعروضة هنا مستقلة عن نوع الكابتشا؛ يكفي تعديل قيمة method والمعاملات لتناسب النوع المطلوب. النمط ذاته يعمل مع Cloudflare Turnstile وGeeTest v3 وتحديات الصور وغيرها من الأنواع التي يدعمها CaptchaAI.
الخطوات التالية
- البدء السريع مع CaptchaAI: حلّ أول كابتشا في خمس دقائق
- حلّ reCAPTCHA v2 عبر الـ API خطوة بخطوة
- حلّ Cloudflare Turnstile عبر الـ API
- حلّ GeeTest v3 عبر الـ API