Naar de inhoud
Terug naar al ons werk
Casestudy

8 Platforms, 2 Strategieën

Wanneer pak je de API, wanneer open je een browser?

Lessen uit het bouwen van een cross-platform social inbox met JSON APIs, AT Protocol en Playwright.

12 min leestijd|TypeScriptPlaywrightAT ProtocolREST APIsBrowser Automation
Social Inbox
12/13 online
Platforms
X
API2m
Instagram
API5m
Reddit
API12m
LinkedIn
Browser1h
TikTok
API8m
Bluesky
API3m
Threads
API15m
Hacker News
Hybrid8m
YouTube
API30m
Dev.to
Hybrid25m
Mastodon
API45m
Discord
Browser2h
IndieHackers
Browser4d
Queue
Redditr/nextjs
publishing next
Instagramcarousel
via Graph API
Blueskyreply
via AT Proto
LinkedIncomment
selector timeout, retry 8m
Engagement Inbox3 new
r/selfhosted·2m ago
92% match

Looking for self-hosted alternatives to Buffer that don't require a dozen OAuth tokens...

ApproveEditSkipvia JSON API
X @indiehackers·8m ago
88% match

Thread: The real cost of building on platform APIs that change every quarter

ApproveEditSkipvia v2 API
Dev.to #typescript·45m ago

Has anyone built cross-platform posting tools? Struggling with LinkedIn's API limitations

AI drafting response...via browser fallback
Published47
Approval rate94%
Avg response3.2m

Het probleem

Ieder platform wil je in zijn eigen app hebben. Sommige geven je een API. Sommige een kapotte API. Sommige helemaal niets.

Ik wilde op acht platforms monitoren, concepten schrijven en posten, allemaal vanuit één terminal. Geen SaaS-dashboard met OAuth-knoppen en een maandbedrag, maar een lokale CLI die op mijn eigen machine draait, sessies in mijn homedirectory bewaart en niks naar buiten stuurt.

Elk platform denkt anders over automatisering. Bluesky geeft je een compleet protocol (AT Protocol) en zegt: "bouw maar wat je wilt." Reddit biedt OAuth en een JSON API. Dev.to heeft een REST API die voor sommige dingen werkt en bij de rest niets doet, zonder ook maar een foutmelding. LinkedIn werkt automatisering op elke laag actief tegen. IndieHackers heeft simpelweg geen API.

Twee slechte opties. Alles via de browser: voor elke actie een Playwright-instantie starten, knoppen aanklikken, tekstvelden vullen. Dat werkt, maar het is traag, het breekt snel en platforms worden steeds beter in het herkennen ervan. Alles via API: snel en schoon, alleen biedt de helft van de platforms er geen, en wie er wel een heeft levert vaak een halve.

De derde optie, die ik uiteindelijk gebouwd heb, kiest per platform en per operatie. Sommige reads lopen via een JSON API. Sommige writes via de browser. En sommige platforms ondersteunen allebei de strategieën tegelijk, waarbij de keuze pas tijdens runtime valt.

De beslismatrix

Elke cel in deze tabel is een beslissing. Groen betekent dat een stabiele API het werk doet. Oranje betekent dat Playwright een browser opent. Rood betekent dat de API op papier bestaat, maar niet werkt.

PlatformMonitorPostComment
RedditJSON APIAPI / BrowserAPI / Browser
Hacker NewsFirebase APINoneBrowser
Dev.toREST APIREST APIBrowser *
BlueskyAT ProtocolAT ProtocolAT Protocol
MastodonREST APIREST APIREST API
LinkedInBrowserOAuth / BrowserBrowser
IndieHackersBrowserBrowserBrowser
DiscordBrowser + GQLBrowserBrowser
API Browser Hybrid Broken API

De criteria zijn simpel:

  1. 1Is er een stabiele, gedocumenteerde API? Die gebruiken.
  2. 2Bestaat de API wel, maar gaat hij stuk? De browser als fallback.
  3. 3Is er helemaal geen API? Dan blijft alleen de browser over.
  4. 4Speurt het platform actief naar automatisering? Vertragingen inbouwen en detectie ontwijken.

Eén interface, twee implementaties

De orchestrator weet niet of hij een API of een browser aanspreekt, en dat hoeft ook niet. Elke adapter implementeert hetzelfde contract:

TypeScript
export interface PlatformAdapter {
  readonly name: string;
  readonly capabilities: readonly Capability[];
  readonly authType: AuthType;

