Neu veröffentlicht: E-Commerce mit Power Pages, Stripe & Analytics

· Webentwicklung  · 7 minuten Lesezeit

Instagram PostId aus Feed und Kanal-Grid korrekt extrahieren

Instagram nutzt im Feed article-Tags, im Kanal-Grid nicht. ancestor traversal löst falsche PostIds, eine Promise-Map verhindert doppelte Backend-Requests.

Instagram nutzt im Feed article-Tags, im Kanal-Grid nicht. ancestor traversal löst falsche PostIds, eine Promise-Map verhindert doppelte Backend-Requests.

Inhalt

Das Problem mit zwei Instagram-Layouts

Instagram rendert Posts nicht überall gleich. Auf der Startseite und im Feed ist jeder Post in einem <article>-Element eingebettet. Auf einer Kanal-Profilseite, also wenn du ein bestimmtes Konto aufrufst, erscheinen die Posts als Grid-Thumbnails ohne <article>.

Das ist kein Edge Case. Das ist eine fundamentale Layoutentscheidung von Instagram, und sie hat direkte Konsequenzen für jede Extension, die mit PostIds arbeitet.

Gegenüberstellung Feed-Layout vs. Kanal-Grid-Layout auf Instagram Abbildung: Die zwei Instagram-Layouts im Vergleich. Links der Feed mit article-Wrapper um jeden einzelnen Post. Rechts das Kanal-Grid ohne article, nur verschachtelte Divs mit einem a-Tag als äußerem Wrapper.

Die Duplikatserkennung aus Phase 2 basiert darauf, beim Hover eine PostId aus dem DOM zu extrahieren und das Backend zu fragen, ob dieser Post bereits gespeichert ist. Auf der Feed-Seite hat das funktioniert. Auf Kanal-Profilseiten zeigte die Extension immer eine rote Umrandung, egal ob der Post neu war oder nicht.

Warum querySelector im falschen Container gefährlich ist

Die ursprüngliche Extraktion war so aufgebaut:

const article = findArticleElement(element);
const postId = article ? quickExtractPostId(article) : null;

findArticleElement traversiert vom gerade gehoverten Element aufwärts und sucht ein <article>-Tag. Auf der Feed-Seite findet es eins. Auf der Kanal-Profilseite nicht.

Der Fehler war, dass bei fehlendem <article> einfach null zurückgegeben wurde. Kein <article>, keine PostId, rote Umrandung. Technisch korrekt, aber nicht das Ziel.

Nach der ersten Behebung entstand ein subtileres Problem. Ich hatte quickExtractPostId so geändert, dass es auch ohne <article> funktioniert, indem querySelector auf dem gerade gehoverten Element aufgerufen wird.

Das führte zu doppelten Backend-Calls mit zwei verschiedenen PostIds.

Der Grund: querySelector durchsucht alle Nachkommen. Wenn event.target ein Container-Div ist, der mehrere Post-Zellen umschließt, findet querySelector('a[href*="/p/"]') den ersten <a>-Link in diesem gesamten Container. Der gehört möglicherweise einem anderen Post als dem, über dem die Maus gerade liegt.

Ein Beispiel: Der Cursor ist über einem äußeren Wrapper-Div. querySelector läuft durch alle Kinder dieses Divs und gibt den Link von Post Nummer 3 zurück, obwohl der Cursor über Post Nummer 7 liegt.

Die zwei Strategien

Die saubere Lösung ist eine klare Trennung nach Layout.

Strategie 1 für Feed/Landing (quickExtractPostId)

Hier ist querySelector sicher, weil jedes <article> genau einen Post umschließt. Ein descendant-Search innerhalb eines <article> kann niemals einen Link eines anderen Posts zurückgeben.

const quickExtractPostId = (article: HTMLElement): string | null => {
  const link =
    article.querySelector<HTMLAnchorElement>('a[href*="/p/"]') ||
    article.querySelector<HTMLAnchorElement>('a[href*="/reel/"]');
  if (!link) return null;
  const match = link.getAttribute('href')?.match(/\/(p|reel)\/([^/]+)/);
  return match ? match[2] : null;
};

Aufgerufen mit dem gefundenen <article>. Sicher. Deterministisch.

Strategie 2 für Kanal-Grid (quickExtractPostIdFromAncestor)

Auf der Kanal-Profilseite sieht die DOM-Struktur so aus:

<div>
  <!-- outer wrapper -->
  <a href="/businessinsider/p/DYck-9TFw40/">
    <!-- ← PostId is here -->
    <div>
      <div><img /></div>
      <!-- ← hovered element -->
      <div></div>
      <!-- empty div -->
    </div>
    <div></div>
    <!-- empty div -->
  </a>
</div>

Das gehoverte Element ist das <img>. Der <a>-Link mit der PostId ist ein Vorfahren-Element, kein Nachfahre. querySelector schaut nach unten. Hier muss nach oben traversiert werden.

