الدروس التطبيقية

تحديد معدل استدعاءات CAPTCHA API باستخدام Token Bucket

الرقم الذي يحكم استقرار أي خط أتمتة ليس عدد الطلبات التي يستطيع كودك إطلاقها، بل عدد الطلبات التي يُفترض أن يطلقها في الثانية الواحدة. خوارزمية Token Bucket تعطيك هذا الرقم بمعاملين اثنين فقط: سعة تحدد أكبر اندفاع مسموح به، ومعدل إعادة تعبئة يحدد الوتيرة المستمرة بعد استهلاك ذلك الاندفاع. النتيجة أن الإرسال يخرج بإيقاع منتظم بدل موجة واحدة تُثقل نقطة النهاية، فيختفي ERROR_TOO_MUCH_REQUESTS وتبقى الـ threads المتاحة في خطتك مشغولة بانتظام بدل أن تتزاحم عليها الطلبات.

الترتيب هنا مقصود: نضبط الأرقام أولاً، ثم ننفّذها في Python وJavaScript، ثم نربطها بعدد الـ threads في خطتك.

متى يتحول تحديد معدل الطلبات إلى ضرورة

في سكربت بخيط واحد لن تلاحظ فرقاً. المشكلة تبدأ حين يتضاعف عدد المرسِلين والمفتاح واحد:

  • أكثر من عامل أو حاوية Docker تتشارك مفتاح الـ API نفسه.
  • دفعات غير منتظمة: قائمة الانتظار تفرغ ببطء ثم تمتلئ بمئات الروابط دفعة واحدة.
  • بيئة الاختبار وبيئة الإنتاج تعملان بالمفتاح ذاته في الوقت نفسه.
  • ظهور ERROR_TOO_MUCH_REQUESTS في السجلات رغم أن متوسط الحِمل اليومي منخفض.

السبب واحد في الحالات الأربع: الذروة اللحظية لا الكمية الإجمالية، وهذا ما يعالجه Token Bucket.

الفكرة كاملة: سعة زائد معدل إعادة تعبئة

تخيّل حاوية تمتلئ بالرموز بوتيرة ثابتة: كل إرسال يستهلك رمزاً، وإذا فرغت ينتظر الطلب الرمز التالي.

[Bucket] capacity=20, refill=10/sec

Time 0:  ████████████████████  20 tokens available
         → 15 requests consume 15 tokens
Time 0:  █████                 5 tokens remain

Time 1s: ███████████████       15 tokens (5 + 10 refilled)
         → 15 requests consume 15 tokens
Time 1s: (empty)               0 tokens

Time 2s: ██████████            10 tokens (0 + 10 refilled)
         → Request waits if bucket is empty

اقرأ المخطط من ثلاث زوايا:

  • السعة — أكبر عدد طلبات يمكن إطلاقها دفعة واحدة بعد فترة هدوء.
  • معدل إعادة التعبئة — عدد الطلبات المستمر في الثانية على المدى الطويل.
  • الانتظار بدل الرفض — الحاوية الفارغة تؤخّر الطلب ولا تُسقطه، فلا تفقد مهمة بسبب التقييد.

اختر الأرقام قبل كتابة سطر واحد

ابدأ من طبيعة الحِمل لا من طاقة الخادم:

نوع الحِمل السعة — أقصى اندفاع معدل إعادة التعبئة — الوتيرة المستمرة
جمع بيانات خفيف 5 2/sec
أتمتة قياسية 20 10/sec
خط إنتاج كبير الحجم 50 30/sec
أقصى إنتاجية 100 50/sec

ثلاث قواعد عملية تختصر عليك جولات التجريب:

  • اجعل السعة ضعف معدل إعادة التعبئة تقريباً، فتسمح باندفاع مدته ثانيتان.
  • ابدأ برقم متحفظ وارفعه تدريجياً مع مراقبة رموز الخطأ، لا العكس.
  • حدّد معدل الإرسال فقط؛ الاستطلاع الدوري خفيف ويحدّ نفسه بنفسه عبر فترة الانتظار بين المحاولات.