  post?(content: PostContent): Promise<PostResult>;
  comment?(target: ThreadTarget, content: string): Promise<CommentResult>;
  monitor?(input: MonitorInput): Promise<Opportunity[]>;
  healthCheck?(): Promise<HealthResult>;
}

Uit de praktijk: de dubbele modus van Reddit

Reddit is het duidelijkste voorbeeld, want het ondersteunt allebei de strategieën tegelijk.

De adapter heeft een mode-property: "api" of "browser". In API-modus gaan posten en reageren via Snoowrap, een OAuth-wrapper voor Reddit. In browser-modus stuurt Playwright hem naar old.reddit.com.

Waarom old.reddit.com? Het nieuwe design gebruikt onleesbaar gemaakte CSS-klassenamen die per deploy veranderen. Succes met een stabiele selector voor het reactieveld. Op old Reddit is dat textarea[name="title"], en die selector ligt al vijftien jaar vast.

TypeScript
public async post(content: PostContent): Promise<PostResult> {
  if (this.mode === "browser") {
    return this.postViaBrowser(content, subreddit, title);
  }
  return this.postViaApi(content, subreddit, title);
}

Vijf regels bepalen de hele postflow.

Beveiligingen die je met de API niet nodig hebt

Het browserpad heeft vangrails nodig die het API-pad kan missen. Twee in het bijzonder.

De self-reply guard. Voordat de adapter een reactie plaatst, kijkt hij of de ingelogde gebruiker niet zelf de auteur is. Zonder die check zit je vrolijk op je eigen bericht te reageren. Achteraf klinkt dat vanzelfsprekend. De eerste keer dat het gebeurde was het dat niet.

De duplicate guard scant de bestaande reacties in de thread op je eigen gebruikersnaam. Sta je er al? Overslaan. Die guards zijn nodig omdat browserautomatisering de impliciete beveiliging mist die een goed ontworpen API je geeft. Wie zelf op knoppen klikt, staat er alleen voor.

TypeScript
const loggedInUser = await page.locator(".user a").first().textContent();
const targetAuthor = await page.locator(".comment .author").first().textContent();

if (loggedInUser?.toLowerCase() === targetAuthor?.toLowerCase()) {
  return { success: false, error: "Self-reply blocked" };
}

Uit de praktijk: de vijandige DOM van LinkedIn

LinkedIn is het lastigste platform en de beste leermeester.

De eerste ontdekking kostte uren: de knop "Bericht" op een LinkedIn-profiel is geen <button>, maar een <a>-tag. Zoek je met page.getByRole('button', { name: 'Bericht' }), dan blijf je zoeken. De DOM trekt zich niets aan van jouw aannames.

Het tweede probleem: lokalisatie. Mijn LinkedIn staat in het Nederlands. De "Post"-knop heet daar "Plaatsen", "Next" heet "Volgende" en "Delete" heet "Verwijderen". Engelse labels hardcoden werkt dus niet.

TypeScript
const POST_BUTTON  = /Plaatsen|Post|Delen|Share/i;
const NEXT_BUTTON  = /Volgende|Next/i;
const DELETE_BTN   = /Verwijderen|Delete/i;

const EDITOR_SELECTOR =
  '[role="dialog"] [contenteditable="true"], .ql-editor';

Regex-patronen die elke taalinstelling overleven.

De regex-aanpak is niet ontworpen, die is om 2 uur 's nachts uit het debuggen gerold. Toch is het het juiste patroon: ik had er meteen mee moeten beginnen in plaats van er via een storing achter te komen.

In de feed van LinkedIn zit ook geen infinite scroll. Er zit een knop "Meer laden". Je klikt, wacht tot er nieuwe content verschijnt, controleert of het aantal posts echt is gegroeid en stopt als dat niet zo is. Handmatige paginering, in 2026.

Drie verschillende authenticatiestrategieën: een persistente browser-sessie voor de meeste acties, een OAuth-token voor posts op de bedrijfspagina, en een hybride variant waarbij reageren altijd via de browser gaat, ook als je dat token hebt.

Als de API liegt

Dev.to heeft een API-endpoint voor reacties. Het staat netjes in de documentatie. Het accepteert de juiste parameters. En het geeft een 404 terug. Elke keer. Al minstens sinds 2025.

