Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save heguro/2b773976ae0ecf2fd17b52c1fc0792b2 to your computer and use it in GitHub Desktop.

Select an option

Save heguro/2b773976ae0ecf2fd17b52c1fc0792b2 to your computer and use it in GitHub Desktop.
How to Extract ChatGPT Deep Research Citation URLs from React Props with chrome-devtools MCP (written by GPT-5.4 with chrome-devtools-mcp@1.0.1)

Quick Reference: Extract ChatGPT Deep Research Citation URLs with chrome-devtools

Goal

Extract the real citation URLs from a ChatGPT deep research page by reading React props, then map them back to exported markdown cite blocks.

Core Idea

  • Do not treat visible citation numbers and exported markdown cite blocks as the same thing.
  • Use any rendered citation badge as the entry point.
  • Walk up the React fiber tree until you find memoizedProps.contentReferences.
  • Use contentReferences[*].matched_text as the join key.
  • Use contentReferences[*].safe_urls as the resolved URL list.

If You Already Have the Markdown

  • You do not need the page to expose the full report text again.
  • If the page still exposes citation elements and contentReferences, that is enough.
  • If your exported or copied markdown still contains the original cite... blocks, that is enough.
  • Join matched_text to those markdown cite blocks, then use safe_urls for the real URLs.

Fast Workflow

  1. Open the ChatGPT conversation in chrome-devtools.
  2. Find any citation badge in the report.
  3. Run an evaluate-script against that element.
  4. Discover the React fiber key.
  5. Walk ancestor fibers until contentReferences appears.
  6. Extract matched_text and safe_urls.
  7. Match matched_text to the markdown's cite... blocks.

1. Confirm React-owned keys

(el) => {
  return Reflect.ownKeys(el)
    .map(String)
    .filter((key) => key.startsWith("__reactFiber") || key.startsWith("__reactProps"));
}

2. Find contentReferences

(el) => {
  const fiberKey = Reflect.ownKeys(el).find((key) => String(key).startsWith("__reactFiber"));
  let fiber = fiberKey ? el[fiberKey] : null;

  for (let depth = 0; fiber && depth < 25; depth += 1, fiber = fiber.return) {
    const props = fiber.memoizedProps;
    if (props && typeof props === "object" && Array.isArray(props.contentReferences)) {
      return {
        depth,
        count: props.contentReferences.length,
        keys: Object.keys(props),
      };
    }
  }

  return { error: "contentReferences not found" };
}

3. Extract the useful mapping

(el) => {
  const fiberKey = Reflect.ownKeys(el).find((key) => String(key).startsWith("__reactFiber"));
  let fiber = fiberKey ? el[fiberKey] : null;
  let contentReferences = null;

  for (let depth = 0; fiber && depth < 25; depth += 1, fiber = fiber.return) {
    const props = fiber.memoizedProps;
    if (props && typeof props === "object" && Array.isArray(props.contentReferences)) {
      contentReferences = props.contentReferences;
      break;
    }
  }

  if (!contentReferences) {
    return { error: "contentReferences not found" };
  }

  return contentReferences.map((ref) => ({
    matched_text: ref.matched_text,
    safe_urls: ref.safe_urls,
    alt: ref.alt,
    type: ref.type,
  }));
}

Join Rule

Use this field:

  • matched_text

Do not join on these alone:

  • visible citation number
  • single turn... ID
  • tooltip text

If the markdown no longer contains the original cite... blocks, the strict join becomes much weaker.

Why this works

  • matched_text preserves the exact exported markdown cite block, such as citeturn23view0turn19view0turn43view0
  • safe_urls preserves the resolved URLs for that cite block
  • one visible citation can collapse multiple internal IDs
  • one cite block can resolve to multiple URLs
  • a collapsed deep research card can still be enough, as long as citation elements still expose contentReferences

Minimal Output Shape

{
  "matched_text": "citeturn23view0turn19view0turn43view0",
  "safe_urls": [
    "https://example.com/a",
    "https://example.com/b"
  ],
  "alt": "([example.com](https://example.com/a))",
  "type": "grouped_webpages"
}

Important Notes

  • Do not hardcode the badge number, DOM UID, or ancestor depth.
  • The React property prefix is stable enough, but the suffix changes.
  • Prefer safe_urls over just items[0].url.
  • Use tooltip hover only for spot-checking, not full extraction.

One-line Summary