const quickExtractPostIdFromAncestor = (element: HTMLElement): string | null => {
  let current: HTMLElement | null = element;
  while (current && current !== document.body) {
    if (current.tagName.toLowerCase() === 'a') {
      const href = current.getAttribute('href') ?? '';
      const match = href.match(/\/(p|reel)\/([^/]+)/);
      if (match) return match[2];
    }
    // Empty divs mark the boundary between grid cells
    if (
      current !== element &&
      current.tagName.toLowerCase() === 'div' &&
      current.children.length === 0 &&
      !current.textContent?.trim()
    ) {
      break;
    }
    current = current.parentElement;
  }
  return null;
};

Ancestor-Traversal geht ausschließlich nach oben. Sie kann nie in einen Nachbar-Post hineinlaufen. Die Stoppbedingung bei leeren Divs verhindert, dass die Traversal über die strukturelle Grenze einer Grid-Zelle hinausläuft.

Ancestor-Traversal im Kanal-Grid von img-Tag bis a-Tag Abbildung: Traversal-Pfad im Kanal-Grid. Gestartet wird beim gehoverten img. Der Algorithmus läuft aufwärts, bis entweder ein a-Tag mit PostId-href gefunden wird oder ein leerer div die Grenze zur nächsten Grid-Zelle markiert.

Im mouseover-Handler wird jetzt explizit verzweigt:

const article = findArticleElement(element);
const postId = article
  ? quickExtractPostId(article) // Feed/Landing: safe because of <article> boundary
  : quickExtractPostIdFromAncestor(element); // Channel grid: traverse upwards

Doppelte Requests durch gleichzeitige Events

Nach dem Fix der falschen PostIds blieb ein weiteres Problem: Für denselben Post wurden zwei identische Backend-Requests gesendet.

Der Grund ist, wie mouseover in Browsern funktioniert. Wenn die Maus über ein <img> fährt, feuert mouseover für das <img> und für alle Eltern-Elemente, die das Event empfangen. In wenigen Millisekunden kann dasselbe mouseover-Handler-Event mehrfach für verschiedene DOM-Elemente ausgeführt werden, die alle dieselbe PostId auflösen.

Ein Cache auf Basis der letzten gespeicherten PostId hilft nur, wenn ein Request bereits abgeschlossen ist. Bei gleichzeitigen Aufrufen sehen beide denselben leeren Cache und schicken beide einen Request.

Die Lösung ist eine In-Flight-Deduplizierungs-Map. Der Check läuft dabei via chrome.runtime.sendMessage an das Background-Skript, das als Proxy zum Backend fungiert. Wie diese Laufzeitkontexte zusammenspielen, erkläre ich im Artikel über die MV3-Architektur.

const pendingColourChecks = new Map<string, Promise<string>>();

const resolveHighlightColour = (postId: string): Promise<string> => {
  if (postId === lastCheckedPostId) return Promise.resolve(lastCheckedColour);

  // Request for this postId already in flight: return the same promise
  const inflight = pendingColourChecks.get(postId);
  if (inflight) return inflight;

  const promise = chrome.runtime
    .sendMessage({ action: 'check-duplicate', postId })
    .then((response) => {
      const colour = response?.exists ? COLOUR_DUPLICATE : COLOUR_NEW;
      lastCheckedPostId = postId;
      lastCheckedColour = colour;
      return colour;
    })
    .catch(() => COLOUR_DEFAULT)
    .finally(() => pendingColourChecks.delete(postId)); // ← clean up after completion

  pendingColourChecks.set(postId, promise);
  return promise;
};

Wenn zwei mouseover-Events gleichzeitig dieselbe PostId anfordern, teilen sie sich eine einzige Promise. Der zweite Aufrufer wartet auf das Ergebnis des ersten, ohne einen eigenen Backend-Request auszulösen.

Zwei Caching-Ebenen, jede für einen anderen Zeitpunkt:

EbeneZeitpunktMechanismus
lastCheckedPostIdNach abgeschlossenem RequestEinfacher String-Vergleich
pendingColourChecksWährend laufendem RequestMap<string, Promise<string>>

Was das über DOM-Traversal-Entscheidungen aussagt

Die zentrale Erkenntnis aus diesem Debugging-Prozess: querySelector und querySelectorAll sind keine allgemeinen Lösungen. Sie funktionieren, wenn du weißt, dass der Container genau einen semantischen Bereich repräsentiert. Auf einem Feed mit <article>-Elementen ist das gegeben. In einem Grid-Layout ohne semantische Container ist es das nicht.

Wer DOM-Traversal in dynamischen Webseiten macht, sollte immer folgende Frage beantworten: Kann mein Such-Container mehrere gleichartige Elemente enthalten? Wenn ja, ist ein descendant-Search unpräzise. Ancestor-Traversal ist in solchen Fällen die zuverlässigere Strategie, weil sie ausschließlich nach oben läuft und damit keine unbeabsichtigten Treffer erzeugen kann.