التنفيذ في Python

حاوية رموز آمنة مع تعدد الخيوط

الفئة التالية تحمي عدّاد الرموز بقفل، وتعيد حساب الرصيد من فارق الوقت بدل تشغيل خيط تعبئة مستقل — أبسط وأدق:

import time
import threading


class TokenBucket:
    def __init__(self, capacity, refill_rate):
        """
        Args:
            capacity: Maximum tokens (burst size)
            refill_rate: Tokens added per second
        """
        self.capacity = capacity
        self.refill_rate = refill_rate
        self.tokens = capacity
        self.last_refill = time.monotonic()
        self.lock = threading.Lock()

    def acquire(self, timeout=None):
        """Block until a token is available."""
        deadline = time.monotonic() + timeout if timeout else float("inf")

        while True:
            with self.lock:
                self._refill()
                if self.tokens >= 1:
                    self.tokens -= 1
                    return True

            # Check timeout
            if time.monotonic() >= deadline:
                return False

            # Wait before retrying (avoid busy loop)
            time.sleep(min(1.0 / self.refill_rate, 0.1))

    def _refill(self):
        now = time.monotonic()
        elapsed = now - self.last_refill
        new_tokens = elapsed * self.refill_rate
        self.tokens = min(self.capacity, self.tokens + new_tokens)
        self.last_refill = now

الاعتماد على time.monotonic مقصود: الساعة الأحادية لا تتأثر بتعديل وقت النظام، فلا يقفز الرصيد عند مزامنة الخادم.

تمرير دالة الحل عبر الحاوية

نقطة الربط سطر واحد قبل الإرسال، وبقية الدالة تبقى كما هي:

import os
import requests
from concurrent.futures import ThreadPoolExecutor, as_completed

API_KEY = os.environ["CAPTCHAAI_API_KEY"]

# Allow 10 submissions/sec with burst of 20
rate_limiter = TokenBucket(capacity=20, refill_rate=10)


def solve_captcha_rate_limited(sitekey, pageurl):
    """Solve with rate limiting on submission."""
    # Wait for token before submitting
    rate_limiter.acquire()

    resp = requests.post("https://ocr.captchaai.com/in.php", data={
        "key": API_KEY,
        "method": "userrecaptcha",
        "googlekey": sitekey,
        "pageurl": pageurl,
        "json": 1
    })
    data = resp.json()

    if data.get("status") != 1:
        raise RuntimeError(data.get("request"))

    captcha_id = data["request"]

    # Polling doesn't need rate limiting (separate concern)
    for _ in range(60):
        time.sleep(5)
        result = requests.get("https://ocr.captchaai.com/res.php", params={
            "key": API_KEY, "action": "get", "id": captcha_id, "json": 1
        }).json()

        if result.get("status") == 1:
            return result["request"]
        if result.get("request") != "CAPCHA_NOT_READY":
            raise RuntimeError(result.get("request"))

    raise TimeoutError("Solve timeout")


# Run 100 tasks through rate limiter
tasks = [
    {"sitekey": "6Le-wvkSAAAAAPBMRTvw0Q4Muexq9bi0DJwx_mJ-",
     "pageurl": f"https://example.com/p/{i}"}
    for i in range(100)
]

with ThreadPoolExecutor(max_workers=30) as executor:
    futures = {
        executor.submit(
            solve_captcha_rate_limited, t["sitekey"], t["pageurl"]
        ): t for t in tasks
    }

    for future in as_completed(futures):
        task = futures[future]
        try:
            solution = future.result()
            print(f"[OK] {task['pageurl']}")
        except Exception as e:
            print(f"[ERR] {task['pageurl']}: {e}")

الحلقة التي تستطلع النتيجة من res.php تعمل خارج الحدّ عمداً: تقييدها يضيف تأخيراً دون فائدة، فكل استطلاع يفصله خمس ثوانٍ أصلاً.

التنفيذ في JavaScript

نسخة غير متزامنة تعمل مع Promise

في Node.js لا توجد أقفال ولا خيوط، لذا يكفي حساب زمن الانتظار المتبقي وتسليم التحكم عبر await:

class TokenBucket {
  constructor(capacity, refillRate) {
    this.capacity = capacity;
    this.refillRate = refillRate; // tokens per second
    this.tokens = capacity;
    this.lastRefill = Date.now();
    this.waitQueue = [];
  }

  _refill() {
    const now = Date.now();
    const elapsed = (now - this.lastRefill) / 1000;
    this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.refillRate);
    this.lastRefill = now;
  }

  async acquire() {
    this._refill();

    if (this.tokens >= 1) {
      this.tokens -= 1;
      return;
    }

    // Wait until a token is available
    const waitTime = ((1 - this.tokens) / this.refillRate) * 1000;
    await new Promise((resolve) => setTimeout(resolve, waitTime));

    this._refill();
    this.tokens -= 1;
  }
}

تشغيل دفعة كاملة تحت الحدّ نفسه

Promise.allSettled يطلق المهام كلها فوراً، لكن حاوية الرموز هي التي تفرض الإيقاع الفعلي عند نقطة الإرسال:

const axios = require("axios");

const API_KEY = process.env.CAPTCHAAI_API_KEY;
const rateLimiter = new TokenBucket(20, 10); // 20 burst, 10/sec sustained

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

async function solveCaptchaLimited(sitekey, pageurl) {
  // Wait for rate limit token
  await rateLimiter.acquire();

  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;

  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");
}

// Solve 100 tasks — rate limiter ensures max 10 submissions/sec
async function batchSolve(tasks) {
  const results = await Promise.allSettled(
    tasks.map((t) => solveCaptchaLimited(t.sitekey, t.pageurl))
  );

  const solved = results.filter((r) => r.status === "fulfilled").length;
  const failed = results.filter((r) => r.status === "rejected").length;
  console.log(`Solved: ${solved}, Failed: ${failed}`);
}

اختيار allSettled بدل all مقصود أيضاً: مهمة واحدة فاشلة لا يجب أن تُسقط الدفعة بأكملها.

مثال تشغيلي: مراقبة الأسعار قبل موسم الجمعة البيضاء

فريق من ثلاثة مطورين في الرياض يراقب أسعار المنافسين على متاجر خليجية قبل موسم التخفيضات. الحِمل غير منتظم: وتيرة هادئة طوال اليوم، ثم ارتفاع حاد بين السابعة والحادية عشرة مساءً بتوقيت الخليج حين تتغير الأسعار. وصفحات المنتج محمية بـ reCAPTCHA v2.

الفريق على خطة ADVANCE بسعر $90 شهرياً مع 50 thread وعمليات حل غير محدودة لكل thread. ويُحل reCAPTCHA v2 عادة في أقل من 60 ثانية، أي أن عدد الـ threads هو السقف الحقيقي للحل المتزامن — لا سرعة الشبكة.

ما فعله الفريق عملياً:

  1. ضبط السعة على 20 لاستيعاب موجة المساء الأولى دون تأخير محسوس.
  2. ضبط معدل إعادة التعبئة على 10 في الثانية، أي دون طاقة الخطة بهامش أمان واضح.
  3. تشغيل حاوية رموز واحدة لكل مفتاح، مشتركة بين عمّال الفحص الثلاثة، لا واحدة لكل عملية.
  4. تسجيل زمن الانتظار عند acquire كمقياس مستقل: ارتفاعه المستمر يعني أن الخطة صارت أضيق من الحِمل، لا أن الأرقام خاطئة.

النتيجة: موجة المساء تُعالَج بإيقاع ثابت خلال دقائق، بدل أن ترتد أخطاء تقييد وإعادة محاولة تضاعف الاستهلاك.

اربط الحدّ بعدد الـ threads في خطتك