Dus monitoren en posten lopen via de REST API, want dat gaat prima. Reageren gaat via de browser. De selector voor het tekstveld probeert vier varianten na elkaar, want zelfs de DOM in de browser blijft niet gelijk tussen deploys.

Andere platforms, andere lessen

  • IndieHackers draait op Ember.js. De fill() van Playwright triggert de data binding van Ember niet. Je hebt keyboard.type() nodig met een bewuste pauze tussen de aanslagen, anders laat het framework tekens vallen.
  • Bij Discord zit een willekeurige pauze van 2 tot 3,5 seconde tussen de acties, om botdetectie te ontlopen. Die pauzes zijn geen decoratie: laat je ze weg, dan zet Discord ongemerkt een rate limit op je sessie.
  • Upwork heeft helemaal geen publieke API. De adapter onderschept daarom GraphQL-responses in de netwerklaag: page.on('response') van Playwright vangt de JSON-payloads op die de frontend zelf ophaalt, zonder dat je ooit de DOM hoeft te parsen.
"Heeft een API" en "heeft een werkende API" zijn twee verschillende dingen. Je adapter moet overleven in het gat tussen wat een platform belooft en wat het levert.

Betrouwbaar maken

Het werkend krijgen is het makkelijke deel. Het om 3 uur 's nachts werkend houden zonder dat iemand meekijkt, dat is het echte werk.

Health tracking

Elk platform heeft een staleness-timer. Is er 72 uur lang geen actie geslaagd, dan gaat het platform op stale en slaat de preflight-check het over.

Exponential backoff

Mislukte queue-items krijgen drie nieuwe pogingen: na 10, na 20 en na 40 minuten. Daarna gaat de status naar permanently_failed en wacht het item op een mens.

Capability checking

Voordat adapter.comment() wordt aangeroepen, controleert de orchestrator hasCapability(adapter, 'comment'). Ontbreekt die capability, dan krijg je een duidelijke fout in plaats van een crash. Dat is het verschil tussen een systeem dat om 3 uur 's nachts netjes stopt en een systeem dat de hele run meesleurt.

TypeScript
const BACKOFF_BASE_MS = 5 * 60 * 1000; // 5 minutes

const newRetryCount = retryCount + 1;
const backoffMs = Math.pow(2, newRetryCount) * BACKOFF_BASE_MS;
const nextEligibleAt = new Date(
  now.getTime() + backoffMs
).toISOString();

// Retry 1: 10 min
// Retry 2: 20 min
// Retry 3: 40 min
// Then:    permanently_failed

Het retry-schema: opschalen, en dan eerlijk opgeven.

Wat ik anders zou doen

Begin API-first, browser als laatste redmiddel.

Ik heb een paar browser-adapters gebouwd zonder eerst te kijken of er een JSON API was. Reddit heeft een prima JSON API om mee te lezen: plak .json achter een willekeurige URL. Ik bouwde de monitoring eerst via de browser en vond de API pas daarna. Weggegooid werk.

Persistente sessies vanaf dag één.

Cookies in een lokale map bewaren en hergebruiken tussen runs scheelt 90% van het gedoe met inloggen. Eén keer handmatig inloggen, daarna pakt de adapter het op waar je gebleven was.

Ontwerp meteen voor meertalige DOMs.

De regex-aanpak voor LinkedIn, die Nederlandse en Engelse knoplabels tegelijk matcht, werkt goed. Alleen ben ik er via een storing achter gekomen in plaats van dat ik het zo bedacht had. Bouw je browserautomatisering voor een meertalig publiek, ga er dan van uit dat de DOM een taal spreekt waar jij niet over gaat.

Maak health checks verplicht.

Alleen Bluesky en Mastodon hebben healthCheck() in hun adapter zitten. De rest leunt op staleness-heuristiek. Een platform kan "vers" zijn, met een recente geslaagde actie, en toch een kapotte authenticatie hebben. Dat is een gat.

De les

De hybride aanpak is niet elegant. Het is een pragmatisch antwoord op een wereld waarin elk platform anders denkt over automatisering. Elk platform krijgt de integratiestrategie die het verdient, en niet een universele wrapper die overal half werkt.

Het oordeel van een engineer zit niet in de keuze tussen API of browser. Het zit in weten wanneer je omschakelt, waar je op moet letten en wat je doet als een platform van de ene op de andere dag de regels verandert.

Dit is het soort systems engineering dat we bij MAD IT doen: rommelige integraties uit de echte wereld die gewoon blijven werken.