doctolib-finder

Claude Skill

doctolib-finder

Find a doctor on Doctolib and check the soonest real appointment slots, filtered by specialty, location and conventionnement (secteur 1/2). Drives a visible Chrome via the DevTools Protocol so the user can solve any captcha by hand, then scrapes each result card (name, sector, address, distance, next availability) and ranks them. Trigger when the user wants to "trouver un médecin/dermato/etc. sur Doctolib", "find a doctor", "check Doctolib availability", "RDV secteur 1", or compare practitioners near a place.

0 files· 6.3 KB

Unzip into your Claude skills directory (e.g. ~/.claude/skills/) or your project's .claude/skills/ to use it.

SKILL.md

doctolib-finder — find a doctor + earliest slots on Doctolib

Goal: given a specialty, a location and a conventionnement preference, return a

ranked list of practitioners with their sector and next real availability, so the

user can book the soonest secteur-1 (or any) appointment.

Doctolib blocks headless bots and shows a DataDome captcha. The trick that works: drive a

visible Google Chrome and let the user solve the captcha by hand in the window. A

persistent profile keeps that trust across runs.

This skill is a single file. The entire program lives in the code block in §2 below.

There is no separate .js file and no npm install. To run it, write that block to a temp

file and execute it with Node.

0. Preflight

The script drives the installed Google Chrome over the DevTools Protocol (CDP) using only

Node built-ins (child_process + global fetch + global WebSocket).

node -v                         # need Node 22+ (global WebSocket)
ls "/Applications/Google Chrome.app" >/dev/null && echo "chrome ok"

1. Materialize the program, then run it

Write the §2 code block to a temp file and launch it with the user's flags:

mkdir -p /tmp/doctolib-finder
# (write the §2 block to /tmp/doctolib-finder/find-doctors.js — see below)
node /tmp/doctolib-finder/find-doctors.js \
  --specialty dermatologue \
  --where paris-75011 --where paris-75020 \
  --pages 3 --sector 1 --new-patients

The cleanest way to materialize it is a heredoc that copies the block verbatim:

cat > /tmp/doctolib-finder/find-doctors.js <<'DOCTOLIB_EOF'DOCTOLIB_EOF.

Flags:

  • --specialty Doctolib URL slug (dermatologue, medecin-generaliste, ophtalmologue, dentiste, gynecologue-medical, …). Default dermatologue.
  • --where Doctolib location slug used in the URL: paris-75011, paris-75020, paris-20e-arrondissement, lyon, or a metro paris-metro-voltaire. Repeatable to sweep several areas.
  • --pages Result pages per location (20 cards/page). Default 3.
  • --sector 1, 2, or any. Default any.
  • --new-patients Drop practitioners whose online booking is reserved to existing patients.
  • --headless No window. Only works once a captcha-trusted profile exists, and availability is less reliable (slot calendar often doesn't load → "dispo non lisible"). Use visible mode for accurate slots.

A visible Chrome opens on the first search page. The user accepts cookies + solves the

captcha once; the script waits up to 3 min for results, then scrapes silently across all

pages/locations. Output: a ranked table in the terminal + full JSON at

/tmp/doctolib-finder/results.json.

2. The program (self-contained, zero deps)

#!/usr/bin/env node
/**
 * doctolib-finder — find a doctor on Doctolib and rank by sector + soonest availability.
 * Self-contained: drives the installed Google Chrome via the DevTools Protocol (CDP) using
 * only Node built-ins (child_process + global fetch + global WebSocket). Requires Node 22+.
 */
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawn, execSync } = require('child_process');

const CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const PORT = 9333;
const PROFILE = path.join(os.homedir(), '.cache', 'doctolib-finder', 'profile');
const OUTDIR = '/tmp/doctolib-finder';

function parseArgs(argv) {
  const a = { specialty: 'dermatologue', where: [], pages: 3, sector: 'any', newPatients: false, headless: false };
  for (let i = 2; i < argv.length; i++) {
    const k = argv[i];
    if (k === '--specialty') a.specialty = argv[++i];
    else if (k === '--where') a.where.push(argv[++i]);
    else if (k === '--pages') a.pages = parseInt(argv[++i], 10) || 3;
    else if (k === '--sector') a.sector = String(argv[++i]);
    else if (k === '--new-patients') a.newPatients = true;
    else if (k === '--headless') a.headless = true;
  }
  if (a.where.length === 0) a.where = ['paris-75011'];
  return a;
}
const ARGS = parseArgs(process.argv);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

// ---------- minimal CDP client (built-ins only) ----------
class CDP {
  constructor(wsUrl) {
    this.ws = new WebSocket(wsUrl);
    this.id = 0;
    this.pending = new Map();
    this.ready = new Promise((res, rej) => {
      this.ws.addEventListener('open', () => res());
      this.ws.addEventListener('error', (e) => rej(new Error('ws error: ' + (e.message || 'unknown'))));
    });
    this.ws.addEventListener('message', (ev) => {
      let msg; try { msg = JSON.parse(ev.data); } catch { return; }
      if (msg.id && this.pending.has(msg.id)) {
        const { resolve, reject } = this.pending.get(msg.id);
        this.pending.delete(msg.id);
        if (msg.error) reject(new Error(msg.error.message)); else resolve(msg.result);
      }
    });
  }
  send(method, params = {}) {
    const id = ++this.id;
    return new Promise((resolve, reject) => {
      this.pending.set(id, { resolve, reject });
      this.ws.send(JSON.stringify({ id, method, params }));
      setTimeout(() => { if (this.pending.has(id)) { this.pending.delete(id); reject(new Error('CDP timeout: ' + method)); } }, 60000);
    });
  }
  async navigate(url) { await this.send('Page.navigate', { url }); }
  async eval(expression) {
    const r = await this.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
    return r && r.result ? r.result.value : undefined;
  }
  close() { try { this.ws.close(); } catch {} }
}

async function getJSON(url) { const res = await fetch(url); return res.json(); }

async function launchChrome() {
  fs.mkdirSync(PROFILE, { recursive: true });
  try { execSync(`pkill -f "doctolib-finder/profile" 2>/dev/null`); } catch {}
  for (const f of ['SingletonLock', 'SingletonCookie', 'SingletonSocket']) {
    try { fs.rmSync(path.join(PROFILE, f), { force: true }); } catch {}
  }
  await sleep(500);
  const args = [
    `--remote-debugging-port=${PORT}`,
    `--user-data-dir=${PROFILE}`,
    '--no-first-run', '--no-default-browser-check', '--disable-sync',
    '--disable-features=Translate,DialMediaRouteProvider',
    'about:blank',
  ];
  if (ARGS.headless) args.unshift('--headless=new');
  const child = spawn(CHROME, args, { stdio: 'ignore', detached: false });
  let wsUrl = null;
  for (let i = 0; i < 60; i++) {
    try {
      const list = await getJSON(`http://127.0.0.1:${PORT}/json`);
      const pageT = list.find((t) => t.type === 'page' && t.webSocketDebuggerUrl);
      if (pageT) { wsUrl = pageT.webSocketDebuggerUrl; break; }
    } catch {}
    await sleep(500);
  }
  if (!wsUrl) { try { child.kill(); } catch {} throw new Error('Chrome DevTools endpoint introuvable sur le port ' + PORT); }
  return { child, wsUrl };
}

// ---------- parsing ----------
function sectorOf(txt) {
  if (/Conventionné secteur 1 avec droit permanent à dépassement/i.test(txt)) return 'secteur 1 (DP)';
  if (/Conventionné secteur 1/i.test(txt)) return 'secteur 1';
  if (/Conventionné secteur 2/i.test(txt)) return 'secteur 2';
  if (/Non conventionné/i.test(txt)) return 'non conventionné';
  if (/Hôpital public|centre de santé|Institut /i.test(txt)) return 'secteur 1';
  return '?';
}
function availabilityOf(txt) {
  const times = txt.match(/\b([01]?\d|2[0-3]):[0-5]\d\b/g);
  if (times && times.length) return { kind: 'slots', label: 'CRÉNEAUX: ' + Array.from(new Set(times)).slice(0, 6).join(', '), rank: 0 };
  const prochain = txt.match(/Prochain RDV le\s+([^\n]+)/i);
  if (prochain) return { kind: 'next', label: 'Prochain RDV le ' + prochain[1].trim(), rank: 1 };
  if (/réserve la prise de rendez-vous en ligne aux patients déjà suivis/i.test(txt))
    return { kind: 'existing-only', label: 'en ligne réservé aux patients suivis → appeler', rank: 3 };
  if (/Aucune disponibilité en ligne/i.test(txt)) return { kind: 'none', label: 'aucune dispo en ligne → appeler', rank: 4 };
  return { kind: 'unknown', label: 'dispo non lisible', rank: 5 };
}
function parseCard(txt) {
  const lines = txt.split('\n').map((s) => s.trim()).filter(Boolean);
  const name = (lines[0] || '').replace(/,\s*$/, '');
  const distM = txt.match(/(\d+[\.,]\d+)\s*km/);
  const dist = distM ? parseFloat(distM[1].replace(',', '.')) : 999;
  const addr = lines.filter((l) =>
    /\b\d{5}\b/.test(l) || /\b(Rue|Avenue|Av\.|Bd|Boulevard|Place|Pl\.|Passage|Impasse|Quai|Allée|Cours|Chemin)\b/i.test(l)
  ).slice(0, 2).join(', ');
  const video = /Consultation vidéo/i.test(txt);
  const isCenter = /Hôpital public|centre de santé|Institut |dermatologues et vénérologues/i.test(txt);
  return { name, sector: sectorOf(txt), addr, dist, video, isCenter, avail: availabilityOf(txt) };
}

const EXTRACT_JS = `(async () => {
  for (let i = 0; i < 5; i++) { window.scrollBy(0, 1400); await new Promise(r => setTimeout(r, 900)); }
  const cards = Array.from(document.querySelectorAll('.dl-card')).map(e => (e.innerText || '').trim());
  return JSON.stringify(cards);
})()`;

(async () => {
  fs.mkdirSync(OUTDIR, { recursive: true });
  const { child, wsUrl } = await launchChrome();
  const cdp = new CDP(wsUrl);
  await cdp.ready;
  await cdp.send('Page.enable').catch(() => {});

  const all = [];
  let firstLoad = true;
  for (const where of ARGS.where) {
    for (let p = 1; p <= ARGS.pages; p++) {
      const url = `https://www.doctolib.fr/${ARGS.specialty}/${where}${p > 1 ? '?page=' + p : ''}`;
      await cdp.navigate(url);
      await sleep(2500);
      if (firstLoad) {
        let n = 0;
        try { n = await cdp.eval(`document.querySelectorAll('.dl-card').length`); } catch {}
        if (!n) {
          console.log('\n================ ACTION REQUISE ================');
          console.log('Une fenêtre Chrome est ouverte. Accepte les cookies et résous le captcha.');
          console.log('Le scraping démarre dès que les résultats s\'affichent (max 3 min).');
          console.log('================================================\n');
          for (let i = 0; i < 180 && !n; i++) { await sleep(1000); try { n = await cdp.eval(`document.querySelectorAll('.dl-card').length`); } catch {} }
        }
        firstLoad = false;
      }
      let cards = [];
      try { cards = JSON.parse(await cdp.eval(EXTRACT_JS) || '[]'); } catch {}
      let n = 0;
      for (const c of cards) {
        if (!c || c.length < 30) continue;
        if (!/secteur|disponibilité|Hôpital|centre de santé|RDV|vénérologue|généraliste|Dermatolog/i.test(c)) continue;
        const parsed = parseCard(c);
        if (!parsed.name || /^Centre d'aide|^Doctolib$/i.test(parsed.name)) continue;
        all.push({ where, page: p, ...parsed });
        n++;
      }
      console.log(`${ARGS.specialty} / ${where} p${p}: ${n} fiches`);
    }
  }

  const seen = new Set();
  let rows = [];
  for (const r of all) { const k = r.name + '|' + r.addr; if (seen.has(k)) continue; seen.add(k); rows.push(r); }
  if (ARGS.sector === '1') rows = rows.filter((r) => r.sector === 'secteur 1' || r.sector === 'secteur 1 (DP)');
  else if (ARGS.sector === '2') rows = rows.filter((r) => r.sector === 'secteur 2');
  if (ARGS.newPatients) rows = rows.filter((r) => r.avail.kind !== 'existing-only');
  const secScore = (s) => (s === 'secteur 1' ? 0 : s === 'secteur 1 (DP)' ? 1 : s === 'secteur 2' ? 2 : 3);
  rows.sort((a, b) => (a.avail.rank - b.avail.rank) || (secScore(a.sector) - secScore(b.sector)) || (a.dist - b.dist));

  fs.writeFileSync(path.join(OUTDIR, 'results.json'), JSON.stringify(rows, null, 2));
  console.log('\n================= RÉSULTATS (' + rows.length + ') =================');
  for (const r of rows) {
    const tags = [r.sector, r.video ? 'vidéo' : null, r.isCenter ? 'centre/hôpital' : null].filter(Boolean).join(' · ');
    const km = r.dist < 900 ? `${r.dist} km` : '';
    console.log(`\n• ${r.name}  [${tags}]  ${km}`);
    if (r.addr) console.log(`  ${r.addr}`);
    console.log(`  → ${r.avail.label}`);
  }
  console.log('\nJSON complet: ' + path.join(OUTDIR, 'results.json'));

  if (!ARGS.headless) await sleep(120000);
  cdp.close();
  try { child.kill(); } catch {}
})().catch((e) => { console.log('FATAL ' + e.message); process.exit(1); });

3. Read the ranking

Ranked by: sector match → has concrete time slots → soonest "Prochain RDV le" → distance.

Availability is one of:

  • CRÉNEAUX: 10:00, 10:15 — bookable slots in the visible week (soonest, book now)
  • Prochain RDV le 22 juin 2026 — next slot beyond the visible week
  • réservé aux patients suivis — online booking blocked for new patients → call
  • aucune dispo en ligne → call the office

4. Conventionnement (important)

  • secteur 1 — no extra fees. What most users mean by "conventionné secteur 1".
  • secteur 1 (DP) — secteur 1 with permanent right to dépassement: can still charge extra. Flag it; not zero-dépassement.
  • secteur 2 — free fees / dépassements.
  • Health centers (centre de santé, Institut …) and public hospitals (AP-HP) are secteur 1 by construction — good fallback when private secteur-1 docs have no online slots. Their agenda is often reserved to existing patients → call.

5. Book

Open the chosen practitioner's profile in the still-open Chrome (or print the Doctolib URL),

let the user select the motif and confirm. Many strong secteur-1 options (hospital services,

centres de santé, some private docs) are phone-only — surface the office phone number too.

Troubleshooting

  • 0 cards / "existing browser session" — a stray Chrome holds the profile. The script

auto-kills it and clears SingletonLock; if it persists:

pkill -f "doctolib-finder/profile"; rm -f ~/.cache/doctolib-finder/profile/Singleton* then re-run.

  • Captcha loops — solve it slowly in the visible window; the persistent profile remembers

the DataDome token, so later runs (even --headless) usually skip it.

  • DOM changed — result cards are .dl-card; the card's innerText carries name, address,

Conventionné secteur X, time slots and Prochain RDV le …. If counts hit 0 on a loaded

page, dump a card's innerText and adjust the parser.

Notes

  • Legitimate personal use: searching public Doctolib listings for one's own appointment. The

script enters no credentials; the user does any login/booking by hand.

  • Profile (cookies, captcha trust) is stored at ~/.cache/doctolib-finder/profile.
readers loved this