Start from any citation badge, find __reactFiber..., walk upward to memoizedProps.contentReferences, then use matched_text -> safe_urls to map real URLs back to exported markdown cites.

How to Extract ChatGPT Deep Research Citation URLs from React Props with chrome-devtools MCP (Long ver.)

What Actually Worked

The key breakthrough was to stop treating visible citation badges and internal markdown IDs as the same thing.

  • Visible badges in the ChatGPT UI were numbered citations such as 1, 2, 20, and so on.
  • The exported markdown used internal blocks like citeturn12search0turn30view0.
  • These were not 1:1.
  • The reliable join key was contentReferences[*].matched_text, because it preserved the exact internal cite block and also carried the resolved URLs in safe_urls.

Why browser-only inspection was not enough

The deep research report lived inside a nested iframe. In the browser tool / Playwright path, the inner document looked empty or inaccessible. chrome-devtools could still:

  • read the accessibility tree with take_snapshot
  • hover the real citation buttons
  • evaluate JavaScript against the actual element nodes

If you already have the markdown export

You do not need the page to yield the full report text again.

  • If you can still extract contentReferences from any citation badge, that is enough.
  • If your exported or copied markdown still contains the original internal cite... blocks, that is enough.
  • Join contentReferences[*].matched_text to those markdown cite blocks, then use safe_urls as the resolved URL bundle.

In other words, full-text scraping from the current page state is optional. The important requirement is preserving the original markdown cite tokens so the join key still exists.

Minimal Tool Flow

  1. Open the shared ChatGPT conversation page.
  2. Use the chrome-devtools page-list/select tool to target the ChatGPT tab.
  3. Use take_snapshot to find a stable anchor:
    • a heading inside the report, or
    • any citation button
  4. If you only need one URL, hover that button and read the tooltip.
  5. If you need the full mapping, inspect the React fiber from any citation button and walk upward until you find memoizedProps.contentReferences.
  6. Extract:
    • matched_text
    • safe_urls
    • items[*].url
    • items[*].supporting_websites
    • refs
  7. Match matched_text against the markdown's internal cite... blocks.
  8. Rewrite the markdown mechanically.

Step 1: Find a real citation element

Pick any rendered citation badge as the anchor. Do not hardcode a badge number or UID, because both can vary by page state, build, and session.

What mattered:

  • the element had an ownerDocument inside the deep research iframe
  • the element exposed React internals via keys such as __reactFiber$...

Step 2: If needed, confirm the tooltip URL for one badge

Use hover on the citation element, then evaluate something like this:

(el) => {
  const doc = el.ownerDocument;
  const describedBy = el.parentElement?.getAttribute("aria-describedby") || null;
  const tooltip = describedBy ? doc.getElementById(describedBy) : null;

  return {
    citationIndex: el.getAttribute("data-citation-index"),
    tooltipText: tooltip
      ? (tooltip.innerText || tooltip.textContent || "").replace(/\s+/g, " ").trim()
      : null,
  };
}

This is useful for validating one badge, but it is not scalable for a whole report.

Step 3: Walk the React fiber chain

The important move was to start from the citation element and inspect React-owned properties.

First, find the React-owned keys:

(el) => {
  const ownKeys = Reflect.ownKeys(el).map(String);
  return ownKeys.filter(
    (key) => key.startsWith("__reactFiber") || key.startsWith("__reactProps")
  );
}

Then walk upward until a parent component exposes contentReferences:

(el) => {
  const fiberKey = Reflect.ownKeys(el).find((key) => String(key).startsWith("__reactFiber"));
  let fiber = fiberKey ? el[fiberKey] : null;

  for (let depth = 0; fiber && depth < 25; depth += 1, fiber = fiber.return) {
    const props = fiber.memoizedProps;
    if (props && typeof props === "object" && Array.isArray(props.contentReferences)) {
      return {
        depth,
        keys: Object.keys(props),
        count: props.contentReferences.length,
      };
    }
  }

  return { error: "contentReferences not found" };
}

In practice, contentReferences is typically found on an ancestor component, not on the button itself, so do not hardcode a fixed depth.

Step 4: Extract the citation mapping

Once contentReferences was found, I extracted compact records like this:

(el) => {
  const fiberKey = Reflect.ownKeys(el).find((key) => String(key).startsWith("__reactFiber"));
  let fiber = fiberKey ? el[fiberKey] : null;
  let contentReferences = null;

  for (let depth = 0; fiber && depth < 25; depth += 1, fiber = fiber.return) {
    const props = fiber.memoizedProps;
    if (props && typeof props === "object" && Array.isArray(props.contentReferences)) {
      contentReferences = props.contentReferences;
      break;
    }
  }

  if (!contentReferences) {
    return { error: "contentReferences not found" };
  }

  return contentReferences.map((ref) => ({
    matched_text: ref.matched_text,
    alt: ref.alt,
    type: ref.type,
    safe_urls: ref.safe_urls,
    refs: ref.refs,
    items: Array.isArray(ref.items)
      ? ref.items.map((item) => ({
          title: item?.title,
          url: item?.url,
          attribution: item?.attribution,
          supporting_websites: item?.supporting_websites?.map((site) => ({
            title: site?.title,
            url: site?.url,
            attribution: site?.attribution,
          })),
        }))
      : null,
  }));
}

This was the crucial result:

  • matched_text contained the exact exported markdown cite block, for example citeturn12search0turn30view0
  • safe_urls contained the resolved URL list
  • items[0].url contained the primary URL
  • items[0].supporting_websites contained secondary URLs when the citation grouped multiple sources

Step 5: Why matched_text is the correct join key

A visible citation number is not the same thing as an internal markdown token.

What failed conceptually:

  • mapping turn12search0 directly to visible badge 20
  • assuming one visible badge equals one internal ID
  • assuming one citation always resolves to one URL

What worked:

  • treating each exported cite... block as the unit of replacement
  • using matched_text to join browser data to markdown text
  • using safe_urls to preserve all resolved URLs for that block

This also means the report can still be reconstructed when the UI only shows a collapsed deep research card. If the card still exposes citation elements and their contentReferences, and your local markdown still has the original cite... blocks, you do not need a second full-text scrape from the page.

Step 6: Rewrite the markdown mechanically

Once the extracted JSON existed, the markdown rewrite was simple:

  1. Scan the markdown for unique cite... blocks.
  2. Map each block to its extracted URL bundle.
  3. Assign sequential labels such as cite-01, cite-02, and so on.
  4. Replace each block with [cite-XX][cite-XX].
  5. Append a Citation References section at the end.

Practical Notes

  • Use any citation badge as the anchor. The ancestor component shares the full contentReferences array.
  • Do not hardcode the badge number, the DOM UID, or the ancestor depth. Discover them at runtime.
  • Prefer safe_urls over only items[0].url, because many citations contain multiple URLs.
  • ownerDocument matters because the report lives in a nested iframe.
  • take_snapshot is useful for discovering stable anchors, even if you later switch to evaluate_script.
  • The exact React internal property names will usually start with __reactFiber or __reactProps, but the suffix will vary.
  • If the tooltip looks like it only shows one URL, that does not prove the citation only has one source. Check contentReferences before deciding the final format.
  • If the exported markdown has already lost its original cite... blocks, the strict join becomes much less reliable.

Recommended Future Workflow

For future AI + chrome-devtools runs, do this in order:

  1. Select the ChatGPT page in chrome-devtools.
  2. Find any citation button in the report.
  3. Confirm the element has a React fiber key.
  4. Walk ancestor fibers until memoizedProps.contentReferences appears.
  5. Export matched_text and safe_urls.
  6. Use matched_text as the only reliable join key back to exported markdown.
  7. Rewrite citations from that extracted JSON, not from tooltip scraping.

Expected Data Shape

A successful extraction usually gives records shaped roughly like this:

{
  "matched_text": "citeturn23view0turn19view0turn43view0",
  "alt": "([lithium.env.go.jp](https://...))",
  "type": "grouped_webpages",
  "safe_urls": [
    "https://lithium.env.go.jp/recycle/waste/lithium_1/pdf/20260319_taisakusyu_01.pdf",
    "https://www.city.kobe.lg.jp/a04164/kurashi/recycle/gomi/dashikata/shushushinai/denchi.html",
    "https://www.city.yamaguchi.lg.jp/site/gomisigen/180903.html"
  ]
}

Bottom line

The citation URLs were not extracted by scraping the rendered markdown or by relying on visible citation numbers alone. They were extracted from the React ancestor props of a citation element, specifically from memoizedProps.contentReferences, and then matched back to the markdown via matched_text.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment