Tutorials

Notion API + CaptchaAI : saisie automatisée des données avec gestion des CAPTCHA

Une base Notion fait une très bonne file d'attente : chaque ligne porte une URL, un sitekey et un statut, et l'API Notion sait la lire puis la réécrire en deux appels HTTP. Il ne manque qu'une brique — la résolution du défi CAPTCHA. C'est le rôle de CaptchaAI ici : un worker Python (ou Node.js) lit les lignes « Pending », envoie le sitekey à l'API, attend le token reCAPTCHA v2 et le réécrit dans la fiche. Vos opérateurs data ne quittent jamais Notion.

Le cas d'usage : Notion comme file de résolution

Une équipe maintient dans Notion une liste d'URL à collecter régulièrement ; certaines affichent un reCAPTCHA v2. Le worker tourne toutes les heures et enchaîne trois opérations :

  1. Il lit les tâches au statut « Pending ».
  2. Il envoie chaque défi CAPTCHA à CaptchaAI et interroge le résultat.
  3. Il réécrit dans la fiche le token, l'horodatage et le statut final.

Tout le monde voit les tâches passer de « Pending » à « Solving » puis à « Solved », et la colonne Error dit ce qui a échoué.

Ce qu'il faut avant de commencer

  • Une intégration interne créée depuis developers.notion.com, avec son token secret copié depuis notion.so/my-integrations
  • Une base Notion partagée avec cette intégration (l'oubli le plus fréquent)
  • Une clé API CaptchaAI
  • Python 3.8+ ou Node.js 18+

Les sept propriétés à créer dans la base

Gardez les noms en anglais : le script les cherche au caractère près, et l'API Notion est sensible à la casse.

Propriété Type Notion Rôle
Name Title Identifiant de la tâche
URL URL Page cible avec CAPTCHA
Sitekey Rich text Sitekey reCAPTCHA de la page
Status Select Pending, Solving, Solved, Failed
Token Rich text Token renvoyé par CaptchaAI
Solved At Date Horodatage de la résolution
Error Rich text Message d'erreur éventuel

Partagez ensuite la base avec l'intégration (menu « … », puis « Ajouter des connexions »). Sans ce partage, l'API renvoie 401 même avec un token valide.

Étape 1 : le worker Python

Le découpage compte : get_pending_tasks() interroge la base, solve_captcha() envoie la tâche sur in.php puis interroge res.php jusqu'au token, et set_status() réécrit la fiche. Vous pouvez remplacer Notion par une autre source sans toucher à la résolution.

# notion_captcha_worker.py
import os
import time
import requests

NOTION_TOKEN = os.environ.get("NOTION_TOKEN")
NOTION_DB_ID = os.environ.get("NOTION_DB_ID")
CAPTCHAAI_KEY = os.environ.get("CAPTCHAAI_KEY", "YOUR_API_KEY")

NOTION_HEADERS = {
    "Authorization": f"Bearer {NOTION_TOKEN}",
    "Content-Type": "application/json",
    "Notion-Version": "2022-06-28",
}

def get_pending_tasks():
    """Fetch tasks with Status = Pending from Notion."""
    url = f"https://api.notion.com/v1/databases/{NOTION_DB_ID}/query"
    payload = {
        "filter": {
            "property": "Status",
            "select": {"equals": "Pending"},
        }
    }
    resp = requests.post(url, headers=NOTION_HEADERS, json=payload)
    resp.raise_for_status()
    return resp.json()["results"]

def update_task(page_id, properties):
    """Update a Notion page with new property values."""
    url = f"https://api.notion.com/v1/pages/{page_id}"
    payload = {"properties": properties}
    resp = requests.patch(url, headers=NOTION_HEADERS, json=payload)
    resp.raise_for_status()

def set_status(page_id, status, token=None, error=None):
    """Update task status in Notion."""
    props = {"Status": {"select": {"name": status}}}

    if token:
        props["Token"] = {"rich_text": [{"text": {"content": token[:2000]}}]}
        props["Solved At"] = {"date": {"start": time.strftime("%Y-%m-%dT%H:%M:%S")}}

    if error:
        props["Error"] = {"rich_text": [{"text": {"content": error[:200]}}]}

    update_task(page_id, props)

def solve_captcha(sitekey, pageurl):
    """Submit to CaptchaAI and poll for result."""
    # Submit
    resp = requests.get("https://ocr.captchaai.com/in.php", params={
        "key": CAPTCHAAI_KEY,
        "method": "userrecaptcha",
        "googlekey": sitekey,
        "pageurl": pageurl,
        "json": "1",
    })
    result = resp.json()

    if result.get("status") != 1:
        raise Exception(f"Submit failed: {result.get('request')}")

    task_id = result["request"]

    # Poll
    time.sleep(15)
    for _ in range(25):
        poll = requests.get("https://ocr.captchaai.com/res.php", params={
            "key": CAPTCHAAI_KEY,
            "action": "get",
            "id": task_id,
            "json": "1",
        })
        poll_result = poll.json()

        if poll_result.get("status") == 1:
            return poll_result["request"]
        if poll_result.get("request") != "CAPCHA_NOT_READY":
            raise Exception(f"Solve failed: {poll_result.get('request')}")

        time.sleep(5)

    raise Exception("Polling timeout")

def extract_property(page, prop_name, prop_type="rich_text"):
    """Extract a property value from a Notion page."""
    prop = page["properties"].get(prop_name, {})
    if prop_type == "rich_text":
        texts = prop.get("rich_text", [])
        return texts[0]["plain_text"] if texts else ""
    elif prop_type == "url":
        return prop.get("url", "")
    return ""

def main():
    tasks = get_pending_tasks()
    print(f"Found {len(tasks)} pending tasks")

    for task in tasks:
        page_id = task["id"]
        sitekey = extract_property(task, "Sitekey")
        pageurl = extract_property(task, "URL", "url")

        if not sitekey or not pageurl:
            set_status(page_id, "Failed", error="Missing sitekey or URL")
            continue

        print(f"Solving: {pageurl}")
        set_status(page_id, "Solving")

        try:
            token = solve_captcha(sitekey, pageurl)
            set_status(page_id, "Solved", token=token)
            print(f"  Solved successfully")
        except Exception as e:
            set_status(page_id, "Failed", error=str(e))
            print(f"  Failed: {e}")

        time.sleep(1)  # Rate limit for Notion API

    print("All tasks processed")

if __name__ == "__main__":
    main()

Deux détails comptent. L'attente de 15 secondes avant le premier polling évite une réponse CAPCHA_NOT_READY inutile, et les 25 tentatives espacées de 5 secondes couvrent le plafond annoncé pour reCAPTCHA v2, moins de 60 s. Le time.sleep(1) final protège votre quota Notion.

Étape 2 : la même boucle en Node.js

Si votre stack est déjà en JavaScript, le SDK @notionhq/client évite d'écrire les en-têtes à la main. La partie CaptchaAI reste un appel HTTP avec axios : envoi, attente, interrogation du résultat.

// notion_captcha_worker.js
const { Client } = require('@notionhq/client');
const axios = require('axios');

const notion = new Client({ auth: process.env.NOTION_TOKEN });
const DB_ID = process.env.NOTION_DB_ID;
const API_KEY = process.env.CAPTCHAAI_KEY || 'YOUR_API_KEY';

async function getPendingTasks() {
  const response = await notion.databases.query({
    database_id: DB_ID,
    filter: { property: 'Status', select: { equals: 'Pending' } },
  });
  return response.results;
}

async function updateTask(pageId, status, token, error) {
  const properties = {
    Status: { select: { name: status } },
  };
  if (token) {
    properties.Token = { rich_text: [{ text: { content: token.slice(0, 2000) } }] };
    properties['Solved At'] = { date: { start: new Date().toISOString() } };
  }
  if (error) {
    properties.Error = { rich_text: [{ text: { content: error.slice(0, 200) } }] };
  }
  await notion.pages.update({ page_id: pageId, properties });
}

async function solveCaptcha(sitekey, pageurl) {
  const submit = await axios.get('https://ocr.captchaai.com/in.php', {
    params: {
      key: API_KEY, method: 'userrecaptcha',
      googlekey: sitekey, pageurl, json: '1',
    },
  });
  if (submit.data.status !== 1) throw new Error(submit.data.request);

  await new Promise(r => setTimeout(r, 15000));

  for (let i = 0; i < 25; i++) {
    const poll = await axios.get('https://ocr.captchaai.com/res.php', {
      params: { key: API_KEY, action: 'get', id: submit.data.request, json: '1' },
    });
    if (poll.data.status === 1) return poll.data.request;
    if (poll.data.request !== 'CAPCHA_NOT_READY') throw new Error(poll.data.request);
    await new Promise(r => setTimeout(r, 5000));
  }
  throw new Error('Timeout');
}

async function main() {
  const tasks = await getPendingTasks();
  console.log(`Found ${tasks.length} pending tasks`);

  for (const task of tasks) {
    const sitekey = task.properties.Sitekey?.rich_text?.[0]?.plain_text;
    const pageurl = task.properties.URL?.url;

    if (!sitekey || !pageurl) {
      await updateTask(task.id, 'Failed', null, 'Missing sitekey or URL');
      continue;
    }

    console.log(`Solving: ${pageurl}`);
    await updateTask(task.id, 'Solving');

    try {
      const token = await solveCaptcha(sitekey, pageurl);
      await updateTask(task.id, 'Solved', token);
      console.log('  Solved');
    } catch (e) {
      await updateTask(task.id, 'Failed', null, e.message);
      console.log(`  Failed: ${e.message}`);
    }

    await new Promise(r => setTimeout(r, 1000));
  }
}

main().catch(console.error);

Faire tourner le worker : cron, OVHcloud ou Scaleway

Où l'héberger

Ce worker passe l'essentiel de son temps à attendre : une petite instance OVHcloud ou Scaleway suffit, ou une VM en région eu-west-3 (Paris) si vos cibles sont européennes.

Planifier les passages

Une ligne */15 * * * * dans la crontab le lance quatre fois par heure. Prévoyez un verrou contre les exécutions qui se chevauchent, et un plafond de lignes par passage.

Combien de threads pour votre volume ?

CaptchaAI facture des threads simultanés, pas des résolutions : chaque formule inclut un nombre de résolutions illimité par thread. Un thread, c'est un CAPTCHA en cours ; dès qu'il se termine, il reprend la tâche suivante.

  • BASIC ($15/mois, 5 threads) : une file qui se remplit par lots quotidiens.
  • STANDARD ($30/mois, 15 threads) : plusieurs bases Notion sur le même worker.
  • ADVANCE ($90/mois, 50 threads) : des files alimentées en continu.

Le script ci-dessus est séquentiel : il n'occupe qu'un thread. Pour exploiter les cinq threads de BASIC, traitez les tâches dans un pool (ThreadPoolExecutor, ou Promise.all par lots), en gardant les écritures Notion sérialisées.

RGPD : ce que vous écrivez dans la base

La base est lisible par tout l'espace de travail, et l'historique conserve les valeurs supprimées. Deux réflexes :

  • Pas de données personnelles dans URL, Sitekey ou Error : un message d'erreur brut peut embarquer une URL à paramètres sensibles. Le script le tronque à 200 caractères, gardez ce garde-fou.
  • Purgez Token une fois la tâche consommée : la minimisation des données est la base du RGPD.

La clé API CaptchaAI, elle, reste dans une variable d'environnement.

Dépannage

Problème Cause probable Correctif
401 Unauthorized (Notion) Base non partagée avec l'intégration Menu « … » → « Ajouter des connexions »
Propriété introuvable Noms de propriétés sensibles à la casse Sitekey n'est pas sitekey : alignez la base sur le script
Token tronqué Limite de 2 000 caractères par bloc rich_text Les tokens font moins de 1 000 caractères ; le découpage suffit
429 Too Many Requests Appels trop rapprochés à l'API Notion Gardez le délai d'une seconde entre deux mises à jour

FAQ

Combien de threads faut-il pour vider une file de 500 lignes ?

Cela dépend de votre fenêtre. Le plafond annoncé pour reCAPTCHA v2 est de moins de 60 s, et les 5 threads de BASIC ($15/mois) avancent cinq résolutions en parallèle. Pour viser moins d'une heure, passez sur STANDARD ($30/mois, 15 threads) et parallélisez.

CaptchaAI peut-il traiter hCaptcha depuis la même base Notion ?

Non — hCaptcha n'est pas pris en charge, pas plus que FunCaptcha (Arkose Labs). Les types disponibles sont reCAPTCHA v2 et v3, Cloudflare Turnstile, Cloudflare Challenge, GeeTest v3 et les CAPTCHA image ou OCR ; GeeTest v4 est annoncé à venir. Pour en couvrir plusieurs, ajoutez une propriété « CAPTCHA Type » et branchez la méthode voulue.

Le token stocké dans Notion reste-t-il utilisable plus tard ?

Non. Un token reCAPTCHA v2 expire en quelques minutes : il doit être consommé par la soumission du formulaire pendant la même exécution. La colonne Token sert à la traçabilité, pas de réserve pour le lendemain.

Que se passe-t-il si deux workers lisent la même ligne ?

Les deux résolvent le même défi et occupent deux threads pour rien. Basculez le statut sur « Solving » dès la lecture — c'est ce que fait set_status(page_id, "Solving") — et filtrez sur « Pending » à chaque requête. Sur plusieurs machines, ajoutez une propriété « Worker ».

Articles connexes

Prochaines étapes

Récupérez votre clé API CaptchaAI, lancez le worker sur une seule ligne, puis ouvrez les vannes.

Guides associés :

Les commentaires sont désactivés pour cet article.