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 :
- Il lit les tâches au statut « Pending ».
- Il envoie chaque défi CAPTCHA à CaptchaAI et interroge le résultat.
- 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,SitekeyouError: un message d'erreur brut peut embarquer une URL à paramètres sensibles. Le script le tronque à 200 caractères, gardez ce garde-fou. - Purgez
Tokenune 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
- Résoudre le callback reCAPTCHA v2 via l'API
- reCAPTCHA v2 et Turnstile sur le même site
- Comprendre le mécanisme de callback reCAPTCHA v2
Prochaines étapes
Récupérez votre clé API CaptchaAI, lancez le worker sur une seule ligne, puis ouvrez les vannes.
Guides associés :