· Webentwicklung · 5 minuten Lesezeit
Instagram Caption mit MutationObserver vollständig laden
Instagram kürzt Captions ab und Klassennamen wechseln bei jedem Deploy. Semantische DOM-Suche und ein MutationObserver laden den vollständigen Text trotzdem zuverlässig.

Inhalt
- Das Problem mit langen Captions
- Den Button semantisch finden
- Klicken und beobachten
- Die async-Kette und die Chrome-Eigenheit
- Der Regression-Bug: Container-Traversal
- Was das liefert
- Alle Artikel der Serie
Das Problem mit langen Captions
Die Chrome Extension erfasst Instagram-Posts und speichert sie in einer Vektordatenbank. Zu jedem Post gehört die Caption, also der Text unterhalb des Bildes. Kurze Captions sind kein Problem. Aber Instagram kürzt lange Texte ab. Der Rest steckt hinter einem Button, der im DOM steht und nur darauf wartet, geklickt zu werden.
Das klingt einfach. Finde den Button, klick ihn, lies den Text.
Der Haken: Kein stabiler Selektor.
Instagram baut seine UI mit automatisch generierten Classnames auf. xhtzj3i, _aacl, x1lliihq. Jeder Deploy kann andere Namen erzeugen. Wer auf Classnames zielt, kämpft gegen eine Infrastruktur, die er nicht kontrolliert.
Die Extension folgt deshalb einem Prinzip: semantische Suche statt Klassennamen. Das hält den Code stabil, auch wenn Instagram sich ändert.
Den Button semantisch finden
Der „mehr”-Button hat in Instagram eine bestimmte Rolle und ein bestimmtes Verhalten. Er ist ein Inline-Element, das direkt nach den sichtbaren Textzeilen im DOM erscheint. Er trägt role="button" und hat ein display: inline-block in seinem Inline-Style-Attribut.
Das wichtigste Merkmal: Sein direktes Elternelement enthält einen Textknoten, der mit "..." oder dem Unicode-Ellipsis "\u2026" beginnt.
function findExpandButton(container: Element): Element | null {
const candidates = container.querySelectorAll('[role="button"]');
for (const el of candidates) {
const style = el.getAttribute('style') ?? '';
if (!style.includes('display: inline-block')) continue;
const parent = el.parentElement;
if (!parent) continue;
const textBefore = Array.from(parent.childNodes).find((n) => n.nodeType === Node.TEXT_NODE);
if (
textBefore?.textContent?.trimStart().startsWith('...') ||
textBefore?.textContent?.trimStart().startsWith('\u2026')
) {
return el;
}
}
return null;
}Kein einziger Classname. Nur Struktur, Rolle und Textinhalt. Das ist der Kern des Ansatzes: Die Frage ist nicht „Wie heißt der Button im CSS?”, sondern „Was macht der Button in der UI?”.
Abbildung: DOM-Struktur einer Instagram-Caption. Der Text endet mit ”…”, direkt danach liegt das [role=“button”]-Element. Die Suche orientiert sich ausschließlich an Struktur und Rolle, nicht an Klassennamen.
Klicken und beobachten
Wenn der Button gefunden ist, wird er per JavaScript geklickt. Dann passiert etwas im DOM: Instagram lädt den vollständigen Text nach, ersetzt oder erweitert den Inhalt, und der Button verschwindet.
Ich brauche einen Mechanismus, der wartet, bis das passiert ist.
MutationObserver ist dafür gemacht. Er beobachtet einen DOM-Knoten auf Veränderungen und gibt Bescheid, wenn Kinder hinzukommen oder entfernt werden.
async function expandCaptionIfTruncated(container: Element): Promise<void> {
const button = findExpandButton(container);
if (!button) return;
return new Promise((resolve) => {
const observer = new MutationObserver(() => {
if (!container.contains(button)) {
observer.disconnect();
resolve();
}
});
observer.observe(container, { childList: true, subtree: true });
(button as HTMLElement).click();
setTimeout(() => {
observer.disconnect();
resolve();
}, 2000);
});
}Die Logik: Beobachte den Container auf Änderungen in seinen Kindknoten. Sobald der Button nicht mehr im Container enthalten ist, ist die Expansion abgeschlossen. Der setTimeout nach 2 Sekunden ist ein Fallback, der verhindert, dass die Funktion bei unerwarteten DOM-Strukturen dauerhaft hängt.
Die async-Kette und die Chrome-Eigenheit
Die Expansion ist async. Das zieht sich durch die gesamte Extraktionskette: expandCaptionIfTruncated → extractCaption → extractInstagramPostMetadata. Alle drei sind jetzt async.
Abbildung: Die async-Aufrufkette vom Message Handler bis zur fertigen Caption. Jede Stufe muss await nutzen und der Chrome Message Handler muss return true zurückgeben, damit der Kanal offen bleibt.
Das hat eine wichtige Konsequenz im Chrome Message Handler. Chrome erlaubt es, asynchron auf Nachrichten zu antworten, aber nur wenn der Message Handler true zurückgibt. Das signalisiert Chrome, dass der Response-Kanal offen bleiben soll.
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message.action === 'get-instagram-metadata') {
extractInstagramPostMetadata(capturedElement)
.then((metadata) => sendResponse({ metadata }))
.catch(() => sendResponse({ metadata: null }));
return true; // ← this return value keeps the channel open
}
});Vergisst man das return true, schließt Chrome den Kanal sofort. sendResponse kommt zu spät, die Antwort geht verloren, das Background-Script bekommt undefined.
Der Regression-Bug: Container-Traversal
Beim Implementieren der Caption-Expansion entstand eine Regression. Ich hatte die Traversal-Logik in eine Hilfsfunktion findCaptionContainer ausgelagert, um den Code übersichtlicher zu machen.
Das Problem: Die ursprüngliche Logik läuft den DOM aufwärts und probiert alle passenden Container aus. Wenn der erste keinen Caption-Text lieferte, wurde der nächste versucht.
Die Hilfsfunktion gab nur den ersten passenden Container zurück. Schon bei der Rückkehr.
// Before refactoring: full traversal
function extractCaption(element: Element): string | null {
let current: Element | null = element;
while (current) {
if (isTargetContainer(current)) {
const text = tryExtractFromContainer(current);
if (text) return text; // keep trying if null
}
current = current.parentElement;
}
return null;
}
// After refactoring: stops at the first container
function findCaptionContainer(element: Element): Element | null {
let current: Element | null = element;
while (current) {
if (isTargetContainer(current)) return current; // ← too early
current = current.parentElement;
}
return null;
}Das Ergebnis: Bei Posts, wo der erste passende Container keinen lesbaren Text enthielt, lieferte die Extraktion null. Immer. Zuverlässig.
Die Lösung war, die Hilfsfunktion zu entfernen und die volle Schleife direkt in extractCaption einzubauen. Diesmal als async mit dem expandCaptionIfTruncated-Schritt davor.
Was das liefert
Der vollständige Text einer Caption, egal wie lang sie ist. Kein Classname-Hardcoding. Kein mancher-Variante-Timeout-Polling. Ein sauberes Observer-Pattern, das genau einmal feuert und sich selbst aufräumt.
Für die RAG-Pipeline ist das direkt relevant: Je vollständiger der Text im Vektorspeicher, desto besser die semantische Retrieval-Qualität. Abgeschnittene Captions erzeugen schlechtere Embeddings.
Alle Artikel der Serie
- Vision und Systemübersicht: Chrome Extension, RAG-Architektur, Projekthintergrund: Artikel lesen
- RAG-System Aufbau: Qdrant, Embeddings, Cosine-Ähnlichkeit in TypeScript: Artikel lesen
- AI Provider Abstraktion: Ollama vs. OpenAI, Interface-Design, kein Vendor-Lock-in: Artikel lesen
- Chrome Extension MV3: Drei isolierte Laufzeitkontexte, Message Passing, Strategy Pattern: Artikel lesen
- Docker Compose Strategie: Override-Pattern, von lokal zu Azure: Artikel lesen
- Ollama lokal vs. Docker: Die Entscheidung und ihre Konsequenzen: Artikel lesen
- Ollama Auto-Pull Entrypoint: Automatisiertes Modell-Setup beim Container-Start: Artikel lesen
- tsconfig und Vite:
Node16vs.bundler, warum Vite eigene Regeln hat: Artikel lesen - Instagram Caption mit MutationObserver vollständig laden (dieser Artikel)
- Chrome Extension Foundation mit Health-Dot und Retry-Queue: Artikel lesen
- Phase 2 Features: Shadow DOM Overlay, Tailwind v4, Duplicate Detection: Artikel lesen
- Race Condition bei der Plattformerkennung: Wie ein UI-Event die Instagram-Erkennung bricht: Artikel lesen
- PostId-Extraktion in zwei Instagram-Layouts: querySelector vs. Ancestor-Traversal: Artikel lesen
Du baust eine Browser-Extension oder ein Daten-Erfassungssystem mit komplexer DOM-Interaktion? Lass uns das gemeinsam einschätzen.