Und für alle gleichzeitigen asynchronen Anfragen auf dasselbe Datum gilt: Eine Map<key, Promise> ist das einfachste und zuverlässigste Mittel gegen doppelte Requests, das ich kenne.

Alle Artikel der Serie

  1. Vision und Systemübersicht: Chrome Extension, RAG-Architektur, Projekthintergrund: Artikel lesen

  2. RAG-System Aufbau: Qdrant, Embeddings, Cosine-Ähnlichkeit in TypeScript: Artikel lesen

  3. AI Provider Abstraktion: Ollama vs. OpenAI, Interface-Design, kein Vendor-Lock-in: Artikel lesen

  4. Chrome Extension MV3: Drei isolierte Laufzeitkontexte, Message Passing, Strategy Pattern: Artikel lesen

  5. Docker Compose Strategie: Override-Pattern, von lokal zu Azure: Artikel lesen

  6. Ollama lokal vs. Docker: Die Entscheidung und ihre Konsequenzen: Artikel lesen

  7. Ollama Auto-Pull Entrypoint: Automatisiertes Modell-Setup beim Container-Start: Artikel lesen

  8. tsconfig und Vite: Node16 vs. bundler, warum Vite eigene Regeln hat: Artikel lesen

  9. Instagram Caption mit MutationObserver vollständig laden: Artikel lesen

  10. Chrome Extension Foundation mit Health-Dot und Retry-Queue: Artikel lesen

  11. Phase 2 Features: Shadow DOM Overlay, Tailwind v4, Duplicate Detection: Artikel lesen

  12. Race Condition bei der Plattformerkennung: Wie ein UI-Event die Instagram-Erkennung bricht: Artikel lesen

  13. PostId-Extraktion in zwei Instagram-Layouts: querySelector vs. Ancestor-Traversal (dieser Artikel)

  14. Instagram Karussell vollständig erfassen mit MutationObserver: Lazy-Loading, Observer-before-click, Timeout-Fallback: Artikel lesen

  15. Notiz und Tags beim Screenshot-Speichern: Artikel lesen

  16. Instagram Tastatur-Shortcuts blockieren Chrome Extension Eingaben: Artikel lesen

  17. Lowercase-Normalisierung und Duplikat-Erkennung im Tag-Input: Artikel lesen

  18. Zitadel Login V2 in Docker Compose: drei versteckte Fehler: Artikel lesen

  19. PKCE OAuth in einer Chrome MV3 Extension: Artikel lesen

  20. React Frontend mit react-oidc-context und Zitadel: Artikel lesen

  21. Vite Build-Time-Umgebungsvariablen in Docker: Artikel lesen

  22. Event-Driven Ingestion mit BullMQ und Redis: Artikel lesen

  23. MinIO statt Azurite: S3-kompatible Objektspeicherung lokal und auf Hetzner: Artikel lesen

  24. access_token, id_token und der Userinfo-Endpoint: was wohin gehört: Artikel lesen

  25. Qdrant Multi-Tenancy: Pro Nutzer eine eigene Collection: Artikel lesen

  26. Wenn Backend und Frontend unterschiedliche Typen kennen: Artikel lesen

  27. Zitadel Bootstrap entfernt: Host-Header-Bug und manuelles Setup: Artikel lesen

  28. Backend Code Review: sechs Probleme vor dem Launch behoben: Artikel lesen

  29. Traefik statt NGINX: Reverse Proxy für einen wachsenden Docker-Compose-Stack: Artikel lesen

  30. Zweischichtiges Rate Limiting: Traefik und express-rate-limit mit Redis: Artikel lesen

  31. DSGVO Art. 17 korrekt implementieren: Promise.allSettled und Export-Batching: Artikel lesen

  32. Embedding-Modell-Lock-in: Warum mxbai-embed-large eine Produktionsentscheidung für immer ist: Artikel lesen

  33. Docker Volumes in Produktion: Named Volumes, Bind Mounts und der Hetzner-Volume-Trick: Artikel lesen

  34. Zwei Sicherheitslücken vor dem Launch: Redis ohne Auth und ein offener Qdrant-Admin-Port: Artikel lesen

  35. Traefik als einziger Einstiegspunkt im Docker Compose Stack: Artikel lesen

  36. Zitadel hinter Traefik richtig verdrahten mit Issuer, JWKS und Login V2: Artikel lesen

  37. Frontend gesund machen wenn der nginx Healthcheck an localhost scheitert: Artikel lesen


Du arbeitest an DOM-Extraktion in komplexen SPAs oder baust ein Monitoring-Tool auf Basis von Browser Extensions? Lass uns das gemeinsam einschätzen.

Zurück zum Blog

Ähnliche Beiträge

Alle Beiträge ansehen