الفوترة في CaptchaAI قائمة على عدد الـ threads المتزامنة لا على عدد عمليات الحل، وكل خطة تشمل عمليات حل غير محدودة لكل thread خلال الشهر. والأسعار أدناه بالدولار الأمريكي:

الخطة السعر الشهري عدد الـ threads
BASIC $15 5
ADVANCE $90 50
ENTERPRISE $300 200

القاعدة البسيطة: اجعل معدل إعادة التعبئة أقل من طاقة الخطة لا مساوياً لها. الـ thread يبقى مشغولاً حتى تكتمل العملية، فإذا أرسلت أسرع من وتيرة تحرره تتراكم الطلبات بدل أن تُنجز.

Token Bucket مقابل خوارزميات التحديد الأخرى

الخوارزمية السلوك الأنسب لـ
Token Bucket وتيرة منتظمة مع سماح بالاندفاع استدعاءات CAPTCHA API
Leaky Bucket معدل خرج ثابت بلا اندفاع حدود صارمة لا تحتمل أي زيادة لحظية
Fixed Window عدّ داخل نافذة زمنية ثابتة مع اندفاع عند الحواف عدّادات بسيطة
Sliding Window عدّ ضمن نافذة متحركة تطبيق دقيق للمعدل

يبقى Token Bucket الخيار الافتراضي الأنسب هنا لأن حِمل CAPTCHA متقطع بطبعه: أداة جمع البيانات تعثر على عشرين اختباراً في لحظة واحدة، ثم لا شيء لدقيقة كاملة.

أخطاء شائعة وكيف تعالجها

العَرَض السبب الأرجح الإجراء
استمرار ERROR_TOO_MUCH_REQUESTS رغم وجود حدّ معدل التعبئة أعلى مما تسمح به الخطة، أو أكثر من عملية تستخدم المفتاح نفسه اخفض المعدل واجمع كل المرسِلين خلف حدّ واحد مشترك
بطء ملحوظ في كل طلب الرموز نفدت والطلبات تنتظر التعبئة ارفع السعة لاستيعاب الاندفاع بدل رفع الوتيرة المستمرة
نمو استهلاك الذاكرة قائمة انتظار مفتوحة بلا سقف حدّد أقصى طول لقائمة الانتظار وارفض الفائض مبكراً
الحدّ لا يُطبَّق بين العمليات الحاوية تعيش في ذاكرة عملية واحدة فقط انقل العدّاد إلى Redis ليصبح الحدّ موزعاً

أسئلة شائعة

هل يغني ضبط Token Bucket عن ترقية الخطة؟

لا. الحدّ ينظّم الإيقاع ولا يزيد الطاقة. إذا ارتفع زمن الانتظار عند طلب الرمز وتأخرت قائمة العمل، فالمطلوب threads أكثر لا معدل أبطأ.

ما الفرق العملي بين Token Bucket و Leaky Bucket؟

Leaky Bucket يخرج الطلبات بمعدل ثابت مهما كان الوارد، فيسوّي الذروة تماماً ويؤخّر أول دفعة. Token Bucket يسمح بإنفاق الرصيد المتراكم دفعة واحدة، وهو ما يناسب الحِمل المتقطع لصفحات محمية بـ CAPTCHA.

كيف أطبّق الحدّ على أكثر من خادم؟

الحاوية أعلاه تعيش في ذاكرة العملية، فتحصل كل عملية على حدّها الخاص. لجعل الحدّ موزعاً، انقل العدّاد إلى Redis مع سكربت Lua ذري للخصم والتعبئة، ثم اقسم المعدل الكلي على عدد الخوادم.

هل أحدّ من الاستطلاع الدوري أيضاً؟

في الغالب لا. طلبات res.php خفيفة ويفصلها خمس ثوانٍ، فهي محدودة ذاتياً؛ وتقييدها يزيد وقت الحل الظاهر دون أن يخفّض الضغط الفعلي.


الخطوات التالية

التعليقات غير مفعّلة لهذا المقال.